package postgres import ( "context" "encoding/json" "errors" "testing" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "knowledge-governance/internal/jobs" ) func TestStoreCompleteRejectsLostLease(t *testing.T) { db := &recordingDB{row: scanBoolRow(false)} store := NewStore(db) now := time.Date(2026, time.September, 3, 10, 0, 0, 0, time.UTC) err := store.Complete(context.Background(), "job-1", "worker-1", now) if !errors.Is(err, ErrLeaseLost) { t.Fatalf("Complete() error = %v, want ErrLeaseLost", err) } if db.queryRowSQL != completeJobSQL { t.Fatal("Complete() did not use completeJobSQL") } if len(db.queryRowArgs) != 3 || db.queryRowArgs[0] != "job-1" || db.queryRowArgs[1] != "worker-1" || db.queryRowArgs[2] != now { t.Fatalf("Complete() args = %#v", db.queryRowArgs) } } func TestStoreCompleteAcceptsOwnedLease(t *testing.T) { db := &recordingDB{row: scanBoolRow(true)} store := NewStore(db) now := time.Date(2026, time.September, 3, 10, 0, 0, 0, time.UTC) if err := store.Complete(context.Background(), "job-1", "worker-1", now); err != nil { t.Fatalf("Complete() error = %v, want nil", err) } } // scanBoolRow answers the EXISTS projection of completeJobSQL. type scanBoolRow bool func (row scanBoolRow) Scan(targets ...any) error { if len(targets) != 1 { return errors.New("scanBoolRow expects one target") } out, ok := targets[0].(*bool) if !ok { return errors.New("scanBoolRow target is not *bool") } *out = bool(row) return nil } func TestStoreHeartbeatAndFailRejectLostLease(t *testing.T) { now := time.Date(2026, time.September, 3, 10, 0, 0, 0, time.UTC) tests := []struct { name string call func(*Store) error }{ { name: "heartbeat", call: func(store *Store) error { return store.Heartbeat(context.Background(), "job-1", "worker-1", now, 30*time.Second) }, }, { name: "fail", call: func(store *Store) error { return store.Fail(context.Background(), "job-1", "worker-1", "temporary", "", now, now.Add(time.Second)) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db := &recordingDB{commandTag: pgconn.NewCommandTag("UPDATE 0")} err := tt.call(NewStore(db)) if !errors.Is(err, ErrLeaseLost) { t.Fatalf("error = %v, want ErrLeaseLost", err) } }) } } func TestStoreClaimMapsLeasedJob(t *testing.T) { now := time.Date(2026, time.September, 3, 10, 0, 0, 0, time.UTC) leaseUntil := now.Add(30 * time.Second) db := &recordingDB{row: fakeJobRow{ id: "job-1", jobType: "demo", payload: []byte(`{"message":"hello"}`), state: string(jobs.StateRunning), priority: 7, availableAt: now, leaseOwner: "worker-1", leaseUntil: &leaseUntil, attempt: 2, maxAttempts: 5, idempotencyKey: "demo:1", createdAt: now.Add(-time.Minute), updatedAt: now, }} store := NewStore(db) job, err := store.Claim(context.Background(), "worker-1", []string{"demo"}, now, 30*time.Second) if err != nil { t.Fatalf("Claim() error = %v", err) } if job.ID != "job-1" || job.State != jobs.StateRunning || job.Attempt != 2 || job.LeaseOwner != "worker-1" { t.Fatalf("Claim() job = %#v", job) } if db.queryRowSQL != claimJobSQL { t.Fatal("Claim() did not use claimJobSQL") } if len(db.queryRowArgs) != 4 || db.queryRowArgs[0] != "worker-1" || db.queryRowArgs[1] != now || db.queryRowArgs[2] != leaseUntil { t.Fatalf("Claim() args = %#v", db.queryRowArgs) } } func TestStoreEnqueueUsesIdempotencyKey(t *testing.T) { now := time.Date(2026, time.September, 3, 11, 0, 0, 0, time.UTC) db := &recordingDB{row: fakeJobRow{ id: "job-4", jobType: "demo", payload: []byte(`{"message":"hello"}`), state: string(jobs.StatePending), availableAt: now, maxAttempts: 5, idempotencyKey: "demo:4", createdAt: now, updatedAt: now, }} store := NewStore(db) job, err := store.Enqueue(context.Background(), jobs.EnqueueRequest{ ID: "job-4", Type: "demo", Payload: []byte(`{"message":"hello"}`), Priority: 3, AvailableAt: now, MaxAttempts: 5, IdempotencyKey: "demo:4", }, now) if err != nil { t.Fatalf("Enqueue() error = %v", err) } if job.ID != "job-4" || job.IdempotencyKey != "demo:4" { t.Fatalf("Enqueue() job = %#v", job) } if db.queryRowSQL != enqueueJobSQL { t.Fatal("Enqueue() did not use enqueueJobSQL") } if len(db.queryRowArgs) != 8 || db.queryRowArgs[0] != "job-4" || db.queryRowArgs[6] != "demo:4" || db.queryRowArgs[7] != now { t.Fatalf("Enqueue() args = %#v", db.queryRowArgs) } } func TestStoreListReturnsNewestJobs(t *testing.T) { now := time.Date(2026, time.September, 3, 12, 0, 0, 0, time.UTC) rows := &fakeJobRows{items: []fakeJobRow{{ id: "job-5", jobType: "demo", payload: []byte(`{}`), state: string(jobs.StateCompleted), availableAt: now, maxAttempts: 5, idempotencyKey: "demo:5", createdAt: now, updatedAt: now, }}} db := &recordingDB{rows: rows} got, err := NewStore(db).List(context.Background(), 25) if err != nil { t.Fatalf("List() error = %v", err) } if len(got) != 1 || got[0].ID != "job-5" || got[0].State != jobs.StateCompleted { t.Fatalf("List() = %#v", got) } if db.querySQL != listJobsSQL || len(db.queryArgs) != 1 || db.queryArgs[0] != 25 { t.Fatalf("List() SQL/args = %q %#v", db.querySQL, db.queryArgs) } } func TestScanJobAcceptsNullLastError(t *testing.T) { job, err := scanJob(nullableLastErrorRow{}) if err != nil { t.Fatalf("scanJob() error = %v", err) } if job.LastError != "" { t.Fatalf("last error = %q, want empty", job.LastError) } } type recordingDB struct { commandTag pgconn.CommandTag execSQL string execSQLs []string execArgs []any row pgx.Row rows pgx.Rows queryRowSQL string queryRowArgs []any querySQL string queryArgs []any } func (d *recordingDB) Begin(context.Context) (pgx.Tx, error) { return nil, errors.New("transactions are not supported by the recording fake") } func (d *recordingDB) QueryRow(_ context.Context, sql string, args ...any) pgx.Row { d.queryRowSQL = sql d.queryRowArgs = args return d.row } func (d *recordingDB) Exec(_ context.Context, sql string, args ...any) (pgconn.CommandTag, error) { d.execSQL = sql d.execSQLs = append(d.execSQLs, sql) d.execArgs = args return d.commandTag, nil } func (d *recordingDB) Query(_ context.Context, sql string, args ...any) (pgx.Rows, error) { d.querySQL = sql d.queryArgs = args return d.rows, nil } type fakeJobRow struct { id string jobType string payload []byte state string priority int availableAt time.Time leaseOwner string leaseUntil *time.Time attempt int maxAttempts int lastError string idempotencyKey string createdAt time.Time updatedAt time.Time } func (r fakeJobRow) Scan(dest ...any) error { *dest[0].(*string) = r.id *dest[1].(*string) = r.jobType *dest[2].(*json.RawMessage) = r.payload *dest[3].(*string) = r.state *dest[4].(*int) = r.priority *dest[5].(*time.Time) = r.availableAt *dest[6].(**string) = &r.leaseOwner *dest[7].(**time.Time) = r.leaseUntil *dest[8].(*int) = r.attempt *dest[9].(*int) = r.maxAttempts *dest[10].(**string) = &r.lastError *dest[11].(*string) = r.idempotencyKey *dest[12].(*time.Time) = r.createdAt *dest[13].(*time.Time) = r.updatedAt return nil } type nullableLastErrorRow struct{} func (nullableLastErrorRow) Scan(dest ...any) error { lastError, ok := dest[10].(**string) if !ok { return errors.New("last_error destination is not nullable") } copyDest := append([]any(nil), dest...) placeholder := "" placeholderPtr := &placeholder copyDest[10] = &placeholderPtr if err := (fakeJobRow{ id: "job-null-error", jobType: "demo", payload: []byte(`{}`), state: "pending", availableAt: time.Date(2026, time.September, 3, 13, 0, 0, 0, time.UTC), maxAttempts: 5, idempotencyKey: "null-error", createdAt: time.Date(2026, time.September, 3, 13, 0, 0, 0, time.UTC), updatedAt: time.Date(2026, time.September, 3, 13, 0, 0, 0, time.UTC), }).Scan(copyDest...); err != nil { return err } *lastError = nil return nil } type fakeJobRows struct { items []fakeJobRow index int closed bool err error } func (r *fakeJobRows) Close() { r.closed = true } func (r *fakeJobRows) Err() error { return r.err } func (r *fakeJobRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} } func (r *fakeJobRows) FieldDescriptions() []pgconn.FieldDescription { return nil } func (r *fakeJobRows) Next() bool { if r.index >= len(r.items) { r.closed = true return false } r.index++ return true } func (r *fakeJobRows) Scan(dest ...any) error { return r.items[r.index-1].Scan(dest...) } func (r *fakeJobRows) Values() ([]any, error) { return nil, errors.New("not implemented") } func (r *fakeJobRows) RawValues() [][]byte { return nil } func (r *fakeJobRows) Conn() *pgx.Conn { return nil }