package httpserver import ( "bytes" "context" "database/sql" "encoding/json" "log/slog" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "github.com/tim/cairnquire/apps/server/internal/config" "github.com/tim/cairnquire/apps/server/internal/database" "github.com/tim/cairnquire/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/markdown" "github.com/tim/cairnquire/apps/server/internal/store" ) func TestBuildBrowserUsesFolderIndex(t *testing.T) { browser := buildBrowser([]docs.DocumentRecord{ { Path: "guide/index.md", Title: "Guide", UpdatedAt: time.Now(), }, { Path: "guide/setup.md", Title: "Setup", UpdatedAt: time.Now(), }, }, "guide/index.md") if len(browser.Columns) != 2 { t.Fatalf("len(browser.Columns) = %d, want 2", len(browser.Columns)) } root := browser.Columns[0].Items if len(root) != 1 { t.Fatalf("len(root items) = %d, want 1", len(root)) } if !root[0].IsFolder || root[0].Name != "Guide" || root[0].URL != "/docs/guide" { t.Fatalf("root folder = %#v, want Guide folder linking to /docs/guide", root[0]) } child := browser.Columns[1].Items if len(child) != 1 { t.Fatalf("len(child items) = %d, want 1", len(child)) } if child[0].Path == "guide/index.md" { t.Fatalf("folder index file should be represented by the folder, not repeated as a child file") } } func TestTrimEditSuffix(t *testing.T) { tests := []struct { name string input string wantPath string wantOK bool }{ {name: "nested path", input: "guide/setup/edit", wantPath: "guide/setup", wantOK: true}, {name: "root edit sentinel", input: "edit", wantPath: "", wantOK: true}, {name: "document route", input: "guide/setup", wantPath: "", wantOK: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotPath, gotOK := trimEditSuffix(tt.input) if gotPath != tt.wantPath || gotOK != tt.wantOK { t.Fatalf("trimEditSuffix(%q) = (%q, %t), want (%q, %t)", tt.input, gotPath, gotOK, tt.wantPath, tt.wantOK) } }) } } func TestIsContentAssetPath(t *testing.T) { tests := []struct { path string want bool }{ {path: "logo.webp", want: true}, {path: "guide/logo.png", want: true}, {path: "guide/page", want: false}, {path: "guide/page.md", want: false}, {path: "guide/page/edit", want: false}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { if got := isContentAssetPath(tt.path); got != tt.want { t.Fatalf("isContentAssetPath(%q) = %t, want %t", tt.path, got, tt.want) } }) } } func TestHandleContentAssetServesImageFromSourceDir(t *testing.T) { root := t.TempDir() image := []byte{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, } if err := os.WriteFile(filepath.Join(root, "logo.png"), image, 0o644); err != nil { t.Fatalf("write fixture: %v", err) } server := &Server{ config: config.Config{ Content: config.ContentConfig{SourceDir: root}, }, } request := httptest.NewRequest(http.MethodGet, "/docs/logo.png", nil) recorder := httptest.NewRecorder() server.handleContentAsset(recorder, request, "logo.png") response := recorder.Result() if response.StatusCode != http.StatusOK { t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK) } if got := response.Header.Get("Content-Type"); got != "image/png" { t.Fatalf("content-type = %q, want image/png", got) } } func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) { tests := []struct { name string filename string content string wantPath string wantURL string wantHTML string }{ { name: "markdown", filename: "release notes.md", content: "# Release Notes\n\nReadable markdown", wantPath: "inbox/release notes.md", wantURL: "/docs/inbox/release%20notes", wantHTML: "

Readable markdown

", }, { name: "html", filename: "status.html", content: "

Readable HTML

", wantPath: "inbox/status.md", wantURL: "/docs/inbox/status", wantHTML: "

Readable HTML

", }, { name: "plain text", filename: "summary.txt", content: "Readable plain text", wantPath: "inbox/summary.md", wantURL: "/docs/inbox/summary", wantHTML: "

Readable plain text

", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server, sourceDir := setupUploadTestServer(t) request := multipartUploadRequest(t, tt.filename, []byte(tt.content), "inbox") recorder := httptest.NewRecorder() server.handleUpload(recorder, request) response := recorder.Result() if response.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusCreated) } var payload struct { Hash string `json:"hash"` Kind string `json:"kind"` URL string `json:"url"` AttachmentURL string `json:"attachmentUrl"` } if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { t.Fatalf("decode response: %v", err) } if payload.Kind != "document" { t.Fatalf("kind = %q, want document", payload.Kind) } if payload.URL != tt.wantURL || payload.AttachmentURL != tt.wantURL { t.Fatalf("urls = (%q, %q), want %q", payload.URL, payload.AttachmentURL, tt.wantURL) } written, err := os.ReadFile(filepath.Join(sourceDir, filepath.FromSlash(tt.wantPath))) if err != nil { t.Fatalf("read promoted document: %v", err) } if string(written) != tt.content { t.Fatalf("promoted content = %q, want %q", string(written), tt.content) } attachment, err := server.repository.GetAttachment(context.Background(), payload.Hash) if err != nil { t.Fatalf("lookup upload history record: %v", err) } if attachment.DocumentPath != tt.wantPath { t.Fatalf("document path = %q, want %q", attachment.DocumentPath, tt.wantPath) } listRecorder := httptest.NewRecorder() server.handleAttachmentsList(listRecorder, httptest.NewRequest(http.MethodGet, "/api/attachments", nil)) var listPayload struct { Attachments []struct { Kind string `json:"kind"` URL string `json:"url"` } `json:"attachments"` } if err := json.NewDecoder(listRecorder.Result().Body).Decode(&listPayload); err != nil { t.Fatalf("decode upload history response: %v", err) } if len(listPayload.Attachments) != 1 || listPayload.Attachments[0].Kind != "document" || listPayload.Attachments[0].URL != tt.wantURL { t.Fatalf("upload history = %#v, want rendered document URL %q", listPayload.Attachments, tt.wantURL) } page, err := server.documents.LoadPage(context.Background(), tt.wantPath) if err != nil { t.Fatalf("load promoted page: %v", err) } if !bytes.Contains([]byte(page.HTML), []byte(tt.wantHTML)) { t.Fatalf("rendered HTML = %q, want to contain %q", page.HTML, tt.wantHTML) } }) } } func TestHandleUploadKeepsPDFAsAttachment(t *testing.T) { for _, filename := range []string{"brief.pdf", "renamed.md"} { t.Run(filename, func(t *testing.T) { server, _ := setupUploadTestServer(t) request := multipartUploadRequest(t, filename, []byte("%PDF-1.4\n"), "inbox") recorder := httptest.NewRecorder() server.handleUpload(recorder, request) response := recorder.Result() if response.StatusCode != http.StatusCreated { t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusCreated) } var payload struct { Hash string `json:"hash"` Kind string `json:"kind"` URL string `json:"url"` } if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { t.Fatalf("decode response: %v", err) } if payload.Kind != "attachment" { t.Fatalf("kind = %q, want attachment", payload.Kind) } if payload.URL != "/attachments/"+payload.Hash { t.Fatalf("url = %q, want hash attachment URL", payload.URL) } }) } } func TestHandleUploadRequiresResolutionForExistingDocumentName(t *testing.T) { server, sourceDir := setupUploadTestServer(t) first := httptest.NewRecorder() server.handleUpload(first, multipartUploadRequest(t, "report.md", []byte("# First"), "inbox")) if first.Result().StatusCode != http.StatusCreated { t.Fatalf("first upload status = %d, want %d", first.Result().StatusCode, http.StatusCreated) } conflict := httptest.NewRecorder() server.handleUpload(conflict, multipartUploadRequest(t, "report.md", []byte("# Second"), "inbox")) if conflict.Result().StatusCode != http.StatusConflict { t.Fatalf("conflict status = %d, want %d", conflict.Result().StatusCode, http.StatusConflict) } var payload struct { ConflictType string `json:"conflictType"` ExistingPath string `json:"existingPath"` } if err := json.NewDecoder(conflict.Result().Body).Decode(&payload); err != nil { t.Fatalf("decode conflict: %v", err) } if payload.ConflictType != "document" || payload.ExistingPath != "inbox/report.md" { t.Fatalf("conflict = %#v, want inbox document conflict", payload) } written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "report.md")) if err != nil { t.Fatalf("read original document: %v", err) } if string(written) != "# First" { t.Fatalf("original document = %q, want unchanged content", string(written)) } renamed := httptest.NewRecorder() server.handleUpload(renamed, multipartUploadRequest(t, "report.md", []byte("# Second"), "inbox", "rename")) if renamed.Result().StatusCode != http.StatusCreated { t.Fatalf("renamed upload status = %d, want %d", renamed.Result().StatusCode, http.StatusCreated) } renamedContent, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "report-2.md")) if err != nil { t.Fatalf("read renamed document: %v", err) } if string(renamedContent) != "# Second" { t.Fatalf("renamed document = %q, want second content", string(renamedContent)) } } func TestHandleUploadCanReuseExistingAttachment(t *testing.T) { server, _ := setupUploadTestServer(t) content := []byte("%PDF-1.4\n") first := httptest.NewRecorder() server.handleUpload(first, multipartUploadRequest(t, "brief.pdf", content, "inbox")) if first.Result().StatusCode != http.StatusCreated { t.Fatalf("first upload status = %d, want %d", first.Result().StatusCode, http.StatusCreated) } conflict := httptest.NewRecorder() server.handleUpload(conflict, multipartUploadRequest(t, "brief.pdf", content, "inbox")) if conflict.Result().StatusCode != http.StatusConflict { t.Fatalf("conflict status = %d, want %d", conflict.Result().StatusCode, http.StatusConflict) } var conflictPayload struct { ConflictType string `json:"conflictType"` ExistingURL string `json:"existingUrl"` } if err := json.NewDecoder(conflict.Result().Body).Decode(&conflictPayload); err != nil { t.Fatalf("decode conflict: %v", err) } if conflictPayload.ConflictType != "attachment" { t.Fatalf("conflict type = %q, want attachment", conflictPayload.ConflictType) } reused := httptest.NewRecorder() server.handleUpload(reused, multipartUploadRequest(t, "brief.pdf", content, "inbox", "reuse")) if reused.Result().StatusCode != http.StatusOK { t.Fatalf("reused upload status = %d, want %d", reused.Result().StatusCode, http.StatusOK) } var reusedPayload struct { URL string `json:"url"` } if err := json.NewDecoder(reused.Result().Body).Decode(&reusedPayload); err != nil { t.Fatalf("decode reused response: %v", err) } if reusedPayload.URL != conflictPayload.ExistingURL { t.Fatalf("reused URL = %q, want %q", reusedPayload.URL, conflictPayload.ExistingURL) } } func setupUploadTestServer(t *testing.T) (*Server, string) { t.Helper() db, err := sql.Open("libsql", "file:"+filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := database.ApplyMigrations(context.Background(), db); err != nil { t.Fatalf("apply migrations: %v", err) } contentStore, err := store.New(t.TempDir()) if err != nil { t.Fatalf("create content store: %v", err) } sourceDir := t.TempDir() repository := docs.NewRepository(db) documents := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), repository, slog.Default()) return &Server{ documents: documents, repository: repository, contentStore: contentStore, }, sourceDir } func multipartUploadRequest(t *testing.T, filename string, content []byte, folder string, duplicateAction ...string) *http.Request { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) if folder != "" { if err := writer.WriteField("folder", folder); err != nil { t.Fatalf("write folder: %v", err) } } if len(duplicateAction) > 0 && duplicateAction[0] != "" { if err := writer.WriteField("duplicateAction", duplicateAction[0]); err != nil { t.Fatalf("write duplicate action: %v", err) } } part, err := writer.CreateFormFile("file", filename) if err != nil { t.Fatalf("create file part: %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("write file part: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("close multipart writer: %v", err) } request := httptest.NewRequest(http.MethodPost, "/api/uploads", &body) request.Header.Set("Content-Type", writer.FormDataContentType()) return request } func TestHandleHealthzReturnsOK(t *testing.T) { server := setupHealthTestServer(t) recorder := httptest.NewRecorder() server.handleHealthz(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil)) if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) } var payload map[string]string if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if payload["status"] != "ok" { t.Fatalf("status = %q, want ok", payload["status"]) } } func TestHandleReadyzReportsOKWhenDatabaseAndStoreAreReachable(t *testing.T) { server := setupHealthTestServer(t) recorder := httptest.NewRecorder() server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) } var payload map[string]string if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if payload["status"] != "ok" { t.Fatalf("status = %q, want ok", payload["status"]) } if payload["database"] != "ok" { t.Fatalf("database = %q, want ok", payload["database"]) } if payload["contentStore"] != "ok" { t.Fatalf("contentStore = %q, want ok", payload["contentStore"]) } } func TestHandleReadyzReportsUnavailableWhenContentStoreMissing(t *testing.T) { server := setupHealthTestServer(t) server.config.Content.StoreDir = filepath.Join(t.TempDir(), "does-not-exist") recorder := httptest.NewRecorder() server.handleReadyz(recorder, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if recorder.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable) } var payload map[string]string if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if payload["status"] != "unavailable" { t.Fatalf("status = %q, want unavailable", payload["status"]) } if payload["contentStore"] == "ok" { t.Fatalf("contentStore = %q, want non-ok", payload["contentStore"]) } } func TestHandleHealthAliasForReadyz(t *testing.T) { server := setupHealthTestServer(t) recorder := httptest.NewRecorder() server.handleHealth(recorder, httptest.NewRequest(http.MethodGet, "/health", nil)) if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) } var payload map[string]string if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if payload["status"] != "ok" { t.Fatalf("status = %q, want ok", payload["status"]) } } func TestHealthzAndReadyzAreRegisteredOnRouter(t *testing.T) { server := newAPITestServer(t) for _, path := range []string{"/healthz", "/readyz", "/health"} { t.Run(path, func(t *testing.T) { recorder := httptest.NewRecorder() server.handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) if recorder.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) } }) } } func setupHealthTestServer(t *testing.T) *Server { t.Helper() root := t.TempDir() storeDir := filepath.Join(root, "files") if err := os.MkdirAll(storeDir, 0o755); err != nil { t.Fatalf("create store dir: %v", err) } db, err := sql.Open("libsql", "file:"+filepath.Join(root, "test.db")) if err != nil { t.Fatalf("open database: %v", err) } t.Cleanup(func() { _ = db.Close() }) if err := database.ApplyMigrations(context.Background(), db); err != nil { t.Fatalf("apply migrations: %v", err) } contentStore, err := store.New(storeDir) if err != nil { t.Fatalf("create content store: %v", err) } repository := docs.NewRepository(db) documents := docs.NewService(t.TempDir(), contentStore, markdown.NewRenderer(), repository, slog.Default()) return &Server{ config: config.Config{ Content: config.ContentConfig{StoreDir: storeDir}, }, documents: documents, repository: repository, contentStore: contentStore, } }