Files
go-cart-actor/pkg/cart/recovery/recovery_test.go
T
mats 26fd6dc970
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
more cart
2026-07-01 22:11:37 +02:00

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")
}
}