Files
go-kata/04-errors-semantics/19-defer-cleanup-chain/backup_test.go
T
2026-07-06 15:23:14 +03:00

441 lines
11 KiB
Go

package defer_cleanup_chain
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeRow struct {
id int
payload string
err error
}
type fakeRows struct {
data []fakeRow
nextIdx int
iterErr error
closeErr error
closed bool
}
func (r *fakeRows) Next() bool {
if r.nextIdx >= len(r.data) {
return false
}
r.nextIdx++
return true
}
func (r *fakeRows) Values() (int, string, error) {
if r.nextIdx == 0 || r.nextIdx > len(r.data) {
return 0, "", errors.New("values called without current row")
}
row := r.data[r.nextIdx-1]
if row.err != nil {
return 0, "", row.err
}
return row.id, row.payload, nil
}
func (r *fakeRows) Err() error {
return r.iterErr
}
func (r *fakeRows) Close() error {
r.closed = true
return r.closeErr
}
type fakeTx struct {
commitErr error
rollbackErr error
commitCalled int
rollbackCalled int
}
func (tx *fakeTx) Commit() error {
tx.commitCalled++
return tx.commitErr
}
func (tx *fakeTx) Rollback() error {
tx.rollbackCalled++
return tx.rollbackErr
}
type fakeDB struct {
tx Tx
rows Rows
beginErr error
queryErr error
closeErr error
beginCalled int
queryCalled int
closeCalled int
}
func (db *fakeDB) Begin(context.Context) (Tx, error) {
db.beginCalled++
if db.beginErr != nil {
return nil, db.beginErr
}
return db.tx, nil
}
func (db *fakeDB) QueryBackupRows(context.Context, Tx) (Rows, error) {
db.queryCalled++
if db.queryErr != nil {
return nil, db.queryErr
}
return db.rows, nil
}
func (db *fakeDB) Close() error {
db.closeCalled++
return db.closeErr
}
func TestBackupDatabase_Matrix(t *testing.T) {
type tc struct {
name string
connectErr error
openErr error
beginErr error
queryErr error
rows []fakeRow
iterErr error
rowsCloseErr error
commitErr error
rollbackErr error
dbCloseErr error
cancelCtx bool
openReadOnly bool
largePayloadRow bool
wantErr bool
wantErrContains []string
wantErrIs []error
wantCommitCalls int
wantRollbackCalls int
wantDBCloseCalls int
wantRowsClosed bool
wantOutput string
}
errConnectBoom := errors.New("connect boom")
errOpenBoom := errors.New("open boom")
errBeginBoom := errors.New("begin boom")
errQueryBoom := errors.New("query boom")
errRollbackBoom := errors.New("rollback boom")
errDBCloseBoom := errors.New("db close boom")
errValuesBoom := errors.New("values boom")
errIterateBoom := errors.New("iterate boom")
errCommitBoom := errors.New("commit boom")
tests := []tc{
{
name: "success commits and writes all rows",
rows: []fakeRow{{id: 1, payload: "alpha"}, {id: 2, payload: "beta"}},
wantCommitCalls: 1,
wantRollbackCalls: 0,
wantDBCloseCalls: 1,
wantRowsClosed: true,
wantOutput: "1,alpha\n2,beta\n",
},
{
name: "connect failure returns wrapped error",
connectErr: errConnectBoom,
wantErr: true,
wantErrContains: []string{"connect DB"},
wantErrIs: []error{errConnectBoom},
wantCommitCalls: 0,
wantRollbackCalls: 0,
wantDBCloseCalls: 0,
wantRowsClosed: false,
},
{
name: "begin failure keeps cleanup error with join",
beginErr: errBeginBoom,
dbCloseErr: errDBCloseBoom,
wantErr: true,
wantErrContains: []string{"begin transaction", "close DB connection"},
wantErrIs: []error{errBeginBoom, errDBCloseBoom},
wantCommitCalls: 0,
wantRollbackCalls: 0,
wantDBCloseCalls: 1,
wantRowsClosed: false,
},
{
name: "query failure triggers rollback and preserves cleanup failures",
queryErr: errQueryBoom,
rollbackErr: errRollbackBoom,
dbCloseErr: errDBCloseBoom,
wantErr: true,
wantErrContains: []string{"query backup rows", "rollback transaction", "close DB connection"},
wantErrIs: []error{errQueryBoom, errRollbackBoom, errDBCloseBoom},
wantCommitCalls: 0,
wantRollbackCalls: 1,
wantDBCloseCalls: 1,
wantRowsClosed: false,
},
{
name: "row values failure stops stream and rolls back",
rows: []fakeRow{{id: 1, payload: "ok"}, {err: errValuesBoom}},
wantErr: true,
wantErrContains: []string{"read row values"},
wantErrIs: []error{errValuesBoom},
wantCommitCalls: 0,
wantRollbackCalls: 1,
wantDBCloseCalls: 1,
wantRowsClosed: true,
},
{
name: "rows err after iteration rolls back",
rows: []fakeRow{{id: 1, payload: "ok"}},
iterErr: errIterateBoom,
wantErr: true,
wantErrContains: []string{"iterate backup rows"},
wantErrIs: []error{errIterateBoom},
wantCommitCalls: 0,
wantRollbackCalls: 1,
wantDBCloseCalls: 1,
wantRowsClosed: true,
},
{
name: "commit failure keeps rollback and close errors",
rows: []fakeRow{{id: 1, payload: "ok"}},
commitErr: errCommitBoom,
rollbackErr: errRollbackBoom,
dbCloseErr: errDBCloseBoom,
wantErr: true,
wantErrContains: []string{"commit transaction", "rollback transaction", "close DB connection"},
wantErrIs: []error{errCommitBoom, errRollbackBoom, errDBCloseBoom},
wantCommitCalls: 1,
wantRollbackCalls: 1,
wantDBCloseCalls: 1,
wantRowsClosed: true,
},
{
name: "context cancellation in loop returns cancellation error",
rows: []fakeRow{{id: 1, payload: "ok"}},
cancelCtx: true,
wantErr: true,
wantErrContains: []string{"backup canceled", context.Canceled.Error()},
wantErrIs: []error{context.Canceled},
wantCommitCalls: 0,
wantRollbackCalls: 1,
wantDBCloseCalls: 1,
wantRowsClosed: true,
},
{
name: "flush failure after successful work is returned",
rows: []fakeRow{{id: 1, payload: "ok"}},
openReadOnly: true,
wantErr: true,
wantErrContains: []string{"flush writer"},
wantCommitCalls: 1,
wantRollbackCalls: 0,
wantDBCloseCalls: 1,
wantRowsClosed: true,
},
{
name: "write failure in loop is returned",
rows: []fakeRow{{id: 1, payload: "ok"}},
openReadOnly: true,
largePayloadRow: true,
wantErr: true,
wantErrContains: []string{"write backup row"},
wantCommitCalls: 0,
wantRollbackCalls: 1,
wantDBCloseCalls: 1,
wantRowsClosed: true,
},
{
name: "open file failure short-circuits immediately",
openErr: errOpenBoom,
wantErr: true,
wantErrContains: []string{"open output file"},
wantErrIs: []error{errOpenBoom},
wantCommitCalls: 0,
wantRollbackCalls: 0,
wantDBCloseCalls: 0,
wantRowsClosed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
filename := filepath.Join(dir, "backup.csv")
if tt.openReadOnly {
require.NoError(t, os.WriteFile(filename, nil, 0o644))
}
rowsData := tt.rows
if tt.largePayloadRow {
rowsData = []fakeRow{{id: 1, payload: strings.Repeat("x", 9000)}}
}
tx := &fakeTx{commitErr: tt.commitErr, rollbackErr: tt.rollbackErr}
rows := &fakeRows{data: rowsData, iterErr: tt.iterErr, closeErr: tt.rowsCloseErr}
db := &fakeDB{
tx: tx,
rows: rows,
beginErr: tt.beginErr,
queryErr: tt.queryErr,
closeErr: tt.dbCloseErr,
}
connect := func(context.Context, string) (DB, error) {
if tt.connectErr != nil {
return nil, tt.connectErr
}
return db, nil
}
openFile := os.Create
if tt.openErr != nil {
openFile = func(string) (*os.File, error) {
return nil, tt.openErr
}
} else if tt.openReadOnly {
openFile = func(name string) (*os.File, error) {
return os.Open(name)
}
}
ctx := context.Background()
if tt.cancelCtx {
ctxWithCancel, cancel := context.WithCancel(context.Background())
cancel()
ctx = ctxWithCancel
}
err := backupDatabase(ctx, "db://test", filename, connect, openFile)
if tt.wantErr {
require.Error(t, err)
for _, substr := range tt.wantErrContains {
assert.ErrorContains(t, err, substr)
}
for _, targetErr := range tt.wantErrIs {
assert.ErrorIs(t, err, targetErr)
}
} else {
require.NoError(t, err)
}
assert.Equal(t, tt.wantCommitCalls, tx.commitCalled)
assert.Equal(t, tt.wantRollbackCalls, tx.rollbackCalled)
assert.Equal(t, tt.wantDBCloseCalls, db.closeCalled)
assert.Equal(t, tt.wantRowsClosed, rows.closed)
if tt.wantOutput != "" {
got, readErr := os.ReadFile(filename)
require.NoError(t, readErr)
assert.Equal(t, tt.wantOutput, string(got))
}
})
}
}
func TestBackupDatabase_SelfCorrection_BeginFails_ClosesFileAndDB(t *testing.T) {
errBegin := errors.New("begin failed")
dir := t.TempDir()
filename := filepath.Join(dir, "backup.csv")
var openedFile *os.File
openFile := func(name string) (*os.File, error) {
f, err := os.Create(name)
if err != nil {
return nil, err
}
openedFile = f
return f, nil
}
db := &fakeDB{beginErr: errBegin}
connect := func(context.Context, string) (DB, error) {
return db, nil
}
err := backupDatabase(context.Background(), "db://test", filename, connect, openFile)
require.Error(t, err)
assert.ErrorContains(t, err, "begin transaction")
assert.ErrorIs(t, err, errBegin)
assert.Equal(t, 1, db.closeCalled, "db close must run even when Begin fails")
require.NotNil(t, openedFile)
assert.ErrorIs(t, openedFile.Close(), os.ErrClosed, "file must already be closed by defer")
}
func TestBackupDatabase_SelfCorrection_CommitFailsAndFileCloseFails_BothErrorsReturned(t *testing.T) {
errCommit := errors.New("commit failed")
badFile := os.NewFile(^uintptr(0), "invalid-fd")
openFile := func(string) (*os.File, error) {
return badFile, nil
}
rows := &fakeRows{}
tx := &fakeTx{commitErr: errCommit}
db := &fakeDB{tx: tx, rows: rows}
connect := func(context.Context, string) (DB, error) {
return db, nil
}
err := backupDatabase(context.Background(), "db://test", "ignored.csv", connect, openFile)
require.Error(t, err)
assert.ErrorContains(t, err, "commit transaction")
assert.ErrorContains(t, err, "close output file")
assert.ErrorIs(t, err, errCommit)
assert.Equal(t, 1, tx.commitCalled)
assert.Equal(t, 1, tx.rollbackCalled, "rollback must run after failed commit")
}
func TestBackupDatabase_SelfCorrection_NoFDLeak_1000Runs(t *testing.T) {
before, err := countOpenFDs()
require.NoError(t, err)
for i := 0; i < 1000; i++ {
dir := t.TempDir()
filename := filepath.Join(dir, "backup.csv")
rows := &fakeRows{}
tx := &fakeTx{}
db := &fakeDB{tx: tx, rows: rows}
connect := func(context.Context, string) (DB, error) {
return db, nil
}
err := backupDatabase(context.Background(), "db://test", filename, connect, os.Create)
require.NoError(t, err)
}
runtime.GC()
after, err := countOpenFDs()
require.NoError(t, err)
// Allow a tiny delta for runtime/testing internals while asserting no growth trend.
assert.LessOrEqual(t, after, before+3, "open file descriptors should not grow after 1000 runs")
}
func countOpenFDs() (int, error) {
entries, err := os.ReadDir("/dev/fd")
if err != nil {
return 0, err
}
return len(entries), nil
}