Files
knowledge-governance/internal/platform/postgres/store_test.go
T

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 }