diff --git a/inventory/memory.go b/inventory/memory.go new file mode 100644 index 0000000..e3f12cf --- /dev/null +++ b/inventory/memory.go @@ -0,0 +1,139 @@ +package inventory + +import ( + "context" + "sync" +) + +// InMemoryReservationPolicy is a thread-safe in-memory implementation of +// ReservationPolicy for tests — no Redis needed. Backed by a map from +// SKU+location to available quantity. Lives in a regular .go file (not _test.go) +// so other packages can use it as a test adapter without spinning up Redis — +// this is the "in-memory map as the test adapter" that the inventory-shape.md +// doc promises kills the cart-tests-need-Redis problem. +// +// Construct with NewInMemoryReservationPolicy(seed...) — seed is a flat list of +// (SKU, LocationID, qty) triples. Quantity starts at qty for that SKU+location. +// Reserves decrement *available* (held stock counts against availability) and +// Release removes a held reservation. Available returns qty minus the sum of all +// active held reservations, clamped to >= 0. +// +// Reserve is idempotent for the same cart (re-reserving a SKU+location adds to +// the existing hold rather than double-charging availability). +type InMemoryReservationPolicy struct { + mu sync.Mutex + qty map[string]int64 // key: "sku|location" → available quantity + held map[string]int64 // key: "sku|location|cartID" → reserved quantity +} + +// NewInMemoryReservationPolicy creates a policy seeded with the given +// sku+location → available quantity entries. Each entry is a triple of +// (sku, location, qty); pass in threes. +func NewInMemoryReservationPolicy(seed ...any) *InMemoryReservationPolicy { + p := &InMemoryReservationPolicy{ + qty: make(map[string]int64), + held: make(map[string]int64), + } + for i := 0; i+2 < len(seed); i += 3 { + sku, ok := seed[i].(SKU) + if !ok { + continue + } + loc, ok := seed[i+1].(LocationID) + if !ok { + continue + } + qty, ok := seed[i+2].(int64) + if !ok { + continue + } + p.qty[key(sku, loc)] = qty + } + return p +} + +// SetStock is a test-helper for seeding or replenishing a SKU+location's +// available quantity. Production callers should never need this — only the +// in-memory adapter exposes it. +func (p *InMemoryReservationPolicy) SetStock(sku SKU, loc LocationID, qty int64) { + p.mu.Lock() + defer p.mu.Unlock() + p.qty[key(sku, loc)] = qty +} + +// Reset clears all reservations and leaves available quantities as-is. Test +// helper only — useful between subtests that share a fixture. +func (p *InMemoryReservationPolicy) Reset() { + p.mu.Lock() + defer p.mu.Unlock() + p.held = make(map[string]int64) +} + +func key(sku SKU, loc LocationID) string { + return string(sku) + "|" + string(loc) +} + +func heldKey(sku SKU, loc LocationID, c CartID) string { + return string(sku) + "|" + string(loc) + "|" + string(c) +} + +// Reserve implements ReservationPolicy. +func (p *InMemoryReservationPolicy) Reserve(_ context.Context, req CartReserveRequest) error { + p.mu.Lock() + defer p.mu.Unlock() + + k := key(req.SKU, req.LocationID) + avail := p.qty[k] + hk := heldKey(req.SKU, req.LocationID, req.CartID) + if _, exists := p.held[hk]; exists { + // Already held — add to existing reservation, so we don't double-charge + // availability against the same cart. + avail += p.held[hk] + } + if avail < int64(req.Quantity) { + return ErrInsufficientStock + } + avail -= int64(req.Quantity) + p.held[hk] += int64(req.Quantity) + return nil +} + +// Release implements ReservationPolicy. Idempotent: releasing an +// already-released or never-held cart is a no-op. +func (p *InMemoryReservationPolicy) Release(_ context.Context, sku SKU, locationID LocationID, cartID CartID) error { + p.mu.Lock() + defer p.mu.Unlock() + + delete(p.held, heldKey(sku, locationID, cartID)) + return nil +} + +// Available implements ReservationPolicy. +func (p *InMemoryReservationPolicy) Available(_ context.Context, sku SKU, locationID LocationID) (int64, error) { + p.mu.Lock() + defer p.mu.Unlock() + + k := key(sku, locationID) + avail := p.qty[k] + // Subtract all held quantities for this sku+location. + prefix := k + "|" + for hk, held := range p.held { + if len(hk) > len(prefix) && hk[:len(prefix)] == prefix { + avail -= held + } + } + if avail < 0 { + avail = 0 + } + return avail, nil +} + +// ErrInsufficientStock is returned by Reserve when not enough stock is available. +// Distinct from go-redis-inventory's ErrInsufficientInventory by package design — +// the ReservationPolicy interface returns generic error; consumers that need to +// branch on this should errors.Is against the adapter's own error. +var ErrInsufficientStock = &reservationError{"insufficient stock"} + +type reservationError struct{ msg string } + +func (e *reservationError) Error() string { return e.msg } diff --git a/inventory/reservation_test.go b/inventory/reservation_test.go index 9b50eaf..8caf9d2 100644 --- a/inventory/reservation_test.go +++ b/inventory/reservation_test.go @@ -2,109 +2,10 @@ package inventory import ( "context" - "sync" "testing" "time" ) -// InMemoryReservationPolicy is a thread-safe in-memory implementation of -// ReservationPolicy for tests — no Redis needed. Backed by a map from -// SKU+location to available quantity. -type InMemoryReservationPolicy struct { - mu sync.Mutex - qty map[string]int64 // key: "sku|location" → available quantity - held map[string]int64 // key: "sku|location|cartID" → reserved quantity -} - -// NewInMemoryReservationPolicy creates a policy seeded with the given -// sku+location → available quantity entries. Each entry is a pair of -// (sku, location, qty); pass in threes. -func NewInMemoryReservationPolicy(seed ...any) *InMemoryReservationPolicy { - p := &InMemoryReservationPolicy{ - qty: make(map[string]int64), - held: make(map[string]int64), - } - for i := 0; i+2 < len(seed); i += 3 { - sku, ok := seed[i].(SKU) - if !ok { - continue - } - loc, ok := seed[i+1].(LocationID) - if !ok { - continue - } - qty, ok := seed[i+2].(int64) - if !ok { - continue - } - p.qty[key(sku, loc)] = qty - } - return p -} - -func key(sku SKU, loc LocationID) string { return string(sku) + "|" + string(loc) } -func heldKey(sku SKU, loc LocationID, c CartID) string { - return string(sku) + "|" + string(loc) + "|" + string(c) -} - -func (p *InMemoryReservationPolicy) Reserve(_ context.Context, req CartReserveRequest) error { - p.mu.Lock() - defer p.mu.Unlock() - - k := key(req.SKU, req.LocationID) - avail := p.qty[k] - hk := heldKey(req.SKU, req.LocationID, req.CartID) - if _, exists := p.held[hk]; exists { - // Already held — add to existing reservation - avail -= p.held[hk] - } - if avail < int64(req.Quantity) { - return ErrInsufficientStock - } - p.held[hk] += int64(req.Quantity) - return nil -} - -func (p *InMemoryReservationPolicy) Release(_ context.Context, sku SKU, locationID LocationID, cartID CartID) error { - p.mu.Lock() - defer p.mu.Unlock() - - delete(p.held, heldKey(sku, locationID, cartID)) - return nil -} - -func (p *InMemoryReservationPolicy) Available(_ context.Context, sku SKU, locationID LocationID) (int64, error) { - p.mu.Lock() - defer p.mu.Unlock() - - k := key(sku, locationID) - avail := p.qty[k] - // Subtract all held quantities for this sku+location - for hk, held := range p.held { - if len(hk) > len(k) && hk[:len(k)] == k && hk[len(k)] == '|' { - avail -= held - } - } - if avail < 0 { - avail = 0 - } - return avail, nil -} - -// Reset clears all reservations and resets available quantities. -func (p *InMemoryReservationPolicy) Reset() { - p.mu.Lock() - defer p.mu.Unlock() - p.held = make(map[string]int64) -} - -// ErrInsufficientStock is returned by Reserve when not enough stock is available. -var ErrInsufficientStock = &reservationError{"insufficient stock"} - -type reservationError struct{ msg string } - -func (e *reservationError) Error() string { return e.msg } - // ── Tests ──────────────────────────────────────────────────────────────── func TestInMemoryReserveAndRelease(t *testing.T) {