diff --git a/backend/internal/web/handlers_files_test.go b/backend/internal/web/handlers_files_test.go index d2a1709..78625ad 100644 --- a/backend/internal/web/handlers_files_test.go +++ b/backend/internal/web/handlers_files_test.go @@ -1,29 +1,41 @@ package web -// handlers_files_test.go — Wave 0 RED test scaffold for FILE-01..06. +// handlers_files_test.go — Wave 1 (Plan 02) test implementation for FILE-01..03, FILE-06. // -// All test functions call t.Skip("FILE handler tests: not yet implemented") so they -// compile but do NOT fail before Plan 02/03 implements the handlers and routes. -// This file is the RED baseline; Plan 02/03 turns it green. +// TestFileUpload and TestFilesTab are activated (no t.Skip). +// TestFileDownload, TestFileDelete, TestFileOwnership (download/delete routes) remain +// as skipped stubs — those are wired in Plan 03. // // Pattern: mirrors handlers_tasks_test.go exactly — setupTestDB, loginUser, // preInsertUser, getCSRFToken, and testCSRFKey are reused from the existing // test files in the same package. import ( + "bytes" "context" "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" "testing" + "backend/internal/auth" + "backend/internal/db/sqlc" "backend/internal/files" + + "github.com/jackc/pgx/v5/pgtype" ) // stubbedFileStorer is a no-op FileStorer for test injection. // Plan 02/03 will replace the Skip calls with real assertions that use this stub. -type stubbedFileStorer struct{} +type stubbedFileStorer struct { + uploadedKey string +} -func (s *stubbedFileStorer) Upload(_ context.Context, _ string, _ io.Reader) (string, int64, error) { - return "application/octet-stream", 0, nil +func (s *stubbedFileStorer) Upload(_ context.Context, key string, _ io.Reader) (string, int64, error) { + s.uploadedKey = key + return "application/octet-stream", 12, nil } func (s *stubbedFileStorer) Delete(_ context.Context, _ string) error { @@ -37,12 +49,166 @@ func (s *stubbedFileStorer) PresignDownload(_ context.Context, _ string) (string // Compile-time assertion: stubbedFileStorer satisfies the files.FileStorer interface. var _ files.FileStorer = (*stubbedFileStorer)(nil) +// newFileTestRouter builds a router with FilesDeps wired for file handler tests. +func newFileTestRouter(q *sqlc.Queries, store *auth.Store, fileStore files.FileStorer) http.Handler { + authDeps := AuthDeps{Queries: q, Store: store, Secure: false} + tabloDeps := TablosDeps{Queries: q} + taskDeps := TasksDeps{Queries: q} + fileDeps := FilesDeps{Queries: q, Files: fileStore, MaxUploadMB: 25} + return NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") +} + // ---- TestFileUpload (FILE-01, FILE-02) ---- // TestFileUpload verifies that POST /tablos/{id}/files uploads a file, // creates a DB row, and stores bytes in S3 (FILE-01 server-proxied upload, FILE-02). func TestFileUpload(t *testing.T) { - t.Skip("FILE handler tests: not yet implemented") + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + stub := &stubbedFileStorer{} + router := newFileTestRouter(q, store, stub) + + user := preInsertUser(t, ctx, q, "fileupload@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Upload Test Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + sessionVal, _, err := store.Create(ctx, user.ID) + if err != nil { + t.Fatalf("store.Create: %v", err) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} + + // Build multipart body with a CSRF token. + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/files", []*http.Cookie{sessionCookie}) + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + // Add CSRF field. + _ = mw.WriteField("gorilla.csrf.Token", csrfToken) + // Add file field. + fw, err := mw.CreateFormFile("file", "hello.txt") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + _, _ = fw.Write([]byte("hello world!")) + mw.Close() + + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/files", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.AddCookie(sessionCookie) + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + // Non-HTMX path: expect 303 redirect to /tablos/{id}/files. + if rec.Code != http.StatusSeeOther { + t.Fatalf("POST /tablos/{id}/files status = %d; want 303\nbody: %s", rec.Code, rec.Body.String()) + } + loc := rec.Header().Get("Location") + if !strings.Contains(loc, "/files") { + t.Errorf("redirect location = %q; want it to contain /files", loc) + } + + // Verify a DB row was created. + dbFiles, err := q.ListFilesByTablo(ctx, tablo.ID) + if err != nil { + t.Fatalf("ListFilesByTablo: %v", err) + } + if len(dbFiles) != 1 { + t.Fatalf("expected 1 file row, got %d", len(dbFiles)) + } + if dbFiles[0].Filename != "hello.txt" { + t.Errorf("filename = %q; want %q", dbFiles[0].Filename, "hello.txt") + } + + // Verify S3 key starts with "files/{tablo_id}/" (D-04). + if stub.uploadedKey == "" { + t.Error("stub.uploadedKey is empty — Upload was not called") + } + expectedPrefix := "files/" + tablo.ID.String() + "/" + if !strings.HasPrefix(stub.uploadedKey, expectedPrefix) { + t.Errorf("S3 key = %q; want prefix %q", stub.uploadedKey, expectedPrefix) + } +} + +// TestFileUploadTooLarge verifies that POST with file body > MaxUploadMB returns 422 +// with an error message containing "too large". +func TestFileUploadTooLarge(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + stub := &stubbedFileStorer{} + + // Use 1 MB limit to keep test fast. + authDeps := AuthDeps{Queries: q, Store: store, Secure: false} + tabloDeps := TablosDeps{Queries: q} + taskDeps := TasksDeps{Queries: q} + fileDeps := FilesDeps{Queries: q, Files: stub, MaxUploadMB: 1} + router := NewRouter(stubPinger{}, "./static", authDeps, tabloDeps, taskDeps, fileDeps, testCSRFKey, "dev", "localhost") + + user := preInsertUser(t, ctx, q, "filelarge@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Large Upload Test Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + sessionVal, _, err := store.Create(ctx, user.ID) + if err != nil { + t.Fatalf("store.Create: %v", err) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} + + csrfToken, csrfCookies := getCSRFToken(t, router, "/tablos/"+tablo.ID.String()+"/files", []*http.Cookie{sessionCookie}) + + // Build a multipart body that exceeds 1 MB. + var body bytes.Buffer + mw := multipart.NewWriter(&body) + _ = mw.WriteField("gorilla.csrf.Token", csrfToken) + fw, err := mw.CreateFormFile("file", "big.bin") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + // Write 2 MB of data. + bigChunk := make([]byte, 2*1024*1024) + _, _ = fw.Write(bigChunk) + mw.Close() + + req := httptest.NewRequest(http.MethodPost, "/tablos/"+tablo.ID.String()+"/files", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.AddCookie(sessionCookie) + for _, c := range csrfCookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("oversized upload status = %d; want 422", rec.Code) + } + if !strings.Contains(rec.Body.String(), "too large") { + t.Errorf("response body = %q; want it to contain 'too large'", rec.Body.String()) + } } // ---- TestFilesList (FILE-03) ---- @@ -50,7 +216,126 @@ func TestFileUpload(t *testing.T) { // TestFilesList verifies that GET /tablos/{id}/files lists files with // original filename and size, newest first (FILE-03). func TestFilesList(t *testing.T) { - t.Skip("FILE handler tests: not yet implemented") + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + stub := &stubbedFileStorer{} + router := newFileTestRouter(q, store, stub) + + user := preInsertUser(t, ctx, q, "fileslist@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "List Test Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + // Pre-insert a file row directly. + _, err = q.InsertTabloFile(ctx, sqlc.InsertTabloFileParams{ + TabloID: tablo.ID, + S3Key: "files/" + tablo.ID.String() + "/test-uuid", + Filename: "myfile.pdf", + ContentType: "application/pdf", + SizeBytes: 2048, + }) + if err != nil { + t.Fatalf("InsertTabloFile: %v", err) + } + + sessionVal, _, err := store.Create(ctx, user.ID) + if err != nil { + t.Fatalf("store.Create: %v", err) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} + + req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/files", nil) + req.AddCookie(sessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /tablos/{id}/files status = %d; want 200", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, "myfile.pdf") { + t.Errorf("file list missing filename 'myfile.pdf'; body snippet: %.200s", body) + } + // Human-readable size should appear (2048 bytes = "2.0 KB"). + if !strings.Contains(body, "KB") && !strings.Contains(body, "2048") { + t.Errorf("file list missing size display; body snippet: %.200s", body) + } +} + +// ---- TestFilesTab (HTMX fragment) ---- + +// TestFilesTab verifies that GET /tablos/{id}/files with HX-Request: true +// returns 200 + HTML fragment (not full Layout), and without HX-Request returns full page. +func TestFilesTab(t *testing.T) { + pool, cleanup := setupTestDB(t) + defer cleanup() + + ctx := context.Background() + q := sqlc.New(pool) + store := auth.NewStore(q) + stub := &stubbedFileStorer{} + router := newFileTestRouter(q, store, stub) + + user := preInsertUser(t, ctx, q, "filestab@example.com", "correct-horse-12") + tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ + UserID: user.ID, + Title: "Tab Test Tablo", + Description: pgtype.Text{Valid: false}, + Color: pgtype.Text{Valid: false}, + }) + if err != nil { + t.Fatalf("InsertTablo: %v", err) + } + + sessionVal, _, err := store.Create(ctx, user.ID) + if err != nil { + t.Fatalf("store.Create: %v", err) + } + sessionCookie := &http.Cookie{Name: auth.SessionCookieName, Value: sessionVal} + + // HX-Request path → should return fragment (no full layout). + req := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/files", nil) + req.AddCookie(sessionCookie) + req.Header.Set("HX-Request", "true") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("HTMX GET /tablos/{id}/files status = %d; want 200", rec.Code) + } + body := rec.Body.String() + // Fragment should NOT contain the full Layout wrapper. + if strings.Contains(body, "") + } + // Should contain the upload form. + if !strings.Contains(body, `name="file"`) { + t.Errorf("HTMX response missing upload form file input; body snippet: %.200s", body) + } + + // Non-HTMX path → should return full page with Layout. + req2 := httptest.NewRequest(http.MethodGet, "/tablos/"+tablo.ID.String()+"/files", nil) + req2.AddCookie(sessionCookie) + rec2 := httptest.NewRecorder() + router.ServeHTTP(rec2, req2) + + if rec2.Code != http.StatusOK { + t.Fatalf("non-HTMX GET /tablos/{id}/files status = %d; want 200", rec2.Code) + } + body2 := rec2.Body.String() + if !strings.Contains(body2, "; body snippet: %.200s", body2) + } } // ---- TestFileDownload (FILE-04) ---- @@ -58,7 +343,7 @@ func TestFilesList(t *testing.T) { // TestFileDownload verifies that GET /tablos/{id}/files/{file_id}/download // returns a 302 redirect to a signed time-limited URL (FILE-04). func TestFileDownload(t *testing.T) { - t.Skip("FILE handler tests: not yet implemented") + t.Skip("FILE handler tests: not yet implemented — Plan 03") } // ---- TestFileDelete (FILE-05) ---- @@ -66,7 +351,7 @@ func TestFileDownload(t *testing.T) { // TestFileDelete verifies that POST /tablos/{id}/files/{file_id}/delete // removes the DB row (and invokes S3 delete via the stub) (FILE-05). func TestFileDelete(t *testing.T) { - t.Skip("FILE handler tests: not yet implemented") + t.Skip("FILE handler tests: not yet implemented — Plan 03") } // ---- TestFileOwnership (FILE-06) ---- @@ -74,5 +359,5 @@ func TestFileDelete(t *testing.T) { // TestFileOwnership verifies that a non-owner gets 404 on // GET /tablos/{id}/files, POST /tablos/{id}/files, and all file sub-routes (FILE-06). func TestFileOwnership(t *testing.T) { - t.Skip("FILE handler tests: not yet implemented") + t.Skip("FILE handler tests: not yet implemented — Plan 03") }