Files
codit/backend/tests/tx_middleware_test.go

425 lines
14 KiB
Go

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")
}
}