// Package movementtest tests the app from the outside: real HTTP // requests against the real router, wired exactly as // cmd/movement/main.go wires it. // // The TDD loop: write a failing test here, make it pass in // internal/movement, repeat. package movementtest import ( "context" "io" "log/slog" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "sync" "testing" "time" "github.com/carlosframework/rastrillo/db" "github.com/carlosframework/rastrillo/mail" movement "amadan.net/carlos/movement/internal/movement" ) // testOrigin is the origin newApp wires and post presents as its // same-origin evidence — the pair a browser form submission would be. const testOrigin = "http://app.test" // testApp is the handler plus the database and the mailbox behind it, so a // test can assert on what a request actually wrote and actually sent rather // than inferring either from a page. type testApp struct { http.Handler DB *db.DB Mail *recorder } // recorder stands in for the platform's SMTP sender. Confirmation tokens are // stored only as hashes, so reading the message is the only way to get one — // which is exactly the property the design intends. type recorder struct { mu sync.Mutex sent []recorded } type recorded struct{ To, Subject, Body string } func (r *recorder) Send(ctx context.Context, to, subject, body string) error { r.mu.Lock() defer r.mu.Unlock() r.sent = append(r.sent, recorded{to, subject, body}) return nil } func (r *recorder) count() int { r.mu.Lock() defer r.mu.Unlock() return len(r.sent) } func (r *recorder) countTo(addr string) int { r.mu.Lock() defer r.mu.Unlock() n := 0 for _, m := range r.sent { if m.To == addr { n++ } } return n } // waitForMailTo blocks until at least atLeast messages have gone to addr, and // returns the latest one's body. // // The wait is needed because sends happen after the write transaction // commits, in a goroutine, so a message is not necessarily on the record by // the time the response has been written. The count matters as much as the // wait: returning as soon as any message to the address exists would hand // back a stale one, and a test reading yesterday's token would pass while // proving nothing. func (r *recorder) waitForMailTo(t *testing.T, addr string, atLeast int) string { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { r.mu.Lock() var last string n := 0 for _, m := range r.sent { if m.To == addr { n++ last = m.Body } } r.mu.Unlock() if n >= atLeast { return last } time.Sleep(5 * time.Millisecond) } t.Fatalf("fewer than %d messages reached %s", atLeast, addr) return "" } // newApp builds the whole app per test over a fresh temp database, exactly as // main.go does, with a recording mailer in place of the platform's SMTP. func newApp(t *testing.T) *testApp { t.Helper() box := &recorder{} app := newAppWith(t, box) app.Mail = box return app } // newAppWith is newApp with a mailer of the test's choosing — for the cases // that need a sender which fails, or one that never returns at all. func newAppWith(t *testing.T, sender mail.Sender) *testApp { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) movement.SetLogger(logger) env := testEnv() env.Mailer = sender mux, err := movement.App(d, env) if err != nil { t.Fatalf("App: %v", err) } return &testApp{Handler: mux, DB: d} } func countSignups(t *testing.T, h *testApp, email string) int64 { t.Helper() var n int64 if err := h.DB.G.Model(&movement.Signup{}).Where("email = ?", email).Count(&n).Error; err != nil { t.Fatalf("counting signups: %v", err) } return n } func loadSignup(t *testing.T, h *testApp, email string) movement.Signup { t.Helper() var row movement.Signup if err := h.DB.G.Where("email = ?", email).First(&row).Error; err != nil { t.Fatalf("loading %s: %v", email, err) } return row } func countNonces(t *testing.T, h *testApp) int64 { t.Helper() var n int64 if err := h.DB.G.Model(&movement.SpentNonce{}).Count(&n).Error; err != nil { t.Fatalf("counting nonces: %v", err) } return n } // confirmSignup marks one address confirmed directly. These tests are about // what /signup does to an already-confirmed row, so they set that state up // rather than driving the confirmation flow, which has its own tests. func confirmSignup(t *testing.T, h *testApp, email string) { t.Helper() now := time.Now() res := h.DB.G.Model(&movement.Signup{}). Where("email = ?", email). Updates(map[string]any{"confirmed": true, "confirmed_at": now, "token_hash": nil}) if res.Error != nil { t.Fatalf("confirming %s: %v", email, res.Error) } if res.RowsAffected != 1 { t.Fatalf("confirming %s: %d rows affected, want 1", email, res.RowsAffected) } } // testEnv is the configuration a test runs against. It is not the canonical // host, which matters: placeholder copy refuses to boot there, and every // test in this package runs against a build that still carries placeholders. func testEnv() movement.Env { return movement.Env{ Origin: testOrigin, InstanceKey: strings.Repeat("k", 64), } } func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) return rec } func post(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // The same-origin evidence csrf.Protect requires — every current // browser sends it on a form POST; a test client must too. req.Header.Set("Origin", testOrigin) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) return rec }