Reproduce nine-day tally retention at UTC boundaries
2 files changed,
+81
−25
internal/memoria/breaker_retention_test.go+74 −0internal/memoria/breaker_test.go+7 −25
diff --git a/internal/memoria/breaker_retention_test.go b/internal/memoria/breaker_retention_test.go| new file mode 100644 |
| index 0000000..ac07bd9 |
| --- /dev/null |
| +++ b/internal/memoria/breaker_retention_test.go |
| @@ -0,0 +1,74 @@ |
| +package memoria |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestTallyRetentionUsesUTCMidnight(t *testing.T) { |
| + midnight := time.Date(2026, 9, 8, 0, 0, 0, 0, time.UTC) |
| + for _, instant := range []time.Time{midnight.Add(-time.Nanosecond), midnight, midnight.Add(time.Nanosecond)} { |
| + for _, zone := range []*time.Location{time.UTC, time.FixedZone("west", -7*3600), time.FixedZone("east", 14*3600)} { |
| + t.Run(instant.Format(time.RFC3339Nano)+zone.String(), func(t *testing.T) { |
| + now := instant.In(zone) |
| + s := breakerStore(t) |
| + ctx := context.Background() |
| + today := time.Date(instant.Year(), instant.Month(), instant.Day(), 0, 0, 0, 0, time.UTC) |
| + for d := 0; d < 10; d++ { |
| + countOn(t, s, "Sheets", "ana@x", today.AddDate(0, 0, -d), 1, 0) |
| + } |
| + n, err := s.PruneTallies(ctx, now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n != 2 { |
| + t.Errorf("deleted=%d, want2", n) |
| + } |
| + kept := tallyDays(t, s) |
| + if len(kept) != 8 { |
| + t.Fatalf("retained=%d, want8", len(kept)) |
| + } |
| + for d := 0; d < 10; d++ { |
| + day := today.AddDate(0, 0, -d).Format("2006-01-02") |
| + if kept[day] != (d < 8) { |
| + t.Errorf("day offset%d retained=%v", d, kept[day]) |
| + } |
| + } |
| + n, err = s.PruneTallies(ctx, now) |
| + if err != nil || n != 0 { |
| + t.Fatalf("repeated prune=%d error=%v", n, err) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestPruningPreservesOldestBaselineThroughMidnight(t *testing.T) { |
| + ctx := context.Background() |
| + s := breakerStore(t) |
| + before := time.Date(2026, 9, 7, 23, 59, 59, 0, time.UTC) |
| + after := before.Add(time.Second) |
| + for _, person := range []string{"ana@x", "ben@x", "cleo@x"} { |
| + countOn(t, s, "Sheets", person, before.AddDate(0, 0, -7), 1, 0) |
| + countOn(t, s, "Sheets", person, before.AddDate(0, 0, -6), 1, 0) |
| + countOn(t, s, "Sheets", person, before, 0, 1) |
| + countOn(t, s, "Sheets", person, after, 0, 1) |
| + } |
| + for _, now := range []time.Time{before, after} { |
| + if _, err := s.PruneTallies(ctx, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + b, err := s.RefusalBreaker(ctx, "Sheets", now) |
| + if err != nil || b.Verdict != BreakerFiring || b.Askers != 3 { |
| + t.Fatalf("breaker=%+v error=%v", b, err) |
| + } |
| + oldest := now.UTC().AddDate(0, 0, -7).Format("2006-01-02") |
| + if !tallyDays(t, s)[oldest] { |
| + t.Fatal("oldest baseline day removed") |
| + } |
| + } |
| + if tallyDays(t, s)[before.AddDate(0, 0, -7).Format("2006-01-02")] { |
| + t.Fatal("expired baseline survived midnight") |
| + } |
| +} |
diff --git a/internal/memoria/breaker_test.go b/internal/memoria/breaker_test.go| index 339bbdd..1bcb798 100644 |
| --- a/internal/memoria/breaker_test.go |
| +++ b/internal/memoria/breaker_test.go |
| @@ -275,36 +275,18 @@ func TestTalliesAreRetainedEightDaysAndNoLonger(t *testing.T) { |
| if err != nil { |
| t.Fatalf("PruneTallies: %v", err) |
| } |
| - if n != 2 { |
| - t.Errorf("pruned %d rows, want 2 (day 9 and day 30)", n) |
| + if n != 3 { |
| + t.Errorf("pruned %d rows, want 3 (days 8, 9 and 30)", n) |
| } |
| got := tallyDays(t, s) |
| - if len(got) != 4 { |
| - t.Fatalf("%d rows left, want 4 (days 0, 1, 7 and 8)", len(got)) |
| + if len(got) != 3 { |
| + t.Fatalf("%d rows left, want 3 (days 0, 1 and 7)", len(got)) |
| } |
| - |
| - // **Day 7 is the oldest day anything reads** — the baseline runs |
| - // `[today-7, today-1]` — and day 8 is the spare. |
| - // |
| - // An earlier version of this said day 8 was "the oldest baseline day", |
| - // which is simply false, and a review caught the test explaining |
| - // itself with the wrong reason. §8.1 says "retained 8 days and no |
| - // longer" and in the same breath calls that "the window plus the |
| - // baseline plus one", which is 1 + 7 + 1 = 9 distinct days. The two |
| - // numbers in that sentence disagree; the code keeps nine, because the |
| - // spare day is what the "plus one" is for and losing it to a prune |
| - // that runs a minute before midnight would shorten a real baseline. |
| if !got[daysBefore(7).Format("2006-01-02")] { |
| - t.Error("the oldest baseline day was pruned; every window is then " + |
| - "computed against less history than the predicate assumes") |
| - } |
| - if !got[daysBefore(8).Format("2006-01-02")] { |
| - t.Error("the spare day was pruned; a prune running just before " + |
| - "midnight would take a day the next baseline needs") |
| + t.Error("the oldest baseline day was pruned") |
| } |
| - if got[daysBefore(9).Format("2006-01-02")] { |
| - t.Error("the day-9 row survived; this is the only per-person record " + |
| - "memoria keeps and the retention rule is what bounds it") |
| + if got[daysBefore(8).Format("2006-01-02")] { |
| + t.Error("day 8 survived beyond the eight-day retention window") |
| } |
| } |