179 lines
6.3 KiB
Go
179 lines
6.3 KiB
Go
package seeder_test
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/krow/krow-backend/go-api/internal/seeder"
|
||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||
)
|
||
|
||
// The shift collection is a rolling 56-day window, and its stable id encodes a
|
||
// shift's POSITION in that window rather than its identity. Seed on Friday and
|
||
// re-seed on Saturday and every id means a different date; one of them —
|
||
// Marcus's, because he works Mon–Fri and a Saturday window holds one fewer of
|
||
// his shifts — is no longer generated at all.
|
||
//
|
||
// Upsert cannot express that. These tests pin the behaviour that can: after any
|
||
// seed, shift_records holds exactly what BuildShifts produced for that instant,
|
||
// and nothing left over from a previous run.
|
||
|
||
func shiftCount(t *testing.T, h *testutil.Harness) int {
|
||
t.Helper()
|
||
var n int
|
||
if err := h.Pool.QueryRow(context.Background(),
|
||
"SELECT count(*) FROM shift_records").Scan(&n); err != nil {
|
||
t.Fatalf("count shift_records: %v", err)
|
||
}
|
||
return n
|
||
}
|
||
|
||
// TestReseedOnALaterDayLeavesNoStaleShifts is the regression itself: the
|
||
// database was seeded on one day, the frontend regenerates on the next, and the
|
||
// two must still describe the same collection.
|
||
func TestReseedOnALaterDayLeavesNoStaleShifts(t *testing.T) {
|
||
h := testutil.New(t)
|
||
fx := testutil.Fixture(t)
|
||
ctx := context.Background()
|
||
|
||
// A Friday, then the Saturday after it — the exact pair that orphaned
|
||
// shift_marcus_41 in the live database.
|
||
friday := time.Date(2026, 8, 21, 12, 0, 0, 0, time.Local)
|
||
saturday := friday.AddDate(0, 0, 1)
|
||
|
||
if _, err := seeder.New(h.Pool, fx, friday).Run(ctx); err != nil {
|
||
t.Fatalf("seed on the Friday: %v", err)
|
||
}
|
||
fridayRows := shiftCount(t, h)
|
||
if want := len(seeder.BuildShifts(friday)); fridayRows != want {
|
||
t.Fatalf("after the Friday seed: %d rows, generator produced %d", fridayRows, want)
|
||
}
|
||
|
||
result, err := seeder.New(h.Pool, fx, saturday).Run(ctx)
|
||
if err != nil {
|
||
t.Fatalf("re-seed on the Saturday: %v", err)
|
||
}
|
||
|
||
generated := seeder.BuildShifts(saturday)
|
||
if got := shiftCount(t, h); got != len(generated) {
|
||
t.Errorf("after re-seeding a day later: %d rows, but the generator produced %d "+
|
||
"— %d stale record(s) survived the re-seed", got, len(generated), got-len(generated))
|
||
}
|
||
if result.Pruned != fridayRows-len(generated) {
|
||
t.Errorf("Pruned = %d, want %d", result.Pruned, fridayRows-len(generated))
|
||
}
|
||
|
||
// Every surviving row must be one this run generated, holding this run's
|
||
// date for that id — not the previous run's.
|
||
want := map[string]string{}
|
||
for _, rec := range generated {
|
||
want[rec["id"].(string)] = rec["shift_date"].(string)
|
||
}
|
||
rows, err := h.Pool.Query(ctx, "SELECT legacy_id, shift_date::text FROM shift_records")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var legacy, date string
|
||
if err := rows.Scan(&legacy, &date); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
switch expected, generatedNow := want[legacy]; {
|
||
case !generatedNow:
|
||
t.Errorf("%s is in the database but was not generated for this instant", legacy)
|
||
case expected != date:
|
||
t.Errorf("%s holds %s, but this run generated it as %s", legacy, date, expected)
|
||
}
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// TestReseedIsConvergentAcrossAWeek walks a whole week, so the assertion does
|
||
// not depend on the one day pair that happened to expose the bug. Every day of
|
||
// the week changes the roster composition differently.
|
||
func TestReseedIsConvergentAcrossAWeek(t *testing.T) {
|
||
h := testutil.New(t)
|
||
fx := testutil.Fixture(t)
|
||
ctx := context.Background()
|
||
|
||
day := time.Date(2026, 8, 17, 9, 0, 0, 0, time.Local) // a Monday
|
||
for i := 0; i < 7; i++ {
|
||
now := day.AddDate(0, 0, i)
|
||
if _, err := seeder.New(h.Pool, fx, now).Run(ctx); err != nil {
|
||
t.Fatalf("seed on %s: %v", now.Weekday(), err)
|
||
}
|
||
want := len(seeder.BuildShifts(now))
|
||
if got := shiftCount(t, h); got != want {
|
||
t.Errorf("%s %s: %d rows, generator produced %d",
|
||
now.Weekday(), now.Format("2006-01-02"), got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Re-seeding the same instant twice must still change nothing — the prune must
|
||
// not delete rows it just wrote.
|
||
func TestReseedSameInstantPrunesNothing(t *testing.T) {
|
||
h := testutil.New(t)
|
||
fx := testutil.Fixture(t)
|
||
ctx := context.Background()
|
||
|
||
before := shiftCount(t, h)
|
||
result, err := seeder.New(h.Pool, fx, h.Now).Run(ctx)
|
||
if err != nil {
|
||
t.Fatalf("re-seed: %v", err)
|
||
}
|
||
if result.Pruned != 0 {
|
||
t.Errorf("re-seeding the same instant pruned %d record(s), want 0", result.Pruned)
|
||
}
|
||
if got := shiftCount(t, h); got != before {
|
||
t.Errorf("shift_records went from %d to %d rows on an identical re-seed", before, got)
|
||
}
|
||
}
|
||
|
||
// The prune is scoped to the organization being seeded. Another organization's
|
||
// shift records are none of its business — and once authentication lands, that
|
||
// is the difference between a re-seed and an incident.
|
||
func TestPruneIsScopedToTheSeededOrganization(t *testing.T) {
|
||
h := testutil.New(t)
|
||
fx := testutil.Fixture(t)
|
||
ctx := context.Background()
|
||
|
||
var otherOrg string
|
||
if err := h.Pool.QueryRow(ctx,
|
||
`INSERT INTO organizations (name, slug) VALUES ('Other', 'other-org') RETURNING id::text`).
|
||
Scan(&otherOrg); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// Copy one of this organization's shifts into the other one, with an id and
|
||
// legacy_id no generation will ever produce.
|
||
if _, err := h.Pool.Exec(ctx,
|
||
`INSERT INTO shift_records (org_id, legacy_id, staff_id, worker_name, worker_email,
|
||
job_posting_id, role, role_category, shift_date, scheduled_start, scheduled_end,
|
||
scheduled_hours, actual_start, actual_end, actual_hours, overtime_hours,
|
||
minutes_late, status, notes, created_date, updated_date)
|
||
SELECT $1::uuid, 'shift_other_99', staff_id, worker_name, worker_email,
|
||
job_posting_id, role, role_category, shift_date, scheduled_start, scheduled_end,
|
||
scheduled_hours, actual_start, actual_end, actual_hours, overtime_hours,
|
||
minutes_late, status, notes, created_date, updated_date
|
||
FROM shift_records LIMIT 1`, otherOrg); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if _, err := seeder.New(h.Pool, fx, h.Now.AddDate(0, 0, 1)).Run(ctx); err != nil {
|
||
t.Fatalf("re-seed: %v", err)
|
||
}
|
||
|
||
var survived int
|
||
if err := h.Pool.QueryRow(ctx,
|
||
"SELECT count(*) FROM shift_records WHERE org_id = $1::uuid", otherOrg).Scan(&survived); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if survived != 1 {
|
||
t.Errorf("the other organization's shift record was pruned: %d survived, want 1", survived)
|
||
}
|
||
}
|