@@ -549,3 +549,161 @@ func TestListDailyBalancesTieBreakByID(t *testing.T) {
549549 t .Fatalf ("intra-day tie-break did not pick the higher-id row: got balance=%v, want 9.0" , got [0 ].Balance )
550550 }
551551}
552+
553+ // TestPurgeOldData pins the retention purge: with a 400-day window it deletes
554+ // earnings and runtime_events rows older than the cutoff, KEEPS the most-recent
555+ // earnings row per platform even when that row is itself older than the cutoff
556+ // (so a long-stale service still shows its last balance), KEEPS rows newer than
557+ // the cutoff (even non-latest ones), returns the total number of rows deleted, is
558+ // idempotent, and does nothing when retention is disabled (retentionDays <= 0).
559+ func TestPurgeOldData (t * testing.T ) {
560+ s := openTestStore (t )
561+
562+ // Timestamps relative to now so the test is stable regardless of the calendar
563+ // date it runs on; all seeds are days apart, well clear of any intra-second
564+ // RFC3339Nano ordering subtlety.
565+ ts := func (daysAgo int ) string {
566+ return time .Now ().UTC ().AddDate (0 , 0 , - daysAgo ).Format (time .RFC3339Nano )
567+ }
568+
569+ // "grows" is an active platform; its latest row (ts(5), highest id) is recent.
570+ // "stale" has not reported in a long time: its latest row (ts(500), highest id)
571+ // is OLDER than the 400-day cutoff and must survive via the keep-latest clause.
572+ // Insertion order fixes the AUTOINCREMENT ids, so the last insert per platform
573+ // is that platform's latest (MAX(id)).
574+ seed := []EarningsRecord {
575+ {Platform : "grows" , Balance : 1.0 , Currency : "USD" , CreatedAt : ts (500 )}, // old, not latest -> DELETE
576+ {Platform : "grows" , Balance : 2.0 , Currency : "USD" , CreatedAt : ts (450 )}, // old, not latest -> DELETE
577+ {Platform : "grows" , Balance : 2.5 , Currency : "USD" , CreatedAt : ts (20 )}, // newer than cutoff, not latest -> KEEP
578+ {Platform : "grows" , Balance : 3.0 , Currency : "USD" , CreatedAt : ts (5 )}, // newest -> latest -> KEEP
579+ {Platform : "stale" , Balance : 10.0 , Currency : "USD" , CreatedAt : ts (600 )}, // old, not latest -> DELETE
580+ {Platform : "stale" , Balance : 20.0 , Currency : "USD" , CreatedAt : ts (500 )}, // old, but latest -> KEEP
581+ }
582+ for _ , r := range seed {
583+ if _ , err := s .SaveEarnings (r ); err != nil {
584+ t .Fatalf ("SaveEarnings(%+v) error: %v" , r , err )
585+ }
586+ }
587+
588+ // runtime_events: one clearly-old row inserted directly (RecordEvent always
589+ // stamps datetime('now'), so an old event can't be produced through it), plus a
590+ // fresh RecordEvent row that must survive.
591+ if _ , err := s .db .Exec (
592+ `INSERT INTO runtime_events(slug, event, detail, created_at) VALUES(?, ?, ?, ?)` ,
593+ "grows" , "old-event" , "" , "2020-01-01 00:00:00" ,
594+ ); err != nil {
595+ t .Fatalf ("insert old runtime_event error: %v" , err )
596+ }
597+ s .RecordEvent ("grows" , "recent-event" , "detail" )
598+
599+ countRows := func (table string ) int {
600+ t .Helper ()
601+ var n int
602+ // table is a test-local literal, never external input.
603+ if err := s .db .QueryRow (`SELECT COUNT(*) FROM ` + table ).Scan (& n ); err != nil {
604+ t .Fatalf ("count %s error: %v" , table , err )
605+ }
606+ return n
607+ }
608+
609+ // Preconditions: everything seeded is present.
610+ if got := countRows ("earnings" ); got != 6 {
611+ t .Fatalf ("expected 6 seeded earnings rows, got %d" , got )
612+ }
613+ if got := countRows ("runtime_events" ); got != 2 {
614+ t .Fatalf ("expected 2 seeded runtime_events rows, got %d" , got )
615+ }
616+
617+ // Retention disabled: a non-positive window deletes nothing and returns 0.
618+ for _ , disabled := range []int {0 , - 1 } {
619+ n , err := s .PurgeOldData (disabled )
620+ if err != nil {
621+ t .Fatalf ("PurgeOldData(%d) error: %v" , disabled , err )
622+ }
623+ if n != 0 {
624+ t .Fatalf ("PurgeOldData(%d) = %d, want 0 (retention disabled)" , disabled , n )
625+ }
626+ }
627+ if countRows ("earnings" ) != 6 || countRows ("runtime_events" ) != 2 {
628+ t .Fatalf ("a disabled purge must not delete anything: earnings=%d events=%d" ,
629+ countRows ("earnings" ), countRows ("runtime_events" ))
630+ }
631+
632+ // Real purge: 3 old earnings (grows ts500, grows ts450, stale ts600) + 1 old
633+ // event = 4 rows deleted.
634+ deleted , err := s .PurgeOldData (400 )
635+ if err != nil {
636+ t .Fatalf ("PurgeOldData(400) error: %v" , err )
637+ }
638+ if deleted != 4 {
639+ t .Fatalf ("PurgeOldData(400) = %d, want 4 (3 earnings + 1 event)" , deleted )
640+ }
641+
642+ // 3 earnings survive: grows ts20 (2.5), grows ts5 (3.0), stale ts500 (20.0).
643+ if got := countRows ("earnings" ); got != 3 {
644+ t .Fatalf ("expected 3 surviving earnings rows, got %d" , got )
645+ }
646+ if got := countRows ("runtime_events" ); got != 1 {
647+ t .Fatalf ("expected 1 surviving runtime_event, got %d" , got )
648+ }
649+
650+ // The exact surviving balances prove: a row newer than the cutoff survives even
651+ // when it is NOT the latest (grows 2.5), and the latest row survives even though
652+ // it is older than the cutoff (stale 20.0).
653+ survived := map [float64 ]bool {}
654+ rows , err := s .db .Query (`SELECT balance FROM earnings` )
655+ if err != nil {
656+ t .Fatalf ("query surviving balances error: %v" , err )
657+ }
658+ for rows .Next () {
659+ var b float64
660+ if err := rows .Scan (& b ); err != nil {
661+ rows .Close ()
662+ t .Fatalf ("scan balance error: %v" , err )
663+ }
664+ survived [b ] = true
665+ }
666+ rows .Close ()
667+ for _ , want := range []float64 {2.5 , 3.0 , 20.0 } {
668+ if ! survived [want ] {
669+ t .Fatalf ("expected balance %v to survive the purge, survivors=%v" , want , survived )
670+ }
671+ }
672+ for _ , gone := range []float64 {1.0 , 2.0 , 10.0 } {
673+ if survived [gone ] {
674+ t .Fatalf ("expected balance %v to be purged, survivors=%v" , gone , survived )
675+ }
676+ }
677+
678+ // The surviving runtime_event is the recent one, not the 2020 row.
679+ var lastEvent string
680+ if err := s .db .QueryRow (`SELECT event FROM runtime_events` ).Scan (& lastEvent ); err != nil {
681+ t .Fatalf ("query surviving event error: %v" , err )
682+ }
683+ if lastEvent != "recent-event" {
684+ t .Fatalf ("expected the recent runtime_event to survive, got %q" , lastEvent )
685+ }
686+
687+ // Both platforms still have a latest balance for the dashboard: grows -> 3.0
688+ // (recent), stale -> 20.0 (old but preserved).
689+ latest := map [string ]float64 {}
690+ for _ , rec := range s .ListLatestEarnings () {
691+ latest [rec .Platform ] = rec .Balance
692+ }
693+ if latest ["grows" ] != 3.0 {
694+ t .Fatalf ("grows latest balance = %v, want 3.0" , latest ["grows" ])
695+ }
696+ if latest ["stale" ] != 20.0 {
697+ t .Fatalf ("stale latest balance = %v, want 20.0 (old latest row preserved)" , latest ["stale" ])
698+ }
699+
700+ // Idempotent: a second purge finds nothing new to delete (the only rows still
701+ // older than the cutoff are the per-platform latest rows, which are protected).
702+ deleted , err = s .PurgeOldData (400 )
703+ if err != nil {
704+ t .Fatalf ("second PurgeOldData(400) error: %v" , err )
705+ }
706+ if deleted != 0 {
707+ t .Fatalf ("second PurgeOldData(400) = %d, want 0 (idempotent)" , deleted )
708+ }
709+ }
0 commit comments