more cart
This commit is contained in:
@@ -86,6 +86,25 @@ type Notice struct {
|
||||
Code *string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// PushToken is the Go-level mirror of cart_messages.PushToken: a generic,
|
||||
// provider-agnostic push-delivery target. Stored on the grain so the
|
||||
// abandoned-cart recovery scanner can hand it to a notifier without
|
||||
// re-decoding proto messages; the wire shape lives in proto/cart.proto.
|
||||
//
|
||||
// SECURITY: the raw Token persists verbatim to the per-pod event log
|
||||
// (CartGrain JSON-marshals PushTokens on every mutation). Anyone with read
|
||||
// access to the cart service's data dir — PVC snapshots, debug dumps, log
|
||||
// archival — can extract device handles. A future hardening pass should
|
||||
// either hash-on-write (hash.compareAtLaunch) for stateless matching or
|
||||
// encrypt-at-rest with a key the notifier owns. v0 keeps the seam: a real
|
||||
// notifier should treat the stored token as opaque, fetch any canonical
|
||||
// canonicalised handle from the input side (frontend/web SDK), and use
|
||||
// what's here only for routing.
|
||||
type PushToken struct {
|
||||
Platform string `json:"platform"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type CartPaymentStatus string
|
||||
|
||||
const (
|
||||
@@ -151,6 +170,14 @@ type CartGrain struct {
|
||||
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
||||
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
||||
|
||||
// Email is the cart's recovery contact address. Set explicitly via the
|
||||
// SetRecoveryContact mutation (independent of the linked profile's email),
|
||||
// so abandoned-cart recovery can fire before login. Empty string = no email.
|
||||
Email string `json:"email,omitempty"`
|
||||
// PushTokens holds every push delivery target the shopper attached to this
|
||||
// cart. Empty list = no push. PUT-style replaced by SetRecoveryContact.
|
||||
PushTokens []PushToken `json:"pushTokens,omitempty"`
|
||||
|
||||
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
|
||||
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
|
||||
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
|
||||
|
||||
@@ -88,6 +88,7 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist
|
||||
actor.NewMutation(SetLineItemCustomFields),
|
||||
actor.NewMutation(SubscriptionAdded),
|
||||
actor.NewMutation(context.SetCartType),
|
||||
actor.NewMutation(SetRecoveryContact),
|
||||
// actor.NewMutation(SubscriptionRemoved),
|
||||
)
|
||||
return reg
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
// SetRecoveryContact replaces the entire recovery-contact bundle on a cart in
|
||||
// a single event. PUT-style semantics — empty email and an empty token list
|
||||
// both persist as "no contact attached"; callers wanting to clear should send
|
||||
// an empty bundle.
|
||||
//
|
||||
// Trivial whitespace-only normalization is intentionally NOT done here: the
|
||||
// notifier layer owns validation (and the LoggingNotifier just logs). A future
|
||||
// real notifier does its own normalization before sending, so we don't want
|
||||
// to lose the original input.
|
||||
func SetRecoveryContact(grain *CartGrain, req *messages.SetRecoveryContact) error {
|
||||
if req == nil {
|
||||
return errors.New("SetRecoveryContact: nil payload")
|
||||
}
|
||||
grain.Email = req.Email
|
||||
if len(req.PushTokens) == 0 {
|
||||
grain.PushTokens = nil
|
||||
return nil
|
||||
}
|
||||
tokens := make([]PushToken, 0, len(req.PushTokens))
|
||||
dropped := 0
|
||||
for _, t := range req.PushTokens {
|
||||
if t == nil {
|
||||
// Defensive: a malformed wire request left a nil slot in the
|
||||
// repeated field. Skip rather than crash and surface at debug
|
||||
// level so a misbehaving client is visible without spamming.
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
tokens = append(tokens, PushToken{
|
||||
Platform: t.Platform,
|
||||
Token: t.Token,
|
||||
})
|
||||
}
|
||||
if dropped > 0 {
|
||||
log.Printf("SetRecoveryContact: dropped %d nil push-token entries (malformed wire)", dropped)
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
grain.PushTokens = nil
|
||||
return nil
|
||||
}
|
||||
grain.PushTokens = tokens
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
// TestSetRecoveryContact_ReplacesEntireBundle verifies PUT semantics: a
|
||||
// second call fully overwrites the first; calling with empty clears it.
|
||||
func TestSetRecoveryContact_ReplacesEntireBundle(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
// Initial set.
|
||||
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
|
||||
Email: "first@example.com",
|
||||
PushTokens: []*messages.PushToken{
|
||||
{Platform: "fcm", Token: "AAA"},
|
||||
{Platform: "apns", Token: "BBB"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("first set: %v", err)
|
||||
}
|
||||
if g.Email != "first@example.com" {
|
||||
t.Errorf("email = %q, want first@example.com", g.Email)
|
||||
}
|
||||
if len(g.PushTokens) != 2 {
|
||||
t.Fatalf("push tokens = %d, want 2", len(g.PushTokens))
|
||||
}
|
||||
if g.PushTokens[0].Platform != "fcm" || g.PushTokens[1].Platform != "apns" {
|
||||
t.Errorf("platforms = %+v, want [fcm apns]", g.PushTokens)
|
||||
}
|
||||
|
||||
// Replace with one push token.
|
||||
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
|
||||
Email: "second@example.com",
|
||||
PushTokens: []*messages.PushToken{{Platform: "webpush", Token: "CCC"}},
|
||||
}); err != nil {
|
||||
t.Fatalf("replace: %v", err)
|
||||
}
|
||||
if g.Email != "second@example.com" {
|
||||
t.Errorf("after replace email = %q, want second@example.com", g.Email)
|
||||
}
|
||||
if len(g.PushTokens) != 1 {
|
||||
t.Fatalf("after replace push tokens = %d, want 1", len(g.PushTokens))
|
||||
}
|
||||
if g.PushTokens[0].Platform != "webpush" {
|
||||
t.Errorf("after replace platform = %q, want webpush", g.PushTokens[0].Platform)
|
||||
}
|
||||
|
||||
// Clear.
|
||||
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{}); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if g.Email != "" || len(g.PushTokens) != 0 {
|
||||
t.Errorf("after clear: email=%q tokens=%d, want empty", g.Email, len(g.PushTokens))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetRecoveryContact_NilPayload verifies defensive nil-input behavior.
|
||||
// We document that nil returns an error (no silent swallow of bad requests).
|
||||
func TestSetRecoveryContact_NilPayload(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
res, err := reg.Apply(ctx, g, (*messages.SetRecoveryContact)(nil))
|
||||
// registry.Apply treats typed-nil proto messages as a no-op (see
|
||||
// mutation_registry.go's "Typed nil" handling). The behavior we want is
|
||||
// "no mutation applied to grain state" — either via no-op via the
|
||||
// typed-nil path or via an error in the result list. We assert that the
|
||||
// grain state was not modified.
|
||||
if g.Email != "" || len(g.PushTokens) != 0 {
|
||||
t.Errorf("nil-set should not modify grain: email=%q tokens=%d", g.Email, len(g.PushTokens))
|
||||
}
|
||||
_ = res
|
||||
_ = err
|
||||
}
|
||||
|
||||
// TestSetRecoveryContact_SkipsNilTokens verifies nil sub-message tokens are
|
||||
// dropped rather than crashing.
|
||||
func TestSetRecoveryContact_SkipsNilTokens(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(2, time.Now())
|
||||
ctx := context.Background()
|
||||
if _, err := reg.Apply(ctx, g, &messages.SetRecoveryContact{
|
||||
Email: "ok@example.com",
|
||||
PushTokens: []*messages.PushToken{
|
||||
nil,
|
||||
{Platform: "fcm", Token: "REAL"},
|
||||
nil,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set with nil tokens: %v", err)
|
||||
}
|
||||
if len(g.PushTokens) != 1 {
|
||||
t.Fatalf("push tokens = %d, want 1 (nil entries dropped)", len(g.PushTokens))
|
||||
}
|
||||
if g.PushTokens[0].Platform != "fcm" {
|
||||
t.Errorf("kept platform = %q, want fcm", g.PushTokens[0].Platform)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// LoggingNotifier is the v0 default: it writes one INFO line per candidate
|
||||
// and returns nil. It deliberately does NOT pretend to send anything.
|
||||
//
|
||||
// Future implementations (MailerSend, FCM, APNs) implement the same Notifier
|
||||
// interface; the scanner doesn't change. LoggingNotifier also serves as the
|
||||
// "dry-run" mode in production by leaving the seam unconfigured.
|
||||
type LoggingNotifier struct{}
|
||||
|
||||
// NewLoggingNotifier returns a Notifier that logs every candidate. The struct
|
||||
// is empty today; the constructor is kept for parity with future notifiers
|
||||
// that need credentials / config.
|
||||
func NewLoggingNotifier() *LoggingNotifier { return &LoggingNotifier{} }
|
||||
|
||||
// NotifyAbandoned logs the cart id, contact present, and a tiny summary.
|
||||
// Push tokens are summarized by platform only — never log the raw token, so
|
||||
// a misconfigured log aggregator can't leak device handles.
|
||||
func (LoggingNotifier) NotifyAbandoned(_ context.Context, c RecoveryCandidate) error {
|
||||
hasEmail := c.Email != ""
|
||||
hasPush := len(c.PushTokens) > 0
|
||||
channel := "none"
|
||||
switch {
|
||||
case hasEmail && hasPush:
|
||||
channel = "email+push"
|
||||
case hasEmail:
|
||||
channel = "email"
|
||||
case hasPush:
|
||||
channel = "push"
|
||||
}
|
||||
log.Printf(
|
||||
"recovery [dry-run]: cart=%d user=%q channel=%s items=%d total=%d currency=%s email=%q pushPlatforms=%v lastChangeUnix=%d",
|
||||
c.CartID,
|
||||
c.UserID,
|
||||
channel,
|
||||
c.ItemCount,
|
||||
c.TotalIncVat,
|
||||
c.Currency,
|
||||
c.Email,
|
||||
platformsOf(c.PushTokens),
|
||||
c.LastChangeUnix,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func platformsOf(tokens []cart.PushToken) []string {
|
||||
out := make([]string, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
out = append(out, t.Platform)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// captureLog writes log output to an in-memory buffer for inspection.
|
||||
func captureLog(t *testing.T) *bytes.Buffer {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
orig := log.Writer()
|
||||
flags := log.Flags()
|
||||
prefix := log.Prefix()
|
||||
log.SetOutput(buf)
|
||||
log.SetFlags(0)
|
||||
log.SetPrefix("")
|
||||
t.Cleanup(func() {
|
||||
log.SetOutput(orig)
|
||||
log.SetFlags(flags)
|
||||
log.SetPrefix(prefix)
|
||||
})
|
||||
return buf
|
||||
}
|
||||
|
||||
func TestLoggingNotifier_LogsPlatformsNotTokens(t *testing.T) {
|
||||
buf := captureLog(t)
|
||||
n := LoggingNotifier{}
|
||||
err := n.NotifyAbandoned(context.Background(), RecoveryCandidate{
|
||||
CartID: 1,
|
||||
Email: "doc@example.com",
|
||||
PushTokens: []cart.PushToken{{Platform: "fcm", Token: "SECRET-TOKEN-MUST-NOT-LEAK"}, {Platform: "apns", Token: "ALSO-SECRET-2"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NotifyAbandoned: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "doc@example.com") {
|
||||
t.Errorf("expected email in log: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "[fcm apns]") {
|
||||
t.Errorf("expected bracketed platform list in log: %s", out)
|
||||
}
|
||||
for _, leak := range []string{"SECRET-TOKEN-MUST-NOT-LEAK", "ALSO-SECRET-2"} {
|
||||
if strings.Contains(out, leak) {
|
||||
t.Errorf("token leaked into log: %s line=%s", leak, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggingNotifier_ClassifiesChannel(t *testing.T) {
|
||||
buf := captureLog(t)
|
||||
n := LoggingNotifier{}
|
||||
cases := []struct {
|
||||
name string
|
||||
cand RecoveryCandidate
|
||||
wantSub string
|
||||
}{
|
||||
{
|
||||
name: "email+push",
|
||||
cand: RecoveryCandidate{Email: "a@b.c", PushTokens: []cart.PushToken{{Platform: "fcm"}}},
|
||||
wantSub: "channel=email+push",
|
||||
},
|
||||
{
|
||||
name: "email only",
|
||||
cand: RecoveryCandidate{Email: "a@b.c"},
|
||||
wantSub: "channel=email",
|
||||
},
|
||||
{
|
||||
name: "push only",
|
||||
cand: RecoveryCandidate{PushTokens: []cart.PushToken{{Platform: "fcm"}}},
|
||||
wantSub: "channel=push",
|
||||
},
|
||||
{
|
||||
name: "no contact",
|
||||
cand: RecoveryCandidate{},
|
||||
wantSub: "channel=none",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
buf.Reset()
|
||||
if err := n.NotifyAbandoned(context.Background(), tc.cand); err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), tc.wantSub) {
|
||||
t.Errorf("%s: log missing %q; got: %s", tc.name, tc.wantSub, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Package recovery is the v0 abandoned-cart-recovery seam.
|
||||
//
|
||||
// The contract is intentionally narrow: a Scanner finds candidates whose last
|
||||
// mutation is within an "abandoned" window and that have BOTH an attachable
|
||||
// contact (email and/or push tokens) and items in the cart, then hands them
|
||||
// to a Notifier. The Notifier decides what to do — for now a logging shim.
|
||||
//
|
||||
// The seam intentionally avoids:
|
||||
//
|
||||
// - Email/push-token delivery (lives in the notifier impl).
|
||||
// - Idempotency tracking ("did we already notify?"). v0: scanner windows
|
||||
// are sized so a cart falls in once per detection threshold. Real
|
||||
// idempotency is the next pass — see suggest_followups / plan-commerce-
|
||||
// maturity C3.
|
||||
// - Cross-pod coordination. Each cart pod owns its own shard of event logs
|
||||
// (replicated read cache, no leader), so per-pod scanning is correct.
|
||||
// A future AMQP fan-out of `cart.recovery.candidate` would let a single
|
||||
// dedicated notifier consume instead — out of scope here.
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// RecoveryCandidate is the projectable "we'd remind this shopper" shape.
|
||||
// Fields surface just enough for a future email template / push payload; the
|
||||
// underlying grain carries everything else.
|
||||
type RecoveryCandidate struct {
|
||||
CartID cart.CartId
|
||||
UserID string // empty if the cart hasn't been linked to a profile
|
||||
Email string
|
||||
PushTokens []cart.PushToken
|
||||
ItemCount int
|
||||
TotalIncVat int64 // minor units (money.Cents == int64)
|
||||
Currency string
|
||||
// LastChange is best-effort: the scanner reads the event-log file's
|
||||
// modification time. A pending notifier shouldn't use it as the canonical
|
||||
// "when was this cart touched?" wall clock — see cart.LastChange.
|
||||
LastChangeUnix int64
|
||||
}
|
||||
|
||||
// Notifier is the seam a real email/push implementation plugs into. The
|
||||
// LoggingNotifier is the v0 default. The cart service ships the seam even
|
||||
// before a real send exists so call-sites and tracking can be wired once.
|
||||
type Notifier interface {
|
||||
NotifyAbandoned(ctx context.Context, c RecoveryCandidate) error
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
)
|
||||
|
||||
// Scanner finds RecoveryCandidates by scanning the per-pod event-log directory.
|
||||
//
|
||||
// The model:
|
||||
// - Each cart pod owns a shard of `<id>.events.log` files under `dir`.
|
||||
// - File mtime approximates "last successful mutation" cheaply — replay only
|
||||
// when something lands in the window.
|
||||
// - For each candidate file, replay the grain (a few kb, ms-scale) and build
|
||||
// a RecoveryCandidate iff it has items + a contact.
|
||||
//
|
||||
// Two important implementation details:
|
||||
//
|
||||
// 1. After LoadEvents we MUST call UpdateTotals(). Replaying event handlers
|
||||
// runs the per-mutation registered handlers (SetUserId, SetRecoveryContact,
|
||||
// AddItem, ...) but NOT the post-mutation processors — those run only on
|
||||
// the live mutation path (see cmd/cart/main.go::RegisterProcessor for the
|
||||
// "Totals and promotions" pipeline). Without this call, g.TotalPrice is
|
||||
// nil and TotalIncVat in the candidate is always 0.
|
||||
//
|
||||
// 2. Read-vs-write races: DiskStorage may be in the middle of appending while
|
||||
// Scan reads. The bufio scanner will fail JSON-unmarshal on a torn line and
|
||||
// we silently continue — LogEvents treats that as "skip", here we do too.
|
||||
// A persistent unreadable file is a separate problem runPurge handles.
|
||||
//
|
||||
// v0 deliberately does NOT track "already notified" within this scanner —
|
||||
// the window sizing plus caller conventions give roughly-once delivery.
|
||||
// Idempotency is the next pass.
|
||||
type Scanner struct {
|
||||
dir string
|
||||
storage *actor.DiskStorage[cart.CartGrain]
|
||||
}
|
||||
|
||||
// NewScanner returns a Scanner over dir (the same CART_DIR used by the cart
|
||||
// service). storage must share the same dir so LoadEvents reading paths
|
||||
// match the scanning paths.
|
||||
func NewScanner(dir string, storage *actor.DiskStorage[cart.CartGrain]) *Scanner {
|
||||
return &Scanner{dir: dir, storage: storage}
|
||||
}
|
||||
|
||||
// Scan returns candidates whose event-log mtime falls in
|
||||
// [now-delay-window, now-delay] AND whose grain has items AND (email OR
|
||||
// push tokens). The half-open window semantics match the caller's intent:
|
||||
//
|
||||
// "carts whose mtime is in this slice of the recent past"
|
||||
//
|
||||
// — younger than now-delay (still active) and older than now-delay-window
|
||||
// (never recovery-eligible yet).
|
||||
//
|
||||
// Empty slice + nil error means "no candidates this pass"; an error means the
|
||||
// pass itself failed and the caller should log + retry.
|
||||
func (s *Scanner) Scan(ctx context.Context, delay, window time.Duration, now time.Time) ([]RecoveryCandidate, error) {
|
||||
if s == nil || s.storage == nil || s.dir == "" {
|
||||
return nil, fmt.Errorf("recovery: scanner not configured")
|
||||
}
|
||||
if delay <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if window <= 0 {
|
||||
window = delay
|
||||
}
|
||||
|
||||
lower := now.Add(-(delay + window))
|
||||
upper := now.Add(-delay)
|
||||
entries, err := os.ReadDir(s.dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("recovery: read dir %s: %w", s.dir, err)
|
||||
}
|
||||
|
||||
out := make([]RecoveryCandidate, 0)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".events.log") {
|
||||
continue
|
||||
}
|
||||
id, ok := fileIDFor(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
info, infoErr := e.Info()
|
||||
if infoErr != nil {
|
||||
continue
|
||||
}
|
||||
mtime := info.ModTime()
|
||||
// Closed window:
|
||||
// mtime BEFORE lower → never reached the abandonment threshold yet.
|
||||
// mtime AFTER upper → still too fresh (caller is actively using it).
|
||||
if mtime.Before(lower) || mtime.After(upper) {
|
||||
continue
|
||||
}
|
||||
|
||||
grain := cart.NewCartGrain(id, mtime)
|
||||
if loadErr := s.storage.LoadEvents(ctx, id, grain); loadErr != nil {
|
||||
// Skip unreadable grains — they're purged separately by runPurge.
|
||||
continue
|
||||
}
|
||||
// Replay does NOT run the Totals-and-promotions processor (see the
|
||||
// package doc); recompute TotalPrice from the item list so the
|
||||
// candidate TotalIncVat matches what the live API would return.
|
||||
grain.UpdateTotals()
|
||||
if !eligible(grain) {
|
||||
continue
|
||||
}
|
||||
out = append(out, buildCandidate(id, grain, mtime))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// eligible mirrors "would we ever want to send a reminder?" — items present
|
||||
// and a contact available. The grain's own UpdateTotals has already been
|
||||
// run by every mutation's processor, so TotalPrice is reliable here.
|
||||
//
|
||||
// Wishlists / offers are excluded — a wishlist cart isn't forgotten, and an
|
||||
// offer cart has its own delivery semantics.
|
||||
func eligible(g *cart.CartGrain) bool {
|
||||
if g == nil || len(g.Items) == 0 {
|
||||
return false
|
||||
}
|
||||
switch g.Type {
|
||||
case cart_messages.CartType_WISHLIST, cart_messages.CartType_OFFER:
|
||||
return false
|
||||
}
|
||||
hasContact := strings.TrimSpace(g.Email) != "" || len(g.PushTokens) > 0
|
||||
return hasContact
|
||||
}
|
||||
|
||||
// buildCandidate projects the replayed grain into a RecoveryCandidate. Email
|
||||
// validation is intentionally lenient — we hand the value to the notifier
|
||||
// and the notifier decides what to send; rejecting "looks-invalid" addresses
|
||||
// here would just hide useful debugging signal from the dry-run logs.
|
||||
func buildCandidate(id uint64, g *cart.CartGrain, mtime time.Time) RecoveryCandidate {
|
||||
var total int64
|
||||
if g.TotalPrice != nil {
|
||||
// tax.Price.IncVat is int64-backed via money.Cents. Reading the field
|
||||
// directly keeps the recovery layer decoupled from platform/money
|
||||
// internals.
|
||||
total = int64(g.TotalPrice.IncVat)
|
||||
}
|
||||
tokens := make([]cart.PushToken, len(g.PushTokens))
|
||||
copy(tokens, g.PushTokens)
|
||||
return RecoveryCandidate{
|
||||
CartID: cart.CartId(id),
|
||||
UserID: g.UserId,
|
||||
Email: g.Email,
|
||||
PushTokens: tokens,
|
||||
ItemCount: len(g.Items),
|
||||
TotalIncVat: total,
|
||||
Currency: g.Currency,
|
||||
LastChangeUnix: mtime.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// IsValidEmail is exported as a convenience the notifier may use to decide
|
||||
// whether to actually send (MailerSend will reject invalid); the scanner
|
||||
// itself is permissive.
|
||||
func IsValidEmail(s string) bool {
|
||||
_, err := mail.ParseAddress(s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// fileIDFor is a small helper that extracts the grain id from an .events.log
|
||||
// filename. Returns 0/false if the name doesn't match the expected shape.
|
||||
func fileIDFor(name string) (uint64, bool) {
|
||||
base := strings.TrimSuffix(name, ".events.log")
|
||||
id, err := strconv.ParseUint(base, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
Reference in New Issue
Block a user