Files
krow_backend/go-api/internal/seeder/shifts_convergence_test.go
2026-08-24 13:06:29 +05:30

179 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}