feat(backend): add PoolProxy, Querier, and per-test transaction infrastructure

New types and helpers for context-aware DB routing and per-test transactions:

- PoolProxy: wraps pgxpool.Pool, routes queries through context transaction when active
- Querier: interface accepted by fixture/helper functions for decoupling
- ContextWithTx / TxFromContext: store/extract pgx.Tx in context.Context
- SetupTestTx (in testutils): begins tx, stores in context, auto-rolls back on cleanup
- SetupTestTx (in testtx): package-level variant with semaphore for parallel safety

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-21 19:28:18 +01:00
co-authored by Sisyphus
parent f2e8e3eb77
commit c69a243f75
4 changed files with 191 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
//go:build test
// +build test
package testtx
import (
"context"
"testing"
"crussell/db"
)
// txSemaphore limits concurrent test transactions per package.
var txSemaphore = make(chan struct{}, 8)
// SetupTestTx begins a transaction, stores it in context, and auto-rolls back.
func SetupTestTx(t *testing.T) (context.Context, db.Querier) {
t.Helper()
txSemaphore <- struct{}{}
pool := db.Conn.Pool()
tx, err := pool.Begin(context.Background())
if err != nil {
<-txSemaphore
t.Fatalf("SetupTestTx: failed to begin transaction: %v", err)
}
ctx := db.ContextWithTx(context.Background(), tx)
t.Cleanup(func() {
tx.Rollback(context.Background())
<-txSemaphore
})
return ctx, tx
}
+52
View File
@@ -0,0 +1,52 @@
//go:build test
// +build test
package testutils
import (
"context"
"testing"
"crussell/db"
)
// txSemaphore limits concurrent test transactions per package to prevent
// pgxpool connection exhaustion when many tests use t.Parallel().
// The cap (8) should match the -parallel N flag used in test invocations
// and should be well below MaxConns per pool (currently 16).
var txSemaphore = make(chan struct{}, 8)
// SetupTestTx begins a transaction on the db.Conn pool, stores it in the
// returned context (for PoolProxy routing), and returns the raw tx for use
// with fixture functions (e.g. fixtures.CreateTestUser(tx)).
//
// The transaction is automatically rolled back in t.Cleanup. After calling
// SetupTestTx, the test should call t.Parallel() and pass the returned context
// to any request helper functions (MakeRequest, makeAdminRequest, etc.).
//
// Example:
//
// func TestMyHandler(t *testing.T) {
// t.Parallel()
// ctx, tx := testutils.SetupTestTx(t)
// userID, _ := fixtures.CreateTestUser(tx)
// w := testutils.MakeUserRequest(handler, "GET", "/api/foo", nil, userID, ctx)
// assert.Equal(t, 200, w.Code)
// }
func SetupTestTx(t *testing.T) (context.Context, db.Querier) {
t.Helper()
txSemaphore <- struct{}{}
pool := db.Conn.Pool()
tx, err := pool.Begin(context.Background())
if err != nil {
<-txSemaphore
t.Fatalf("SetupTestTx: failed to begin transaction: %v", err)
}
ctx := db.ContextWithTx(context.Background(), tx)
t.Cleanup(func() {
tx.Rollback(context.Background())
<-txSemaphore
})
return ctx, tx
}