- HeartbeatArgs + HeartbeatWorker (logs slog.Info on each tick) - OrphanCleanupArgs + OrphanCleanupWorker (S3 delete then DB delete loop) - NewOrphanCleanupWorker constructor with pool + FileStorer injection - SlogErrorHandler implementing river.ErrorHandler (HandleError + HandlePanic) - fileQuerier interface for test injection without real DB - Unit tests: 7 tests pass (pure mock-based, no DB required) - go build ./... exits 0
115 lines
3.1 KiB
Go
115 lines
3.1 KiB
Go
package jobs
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"testing"
|
|
|
|
"backend/internal/db/sqlc"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/riverqueue/river"
|
|
"github.com/riverqueue/river/rivertype"
|
|
)
|
|
|
|
// mockQuerier implements fileQuerier for unit tests.
|
|
type mockQuerier struct {
|
|
orphans []sqlc.ListOrphanFilesRow
|
|
listErr error
|
|
deleteFileCalls []sqlc.DeleteTabloFileParams
|
|
deleteFileErr error
|
|
}
|
|
|
|
func (m *mockQuerier) ListOrphanFiles(_ context.Context) ([]sqlc.ListOrphanFilesRow, error) {
|
|
return m.orphans, m.listErr
|
|
}
|
|
|
|
func (m *mockQuerier) DeleteTabloFile(_ context.Context, arg sqlc.DeleteTabloFileParams) error {
|
|
m.deleteFileCalls = append(m.deleteFileCalls, arg)
|
|
return m.deleteFileErr
|
|
}
|
|
|
|
// mockFileStorer implements files.FileStorer for unit tests.
|
|
type mockFileStorer struct {
|
|
deletedKeys []string
|
|
deleteErr error
|
|
}
|
|
|
|
func (m *mockFileStorer) Delete(_ context.Context, key string) error {
|
|
m.deletedKeys = append(m.deletedKeys, key)
|
|
return m.deleteErr
|
|
}
|
|
|
|
func (m *mockFileStorer) Upload(_ context.Context, _ string, _ io.Reader) (string, int64, error) {
|
|
return "", 0, errors.New("not implemented")
|
|
}
|
|
|
|
func (m *mockFileStorer) PresignDownload(_ context.Context, _ string) (string, error) {
|
|
return "", errors.New("not implemented")
|
|
}
|
|
|
|
func makeWorkerJob() *river.Job[OrphanCleanupArgs] {
|
|
return &river.Job[OrphanCleanupArgs]{
|
|
JobRow: &rivertype.JobRow{ID: 1, Attempt: 1},
|
|
Args: OrphanCleanupArgs{},
|
|
}
|
|
}
|
|
|
|
func TestOrphanCleanupWorker_NoOrphans(t *testing.T) {
|
|
q := &mockQuerier{}
|
|
store := &mockFileStorer{}
|
|
w := &OrphanCleanupWorker{
|
|
store: store,
|
|
querier: q,
|
|
}
|
|
|
|
if err := w.Work(context.Background(), makeWorkerJob()); err != nil {
|
|
t.Fatalf("Work returned unexpected error: %v", err)
|
|
}
|
|
if len(store.deletedKeys) != 0 {
|
|
t.Errorf("expected 0 S3 deletes, got %d", len(store.deletedKeys))
|
|
}
|
|
if len(q.deleteFileCalls) != 0 {
|
|
t.Errorf("expected 0 DB deletes, got %d", len(q.deleteFileCalls))
|
|
}
|
|
}
|
|
|
|
func TestOrphanCleanupWorker_DeletesOrphan(t *testing.T) {
|
|
fileID := uuid.New()
|
|
tabloID := uuid.New()
|
|
q := &mockQuerier{
|
|
orphans: []sqlc.ListOrphanFilesRow{
|
|
{ID: fileID, TabloID: tabloID, S3Key: "orphan-key"},
|
|
},
|
|
}
|
|
store := &mockFileStorer{}
|
|
w := &OrphanCleanupWorker{
|
|
store: store,
|
|
querier: q,
|
|
}
|
|
|
|
if err := w.Work(context.Background(), makeWorkerJob()); err != nil {
|
|
t.Fatalf("Work returned unexpected error: %v", err)
|
|
}
|
|
|
|
// Assert S3 Delete was called with the correct key.
|
|
if len(store.deletedKeys) != 1 || store.deletedKeys[0] != "orphan-key" {
|
|
t.Errorf("expected S3 Delete called with %q, got %v", "orphan-key", store.deletedKeys)
|
|
}
|
|
|
|
// Assert DB DeleteTabloFile was called with matching ID and TabloID.
|
|
if len(q.deleteFileCalls) != 1 {
|
|
t.Fatalf("expected 1 DB delete call, got %d", len(q.deleteFileCalls))
|
|
}
|
|
got := q.deleteFileCalls[0]
|
|
if got.ID != fileID || got.TabloID != tabloID {
|
|
t.Errorf("DeleteTabloFile called with {%v, %v}, want {%v, %v}", got.ID, got.TabloID, fileID, tabloID)
|
|
}
|
|
}
|
|
|
|
func TestOrphanCleanupArgs_Kind(t *testing.T) {
|
|
if got := (OrphanCleanupArgs{}).Kind(); got != "orphan_file_cleanup" {
|
|
t.Errorf("OrphanCleanupArgs.Kind() = %q, want %q", got, "orphan_file_cleanup")
|
|
}
|
|
}
|