hsa-app/internal/web/upload_test.go

229 lines
6.7 KiB
Go
Raw Normal View History

package web
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"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"}}
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 the receipt-year subdir with a dated, amount-
// tagged name (date 2026-06-01, amount 12.34 -> 2026/06_01_12.34.png).
entries, err := os.ReadDir(filepath.Join(s.cfg.StorageDir, "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, "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_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)
}
}
}