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 }