package recovery import ( "context" "log" "os" "path/filepath" "strconv" "testing" "time" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/cart" messages "git.k6n.net/mats/go-cart-actor/proto/cart" "google.golang.org/protobuf/proto" ) // writeFixtureLog writes a synthetic `.events.log` to dir with the given // mutations, then pins the file's mtime AFTER Close so the canonical // DiskStorage.SaveLoop flush can't overwrite it on test machines that run // the inner save() during teardown. func writeFixtureLog(t *testing.T, dir string, id uint64, mtime time.Time, muts []proto.Message) { t.Helper() path := filepath.Join(dir, strconv.FormatUint(id, 10)+".events.log") reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) storage := actor.NewDiskStorage[cart.CartGrain](dir, reg) if err := storage.AppendMutations(id, muts...); err != nil { t.Fatalf("AppendMutations for fixture %d: %v", id, err) } // DiskStorage.Close runs save() which opens+appends the file; that // updates mtime. Pin AFTER Close so the test mtime wins. storage.Close() if err := os.Chtimes(path, mtime, mtime); err != nil { t.Fatalf("Chtimes %s: %v", path, err) } } // silenceLogs redirects the standard logger while the scanner runs so test // output stays readable. func silenceLogs(t *testing.T) { t.Helper() orig := log.Writer() prevFlags := log.Flags() log.SetOutput(devNull{}) log.SetFlags(0) t.Cleanup(func() { log.SetOutput(orig) log.SetFlags(prevFlags) }) } type devNull struct{} func (devNull) Write(p []byte) (int, error) { return len(p), nil } func TestScanner_FindsCandidateInWindow(t *testing.T) { silenceLogs(t) dir := t.TempDir() now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) storage := actor.NewDiskStorage[cart.CartGrain](dir, reg) // Cart that became eligible exactly in the middle of the window: // delay 1h, window 1h → eligible mtime ∈ [now-2h, now-1h]. mtime := now.Add(-90 * time.Minute) writeFixtureLog(t, dir, 42, mtime, []proto.Message{ &messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000, Sku: "A"}, &messages.SetRecoveryContact{Email: "abandoned@example.com"}, }) defer storage.Close() scanner := NewScanner(dir, storage) got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now) if err != nil { t.Fatalf("Scan: %v", err) } if len(got) != 1 { t.Fatalf("candidates = %d, want 1; full result=%+v", len(got), got) } c := got[0] if c.CartID != cart.CartId(42) { t.Errorf("CartID = %d, want 42", c.CartID) } if c.Email != "abandoned@example.com" { t.Errorf("Email = %q, want abandoned@example.com", c.Email) } if c.ItemCount != 1 { t.Errorf("ItemCount = %d, want 1", c.ItemCount) } if c.TotalIncVat != 1000 { t.Errorf("TotalIncVat = %d, want 1000 (AddItem price 1000)", c.TotalIncVat) } } func TestScanner_SkipsCartsOutsideWindow(t *testing.T) { silenceLogs(t) dir := t.TempDir() now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) storage := actor.NewDiskStorage[cart.CartGrain](dir, reg) // Too fresh (mtime = now-30m, inside the 1h "still active" cutoff). fresh := now.Add(-30 * time.Minute) writeFixtureLog(t, dir, 7, fresh, []proto.Message{ &messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000}, &messages.SetRecoveryContact{Email: "fresh@example.com"}, }) // Too old (mtime = now-25h, past the 2h window upper bound). old := now.Add(-25 * time.Hour) writeFixtureLog(t, dir, 8, old, []proto.Message{ &messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000}, &messages.SetRecoveryContact{Email: "old@example.com"}, }) // Inside window with NO contact (eligibility gate). mid := now.Add(-90 * time.Minute) writeFixtureLog(t, dir, 9, mid, []proto.Message{ &messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000}, // no SetRecoveryContact }) defer storage.Close() scanner := NewScanner(dir, storage) got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now) if err != nil { t.Fatalf("Scan: %v", err) } if len(got) != 0 { t.Fatalf("candidates = %d, want 0 (all filtered out); result=%+v", len(got), got) } } func TestScanner_FindsPushOnlyCandidate(t *testing.T) { silenceLogs(t) dir := t.TempDir() now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) storage := actor.NewDiskStorage[cart.CartGrain](dir, reg) mtime := now.Add(-75 * time.Minute) writeFixtureLog(t, dir, 11, mtime, []proto.Message{ &messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000}, &messages.SetRecoveryContact{ PushTokens: []*messages.PushToken{ {Platform: "fcm", Token: "tok-1"}, {Platform: "apns", Token: "tok-2"}, }, }, }) defer storage.Close() scanner := NewScanner(dir, storage) got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now) if err != nil { t.Fatalf("Scan: %v", err) } if len(got) != 1 { t.Fatalf("candidates = %d, want 1", len(got)) } if got[0].Email != "" { t.Errorf("Email = %q, want empty (push-only)", got[0].Email) } if len(got[0].PushTokens) != 2 { t.Fatalf("PushTokens = %d, want 2", len(got[0].PushTokens)) } } func TestScanner_IgnoresWishlistCarts(t *testing.T) { silenceLogs(t) dir := t.TempDir() now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) storage := actor.NewDiskStorage[cart.CartGrain](dir, reg) mtime := now.Add(-90 * time.Minute) writeFixtureLog(t, dir, 13, mtime, []proto.Message{ &messages.AddItem{ItemId: 1, Quantity: 1, Price: 1000}, &messages.SetRecoveryContact{Email: "wishlist@example.com"}, &messages.SetCartType{Type: messages.CartType_WISHLIST}, }) defer storage.Close() scanner := NewScanner(dir, storage) got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now) if err != nil { t.Fatalf("Scan: %v", err) } if len(got) != 0 { t.Fatalf("wishlist candidate leaked: got %+v", got) } } // TestScanner_TotalPostReplay verifies the bug fix: replay does not run the // post-mutation processor, so the scanner MUST call UpdateTotals to surface // an accurate TotalIncVat. func TestScanner_TotalPostReplay(t *testing.T) { silenceLogs(t) dir := t.TempDir() now := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) storage := actor.NewDiskStorage[cart.CartGrain](dir, reg) mtime := now.Add(-75 * time.Minute) writeFixtureLog(t, dir, 21, mtime, []proto.Message{ &messages.AddItem{ItemId: 1, Quantity: 2, Price: 500, Sku: "A"}, &messages.AddItem{ItemId: 2, Quantity: 1, Price: 1500, Sku: "B"}, &messages.SetRecoveryContact{Email: "totals@example.com"}, }) defer storage.Close() scanner := NewScanner(dir, storage) got, err := scanner.Scan(context.Background(), time.Hour, time.Hour, now) if err != nil { t.Fatalf("Scan: %v", err) } if len(got) != 1 { t.Fatalf("candidates = %d, want 1", len(got)) } // 2*500 + 1*1500 = 2500 inc-vat if got[0].TotalIncVat != 2500 { t.Errorf("TotalIncVat = %d, want 2500 (1000+1500 across two lines)", got[0].TotalIncVat) } if got[0].ItemCount != 2 { t.Errorf("ItemCount = %d, want 2", got[0].ItemCount) } } // TestIsValidEmail sanity for the optional helper exported for notifiers. func TestIsValidEmail(t *testing.T) { if !IsValidEmail("user@example.com") { t.Errorf("user@example.com should be valid") } if IsValidEmail("not-an-email") { t.Errorf("not-an-email should be invalid") } } // TestFileIDFor verifies the helper that parses .events.log filenames. func TestFileIDFor(t *testing.T) { if id, ok := fileIDFor("12345.events.log"); !ok || id != 12345 { t.Errorf("12345.events.log -> %d, %v; want 12345, true", id, ok) } if _, ok := fileIDFor("not-a-number.events.log"); ok { t.Errorf("non-numeric should fail") } if _, ok := fileIDFor("0.events.log"); ok { t.Errorf("id=0 should fail (no implicit zero-cart grain)") } if _, ok := fileIDFor("garbage.txt"); ok { t.Errorf("non-event-log suffix should fail") } }