diff --git a/backend/internal/cleanup/repository.go b/backend/internal/cleanup/repository.go index d9a3df2..4217c3e 100644 --- a/backend/internal/cleanup/repository.go +++ b/backend/internal/cleanup/repository.go @@ -7,9 +7,16 @@ import ( "github.com/google/uuid" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/pgconn" ) +// DBPool defines the interface for database operations +type DBPool interface { + Exec(ctx context.Context, sql string, arguments ...interface{}) (pgconn.CommandTag, error) + Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row +} + // Repository defines the cleanup repository interface type Repository interface { // Job operations @@ -100,11 +107,11 @@ type JobFilters struct { // repository implements the Repository interface type repository struct { - db *pgxpool.Pool + db DBPool } // NewRepository creates a new cleanup repository -func NewRepository(db *pgxpool.Pool) Repository { +func NewRepository(db DBPool) Repository { return &repository{db: db} } diff --git a/backend/internal/cleanup/repository_test.go b/backend/internal/cleanup/repository_test.go new file mode 100644 index 0000000..e1b3686 --- /dev/null +++ b/backend/internal/cleanup/repository_test.go @@ -0,0 +1,415 @@ +package cleanup + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/pashagolub/pgxmock/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_CreateJob(t *testing.T) { + mock, err := pgxmock.NewPool() + require.NoError(t, err) + defer mock.Close() + + repo := NewRepository(mock) + ctx := context.Background() + + t.Run("successful job creation", func(t *testing.T) { + job := &CleanupJob{ + JobID: uuid.New().String(), + Type: JobTypeOrphanedUploads, + Status: JobStatusPending, + LocationID: "loc-123", + Bucket: "test-bucket", + Prefix: "uploads/", + Action: ActionDelete, + UserID: "user-123", + BreakGlass: false, + } + + mock.ExpectExec("INSERT INTO cleanup_jobs"). + WithArgs( + job.JobID, + job.Type, + job.Status, + job.LocationID, + job.Bucket, + job.Prefix, + job.Action, + pgxmock.AnyArg(), // created_at + job.UserID, + job.BreakGlass, + job.Justification, + ). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + + err := repo.CreateJob(ctx, job) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("database error", func(t *testing.T) { + job := &CleanupJob{ + JobID: uuid.New().String(), + Type: JobTypeOrphanedUploads, + Status: JobStatusPending, + LocationID: "loc-123", + } + + mock.ExpectExec("INSERT INTO cleanup_jobs"). + WithArgs( + pgxmock.AnyArg(), // job_id + job.Type, + job.Status, + job.LocationID, + job.Bucket, + job.Prefix, + job.Action, + pgxmock.AnyArg(), // created_at + job.UserID, + job.BreakGlass, + job.Justification, + ). + WillReturnError(fmt.Errorf("tx is closed")) + + err := repo.CreateJob(ctx, job) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create cleanup job") + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRepository_GetJob(t *testing.T) { + mock, err := pgxmock.NewPool() + require.NoError(t, err) + defer mock.Close() + + repo := NewRepository(mock) + ctx := context.Background() + + t.Run("successful job retrieval", func(t *testing.T) { + jobID := uuid.New().String() + createdAt := time.Now() + + rows := pgxmock.NewRows([]string{ + "job_id", "type", "status", "location_id", "bucket", "prefix", + "action", "created_at", "started_at", "completed_at", "error_message", + "user_id", "break_glass", "justification", + }).AddRow( + jobID, JobTypeOrphanedUploads, JobStatusCompleted, "loc-123", + "test-bucket", "uploads/", ActionDelete, createdAt, nil, nil, "", + "user-123", false, "", + ) + + mock.ExpectQuery("SELECT (.+) FROM cleanup_jobs WHERE"). + WithArgs(jobID). + WillReturnRows(rows) + + job, err := repo.GetJob(ctx, jobID) + assert.NoError(t, err) + assert.NotNil(t, job) + assert.Equal(t, jobID, job.JobID) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("job not found", func(t *testing.T) { + jobID := uuid.New().String() + + mock.ExpectQuery("SELECT (.+) FROM cleanup_jobs WHERE"). + WithArgs(jobID). + WillReturnError(pgx.ErrNoRows) + + job, err := repo.GetJob(ctx, jobID) + assert.Error(t, err) + assert.Nil(t, job) + assert.Contains(t, err.Error(), "cleanup job not found") + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRepository_UpdateJob(t *testing.T) { + mock, err := pgxmock.NewPool() + require.NoError(t, err) + defer mock.Close() + + repo := NewRepository(mock) + ctx := context.Background() + + t.Run("successful job update", func(t *testing.T) { + now := time.Now() + job := &CleanupJob{ + JobID: uuid.New().String(), + Status: JobStatusCompleted, + StartedAt: &now, + CompletedAt: &now, + } + + mock.ExpectExec("UPDATE cleanup_jobs SET"). + WithArgs( + job.JobID, + job.Status, + job.StartedAt, + job.CompletedAt, + job.ErrorMessage, + ). + WillReturnResult(pgxmock.NewResult("UPDATE", 1)) + + err := repo.UpdateJob(ctx, job) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("job not found", func(t *testing.T) { + now := time.Now() + job := &CleanupJob{ + JobID: uuid.New().String(), + Status: JobStatusCompleted, + StartedAt: &now, + CompletedAt: &now, + } + + mock.ExpectExec("UPDATE cleanup_jobs SET"). + WithArgs( + job.JobID, + job.Status, + job.StartedAt, + job.CompletedAt, + job.ErrorMessage, + ). + WillReturnResult(pgxmock.NewResult("UPDATE", 0)) + + err := repo.UpdateJob(ctx, job) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cleanup job not found") + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRepository_ListJobs(t *testing.T) { + mock, err := pgxmock.NewPool() + require.NoError(t, err) + defer mock.Close() + + repo := NewRepository(mock) + ctx := context.Background() + + t.Run("list jobs with filters", func(t *testing.T) { + filters := &JobFilters{ + LocationID: "loc-123", + Status: JobStatusCompleted, + Limit: int32(10), + Offset: int32(0), + } + + createdAt := time.Now() + rows := pgxmock.NewRows([]string{ + "job_id", "type", "status", "location_id", "bucket", "prefix", + "action", "created_at", "started_at", "completed_at", "error_message", + "user_id", "break_glass", "justification", + }).AddRow( + "a7d3525a-d3ba-4713-aa0f-37eab0321f95", JobTypeOrphanedUploads, JobStatusCompleted, + "loc-123", "test-bucket", "uploads/", ActionDelete, createdAt, nil, nil, "", + "user-123", false, "", + ) + + countRows := pgxmock.NewRows([]string{"count"}).AddRow(int64(1)) + + // Count query expects both location_id and status + mock.ExpectQuery("SELECT COUNT"). + WithArgs("loc-123", JobStatusCompleted). + WillReturnRows(countRows) + + // List query expects location_id, status, limit (no offset since it's 0) + mock.ExpectQuery("SELECT (.+) FROM cleanup_jobs WHERE"). + WithArgs("loc-123", JobStatusCompleted, int32(10)). + WillReturnRows(rows) + + jobs, total, err := repo.ListJobs(ctx, filters) + assert.NoError(t, err) + assert.Len(t, jobs, 1) + assert.Equal(t, int64(1), total) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("empty result", func(t *testing.T) { + filters := &JobFilters{ + LocationID: "loc-123", + Limit: int32(10), + Offset: int32(0), + } + + rows := pgxmock.NewRows([]string{ + "job_id", "type", "status", "location_id", "bucket", "prefix", + "action", "created_at", "started_at", "completed_at", "error_message", + "user_id", "break_glass", "justification", + }) + + countRows := pgxmock.NewRows([]string{"count"}).AddRow(int64(0)) + + // Count query expects only location_id (no status filter) + mock.ExpectQuery("SELECT COUNT"). + WithArgs("loc-123"). + WillReturnRows(countRows) + + // List query expects location_id, limit (no offset since it's 0) + mock.ExpectQuery("SELECT (.+) FROM cleanup_jobs WHERE"). + WithArgs("loc-123", int32(10)). + WillReturnRows(rows) + + jobs, total, err := repo.ListJobs(ctx, filters) + assert.NoError(t, err) + assert.Empty(t, jobs) + assert.Equal(t, int64(0), total) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRepository_DeleteJob(t *testing.T) { + mock, err := pgxmock.NewPool() + require.NoError(t, err) + defer mock.Close() + + repo := NewRepository(mock) + ctx := context.Background() + + t.Run("successful job deletion", func(t *testing.T) { + jobID := uuid.New().String() + + mock.ExpectExec("DELETE FROM cleanup_jobs WHERE"). + WithArgs(jobID). + WillReturnResult(pgxmock.NewResult("DELETE", 1)) + + err := repo.DeleteJob(ctx, jobID) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("job not found", func(t *testing.T) { + jobID := uuid.New().String() + + mock.ExpectExec("DELETE FROM cleanup_jobs WHERE"). + WithArgs(jobID). + WillReturnResult(pgxmock.NewResult("DELETE", 0)) + + err := repo.DeleteJob(ctx, jobID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cleanup job not found") + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRepository_GetJobStats(t *testing.T) { + mock, err := pgxmock.NewPool() + require.NoError(t, err) + defer mock.Close() + + repo := NewRepository(mock) + ctx := context.Background() + + t.Run("successful stats retrieval", func(t *testing.T) { + jobID := uuid.New().String() + + rows := pgxmock.NewRows([]string{ + "items_scanned", "items_found", "items_cleaned", "items_failed", + "bytes_scanned", "bytes_freed", "bytes_failed", + }).AddRow(100, 50, 45, 5, 1024000, 512000, 10000) + + mock.ExpectQuery("SELECT (.+) FROM cleanup_job_stats"). + WithArgs(jobID). + WillReturnRows(rows) + + stats, err := repo.GetJobStats(ctx, jobID) + assert.NoError(t, err) + assert.NotNil(t, stats) + assert.Equal(t, int64(100), stats.ItemsScanned) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("stats not found", func(t *testing.T) { + jobID := uuid.New().String() + + mock.ExpectQuery("SELECT (.+) FROM cleanup_job_stats"). + WithArgs(jobID). + WillReturnError(pgx.ErrNoRows) + + // The implementation returns empty stats instead of error when not found + stats, err := repo.GetJobStats(ctx, jobID) + assert.NoError(t, err) + assert.NotNil(t, stats) + assert.Equal(t, int64(0), stats.ItemsScanned) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRepository_UpdateJobStats(t *testing.T) { + mock, err := pgxmock.NewPool() + require.NoError(t, err) + defer mock.Close() + + repo := NewRepository(mock) + ctx := context.Background() + + t.Run("successful stats update", func(t *testing.T) { + jobID := uuid.New().String() + stats := &CleanupJobStats{ + ItemsScanned: 100, + ItemsFound: 50, + ItemsCleaned: 45, + ItemsFailed: 5, + BytesScanned: 1024000, + BytesFreed: 512000, + BytesFailed: 10000, + } + + mock.ExpectExec("INSERT INTO cleanup_job_stats"). + WithArgs( + jobID, + stats.ItemsScanned, + stats.ItemsFound, + stats.ItemsCleaned, + stats.ItemsFailed, + stats.BytesScanned, + stats.BytesFreed, + stats.BytesFailed, + ). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + + err := repo.UpdateJobStats(ctx, jobID, stats) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("database error", func(t *testing.T) { + jobID := uuid.New().String() + stats := &CleanupJobStats{ + ItemsScanned: 100, + } + + mock.ExpectExec("INSERT INTO cleanup_job_stats"). + WithArgs( + jobID, + stats.ItemsScanned, + stats.ItemsFound, + stats.ItemsCleaned, + stats.ItemsFailed, + stats.BytesScanned, + stats.BytesFreed, + stats.BytesFailed, + ). + WillReturnError(fmt.Errorf("tx is closed")) + + err := repo.UpdateJobStats(ctx, jobID, stats) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to update job stats") + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +// Made with Bob diff --git a/go.mod b/go.mod index 071dcb9..e840542 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 - github.com/jackc/pgx/v5 v5.5.5 + github.com/jackc/pgx/v5 v5.7.4 github.com/lib/pq v1.10.9 github.com/minio/minio-go/v7 v7.0.98 github.com/nats-io/nats.go v1.48.0 @@ -97,8 +97,8 @@ require ( github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/hashicorp/vault/api v1.22.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect - github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/klauspost/crc32 v1.3.0 // indirect @@ -123,6 +123,7 @@ require ( github.com/nexus-rpc/sdk-go v0.5.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pashagolub/pgxmock/v4 v4.9.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/go.sum b/go.sum index 8eded55..d40be01 100644 --- a/go.sum +++ b/go.sum @@ -147,10 +147,16 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= @@ -219,6 +225,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pashagolub/pgxmock/v3 v3.4.0 h1:87VMr2q7m2+6VzXo4Tsp9kMklGlj6mMN19Hp/bp2Rwo= github.com/pashagolub/pgxmock/v3 v3.4.0/go.mod h1:FvCl7xqPbLLI3XohihJ1NzXnikjM3q/NWSixg4t9hrU= +github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj/gGl7I= +github.com/pashagolub/pgxmock/v4 v4.9.0/go.mod h1:9L57pC193h2aKRHVyiiE817avasIPZnPwPlw3JczWvM= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=