more cart
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user