188 lines
5.0 KiB
Go
188 lines
5.0 KiB
Go
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) {
|
||
ctx := context.Background()
|
||
p := NewInMemoryReservationPolicy(
|
||
SKU("ABC-1"), LocationID("se"), int64(10),
|
||
SKU("ABC-1"), LocationID("no"), int64(5),
|
||
)
|
||
|
||
// Reserve available stock
|
||
err := p.Reserve(ctx, CartReserveRequest{
|
||
InventoryReference: &InventoryReference{SKU: "ABC-1", LocationID: "se"},
|
||
CartID: "cart-1",
|
||
Quantity: 3,
|
||
TTL: 15 * time.Minute,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Reserve: %v", err)
|
||
}
|
||
avail, _ := p.Available(ctx, "ABC-1", "se")
|
||
if avail != 7 {
|
||
t.Fatalf("after reserve 3 of 10, expected 7 available, got %d", avail)
|
||
}
|
||
|
||
// Release
|
||
if err := p.Release(ctx, "ABC-1", "se", "cart-1"); err != nil {
|
||
t.Fatalf("Release: %v", err)
|
||
}
|
||
avail, _ = p.Available(ctx, "ABC-1", "se")
|
||
if avail != 10 {
|
||
t.Fatalf("after release, expected 10 available, got %d", avail)
|
||
}
|
||
}
|
||
|
||
func TestInMemoryInsufficientStock(t *testing.T) {
|
||
ctx := context.Background()
|
||
p := NewInMemoryReservationPolicy(
|
||
SKU("ABC-1"), LocationID("se"), int64(2),
|
||
)
|
||
|
||
err := p.Reserve(ctx, CartReserveRequest{
|
||
InventoryReference: &InventoryReference{SKU: "ABC-1", LocationID: "se"},
|
||
CartID: "cart-1",
|
||
Quantity: 5,
|
||
TTL: 15 * time.Minute,
|
||
})
|
||
if err == nil {
|
||
t.Fatal("expected insufficient stock error, got nil")
|
||
}
|
||
}
|
||
|
||
func TestInMemoryConcurrentReserves(t *testing.T) {
|
||
ctx := context.Background()
|
||
p := NewInMemoryReservationPolicy(
|
||
SKU("ABC-1"), LocationID("se"), int64(100),
|
||
)
|
||
|
||
errs := make(chan error, 10)
|
||
for i := 0; i < 10; i++ {
|
||
go func(n int) {
|
||
errs <- p.Reserve(ctx, CartReserveRequest{
|
||
InventoryReference: &InventoryReference{SKU: "ABC-1", LocationID: "se"},
|
||
CartID: CartID(string(rune('0' + n))),
|
||
Quantity: 10,
|
||
TTL: 15 * time.Minute,
|
||
})
|
||
}(i)
|
||
}
|
||
|
||
for i := 0; i < 10; i++ {
|
||
if err := <-errs; err != nil {
|
||
t.Errorf("concurrent reserve %d: %v", i, err)
|
||
}
|
||
}
|
||
|
||
avail, _ := p.Available(ctx, "ABC-1", "se")
|
||
if avail != 0 {
|
||
t.Fatalf("after 10×10 reserves from 100, expected 0 available, got %d", avail)
|
||
}
|
||
}
|