258 lines
8.0 KiB
Go
258 lines
8.0 KiB
Go
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 `<id>.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 <id>.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")
|
|
}
|
|
}
|