package testutil // Package testutil provides test helpers for handler and integration tests. // It wires up an in-memory SQLite database, a lightweight HTTP request helper, // and common assertion utilities. import ( "Engine/internal/database" "bytes" "database/sql" "encoding/json" "io" "net/http" "net/http/httptest" "testing" _ "modernc.org/sqlite" // pure-Go SQLite driver, no CGO required ) // ── Database ────────────────────────────────────────────────────────────────── // NewTestDB opens a fresh in-memory SQLite database, runs your schema // migrations, and registers a t.Cleanup to close it when the test ends. // // Each call gets its own isolated database — tests never share state. func NewTestDB(t *testing.T) *sql.DB { t.Helper() // "file::memory:?cache=shared" would share across connections; // the plain ":memory:" gives a fully isolated DB per open call. db, err := sql.Open("sqlite", ":memory:") if err != nil { t.Fatalf("testutil.NewTestDB: open: %v", err) } // SQLite in-memory works best with a single connection. db.SetMaxOpenConns(1) if err := database.Migrate(db); err != nil { db.Close() t.Fatalf("testutil.NewTestDB: migrate: %v", err) } t.Cleanup(func() { db.Close() }) return db } // ── HTTP helpers ────────────────────────────────────────────────────────────── // Do fires an HTTP request directly at handler h and returns the recorded // response. body is marshalled to JSON; pass nil to send no body. // // Usage: // // w := testutil.Do(t, http.HandlerFunc(h.Ingest), http.MethodPost, "/", payload) func Do(t *testing.T, h http.Handler, method, path string, body any) *httptest.ResponseRecorder { t.Helper() var r io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { t.Fatalf("testutil.Do: marshal body: %v", err) } r = bytes.NewReader(b) } req := httptest.NewRequest(method, path, r) if body != nil { req.Header.Set("Content-Type", "application/json") } w := httptest.NewRecorder() h.ServeHTTP(w, req) return w } // MustJSON marshals v to JSON and returns it as an io.Reader. // Intended for use with http.Client.Post in full-server integration tests. // // Usage: // // resp, _ := client.Post(srv.URL+"/api/v1/budgets", "application/json", testutil.MustJSON(t, payload)) func MustJSON(t *testing.T, v any) io.Reader { t.Helper() b, err := json.Marshal(v) if err != nil { t.Fatalf("testutil.MustJSON: %v", err) } return bytes.NewReader(b) } // ── Assertions ──────────────────────────────────────────────────────────────── // AssertStatus fails the test if the recorder's status code does not match want. func AssertStatus(t *testing.T, w *httptest.ResponseRecorder, want int) { t.Helper() if w.Code != want { t.Errorf("status: got %d, want %d\n\tbody: %s", w.Code, want, w.Body.String()) } } // AssertJSONField decodes the recorder's body as a JSON object and checks that // the top-level key equals the expected string value. Useful for quick smoke // checks without defining a full response struct. // // Usage: // // testutil.AssertJSONField(t, w, "status", "ok") func AssertJSONField(t *testing.T, w *httptest.ResponseRecorder, key, want string) { t.Helper() var m map[string]any if err := json.NewDecoder(w.Body).Decode(&m); err != nil { t.Fatalf("AssertJSONField: decode body: %v", err) } got, ok := m[key] if !ok { t.Errorf("AssertJSONField: key %q not found in response", key) return } if got != want { t.Errorf("AssertJSONField: %q = %q, want %q", key, got, want) } } // DecodeJSON decodes the recorder's body into dst. // Fails the test immediately if decoding errors. func DecodeJSON(t *testing.T, w *httptest.ResponseRecorder, dst any) { t.Helper() if err := json.NewDecoder(w.Body).Decode(dst); err != nil { t.Fatalf("DecodeJSON: %v\n\tbody: %s", err, w.Body.String()) } } // SeedFixtures inserts one department and one GL account into db and returns // their IDs. Call this at the top of any test that needs FK-valid actuals or // budget rows. // // Usage: // // deptID, glID := testutil.SeedFixtures(t, db) func SeedFixtures(t *testing.T, db *sql.DB) (deptID int, glAccountID int) { t.Helper() res, err := db.Exec(` INSERT INTO departments (code, name, cost_center, active) VALUES ('TEST', 'Test Department', 'CC-TEST', 1) ON CONFLICT(code) DO UPDATE SET name = excluded.name `) if err != nil { t.Fatalf("SeedFixtures: insert department: %v", err) } id, _ := res.LastInsertId() deptID = int(id) res, err = db.Exec(` INSERT INTO gl_accounts (code, description, type, favour_high, active) VALUES ('TEST', 'Test Revenue', 'revenue', 1, 1) ON CONFLICT(code) DO UPDATE SET description = excluded.description `) if err != nil { t.Fatalf("SeedFixtures: insert gl_account: %v", err) } id, _ = res.LastInsertId() glAccountID = int(id) return deptID, glAccountID }