test(tx): regression tests for per-request transactions across all specs

This commit is contained in:
2026-09-30 23:19:58 +02:00
parent 2042205817
commit 7f84debdc5
9 changed files with 712 additions and 1 deletions
+4 -1
View File
@@ -96,7 +96,10 @@
- DONE (AfterRead, decided by user): restheadspec `AfterRead` now runs in a second short tx. Test: `pkg/restheadspec/read_tx_test.go`.
- DONE P7: `pkg/security/txsettings.go`: `SecurityList.SetTxSettings(fn)`, `StampTxSettings`, `ApplyTxSettings` (configurable map, decided by user; `set_config(name, value, true)`, value hex-encoded, name validated, Postgres only, fail closed). Every spec's `RegisterSecurityHooks` registers it on `OnTxBegin`. Tests: `pkg/security/txsettings_test.go`, `pkg/resolvespec/tx_settings_test.go`. Docs: `pkg/common/TRANSACTIONS.md`.
- DONE real-Postgres check (resolvespec, testserver via compose): create `tx=1 pooled=0`, read `tx=1 pooled=0`, update `tx=2 pooled=0` (was `pooled=1`), single delete `tx=1 pooled=0`, batch create/delete `tx=1 pooled=0`. Compose now uses host networking (bridge fails here): testserver on 8123, Postgres on 8124 (was 8080/5434); integration test DSNs updated. Smoke script covers read and update. websocketspec/mqttspec/resolvemcp/restheadspec/funcspec not measured on real Postgres.
- NEXT: extra per-spec create/update tests (optional).
- DONE regression tests: per-spec read/create/update/delete hook-on-tx, failure-rollback (Before*/After*/`OnTxBegin`) and second-tx tests in all six specs (`ops_tx_test.go`, `tx_test.go`, `read_tx_test.go`); stamping tests for resolvespec, resolvemcp, funcspec; pgsql adapter preload tests (same connection, error returned); source guard `pkg/common/tx_guard_test.go` (no direct `RunInTransaction`/`BeginTx`, no `Tx = h.db` beyond the allowlisted BeforeHandle placeholders, no pool statements in spec handlers).
- NOTE: resolvespec never fires `AfterRead`/`AfterCreate` (hook types exist, no call site); not tested, pre-existing.
- NOTE: restheadspec total-count cache is process-wide and ignores the record id; read tests call `resetTotalCache`.
- NOTE: `pkg/security` `TestDatabaseAuthenticator` fails with `-count=2` (also on `cd96404`, before this work); use `-count=1`.
## Tests
- Existing: per-spec `handler_test.go`, `hooks_test.go`, `integration_test.go`; models in `pkg/testmodels/business.go`; `dbtrace` unit tests.
@@ -5,6 +5,7 @@ import (
"errors"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
@@ -54,3 +55,35 @@ func TestSubqueryPreloadErrorIsReturned(t *testing.T) {
})
}
}
// With a single pooled connection, a preload that escaped the transaction would
// block on the pool and fail on the context timeout.
func TestSubqueryPreloadRunsOnTheTransaction(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
db.SetMaxOpenConns(1)
mock.ExpectBegin()
mock.ExpectQuery(`FROM parents`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
mock.ExpectQuery(`FROM children`).WillReturnRows(sqlmock.NewRows([]string{"id", "user_id"}).AddRow(10, 1))
mock.ExpectCommit()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
var parents []preloadParent
err = NewPgSQLAdapter(db).RunInTransaction(ctx, func(tx common.Database) error {
return tx.NewSelect().Model(&preloadParent{}).PreloadRelation("Children").Scan(ctx, &parents)
})
if err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if len(parents) != 1 || len(parents[0].Children) != 1 {
t.Fatalf("preloaded children missing: %+v", parents)
}
}
+99
View File
@@ -0,0 +1,99 @@
package common
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// Source-level regression guard for the single-transaction-per-request rule
// (audit/single_tran.md). Runtime tests prove the current paths; this catches a
// new code path that quietly reaches for the pool.
var guardedSpecs = []string{"resolvespec", "restheadspec", "websocketspec", "mqttspec", "resolvemcp", "funcspec"}
var (
directTxRE = regexp.MustCompile(`\.(RunInTransaction|BeginTx)\(`)
poolHookTxRE = regexp.MustCompile(`\bTx(:\s+|\s*=\s*)(h|handler|h\.handler)\.db\b`)
poolQueryRE = regexp.MustCompile(`\b(h|handler)\.db\.(NewSelect|NewInsert|NewUpdate|NewDelete|Exec|Query)\(`)
)
// allowedPoolHookTx: hook contexts that start life on the pool before the handler
// opens its transaction (BeforeHandle runs before any tx and must be DB-free).
// runInTx replaces Tx with the transaction before any other hook runs.
var allowedPoolHookTx = map[string]int{
"resolvespec/handler.go": 1,
"websocketspec/handler.go": 1,
"resolvemcp/handler.go": 4,
}
// allowedPoolQuery: statements outside the request path.
var allowedPoolQuery = map[string]int{
"resolvemcp/annotation.go": 2, // tool annotations, not a data request
}
func guardedFiles(t *testing.T) map[string][]string {
t.Helper()
out := map[string][]string{}
for _, spec := range guardedSpecs {
files, err := filepath.Glob(filepath.Join("..", spec, "*.go"))
if err != nil || len(files) == 0 {
t.Fatalf("no sources found for %s: %v", spec, err)
}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
raw, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
var lines []string
for _, l := range strings.Split(string(raw), "\n") {
if s := strings.TrimSpace(l); strings.HasPrefix(s, "//") {
continue
}
lines = append(lines, l)
}
out[spec+"/"+filepath.Base(f)] = lines
}
}
return out
}
func countMatches(lines []string, re *regexp.Regexp) int {
n := 0
for _, l := range lines {
if re.MatchString(l) {
n++
}
}
return n
}
func TestNoDirectTransactionsInSpecHandlers(t *testing.T) {
for file, lines := range guardedFiles(t) {
if n := countMatches(lines, directTxRE); n > 0 {
t.Errorf("%s opens a transaction directly (%d): use the handler's runInTx so OnTxBegin fires", file, n)
}
}
}
func TestHookContextsDoNotRetainThePool(t *testing.T) {
files := guardedFiles(t)
for file, lines := range files {
if got, want := countMatches(lines, poolHookTxRE), allowedPoolHookTx[file]; got != want {
t.Errorf("%s has %d hook contexts set to the pool, allowed %d: hooks must get the transaction", file, got, want)
}
}
}
func TestSpecHandlersDoNotQueryThePoolDirectly(t *testing.T) {
for file, lines := range guardedFiles(t) {
if got, want := countMatches(lines, poolQueryRE), allowedPoolQuery[file]; got != want {
t.Errorf("%s runs %d statements on the pool, allowed %d: use the transaction", file, got, want)
}
}
}
+43
View File
@@ -9,6 +9,7 @@ import (
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
// txFactory returns a pool whose every transaction is a distinct MockDatabase.
@@ -100,3 +101,45 @@ func TestOnTxBeginErrorAnswersTransactionError(t *testing.T) {
t.Fatalf("no query may run after a failed OnTxBegin, ran %d", queries)
}
}
type stubProvider struct{ security.SecurityProvider }
func TestSecurityHooksStampTxSettingsOnEveryTransaction(t *testing.T) {
var execs []string
h := NewHandler(&MockDatabase{
RunInTransactionFunc: func(ctx context.Context, fn func(common.Database) error) error {
return fn(&MockDatabase{
ExecFunc: func(ctx context.Context, query string, args ...interface{}) (common.Result, error) {
execs = append(execs, query)
return &MockResult{}, nil
},
QueryFunc: func(ctx context.Context, dest interface{}, query string, args ...interface{}) error {
execs = append(execs, "QUERY")
if rows, ok := dest.(*[]map[string]interface{}); ok {
*rows = []map[string]interface{}{{"id": float64(1)}}
}
return nil
},
})
},
})
list, err := security.NewSecurityList(stubProvider{})
if err != nil {
t.Fatal(err)
}
list.SetTxSettings(func(security.SecurityContext) (map[string]string, error) {
return map[string]string{"app.user_id": "1"}, nil
})
RegisterSecurityHooks(h, list)
w := httptest.NewRecorder()
h.SqlQuery("SELECT * FROM users WHERE id = 1", SqlQueryOptions{})(w, createTestRequest("GET", "/t", nil, nil, nil))
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body)
}
// tx 1: stamp, then the query; tx 2 (BeforeResponse): stamp again.
if len(execs) != 3 || !strings.Contains(execs[0], "set_config('app.user_id'") || execs[1] != "QUERY" || !strings.Contains(execs[2], "set_config('app.user_id'") {
t.Fatalf("each transaction must be stamped before any other SQL, got %v", execs)
}
}
+51
View File
@@ -89,3 +89,54 @@ func TestHandler_UpdateRunsAfterHookOnSecondTransaction(t *testing.T) {
assert.NotEqual(t, begins[0], begins[1])
assert.Equal(t, begins[1], afterTx)
}
func TestHandler_ReadRunsHooksOnOneTransaction(t *testing.T) {
handler, db := setupTestHandler(t)
require.NoError(t, db.Create(&TestUser{ID: 1, Name: "a", Email: "a@example.com", Status: "active"}).Error)
var begins []common.Database
var beforeTx, afterTx common.Database
handler.hooks.Register(OnTxBegin, func(c *HookContext) error { begins = append(begins, c.Tx); return nil })
handler.hooks.Register(BeforeRead, func(c *HookContext) error { beforeTx = c.Tx; return nil })
handler.hooks.Register(AfterRead, func(c *HookContext) error { afterTx = c.Tx; return nil })
handler.handleRead(&Client{ID: "c1"}, &Message{ID: "m1"}, newTxHook(handler, nil))
require.Len(t, begins, 1)
assert.NotEqual(t, handler.db, begins[0])
assert.Equal(t, begins[0], beforeTx)
assert.Equal(t, begins[0], afterTx)
}
func TestHandler_CreateRunsHooksOnTwoTransactions(t *testing.T) {
handler, db := setupTestHandler(t)
var begins []common.Database
var beforeTx, afterTx common.Database
handler.hooks.Register(OnTxBegin, func(c *HookContext) error { begins = append(begins, c.Tx); return nil })
handler.hooks.Register(BeforeCreate, func(c *HookContext) error { beforeTx = c.Tx; return nil })
handler.hooks.Register(AfterCreate, func(c *HookContext) error { afterTx = c.Tx; return nil })
hook := newTxHook(handler, map[string]interface{}{"id": 5, "name": "n", "email": "n@example.com", "status": "active"})
hook.ID = ""
handler.handleCreate(&Client{ID: "c1"}, &Message{ID: "m1"}, hook)
var got TestUser
require.NoError(t, db.First(&got, 5).Error)
require.Len(t, begins, 2)
assert.NotEqual(t, begins[0], begins[1])
assert.Equal(t, begins[0], beforeTx)
assert.Equal(t, begins[1], afterTx)
}
func TestHandler_BeforeHookErrorAbortsUpdateWithoutWriting(t *testing.T) {
handler, db := setupTestHandler(t)
require.NoError(t, db.Create(&TestUser{ID: 1, Name: "a", Email: "a@example.com", Status: "active"}).Error)
handler.hooks.Register(BeforeUpdate, func(c *HookContext) error { return errors.New("denied") })
handler.handleUpdate(&Client{ID: "c1"}, &Message{ID: "m1"}, newTxHook(handler, map[string]interface{}{"name": "b"}))
var got TestUser
require.NoError(t, db.First(&got, 1).Error)
assert.Equal(t, "a", got.Name)
}
+119
View File
@@ -11,6 +11,7 @@ import (
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
"github.com/bitechdev/ResolveSpec/pkg/security"
)
type txItem struct {
@@ -205,3 +206,121 @@ func TestUpdateRefetchRunsInSecondTransaction(t *testing.T) {
}
}
}
func TestAfterDeleteErrorRollsBackDelete(t *testing.T) {
h, mock, ctx := newTxHarness(t)
h.Hooks().Register(AfterDelete, func(*HookContext) error { return sql.ErrConnDone })
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "a"))
mock.ExpectExec(`DELETE`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectRollback()
if _, err := h.executeDelete(ctx, "public", "items", "7"); err == nil {
t.Fatal("a failing AfterDelete must fail the request")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestCreateBeforeHookErrorRollsBackWithoutInsert(t *testing.T) {
h, mock, ctx := newTxHarness(t)
h.Hooks().Register(BeforeCreate, func(*HookContext) error { return sql.ErrConnDone })
mock.ExpectBegin()
mock.ExpectRollback()
if _, err := h.executeCreate(ctx, "public", "items", map[string]interface{}{"name": "a"}); err == nil {
t.Fatal("expected error")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestCreateInsertErrorRollsBackBatch(t *testing.T) {
h, mock, ctx := newTxHarness(t)
mock.ExpectBegin()
mock.ExpectQuery(`INSERT`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1))
mock.ExpectQuery(`INSERT`).WillReturnError(sql.ErrConnDone)
mock.ExpectRollback()
items := []interface{}{map[string]interface{}{"name": "a"}, map[string]interface{}{"name": "b"}}
if _, err := h.executeCreate(ctx, "public", "items", items); err == nil {
t.Fatal("expected error")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestOnTxBeginErrorRollsBackEveryOperation(t *testing.T) {
ops := map[string]func(h *Handler, ctx context.Context) error{
"read": func(h *Handler, ctx context.Context) error {
_, _, err := h.executeRead(ctx, "public", "items", "7", common.RequestOptions{})
return err
},
"create": func(h *Handler, ctx context.Context) error {
_, err := h.executeCreate(ctx, "public", "items", map[string]interface{}{"name": "a"})
return err
},
"update": func(h *Handler, ctx context.Context) error {
_, err := h.executeUpdate(ctx, "public", "items", "7", map[string]interface{}{"name": "a"})
return err
},
"delete": func(h *Handler, ctx context.Context) error {
_, err := h.executeDelete(ctx, "public", "items", "7")
return err
},
}
for name, op := range ops {
t.Run(name, func(t *testing.T) {
h, mock, ctx := newTxHarness(t)
h.Hooks().Register(OnTxBegin, func(*HookContext) error { return sql.ErrConnDone })
mock.ExpectBegin()
mock.ExpectRollback()
if err := op(h, ctx); err == nil {
t.Fatal("expected error")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
type stubProvider struct{ security.SecurityProvider }
func TestSecurityHooksStampTxSettingsOnEveryTransaction(t *testing.T) {
h, mock, ctx := newTxHarness(t)
list, err := security.NewSecurityList(stubProvider{})
if err != nil {
t.Fatal(err)
}
list.SetTxSettings(func(security.SecurityContext) (map[string]string, error) {
return map[string]string{"app.user_id": "7"}, nil
})
RegisterSecurityHooks(h, list)
ctx = context.WithValue(ctx, security.UserContextKey, &security.UserContext{UserID: 7, UserName: "u"})
// Update opens two transactions; each must be stamped before any other SQL.
cols := []string{"id", "name"}
mock.ExpectBegin()
mock.ExpectExec(`set_config\('app\.user_id'`).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows(cols).AddRow(7, "a"))
mock.ExpectExec(`UPDATE`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectBegin()
mock.ExpectExec(`set_config\('app\.user_id'`).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows(cols).AddRow(7, "b"))
mock.ExpectCommit()
if _, err := h.executeUpdate(ctx, "public", "items", "7", map[string]interface{}{"name": "b"}); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+186
View File
@@ -0,0 +1,186 @@
package resolvespec
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
func opCtx(t *testing.T) (context.Context, common.ResponseWriter, *httptest.ResponseRecorder) {
t.Helper()
rec := httptest.NewRecorder()
w, _ := common.WrapHTTPRequest(rec, httptest.NewRequest(http.MethodPost, "/", nil))
base, cancel := context.WithTimeout(context.Background(), 2*time.Second)
t.Cleanup(cancel)
return WithRequestData(base, "public", "items", "items", &delItem{}, &delItem{}), w, rec
}
// hookTrace records hook order and the Tx each hook saw.
type hookTrace struct {
order []string
tx map[HookType][]common.Database
}
func traceHooks(h *Handler, types ...HookType) *hookTrace {
tr := &hookTrace{tx: map[HookType][]common.Database{}}
for _, ht := range types {
ht := ht
h.Hooks().Register(ht, func(c *HookContext) error {
tr.order = append(tr.order, string(ht))
tr.tx[ht] = append(tr.tx[ht], c.Tx)
return nil
})
}
return tr
}
func (tr *hookTrace) mustBeOn(t *testing.T, tx common.Database, types ...HookType) {
t.Helper()
for _, ht := range types {
if len(tr.tx[ht]) == 0 || tr.tx[ht][0] != tx {
t.Fatalf("%s must run on the OnTxBegin transaction", ht)
}
}
}
func TestReadRunsHooksOnOneTransaction(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
tr := traceHooks(h, OnTxBegin, BeforeRead)
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "a"))
mock.ExpectCommit()
ctx, w, rec := opCtx(t)
h.handleRead(ctx, w, "7", common.RequestOptions{})
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if len(tr.tx[OnTxBegin]) != 1 || tr.order[0] != "on_tx_begin" {
t.Fatalf("OnTxBegin must fire once and first, got %v", tr.order)
}
tr.mustBeOn(t, tr.tx[OnTxBegin][0], BeforeRead)
}
func TestReadBeforeHookErrorRollsBackWithoutQueries(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
h.Hooks().Register(BeforeRead, func(*HookContext) error { return errors.New("denied") })
mock.ExpectBegin()
mock.ExpectRollback()
ctx, w, rec := opCtx(t)
h.handleRead(ctx, w, "7", common.RequestOptions{})
if rec.Code == http.StatusOK {
t.Fatalf("a failing BeforeRead must not return data: %s", rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestCreateRunsHooksOnTransaction(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
tr := traceHooks(h, OnTxBegin, BeforeCreate)
mock.ExpectBegin()
mock.ExpectQuery(`INSERT`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(7))
mock.ExpectCommit()
ctx, w, rec := opCtx(t)
h.handleCreate(ctx, w, map[string]interface{}{"name": "a"}, common.RequestOptions{})
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if tr.order[0] != "on_tx_begin" {
t.Fatalf("OnTxBegin must fire first, got %v", tr.order)
}
for _, tx := range tr.tx[BeforeCreate] {
if tx == nil || tx == h.db {
t.Fatal("BeforeCreate must not get the pool")
}
}
}
func TestCreateBeforeHookErrorRollsBack(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
h.Hooks().Register(BeforeCreate, func(*HookContext) error { return errors.New("denied") })
mock.ExpectBegin()
mock.ExpectRollback()
ctx, w, rec := opCtx(t)
h.handleCreate(ctx, w, map[string]interface{}{"name": "a"}, common.RequestOptions{})
if rec.Code == http.StatusOK {
t.Fatalf("a failing BeforeCreate must not create: %s", rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestUpdateAfterHookErrorRollsBackAndSkipsRefetch(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
h.Hooks().Register(AfterUpdate, func(*HookContext) error { return errors.New("audit failed") })
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "a"))
mock.ExpectExec(`UPDATE`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectRollback()
ctx, w, rec := opCtx(t)
h.handleUpdate(ctx, w, "7", nil, map[string]interface{}{"name": "b"}, common.RequestOptions{})
if rec.Code == http.StatusOK {
t.Fatalf("a failing AfterUpdate must fail the request: %s", rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestUpdateRunsBeforeAndAfterOnFirstTransaction(t *testing.T) {
h, mock, _ := newDeleteHarness(t)
tr := traceHooks(h, OnTxBegin, BeforeUpdate, AfterUpdate)
cols := []string{"id", "name"}
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows(cols).AddRow(7, "a"))
mock.ExpectExec(`UPDATE`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows(cols).AddRow(7, "b"))
mock.ExpectCommit()
ctx, w, rec := opCtx(t)
h.handleUpdate(ctx, w, "7", nil, map[string]interface{}{"name": "b"}, common.RequestOptions{})
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if len(tr.tx[OnTxBegin]) != 2 {
t.Fatalf("expected OnTxBegin twice, got %d", len(tr.tx[OnTxBegin]))
}
tr.mustBeOn(t, tr.tx[OnTxBegin][0], BeforeUpdate, AfterUpdate)
}
+99
View File
@@ -11,12 +11,14 @@ import (
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/bitechdev/ResolveSpec/pkg/cache"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
"github.com/bitechdev/ResolveSpec/pkg/modelregistry"
)
func TestAfterReadRunsInSecondTransaction(t *testing.T) {
resetTotalCache(t)
sqlDB, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
@@ -60,3 +62,100 @@ func TestAfterReadRunsInSecondTransaction(t *testing.T) {
t.Fatal("BeforeRead must run on the first tx and AfterRead on the second")
}
}
// resetTotalCache empties the process-wide query-total cache. Its key ignores the
// record id, so a cached total would skip the count query and desync the mock.
func resetTotalCache(t *testing.T) {
t.Helper()
_ = cache.GetDefaultCache().Clear(context.Background())
t.Cleanup(func() { _ = cache.GetDefaultCache().Clear(context.Background()) })
}
func newBunHarness(t *testing.T) (*Handler, sqlmock.Sqlmock) {
t.Helper()
sqlDB, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
return NewHandler(database.NewBunAdapter(bun.NewDB(sqlDB, pgdialect.New())), modelregistry.NewModelRegistry()), mock
}
func itemCtx(t *testing.T) context.Context {
t.Helper()
base, cancel := context.WithTimeout(context.Background(), 2*time.Second)
t.Cleanup(cancel)
ctx := WithSchema(base, "public")
ctx = WithEntity(ctx, "items")
ctx = WithTableName(ctx, "items")
return WithModel(ctx, delItem{})
}
func TestAfterReadErrorFailsRequestOnSecondTransaction(t *testing.T) {
resetTotalCache(t)
h, mock := newBunHarness(t)
h.Hooks().Register(AfterRead, func(*HookContext) error { return http.ErrAbortHandler })
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "a"))
mock.ExpectCommit()
mock.ExpectBegin()
mock.ExpectRollback()
rec := httptest.NewRecorder()
w, _ := common.WrapHTTPRequest(rec, httptest.NewRequest(http.MethodGet, "/", nil))
h.handleRead(itemCtx(t), w, "7", ExtendedRequestOptions{})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestBeforeReadErrorRollsBackWithoutQueries(t *testing.T) {
h, mock := newBunHarness(t)
h.Hooks().Register(BeforeRead, func(*HookContext) error { return http.ErrAbortHandler })
mock.ExpectBegin()
mock.ExpectRollback()
rec := httptest.NewRecorder()
w, _ := common.WrapHTTPRequest(rec, httptest.NewRequest(http.MethodGet, "/", nil))
h.handleRead(itemCtx(t), w, "7", ExtendedRequestOptions{})
if rec.Code == http.StatusOK {
t.Fatalf("a failing BeforeRead must not return data: %s", rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAfterUpdateErrorFailsRequest(t *testing.T) {
h, mock := newBunHarness(t)
h.Hooks().Register(AfterUpdate, func(*HookContext) error { return http.ErrAbortHandler })
cols := []string{"id", "name"}
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows(cols).AddRow(7, "a"))
mock.ExpectExec(`UPDATE`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows(cols).AddRow(7, "b"))
mock.ExpectRollback()
rec := httptest.NewRecorder()
w, _ := common.WrapHTTPRequest(rec, httptest.NewRequest(http.MethodPut, "/", nil))
h.handleUpdate(itemCtx(t), w, "7", nil, map[string]interface{}{"name": "b"}, ExtendedRequestOptions{})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d body %s", rec.Code, rec.Body)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
+78
View File
@@ -8,6 +8,8 @@ import (
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/bitechdev/ResolveSpec/pkg/common"
"github.com/bitechdev/ResolveSpec/pkg/common/adapters/database"
@@ -171,3 +173,79 @@ func TestOnTxBeginErrorRollsBackWithoutDetail(t *testing.T) {
t.Fatalf("unexpected response %+v", resp)
}
}
func TestCreateRunsHooksOnTwoTransactions(t *testing.T) {
// The bun adapter builds model-based inserts; the pgsql adapter does not.
h, mock, conn, hookCtx := newTxHarness(t)
sqlDB, bunMock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
h.db = database.NewBunAdapter(bun.NewDB(sqlDB, pgdialect.New()))
conn = NewConnection("c2", nil, h)
mock = bunMock
hookCtx.ID = ""
hookCtx.Data = map[string]interface{}{"id": 7, "name": "a"}
var begins []common.Database
var beforeTx, afterTx common.Database
h.Hooks().Register(OnTxBegin, func(c *HookContext) error { begins = append(begins, c.Tx); return nil })
h.Hooks().Register(BeforeCreate, func(c *HookContext) error { beforeTx = c.Tx; return nil })
h.Hooks().Register(AfterCreate, func(c *HookContext) error { afterTx = c.Tx; return nil })
mock.ExpectBegin()
mock.ExpectExec(`INSERT`).WillReturnResult(sqlmock.NewResult(7, 1))
mock.ExpectCommit()
mock.ExpectBegin()
mock.ExpectQuery(`SELECT`).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(7, "a"))
mock.ExpectCommit()
h.handleCreate(conn, &Message{ID: "m1"}, hookCtx)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if !response(t, conn).Success {
t.Fatal("expected success")
}
if len(begins) != 2 || begins[0] == begins[1] || beforeTx != begins[0] || afterTx != begins[1] {
t.Fatalf("BeforeCreate must run on tx 1 and AfterCreate on tx 2, begins=%d", len(begins))
}
}
func TestBeforeHookErrorRollsBackWithoutWriting(t *testing.T) {
h, mock, conn, hookCtx := newTxHarness(t)
hookCtx.Data = map[string]interface{}{"name": "b"}
h.Hooks().Register(BeforeUpdate, func(*HookContext) error { return errors.New("denied") })
mock.ExpectBegin()
mock.ExpectRollback()
h.handleUpdate(conn, &Message{ID: "m1"}, hookCtx)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if resp := response(t, conn); resp.Success || resp.Error == nil || resp.Error.Code != "hook_error" {
t.Fatalf("unexpected response %+v", resp)
}
}
func TestAfterDeleteErrorRollsBackDelete(t *testing.T) {
h, mock, conn, hookCtx := newTxHarness(t)
h.Hooks().Register(AfterDelete, func(*HookContext) error { return errors.New("audit failed") })
mock.ExpectBegin()
mock.ExpectExec(`DELETE FROM`).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectRollback()
h.handleDelete(conn, &Message{ID: "m1"}, hookCtx)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
if response(t, conn).Success {
t.Fatal("a failing AfterDelete must fail the request")
}
}