test(05-02): add RED test scaffold for file upload and tab handlers
- TestFileUpload: POST /tablos/{id}/files → 303 redirect + DB row + S3 key check
- TestFileUploadTooLarge: oversized file → 422 + 'too large' message
- TestFilesList: GET /tablos/{id}/files lists pre-inserted file with filename + size
- TestFilesTab: HTMX fragment vs full-page rendering
- stubbedFileStorer records uploadedKey for assertion
- TestFileDownload/Delete/Ownership remain t.Skip (Plan 03)
This commit is contained in:
parent
74af9d7052
commit
cc0d6cfd4e
1 changed files with 297 additions and 12 deletions
|
|
@ -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 <html> 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 <html> wrapper.
|
||||
if strings.Contains(body, "<html") {
|
||||
t.Errorf("HTMX response should be a fragment, not full page; body contains <html>")
|
||||
}
|
||||
// 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, "<html") {
|
||||
t.Errorf("non-HTMX response should be full page with <html>; 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")
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue