mirror of
https://github.com/MedUnes/go-kata.git
synced 2026-07-28 10:40:14 +07:00
Create backup_test.go
This commit is contained in:
@@ -0,0 +1,440 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user