diff --git a/internal/repository/repo_test.go b/internal/repository/repo_test.go new file mode 100644 index 0000000..552cc2e --- /dev/null +++ b/internal/repository/repo_test.go @@ -0,0 +1,302 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type unrelatedKey struct{} + +func newTransactor(t *testing.T) (*Transactor, sqlmock.Sqlmock) { + t.Helper() + + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + require.NoError(t, err) + + t.Cleanup(func() { + assert.NoError(t, mock.ExpectationsWereMet()) + _ = db.Close() + }) + + return NewTransactor(db), mock +} + +func TestNewRepo(t *testing.T) { + tr, _ := newTransactor(t) + + repo := NewRepo(tr) + + assert.Same(t, tr, repo.Transactor) +} + +func TestRepo_Conn(t *testing.T) { + t.Run("falls back to the pool without a transaction", func(t *testing.T) { + tr, mock := newTransactor(t) + repo := NewRepo(tr) + + mock.ExpectExec("DELETE FROM users WHERE id = $1"). + WithArgs("user-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + ctx := context.Background() + assert.Same(t, tr.db, repo.conn(ctx)) + + _, err := repo.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", "user-1") + require.NoError(t, err) + }) + + t.Run("returns the transaction carried by the context", func(t *testing.T) { + tr, mock := newTransactor(t) + repo := NewRepo(tr) + + mock.ExpectBegin() + mock.ExpectRollback() + + tx, err := tr.db.BeginTx(context.Background(), nil) + require.NoError(t, err) + + ctx := context.WithValue(context.Background(), txKey{}, tx) + assert.Same(t, tx, repo.conn(ctx)) + + require.NoError(t, tx.Rollback()) + }) + + t.Run("ignores values stored under other keys", func(t *testing.T) { + tr, _ := newTransactor(t) + repo := NewRepo(tr) + + ctx := context.WithValue(context.Background(), unrelatedKey{}, "irrelevant") + + assert.Same(t, tr.db, repo.conn(ctx)) + }) + + t.Run("ignores a value of the wrong type under txKey", func(t *testing.T) { + tr, _ := newTransactor(t) + repo := NewRepo(tr) + + ctx := context.WithValue(context.Background(), txKey{}, "not a transaction") + + assert.Same(t, tr.db, repo.conn(ctx)) + }) +} + +func TestRepo_WithinTxIsPromoted(t *testing.T) { + tr, mock := newTransactor(t) + repo := NewRepo(tr) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM users WHERE id = $1"). + WithArgs("user-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err := repo.WithinTx(context.Background(), func(ctx context.Context) error { + _, execErr := repo.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", "user-1") + return execErr + }) + + require.NoError(t, err) +} + +func TestRepo_SiblingReposShareTheTransaction(t *testing.T) { + tr, mock := newTransactor(t) + users := NewRepo(tr) + lists := NewRepo(tr) + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO lists (name) VALUES ($1)"). + WithArgs("Groceries"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec("DELETE FROM users WHERE id = $1"). + WithArgs("user-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err := tr.WithinTx(context.Background(), func(ctx context.Context) error { + assert.Same(t, lists.conn(ctx), users.conn(ctx)) + + if _, err := lists.conn(ctx).ExecContext(ctx, "INSERT INTO lists (name) VALUES ($1)", "Groceries"); err != nil { + return err + } + + _, err := users.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", "user-1") + return err + }) + + require.NoError(t, err) +} + +func TestTransactor_WithinTx(t *testing.T) { + t.Run("commits and routes statements through the tx", func(t *testing.T) { + tr, mock := newTransactor(t) + repo := NewRepo(tr) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM users WHERE id = $1"). + WithArgs("user-1"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err := tr.WithinTx(context.Background(), func(ctx context.Context) error { + tx, ok := ctx.Value(txKey{}).(*sql.Tx) + require.True(t, ok) + assert.Same(t, tx, repo.conn(ctx)) + + _, execErr := repo.conn(ctx).ExecContext(ctx, "DELETE FROM users WHERE id = $1", "user-1") + return execErr + }) + + require.NoError(t, err) + }) + + t.Run("callback error rolls back and propagates", func(t *testing.T) { + tr, mock := newTransactor(t) + + mock.ExpectBegin() + mock.ExpectRollback() + + fnErr := errors.New("business rule violated") + err := tr.WithinTx(context.Background(), func(ctx context.Context) error { + return fnErr + }) + + assert.ErrorIs(t, err, fnErr) + }) + + t.Run("begin error skips the callback", func(t *testing.T) { + tr, mock := newTransactor(t) + + beginErr := errors.New("too many connections") + mock.ExpectBegin().WillReturnError(beginErr) + + called := false + err := tr.WithinTx(context.Background(), func(ctx context.Context) error { + called = true + return nil + }) + + assert.ErrorIs(t, err, beginErr) + assert.False(t, called) + }) + + t.Run("commit error is returned", func(t *testing.T) { + tr, mock := newTransactor(t) + + commitErr := errors.New("could not serialize access") + mock.ExpectBegin() + mock.ExpectCommit().WillReturnError(commitErr) + + err := tr.WithinTx(context.Background(), func(ctx context.Context) error { + return nil + }) + + assert.ErrorIs(t, err, commitErr) + }) +} + +func TestTransactor_WithinTxNested(t *testing.T) { + t.Run("reuses the outer tx and releases the savepoint", func(t *testing.T) { + tr, mock := newTransactor(t) + + mock.ExpectBegin() + mock.ExpectExec("SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("RELEASE SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectCommit() + + var outerTx *sql.Tx + err := tr.WithinTx(context.Background(), func(ctx context.Context) error { + outerTx = ctx.Value(txKey{}).(*sql.Tx) + + return tr.WithinTx(ctx, func(ctx context.Context) error { + assert.Same(t, outerTx, ctx.Value(txKey{}).(*sql.Tx)) + return nil + }) + }) + + require.NoError(t, err) + }) + + t.Run("inner error rolls back to the savepoint", func(t *testing.T) { + tr, mock := newTransactor(t) + + mock.ExpectBegin() + mock.ExpectExec("SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("ROLLBACK TO SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectRollback() + + innerErr := errors.New("nested failure") + err := tr.WithinTx(context.Background(), func(ctx context.Context) error { + return tr.WithinTx(ctx, func(ctx context.Context) error { + return innerErr + }) + }) + + assert.ErrorIs(t, err, innerErr) + }) + + t.Run("savepoint creation error skips the callback", func(t *testing.T) { + tr, mock := newTransactor(t) + + spErr := errors.New("savepoint failed") + mock.ExpectBegin() + mock.ExpectExec("SAVEPOINT sp_1").WillReturnError(spErr) + mock.ExpectRollback() + + called := false + err := tr.WithinTx(context.Background(), func(ctx context.Context) error { + return tr.WithinTx(ctx, func(ctx context.Context) error { + called = true + return nil + }) + }) + + assert.ErrorIs(t, err, spErr) + assert.False(t, called) + }) + + t.Run("release error is returned", func(t *testing.T) { + tr, mock := newTransactor(t) + + releaseErr := errors.New("no such savepoint") + mock.ExpectBegin() + mock.ExpectExec("SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("RELEASE SAVEPOINT sp_1").WillReturnError(releaseErr) + mock.ExpectRollback() + + err := tr.WithinTx(context.Background(), func(ctx context.Context) error { + return tr.WithinTx(ctx, func(ctx context.Context) error { + return nil + }) + }) + + assert.ErrorIs(t, err, releaseErr) + }) + + t.Run("each savepoint gets its own name", func(t *testing.T) { + tr, mock := newTransactor(t) + + mock.ExpectBegin() + mock.ExpectExec("SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("RELEASE SAVEPOINT sp_1").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("SAVEPOINT sp_2").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("RELEASE SAVEPOINT sp_2").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectCommit() + + noop := func(ctx context.Context) error { return nil } + + err := tr.WithinTx(context.Background(), func(ctx context.Context) error { + if err := tr.WithinTx(ctx, noop); err != nil { + return err + } + return tr.WithinTx(ctx, noop) + }) + + require.NoError(t, err) + }) +}