package main import ( "archive/zip" "bytes" "encoding/json" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/gorilla/mux" ) func TestAPISearchFilesFuzzyAcrossAllFiles(t *testing.T) { t.Parallel() s := makeTestServer(t, nil) user, err := s.createUser("alice", "password123", "dracula", "auto") if err != nil { t.Fatalf("createUser failed: %v", err) } if err := s.storage.Mkdir(user.ID, "/docs"); err != nil { t.Fatalf("mkdir failed: %v", err) } if err := s.storage.SaveBytes(user.ID, "/docs/project-plan.md", []byte("# project plan")); err != nil { t.Fatalf("save bytes failed: %v", err) } if err := s.storage.SaveBytes(user.ID, "/notes.txt", []byte("notes")); err != nil { t.Fatalf("save bytes failed: %v", err) } loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"alice","password":"password123"}`)) loginReq.Header.Set("Content-Type", "application/json") loginRec := httptest.NewRecorder() s.handleLogin(loginRec, loginReq) if loginRec.Code != http.StatusOK { t.Fatalf("login status = %d", loginRec.Code) } access := cookieByName(loginRec.Result().Cookies(), "access_token") if access == nil { t.Fatal("missing access token") } req := httptest.NewRequest(http.MethodGet, "/api/files/search?q=prjpln&limit=10", nil) req.AddCookie(access) rec := httptest.NewRecorder() s.authMiddleware(http.HandlerFunc(s.handleSearchFiles)).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("search status = %d, want %d", rec.Code, http.StatusOK) } var out struct { Entries []FileEntry `json:"entries"` } if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { t.Fatalf("decode search response failed: %v", err) } if len(out.Entries) == 0 { t.Fatal("expected at least one search result") } if out.Entries[0].Path != "/docs/project-plan.md" { t.Fatalf("top search result = %q, want %q", out.Entries[0].Path, "/docs/project-plan.md") } } func TestSharePageReturnsPublicURLAndDiscordMeta(t *testing.T) { t.Parallel() s := makeTestServer(t, nil) user, err := s.createUser("alice", "password123", "dracula", "auto") if err != nil { t.Fatalf("createUser failed: %v", err) } if err := s.storage.SaveBytes(user.ID, "/cover.png", []byte("png-data")); err != nil { t.Fatalf("save bytes failed: %v", err) } loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"alice","password":"password123"}`)) loginReq.Header.Set("Content-Type", "application/json") loginRec := httptest.NewRecorder() s.handleLogin(loginRec, loginReq) if loginRec.Code != http.StatusOK { t.Fatalf("login status = %d", loginRec.Code) } access := cookieByName(loginRec.Result().Cookies(), "access_token") if access == nil { t.Fatal("missing access token") } createReq := httptest.NewRequest(http.MethodPost, "/api/files/share", bytes.NewBufferString(`{"path":"/cover.png","expiresMinutes":60}`)) createReq.Header.Set("Content-Type", "application/json") createReq.AddCookie(access) createRec := httptest.NewRecorder() s.authMiddleware(http.HandlerFunc(s.handleCreateShareLink)).ServeHTTP(createRec, createReq) if createRec.Code != http.StatusCreated { t.Fatalf("create share status = %d, want %d", createRec.Code, http.StatusCreated) } var shareResp struct { URL string `json:"url"` Token string `json:"token"` } if err := json.NewDecoder(createRec.Body).Decode(&shareResp); err != nil { t.Fatalf("decode share response failed: %v", err) } if !strings.Contains(shareResp.URL, "/share/") { t.Fatalf("share url = %q, want public /share/ URL", shareResp.URL) } router := mux.NewRouter() router.HandleFunc("/share/{token}", s.handleSharedPage).Methods(http.MethodGet) pageReq := httptest.NewRequest(http.MethodGet, "/share/"+shareResp.Token, nil) pageRec := httptest.NewRecorder() router.ServeHTTP(pageRec, pageReq) if pageRec.Code != http.StatusOK { t.Fatalf("share page status = %d, want %d", pageRec.Code, http.StatusOK) } body := pageRec.Body.String() if !strings.Contains(body, `property="og:title"`) { t.Fatal("share page is missing og:title metadata") } if !strings.Contains(body, "/api/share/"+shareResp.Token+"/preview") { t.Fatal("share page is missing preview metadata URL") } } func TestAPISearchFilesFindsPlainTextContent(t *testing.T) { t.Parallel() s := makeTestServer(t, nil) user, err := s.createUser("alice", "password123", "dracula", "auto") if err != nil { t.Fatalf("createUser failed: %v", err) } if err := s.storage.SaveBytes(user.ID, "/docs/meeting-notes.txt", []byte("Budget approval is scheduled for Monday morning.")); err != nil { t.Fatalf("save bytes failed: %v", err) } access := loginAccessToken(t, s, "alice", "password123") entries := runSearchRequest(t, s, access, "budget approval") if len(entries) == 0 { t.Fatal("expected at least one search result") } if entries[0].Path != "/docs/meeting-notes.txt" { t.Fatalf("top search result = %q, want %q", entries[0].Path, "/docs/meeting-notes.txt") } } func TestAPISearchFilesFindsDocxContent(t *testing.T) { t.Parallel() s := makeTestServer(t, nil) user, err := s.createUser("alice", "password123", "dracula", "auto") if err != nil { t.Fatalf("createUser failed: %v", err) } docPath := filepath.Join(t.TempDir(), "proposal.docx") writeDocxFixture(t, docPath, "Quarterly roadmap milestone") data, err := os.ReadFile(docPath) if err != nil { t.Fatalf("read docx failed: %v", err) } if err := s.storage.SaveBytes(user.ID, "/docs/proposal.docx", data); err != nil { t.Fatalf("save bytes failed: %v", err) } access := loginAccessToken(t, s, "alice", "password123") entries := runSearchRequest(t, s, access, "roadmap milestone") if len(entries) == 0 { t.Fatal("expected at least one search result") } if entries[0].Path != "/docs/proposal.docx" { t.Fatalf("top search result = %q, want %q", entries[0].Path, "/docs/proposal.docx") } } func TestAPISearchFilesFindsImageOCRContent(t *testing.T) { t.Parallel() if _, err := exec.LookPath("tesseract"); err != nil { t.Skip("tesseract not installed") } convertBinary, err := exec.LookPath("convert") if err != nil { t.Skip("ImageMagick convert not installed") } s := makeTestServer(t, nil) user, err := s.createUser("alice", "password123", "dracula", "auto") if err != nil { t.Fatalf("createUser failed: %v", err) } imagePath := filepath.Join(t.TempDir(), "searchable.png") cmd := exec.Command(convertBinary, "-background", "white", "-fill", "black", "-font", "DejaVu-Sans-Bold", "-pointsize", "96", "-size", "1400x280", "-gravity", "center", "label:SEARCHABLE", imagePath, ) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("convert failed: %v (%s)", err, strings.TrimSpace(string(out))) } data, err := os.ReadFile(imagePath) if err != nil { t.Fatalf("read image failed: %v", err) } if err := s.storage.SaveBytes(user.ID, "/images/searchable.png", data); err != nil { t.Fatalf("save bytes failed: %v", err) } access := loginAccessToken(t, s, "alice", "password123") entries := runSearchRequest(t, s, access, "searchable") if len(entries) == 0 { t.Fatal("expected at least one search result") } found := false for _, entry := range entries { if entry.Path == "/images/searchable.png" { found = true break } } if !found { t.Fatalf("expected OCR result for %q in search results", "/images/searchable.png") } } func TestAPISearchFilesUsesPersistedOCRCache(t *testing.T) { t.Parallel() s := makeTestServer(t, nil) user, err := s.createUser("alice", "password123", "dracula", "auto") if err != nil { t.Fatalf("createUser failed: %v", err) } if err := s.storage.SaveBytes(user.ID, "/images/cached.png", []byte("not-a-real-image")); err != nil { t.Fatalf("save bytes failed: %v", err) } meta, err := s.storage.Stat(user.ID, "/images/cached.png") if err != nil { t.Fatalf("stat failed: %v", err) } if _, err := s.db.Exec( `INSERT INTO search_content_cache(user_id, rel_path, extractor, file_size, mod_time_ns, content) VALUES (?, ?, ?, ?, ?, ?)`, user.ID, "/images/cached.png", "ocr", meta.Size, meta.ModTime.UTC().UnixNano(), "vault phrase", ); err != nil { t.Fatalf("insert search cache failed: %v", err) } s.searchContent = newSearchContentCache(256) access := loginAccessToken(t, s, "alice", "password123") entries := runSearchRequest(t, s, access, "vault phrase") if len(entries) == 0 { t.Fatal("expected persisted OCR cache search result") } if entries[0].Path != "/images/cached.png" { t.Fatalf("top search result = %q, want %q", entries[0].Path, "/images/cached.png") } } func TestAPIContentPreviewReturnsDocxText(t *testing.T) { t.Parallel() s := makeTestServer(t, nil) user, err := s.createUser("alice", "password123", "dracula", "auto") if err != nil { t.Fatalf("createUser failed: %v", err) } docPath := filepath.Join(t.TempDir(), "preview.docx") writeDocxFixture(t, docPath, "Quarterly preview memo") data, err := os.ReadFile(docPath) if err != nil { t.Fatalf("read docx failed: %v", err) } if err := s.storage.SaveBytes(user.ID, "/docs/preview.docx", data); err != nil { t.Fatalf("save bytes failed: %v", err) } access := loginAccessToken(t, s, "alice", "password123") req := httptest.NewRequest(http.MethodGet, "/api/files/content-preview?path="+url.QueryEscape("/docs/preview.docx"), nil) req.AddCookie(access) rec := httptest.NewRecorder() s.authMiddleware(http.HandlerFunc(s.handleContentPreview)).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("preview status = %d, want %d", rec.Code, http.StatusOK) } var out struct { Content string `json:"content"` } if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { t.Fatalf("decode preview response failed: %v", err) } if !strings.Contains(out.Content, "Quarterly preview memo") { t.Fatalf("preview content = %q, want extracted doc text", out.Content) } } func TestAPIThumbnailReturnsDocxPreviewImage(t *testing.T) { if _, err := exec.LookPath("soffice"); err != nil { t.Skip("soffice not installed") } if _, err := exec.LookPath("pdftoppm"); err != nil { t.Skip("pdftoppm not installed") } if _, err := exec.LookPath("convert"); err != nil { t.Skip("ImageMagick convert not installed") } s := makeTestServer(t, nil) user, err := s.createUser("alice", "password123", "dracula", "auto") if err != nil { t.Fatalf("createUser failed: %v", err) } docPath := filepath.Join(t.TempDir(), "thumbnail.docx") writeDocxFixture(t, docPath, "Quarterly thumbnail memo") data, err := os.ReadFile(docPath) if err != nil { t.Fatalf("read docx failed: %v", err) } if err := s.storage.SaveBytes(user.ID, "/docs/thumbnail.docx", data); err != nil { t.Fatalf("save bytes failed: %v", err) } access := loginAccessToken(t, s, "alice", "password123") req := httptest.NewRequest(http.MethodGet, "/api/files/thumbnail?path="+url.QueryEscape("/docs/thumbnail.docx"), nil) req.AddCookie(access) rec := httptest.NewRecorder() s.authMiddleware(http.HandlerFunc(s.handleThumbnail)).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("thumbnail status = %d, want %d (%s)", rec.Code, http.StatusOK, strings.TrimSpace(rec.Body.String())) } if ctype := rec.Header().Get("Content-Type"); !strings.HasPrefix(ctype, "image/png") { t.Fatalf("thumbnail content-type = %q, want image/png", ctype) } if rec.Body.Len() == 0 { t.Fatal("expected non-empty thumbnail image") } } func TestAPIThumbnailUsesPersistedCache(t *testing.T) { t.Parallel() s := makeTestServer(t, nil) user, err := s.createUser("alice", "password123", "dracula", "auto") if err != nil { t.Fatalf("createUser failed: %v", err) } if err := s.storage.SaveBytes(user.ID, "/docs/cached.docx", []byte("not-a-real-docx")); err != nil { t.Fatalf("save bytes failed: %v", err) } meta, err := s.storage.Stat(user.ID, "/docs/cached.docx") if err != nil { t.Fatalf("stat failed: %v", err) } cachedImage := []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, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0xc9, 0xfe, 0x92, 0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, } if _, err := s.db.Exec( `INSERT INTO preview_thumbnail_cache(user_id, rel_path, renderer, file_size, mod_time_ns, content_type, image) VALUES (?, ?, ?, ?, ?, ?, ?)`, user.ID, "/docs/cached.docx", cacheableThumbnailRenderer(FileEntry{Name: "cached.docx"}), meta.Size, meta.ModTime.UTC().UnixNano(), "image/png", cachedImage, ); err != nil { t.Fatalf("insert thumbnail cache failed: %v", err) } access := loginAccessToken(t, s, "alice", "password123") req := httptest.NewRequest(http.MethodGet, "/api/files/thumbnail?path="+url.QueryEscape("/docs/cached.docx"), nil) req.AddCookie(access) rec := httptest.NewRecorder() s.authMiddleware(http.HandlerFunc(s.handleThumbnail)).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("thumbnail status = %d, want %d (%s)", rec.Code, http.StatusOK, strings.TrimSpace(rec.Body.String())) } if !bytes.Equal(rec.Body.Bytes(), cachedImage) { t.Fatal("thumbnail response did not use persisted cache bytes") } } func loginAccessToken(t *testing.T, s *Server, username, password string) *http.Cookie { t.Helper() loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"`+username+`","password":"`+password+`"}`)) loginReq.Header.Set("Content-Type", "application/json") loginRec := httptest.NewRecorder() s.handleLogin(loginRec, loginReq) if loginRec.Code != http.StatusOK { t.Fatalf("login status = %d", loginRec.Code) } access := cookieByName(loginRec.Result().Cookies(), "access_token") if access == nil { t.Fatal("missing access token") } return access } func runSearchRequest(t *testing.T, s *Server, access *http.Cookie, query string) []FileEntry { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/files/search?q="+url.QueryEscape(query)+"&limit=20", nil) req.AddCookie(access) rec := httptest.NewRecorder() s.authMiddleware(http.HandlerFunc(s.handleSearchFiles)).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("search status = %d, want %d", rec.Code, http.StatusOK) } var out struct { Entries []FileEntry `json:"entries"` } if err := json.NewDecoder(rec.Body).Decode(&out); err != nil { t.Fatalf("decode search response failed: %v", err) } return out.Entries } func writeDocxFixture(t *testing.T, filePath, text string) { t.Helper() f, err := os.Create(filePath) if err != nil { t.Fatalf("create docx failed: %v", err) } defer f.Close() zw := zip.NewWriter(f) writeZipFixtureFile(t, zw, "[Content_Types].xml", ` `) writeZipFixtureFile(t, zw, "_rels/.rels", ` `) writeZipFixtureFile(t, zw, "word/document.xml", ` `+text+` `) if err := zw.Close(); err != nil { t.Fatalf("close docx failed: %v", err) } } func writeZipFixtureFile(t *testing.T, zw *zip.Writer, name, content string) { t.Helper() w, err := zw.Create(name) if err != nil { t.Fatalf("create zip entry %q failed: %v", name, err) } if _, err := w.Write([]byte(content)); err != nil { t.Fatalf("write zip entry %q failed: %v", name, err) } }