package web import ( "bytes" "fmt" "mime/multipart" "net/http" "net/http/httptest" "os" "path/filepath" "strconv" "strings" "testing" "time" "maisym.com/hsa/internal/auth" "maisym.com/hsa/internal/config" "maisym.com/hsa/internal/storage" ) func testServerWithStore(t *testing.T) *Server { t.Helper() var key [32]byte copy(key[:], "web-test-key-32-bytes-padded!!!!") store, err := storage.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { store.Close() }) return &Server{ cfg: config.Config{ SessionKey: key, RequiredGroup: "hsa-users", StorageDir: filepath.Join(t.TempDir(), "files"), MaxUploadBytes: 32 << 20, }, store: store, } } func authCookie(t *testing.T, s *Server) *http.Cookie { t.Helper() sess := auth.Session{Subject: "jm@example.com", Groups: []string{"hsa-users"}, IssuedAt: time.Now()} v, err := auth.EncodeSession(sess, s.cfg.SessionKey) if err != nil { t.Fatal(err) } return &http.Cookie{Name: sessionCookie, Value: v} } // aCategoryID returns the id of a seeded category as a string. func aCategoryID(t *testing.T, s *Server) string { t.Helper() cats, err := s.store.ListCategories() if err != nil || len(cats) == 0 { t.Fatalf("ListCategories: %v", err) } return strconv.FormatInt(cats[0].ID, 10) } // fakePNG returns bytes that http.DetectContentType recognises as image/png. func fakePNG() []byte { return append([]byte("\x89PNG\r\n\x1a\n"), []byte("fake-image-content")...) } func multipartUpload(t *testing.T, fields map[string]string, fileField, fileName string, fileData []byte) (*bytes.Buffer, string) { t.Helper() var body bytes.Buffer mw := multipart.NewWriter(&body) for k, v := range fields { _ = mw.WriteField(k, v) } if fileData != nil { fw, err := mw.CreateFormFile(fileField, fileName) if err != nil { t.Fatal(err) } fw.Write(fileData) } mw.Close() return &body, mw.FormDataContentType() } func TestUpload_HappyPath_WritesFileAndRow(t *testing.T) { s := testServerWithStore(t) body, ct := multipartUpload(t, map[string]string{"amount": "12.34", "receipt_date": "2026-06-01", "category_id": aCategoryID(t, s)}, "receipt", "receipt.png", fakePNG()) req := httptest.NewRequest(http.MethodPost, "/upload", body) req.Header.Set("Content-Type", ct) req.AddCookie(authCookie(t, s)) rec := httptest.NewRecorder() s.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "Receipt saved") { t.Errorf("missing confirmation: %s", rec.Body.String()) } // Row written. n, err := s.store.CountActive() if err != nil { t.Fatal(err) } if n != 1 { t.Fatalf("CountActive = %d, want 1", n) } // File written to disk under receipts/ with a dated, amount-tagged name // (date 2026-06-01, amount 12.34 -> receipts/2026/06_01_12.34.png). entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "receipts", "2026")) if err != nil { t.Fatal(err) } if len(entries) != 1 { t.Fatalf("year dir has %d files, want 1", len(entries)) } if entries[0].Name() != "06_01_12.34.png" { t.Errorf("stored file name = %q, want 06_01_12.34.png", entries[0].Name()) } } func TestUpload_SameDateAmount_DisambiguatesFilename(t *testing.T) { s := testServerWithStore(t) cat := aCategoryID(t, s) post := func() { body, ct := multipartUpload(t, map[string]string{"amount": "12.34", "receipt_date": "2026-06-01", "category_id": cat}, "receipt", "receipt.png", fakePNG()) req := httptest.NewRequest(http.MethodPost, "/upload", body) req.Header.Set("Content-Type", ct) req.AddCookie(authCookie(t, s)) rec := httptest.NewRecorder() s.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String()) } } post() post() // same date + amount → must not overwrite the first file entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "receipts", "2026")) if err != nil { t.Fatal(err) } got := map[string]bool{} for _, e := range entries { got[e.Name()] = true } if !got["06_01_12.34.png"] || !got["06_01_12.34_1.png"] { t.Errorf("expected both base and _1 files, got %v", got) } } func TestUpload_WithAttachments(t *testing.T) { s := testServerWithStore(t) var body bytes.Buffer mw := multipart.NewWriter(&body) _ = mw.WriteField("amount", "12.34") _ = mw.WriteField("receipt_date", "2026-06-01") _ = mw.WriteField("category_id", aCategoryID(t, s)) rw, _ := mw.CreateFormFile("receipt", "receipt.png") rw.Write(fakePNG()) for i := 0; i < 2; i++ { // two extra files in the same submit aw, _ := mw.CreateFormFile("attachments", fmt.Sprintf("page%d.png", i)) aw.Write(fakePNG()) } mw.Close() req := httptest.NewRequest(http.MethodPost, "/upload", &body) req.Header.Set("Content-Type", mw.FormDataContentType()) req.AddCookie(authCookie(t, s)) rec := httptest.NewRecorder() s.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "attachment(s)") { t.Errorf("confirm missing attachment listing: %s", rec.Body.String()) } // Files written under attachments/2026 with parent-stem _att naming. entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "attachments", "2026")) if err != nil { t.Fatal(err) } got := map[string]bool{} for _, e := range entries { got[e.Name()] = true } if !got["06_01_12.34_att.png"] || !got["06_01_12.34_att_1.png"] { t.Errorf("attachment files = %v, want _att and _att_1", got) } // Two attachment rows linked to the receipt; serving works. rows, _, err := s.store.ListRecent("uploaded_at", 10, 0) if err != nil || len(rows) != 1 { t.Fatalf("ListRecent: %v len=%d", err, len(rows)) } atts, err := s.store.ListAttachmentMeta(rows[0].ID) if err != nil { t.Fatal(err) } if len(atts) != 2 { t.Fatalf("attachments = %d, want 2", len(atts)) } rec2 := get(t, s, "/attachment/"+atts[0].ID+"/file") if rec2.Code != http.StatusOK || !strings.HasPrefix(rec2.Body.String(), "\x89PNG") { t.Errorf("attachment serve failed: %d %q", rec2.Code, rec2.Body.String()) } } func TestUploadForm_RendersTagPicker(t *testing.T) { s := testServerWithStore(t) if err := s.store.SetReceiptTags("noop", nil); err != nil { // ensure table is queryable t.Fatal(err) } rec := get(t, s, "/") if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } body := rec.Body.String() for _, want := range []string{`id="tags-btn"`, `id="tags-modal"`, `Done with tags`} { if !strings.Contains(body, want) { t.Errorf("upload form missing %q", want) } } } func TestUpload_WithTags(t *testing.T) { s := testServerWithStore(t) var body bytes.Buffer mw := multipart.NewWriter(&body) _ = mw.WriteField("amount", "12.34") _ = mw.WriteField("receipt_date", "2026-06-01") _ = mw.WriteField("category_id", aCategoryID(t, s)) _ = mw.WriteField("tags", "Dental") _ = mw.WriteField("tags", "tax-2026") _ = mw.WriteField("tags", "dental") // case-dup of the first — must collapse rw, _ := mw.CreateFormFile("receipt", "receipt.png") rw.Write(fakePNG()) mw.Close() req := httptest.NewRequest(http.MethodPost, "/upload", &body) req.Header.Set("Content-Type", mw.FormDataContentType()) req.AddCookie(authCookie(t, s)) rec := httptest.NewRecorder() s.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "Tags:") { t.Errorf("confirm page missing tags listing: %s", rec.Body.String()) } rows, _, err := s.store.ListRecent("uploaded_at", 10, 0) if err != nil || len(rows) != 1 { t.Fatalf("ListRecent: %v len=%d", err, len(rows)) } got, err := s.store.ListReceiptTags(rows[0].ID) if err != nil { t.Fatal(err) } if len(got) != 2 || got[0] != "Dental" || got[1] != "tax-2026" { t.Errorf("receipt tags = %v, want [Dental tax-2026]", got) } } func TestUpload_RejectsBadAttachment(t *testing.T) { s := testServerWithStore(t) var body bytes.Buffer mw := multipart.NewWriter(&body) _ = mw.WriteField("amount", "12.34") _ = mw.WriteField("receipt_date", "2026-06-01") _ = mw.WriteField("category_id", aCategoryID(t, s)) rw, _ := mw.CreateFormFile("receipt", "receipt.png") rw.Write(fakePNG()) bad, _ := mw.CreateFormFile("attachments", "notes.txt") bad.Write([]byte("this is plain text, not an image or pdf")) mw.Close() req := httptest.NewRequest(http.MethodPost, "/upload", &body) req.Header.Set("Content-Type", mw.FormDataContentType()) req.AddCookie(authCookie(t, s)) rec := httptest.NewRecorder() s.Routes().ServeHTTP(rec, req) if !strings.Contains(rec.Body.String(), "Attachments must be images or PDFs") { t.Errorf("expected attachment-type error, got: %s", rec.Body.String()) } // Nothing should have been written. if n, _ := s.store.CountActive(); n != 0 { t.Errorf("CountActive = %d, want 0 (rejected upload)", n) } } func TestUpload_BadAmount_RerendersWithErrorNoRow(t *testing.T) { s := testServerWithStore(t) body, ct := multipartUpload(t, map[string]string{"amount": "abc", "receipt_date": "2026-06-01", "category_id": aCategoryID(t, s)}, "receipt", "receipt.png", fakePNG()) req := httptest.NewRequest(http.MethodPost, "/upload", body) req.Header.Set("Content-Type", ct) req.AddCookie(authCookie(t, s)) rec := httptest.NewRecorder() s.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (re-render)", rec.Code) } if !strings.Contains(rec.Body.String(), "valid amount") { t.Errorf("missing amount error: %s", rec.Body.String()) } n, _ := s.store.CountActive() if n != 0 { t.Errorf("CountActive = %d, want 0 (nothing stored on validation error)", n) } } func TestUpload_RejectsNonImageNonPDF(t *testing.T) { s := testServerWithStore(t) body, ct := multipartUpload(t, map[string]string{"amount": "5.00", "receipt_date": "2026-06-01", "category_id": aCategoryID(t, s)}, "receipt", "notes.txt", []byte("just plain text, not an image or pdf")) req := httptest.NewRequest(http.MethodPost, "/upload", body) req.Header.Set("Content-Type", ct) req.AddCookie(authCookie(t, s)) rec := httptest.NewRecorder() s.Routes().ServeHTTP(rec, req) if !strings.Contains(rec.Body.String(), "images and PDFs") { t.Errorf("expected mime rejection: %s", rec.Body.String()) } n, _ := s.store.CountActive() if n != 0 { t.Errorf("CountActive = %d, want 0", n) } } func TestExportDB_StreamsSQLiteFile(t *testing.T) { s := testServerWithStore(t) // Seed one receipt via the upload handler. body, ct := multipartUpload(t, map[string]string{"amount": "9.99", "receipt_date": "2026-06-01", "category_id": aCategoryID(t, s)}, "receipt", "r.png", fakePNG()) req := httptest.NewRequest(http.MethodPost, "/upload", body) req.Header.Set("Content-Type", ct) req.AddCookie(authCookie(t, s)) s.Routes().ServeHTTP(httptest.NewRecorder(), req) for _, blobs := range []string{"true", "false"} { req := httptest.NewRequest(http.MethodGet, "/export/db?blobs="+blobs, nil) req.AddCookie(authCookie(t, s)) rec := httptest.NewRecorder() s.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("blobs=%s: status = %d, want 200", blobs, rec.Code) } if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" { t.Errorf("blobs=%s: content-type = %q", blobs, ct) } if !strings.HasPrefix(rec.Body.String(), "SQLite format 3") { t.Errorf("blobs=%s: body is not a SQLite file", blobs) } } }