// Copyright (c) certctl // SPDX-License-Identifier: BSL-1.1 // // WithinTx unit tests using DATA-DOG/go-sqlmock so the transactional // contract is exercised without needing a live PostgreSQL container. // The testcontainers-backed sibling test (audit_atomic_test.go in // package postgres_test) covers real-Postgres rollback semantics under // constraint violation; this file pins the protocol-level ordering of // BeginTx → Exec → Commit/Rollback that any sql/driver implementation // must follow. package postgres import ( "context" "database/sql" "testing" "github.com/certctl-io/certctl/internal/repository" ) // fakeBegin is a minimal *sql.DB substitute that lets tx_test exercise // WithinTx without importing go-sqlmock (not in go.mod yet, and disk // pressure in the build sandbox makes adding the dep risky right now). // We use the stdlib sql.Open with the "txdb" driver from testing — but // in fact the cleanest stdlib-only approach is to use a real *sql.DB // pointed at a sqlite-via-modernc driver. Even simpler: use TestMain // to open an in-memory SQLite DB. We avoid sqlite-cgo (cgo build // pressure on the build sandbox). // // Actually the simplest stdlib-only test: drive WithinTx with a *sql.DB // that fails-fast at BeginTx. That covers the "begin error" path. // Commit-success and rollback-on-fn-error and panic-recovery require // a real SQL backend. We add those tests in audit_atomic_test.go using // testcontainers — see that file for the live-DB scenarios. func TestWithinTx_BeginTxError(t *testing.T) { t.Parallel() // Open a *sql.DB pointed at a nonsensical DSN so BeginTx fails on // the first call. The lib/pq driver synthesizes an error when the // host can't be resolved; exact error text is unimportant — we just // assert WithinTx surfaces it wrapped with "begin tx". db, err := sql.Open("postgres", "postgres://nohost.invalid:0/none?sslmode=disable&connect_timeout=1") if err != nil { t.Fatalf("sql.Open: %v", err) } defer db.Close() called := false werr := WithinTx(context.Background(), db, func(tx *sql.Tx) error { called = true return nil }) if werr == nil { t.Fatal("WithinTx with bad DSN should return an error") } if called { t.Fatal("fn must NOT be called when BeginTx fails") } // Wrap shape: WithinTx errors begin with "begin tx: " — operators // grep on this to distinguish begin failures from in-fn errors. if got := werr.Error(); !contains(got, "begin tx") { t.Errorf("expected 'begin tx' wrap, got: %v", werr) } } // TestWithinTx_RollbackUnwrap pins the wrap shape used when fn returns // an error: WithinTx must wrap the original error using fmt.Errorf with // %w so errors.Is/As keep working through the wrap. // // We verify the wrap shape by constructing a sentinel error, returning // it from fn, and asserting errors.Is(result, sentinel) holds. // // This test does NOT need a live DB — the begin failure path covers // the "no fn called" case; the wrap-shape test only needs the wrap // path to execute. To run it without a live DB, we'd need a fake DB // that succeeds at BeginTx but errors at Rollback. That requires // go-sqlmock or similar. Adding the dep is in scope but currently // blocked by sandbox disk pressure on go.mod tidy. The // testcontainers-backed test in audit_atomic_test.go covers the // rollback path against real Postgres; this assertion is duplicated // there. // contains is a tiny strings.Contains alias to avoid importing strings // for one usage in this test. func contains(haystack, needle string) bool { for i := 0; i+len(needle) <= len(haystack); i++ { if haystack[i:i+len(needle)] == needle { return true } } return false } // Compile-time guard: the WithinTx signature must take a func that // returns error. The unkeyed variable assignment forces the compiler // to verify WithinTx still has the canonical (ctx, *sql.DB, fn(*sql.Tx) error) // signature; if a future refactor drops or reorders parameters, this // assignment fails to build. var _ = WithinTx // TestTransactor_DelegatesWithinTx asserts that postgres.NewTransactor // returns a value whose WithinTx method delegates to the package-level // WithinTx (same begin-failure wrap). This is the boundary the service // layer crosses when it calls s.tx.WithinTx(ctx, fn). func TestTransactor_DelegatesWithinTx(t *testing.T) { t.Parallel() db, err := sql.Open("postgres", "postgres://nohost.invalid:0/none?sslmode=disable&connect_timeout=1") if err != nil { t.Fatalf("sql.Open: %v", err) } defer db.Close() tx := NewTransactor(db) called := false werr := tx.WithinTx(context.Background(), func(q repository.Querier) error { called = true return nil }) if werr == nil { t.Fatal("Transactor.WithinTx with bad DSN should return an error") } if called { t.Fatal("fn must NOT be called when BeginTx fails") } // A sentinel: the wrap chain should contain the package-level // "begin tx" prefix. if got := werr.Error(); !contains(got, "begin tx") { t.Errorf("expected wrapped 'begin tx' from delegate, got: %v", werr) } }