//go:build integration package integration_test import ( "bytes" "context" "encoding/json" "io" "log/slog" "mime/multipart" "net/http" "net/http/httptest" "testing" "time" "github.com/alchemistkay/guestguard/internal/api" "github.com/alchemistkay/guestguard/internal/storage" ) // TestCsvImportFlow walks the happy path: preview, then commit, then a // re-import to confirm dedup is honoured. func TestCsvImportFlow(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) srv, db, host, token := setupAuthedAPI(t, ctx) eventID := createEvent(t, srv.URL, token, "Import Event", "import-event") _ = host const csvOne = `name,email,phone,plus_ones Alex,alex@example.test,+447700900111,1 Sam,sam@example.test,,0 Jordan,,+15551234567,2 ,,, Mira,malformed-email,,0 ` // Preview. var preview struct { Rows []map[string]any `json:"rows"` Errors []map[string]any `json:"errors"` TotalCount int `json:"total_count"` } resp := postMultipart(t, srv.URL+"/events/"+eventID.String()+"/guests/import/preview", token, csvOne) must(t, json.NewDecoder(resp.Body).Decode(&preview), "decode preview") resp.Body.Close() if len(preview.Rows) != 3 { t.Fatalf("preview rows: got %d want 3 (errors=%+v)", len(preview.Rows), preview.Errors) } if len(preview.Errors) != 1 { t.Fatalf("preview errors: got %d want 1: %+v", len(preview.Errors), preview.Errors) } // Commit. var commit struct { Added int `json:"added"` Skipped int `json:"skipped"` SkippedEmails []string `json:"skipped_emails"` } resp = postMultipart(t, srv.URL+"/events/"+eventID.String()+"/guests/import", token, csvOne) must(t, json.NewDecoder(resp.Body).Decode(&commit), "decode commit") resp.Body.Close() if commit.Added != 3 || commit.Skipped != 0 { t.Fatalf("commit: added=%d skipped=%d (want 3/0)", commit.Added, commit.Skipped) } // Re-import the same file — emails should dedup. var commit2 struct { Added int `json:"added"` Skipped int `json:"skipped"` SkippedEmails []string `json:"skipped_emails"` } resp = postMultipart(t, srv.URL+"/events/"+eventID.String()+"/guests/import", token, csvOne) must(t, json.NewDecoder(resp.Body).Decode(&commit2), "decode commit2") resp.Body.Close() // Jordan has no email, so they re-import as a new row each time. // Alex + Sam have emails and should be skipped. if commit2.Skipped != 2 { t.Fatalf("re-import dedup: skipped=%d want 2 (added=%d)", commit2.Skipped, commit2.Added) } // Verify the row count in the DB matches expectations: 3 from first // commit + 1 (Jordan again) from second. var count int must(t, db.Pool.QueryRow(ctx, "SELECT count(*) FROM guests WHERE event_id = $1", eventID, ).Scan(&count), "count guests") if count != 4 { t.Fatalf("guest count: got %d want 4", count) } } // TestCsvImportAtomicRollback verifies that a runtime error mid-batch // leaves NO partial rows. We trigger this by injecting a name longer than // the column allows (VARCHAR(255)) on row 3 of 4. func TestCsvImportAtomicRollback(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) t.Cleanup(cancel) srv, db, _, token := setupAuthedAPI(t, ctx) eventID := createEvent(t, srv.URL, token, "Atomic Event", "atomic-event") longName := bytes.Repeat([]byte("Aa"), 200) // 400 chars > VARCHAR(255) csv := "name,email\nAlice,a@example.test\nBob,b@example.test\n" + string(longName) + ",c@example.test\nDave,d@example.test\n" resp := postMultipart(t, srv.URL+"/events/"+eventID.String()+"/guests/import", token, csv) body, _ := io.ReadAll(resp.Body) resp.Body.Close() if resp.StatusCode != http.StatusInternalServerError { t.Fatalf("expected 500 on row insert error, got %d body=%s", resp.StatusCode, body) } var count int must(t, db.Pool.QueryRow(ctx, "SELECT count(*) FROM guests WHERE event_id = $1", eventID, ).Scan(&count), "count after rollback") if count != 0 { t.Fatalf("expected 0 guests after rollback, got %d", count) } } // --- helpers shared with other tests in this dir --- // setupAuthedAPI builds a fresh API server + a verified host + bearer // token. Tests that just need a logged-in host can use this directly. func setupAuthedAPI(t *testing.T, ctx context.Context) (srv *httptest.Server, db *storage.DB, hostID [16]byte, bearer string) { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) dsn := startPostgres(t, ctx) var err error db, err = storage.NewDB(ctx, dsn) must(t, err, "connect db") t.Cleanup(db.Close) must(t, db.Migrate(ctx), "migrate") apiSrv, err := api.NewServer(api.ServerDeps{ Logger: logger, DB: db, TokenTTL: 24 * time.Hour, JWTSecret: testJWTSecret, JWTIssuer: testJWTIssuer, AccessTokenTTL: 5 * time.Minute, RefreshTokenTTL: 24 * time.Hour, EmailVerificationTTL: 1 * time.Hour, PasswordResetTTL: 1 * time.Hour, PublicBaseURL: "http://localhost", }) must(t, err, "build api server") srv = httptest.NewServer(apiSrv.Handler()) t.Cleanup(srv.Close) host := insertHost(t, ctx, db.Pool) hostID = host bearer = issueHostToken(t, host) return } func postMultipart(t *testing.T, url, bearer, body string) *http.Response { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, err := mw.CreateFormFile("file", "guests.csv") must(t, err, "create form file") _, err = fw.Write([]byte(body)) must(t, err, "write csv") must(t, mw.Close(), "close mw") req, err := http.NewRequest(http.MethodPost, url, &buf) must(t, err, "build req") req.Header.Set("Content-Type", mw.FormDataContentType()) if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } resp, err := http.DefaultClient.Do(req) must(t, err, "do multipart") return resp }