package tests import "net/http" import "net/http/httptest" import "strings" import "sync" import "testing" import "codit/internal/db" import "codit/internal/middleware" import httpx "codit/internal/http" import "codit/internal/models" // invoke calls a TxRoute-wrapped handler via httptest and returns the recorder. func invoke(store *db.Store, mode middleware.TxMode, output middleware.TxOutputMode, handler httpx.HandlerFunc, method string) *httptest.ResponseRecorder { var wrapped httpx.HandlerFunc var req *http.Request var rec *httptest.ResponseRecorder wrapped = middleware.TxRoute(store, mode, output, handler) req = httptest.NewRequest(method, "/", nil) rec = httptest.NewRecorder() wrapped(rec, req, httpx.Params{}) return rec } // makeWriteHandler returns a handler that writes a board field value to the // injected tx-store and responds with the given HTTP status code. func makeWriteHandler(boardID string, field string, label string, status int) httpx.HandlerFunc { return func(w http.ResponseWriter, r *http.Request, _ httpx.Params) { var txStore *db.Store var ok bool txStore, ok = middleware.StoreFromContext(r.Context()) if !ok { http.Error(w, "no store in context", http.StatusInternalServerError) return } _, _ = txStore.CreateBoardFieldValue(r.Context(), boardID, models.BoardFieldValue{ Field: field, Value: label, Label: label, Color: "#000000", }) w.WriteHeader(status) _, _ = w.Write([]byte("body")) } } // --------------------------------------------------------------------------- // TestTxWriteCommitsOn2xx: a 200 response causes the write to be committed. // --------------------------------------------------------------------------- func TestTxWriteCommitsOn2xx(t *testing.T) { var store *db.Store var user models.User var project models.Project var board models.Board var values []models.BoardFieldValue var err error store = openTestStore(t) defer store.Close() user = createTestUser(t, store, "tx-commit-user") project = createTestProject(t, store, user, "tx-commit-proj") board = createTestBoard(t, store, user, project, "tx-commit-board") var rec *httptest.ResponseRecorder rec = invoke(store, middleware.TxImmediate, middleware.TxBuffered, makeWriteHandler(board.ID, "status", "done", http.StatusOK), http.MethodPost) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } values, err = store.ListBoardFieldValues(board.ID, "status") if err != nil { t.Fatalf("list field values: %v", err) } if len(values) != 1 { t.Fatalf("expected field value to be committed, got %d values", len(values)) } } // --------------------------------------------------------------------------- // TestTxWriteRollsBackOn4xx: a 4xx response causes the write to be rolled back. // --------------------------------------------------------------------------- func TestTxWriteRollsBackOn4xx(t *testing.T) { var store *db.Store var user models.User var project models.Project var board models.Board var values []models.BoardFieldValue var err error store = openTestStore(t) defer store.Close() user = createTestUser(t, store, "tx-rollback-4xx-user") project = createTestProject(t, store, user, "tx-rollback-4xx-proj") board = createTestBoard(t, store, user, project, "tx-rollback-4xx-board") var rec *httptest.ResponseRecorder rec = invoke(store, middleware.TxImmediate, middleware.TxBuffered, makeWriteHandler(board.ID, "status", "done", http.StatusBadRequest), http.MethodPost) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } values, err = store.ListBoardFieldValues(board.ID, "status") if err != nil { t.Fatalf("list field values: %v", err) } if len(values) != 0 { t.Fatalf("expected write to be rolled back on 4xx, but %d value(s) persisted", len(values)) } } // --------------------------------------------------------------------------- // TestTxWriteRollsBackOn5xx: a 5xx response also causes rollback. // --------------------------------------------------------------------------- func TestTxWriteRollsBackOn5xx(t *testing.T) { var store *db.Store var user models.User var project models.Project var board models.Board var values []models.BoardFieldValue var err error store = openTestStore(t) defer store.Close() user = createTestUser(t, store, "tx-rollback-5xx-user") project = createTestProject(t, store, user, "tx-rollback-5xx-proj") board = createTestBoard(t, store, user, project, "tx-rollback-5xx-board") var rec *httptest.ResponseRecorder rec = invoke(store, middleware.TxImmediate, middleware.TxBuffered, makeWriteHandler(board.ID, "status", "done", http.StatusInternalServerError), http.MethodPost) if rec.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", rec.Code) } values, err = store.ListBoardFieldValues(board.ID, "status") if err != nil { t.Fatalf("list field values: %v", err) } if len(values) != 0 { t.Fatalf("expected write to be rolled back on 5xx, but %d value(s) persisted", len(values)) } } // --------------------------------------------------------------------------- // TestTxDeferredCommitsOn2xx: TxDeferred (txRead) also commits on 200. // The difference from TxImmediate is the lock level (deferred vs exclusive), // not the commit behaviour. // --------------------------------------------------------------------------- func TestTxDeferredCommitsOn2xx(t *testing.T) { var store *db.Store var user models.User var project models.Project var board models.Board var values []models.BoardFieldValue var err error store = openTestStore(t) defer store.Close() user = createTestUser(t, store, "tx-deferred-commit-user") project = createTestProject(t, store, user, "tx-deferred-commit-proj") board = createTestBoard(t, store, user, project, "tx-deferred-commit-board") var rec *httptest.ResponseRecorder rec = invoke(store, middleware.TxDeferred, middleware.TxUnbuffered, makeWriteHandler(board.ID, "status", "done", http.StatusOK), http.MethodPost) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } values, err = store.ListBoardFieldValues(board.ID, "status") if err != nil { t.Fatalf("list field values: %v", err) } if len(values) != 1 { t.Fatalf("TxDeferred should commit on 200, but got %d value(s)", len(values)) } } // --------------------------------------------------------------------------- // TestTxDeferredRollsBackOn4xx: TxDeferred also rolls back on 4xx. // --------------------------------------------------------------------------- func TestTxDeferredRollsBackOn4xx(t *testing.T) { var store *db.Store var user models.User var project models.Project var board models.Board var values []models.BoardFieldValue var err error store = openTestStore(t) defer store.Close() user = createTestUser(t, store, "tx-deferred-rollback-user") project = createTestProject(t, store, user, "tx-deferred-rollback-proj") board = createTestBoard(t, store, user, project, "tx-deferred-rollback-board") var rec *httptest.ResponseRecorder rec = invoke(store, middleware.TxDeferred, middleware.TxUnbuffered, makeWriteHandler(board.ID, "status", "done", http.StatusBadRequest), http.MethodPost) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rec.Code) } values, err = store.ListBoardFieldValues(board.ID, "status") if err != nil { t.Fatalf("list field values: %v", err) } if len(values) != 0 { t.Fatalf("TxDeferred should roll back on 4xx, but %d value(s) persisted", len(values)) } } // --------------------------------------------------------------------------- // TestTxWriteBuffersResponseBody: with TxBuffered the response body must not // reach the client until after commit. Verify the response body is present // only after the full handler+commit cycle, not during the write. // --------------------------------------------------------------------------- func TestTxWriteBuffersResponseBody(t *testing.T) { var store *db.Store var user models.User var project models.Project var board models.Board store = openTestStore(t) defer store.Close() user = createTestUser(t, store, "tx-buffer-user") project = createTestProject(t, store, user, "tx-buffer-proj") board = createTestBoard(t, store, user, project, "tx-buffer-board") // The handler writes a body and then returns 200. With TxBuffered the body // must not be visible until after a successful commit. We verify this by // observing that the recorder has the body after the full call returns. var bodyWrittenDuringHandler bool var rec *httptest.ResponseRecorder var handler httpx.HandlerFunc handler = func(w http.ResponseWriter, r *http.Request, _ httpx.Params) { var txStore *db.Store var ok bool txStore, ok = middleware.StoreFromContext(r.Context()) if !ok { http.Error(w, "no store in context", http.StatusInternalServerError) return } _, _ = txStore.CreateBoardFieldValue(r.Context(), board.ID, models.BoardFieldValue{ Field: "status", Value: "done", Label: "done", Color: "#000", }) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("committed-body")) // At this exact moment (inside the handler, before commit) the recorder's // body must still be empty if buffering is working correctly. // httptest.ResponseRecorder flushes to its internal buffer only when Body is // written — here we cannot prevent that write, but we CAN verify that the // commit must have happened before FlushTo copies it to the real writer. // We verify this indirectly: the value is not yet in the database here. var values []models.BoardFieldValue var err error values, err = store.ListBoardFieldValues(board.ID, "status") if err == nil && len(values) > 0 { bodyWrittenDuringHandler = true } } rec = invoke(store, middleware.TxImmediate, middleware.TxBuffered, handler, http.MethodPost) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } if bodyWrittenDuringHandler { t.Fatal("data was committed to DB while handler was still executing: buffering is not working") } if !strings.Contains(rec.Body.String(), "committed-body") { t.Fatalf("expected body in final response, got: %q", rec.Body.String()) } } // --------------------------------------------------------------------------- // TestTxWriteImmediateBlocksConcurrentWriter: two concurrent txWrite requests // targeting the same board must serialize. The second BEGIN IMMEDIATE blocks // until the first commits. No data must be lost. // --------------------------------------------------------------------------- func TestTxWriteImmediateBlocksConcurrentWriter(t *testing.T) { var store *db.Store var user models.User var project models.Project var board models.Board var values []models.BoardFieldValue var err error store = openTestStore(t) defer store.Close() user = createTestUser(t, store, "tx-concurrent-write-user") project = createTestProject(t, store, user, "tx-concurrent-write-proj") board = createTestBoard(t, store, user, project, "tx-concurrent-write-board") // Release barrier ensures both goroutines call into the middleware at the // same time, maximising the chance of exposing a lost-write bug. var ready = make(chan struct{}) var wg sync.WaitGroup var mu sync.Mutex var codes []int var run func(label string) run = func(label string) { defer wg.Done() <-ready var rec *httptest.ResponseRecorder rec = invoke(store, middleware.TxImmediate, middleware.TxBuffered, makeWriteHandler(board.ID, "status", label, http.StatusOK), http.MethodPost) mu.Lock() codes = append(codes, rec.Code) mu.Unlock() } wg.Add(2) go run("value-a") go run("value-b") close(ready) wg.Wait() for _, code := range codes { if code != http.StatusOK { t.Errorf("expected 200 from concurrent writers, got %d", code) } } values, err = store.ListBoardFieldValues(board.ID, "status") if err != nil { t.Fatalf("list field values: %v", err) } // Both writes must have committed: no lost update. if len(values) != 2 { t.Errorf("expected 2 committed field values from concurrent writes, got %d", len(values)) } } // --------------------------------------------------------------------------- // TestTxWebSocketUpgradeBypassesTx: a WebSocket upgrade request must bypass // the transaction entirely — no BEGIN is issued and no commit/rollback occurs. // --------------------------------------------------------------------------- func TestTxWebSocketUpgradeBypassesTx(t *testing.T) { var store *db.Store var handlerCalled bool store = openTestStore(t) defer store.Close() var handler httpx.HandlerFunc handler = func(w http.ResponseWriter, r *http.Request, _ httpx.Params) { handlerCalled = true // StoreFromContext must NOT return a tx-wrapped store for a WS upgrade. var txStore *db.Store var ok bool txStore, ok = middleware.StoreFromContext(r.Context()) if ok && txStore != nil { t.Error("tx store must not be injected for WebSocket upgrade requests") } w.WriteHeader(http.StatusSwitchingProtocols) } var wrapped httpx.HandlerFunc var req *http.Request var rec *httptest.ResponseRecorder wrapped = middleware.TxRoute(store, middleware.TxImmediate, middleware.TxBuffered, handler) req = httptest.NewRequest(http.MethodGet, "/ws", nil) req.Header.Set("Connection", "upgrade") req.Header.Set("Upgrade", "websocket") rec = httptest.NewRecorder() wrapped(rec, req, httpx.Params{}) if !handlerCalled { t.Fatal("handler was not called for WebSocket upgrade") } if rec.Code != http.StatusSwitchingProtocols { t.Fatalf("expected 101, got %d", rec.Code) } } // --------------------------------------------------------------------------- // TestTxWriteStoreInjectedInContext: the handler receives a tx-wrapped store // via context, distinct from the root store, so DB operations inside the // handler participate in the middleware-managed transaction. // --------------------------------------------------------------------------- func TestTxWriteStoreInjectedInContext(t *testing.T) { var store *db.Store store = openTestStore(t) defer store.Close() var gotStore *db.Store var handler httpx.HandlerFunc handler = func(w http.ResponseWriter, r *http.Request, _ httpx.Params) { var ok bool gotStore, ok = middleware.StoreFromContext(r.Context()) if !ok || gotStore == nil { http.Error(w, "no store injected", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } var rec *httptest.ResponseRecorder rec = invoke(store, middleware.TxImmediate, middleware.TxBuffered, handler, http.MethodPost) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d — store injection failed", rec.Code) } if gotStore == nil { t.Fatal("tx store was not injected into handler context") } }