Files
platform/inventory/reservation_test.go
T
2026-06-29 12:35:32 +02:00

188 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}