320 lines
9.1 KiB
Go
320 lines
9.1 KiB
Go
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 }
|