425 lines
14 KiB
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")
|
|
}
|
|
}
|