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) } }