package shipping import ( "context" "errors" "sync" "testing" ) // InMemoryProvider is a thread-safe in-memory implementation of Provider for // tests — no external API calls needed. Backed by a map from // "country|postcode" to available delivery option groups. type InMemoryProvider struct { mu sync.Mutex opts map[string][]DeliveryOptionGroup // key: "country|postcode" err error // injected error for testing failure paths } // NewInMemoryProvider creates a provider seeded with delivery option groups. // seed is a sequence of (country, postcode, []DeliveryOptionGroup) triples. func NewInMemoryProvider(seed ...any) *InMemoryProvider { p := &InMemoryProvider{ opts: make(map[string][]DeliveryOptionGroup), } for i := 0; i+2 < len(seed); i += 3 { country, ok := seed[i].(string) if !ok { continue } postcode, ok := seed[i+1].(string) if !ok { continue } groups, ok := seed[i+2].([]DeliveryOptionGroup) if !ok { continue } p.opts[providerKey(country, postcode)] = groups } return p } func providerKey(country, postcode string) string { return country + "|" + postcode } // SetError injects an error that GetShippingOptions returns on every call // (for testing failure paths). Nil clears the error. func (p *InMemoryProvider) SetError(err error) { p.mu.Lock() defer p.mu.Unlock() p.err = err } func (p *InMemoryProvider) GetShippingOptions(_ context.Context, req GetOptionsRequest) ([]DeliveryOptionGroup, error) { p.mu.Lock() defer p.mu.Unlock() if p.err != nil { return nil, p.err } groups, ok := p.opts[providerKey(req.Country, req.PostCode)] if !ok { return []DeliveryOptionGroup{}, nil } return groups, nil } // ── Tests ───────────────────────────────────────────────────────────────── func TestProviderReturnsOptions(t *testing.T) { ctx := context.Background() p := NewInMemoryProvider( "SE", "17173", []DeliveryOptionGroup{ { Type: "home", Name: "Home delivery", DefaultOption: DeliveryOption{ ID: "home-1", Name: "Home delivery", Description: "Delivered to your door", DeliveryDays: "2-3 working days", }, }, { Type: "pickup", Name: "Pickup point", DefaultOption: DeliveryOption{ ID: "pickup-1", Name: "PostNord Storgatan", Description: "Collect at PostNord Storgatan", DeliveryDays: "2-3 working days", PickupLocation: &PickupLocation{ ID: "loc-1", Name: "PostNord Storgatan", Address: Address{ Street: "Storgatan 9", City: "Solna", PostCode: "17173", Country: "SE", }, }, }, }, }, ) groups, err := p.GetShippingOptions(ctx, GetOptionsRequest{ Country: "SE", PostCode: "17173", Locale: "sv", }) if err != nil { t.Fatalf("GetShippingOptions: %v", err) } if len(groups) != 2 { t.Fatalf("expected 2 option groups, got %d", len(groups)) } // Verify home delivery option if groups[0].Type != "home" { t.Fatalf("first group: expected type=home, got %q", groups[0].Type) } if groups[0].DefaultOption.ID != "home-1" { t.Fatalf("home default option: expected ID=home-1, got %q", groups[0].DefaultOption.ID) } // Verify pickup option with location if groups[1].Type != "pickup" { t.Fatalf("second group: expected type=pickup, got %q", groups[1].Type) } loc := groups[1].DefaultOption.PickupLocation if loc == nil { t.Fatal("pickup option: expected a PickupLocation, got nil") } if loc.Address.City != "Solna" { t.Fatalf("pickup address city: expected Solna, got %q", loc.Address.City) } } func TestProviderUnsupportedAddress(t *testing.T) { ctx := context.Background() p := NewInMemoryProvider() // no seed data groups, err := p.GetShippingOptions(ctx, GetOptionsRequest{ Country: "NO", PostCode: "0150", }) if err != nil { t.Fatalf("expected nil error for unsupported address, got %v", err) } if groups == nil { t.Fatal("expected non-nil empty slice for unsupported address, got nil") } if len(groups) != 0 { t.Fatalf("expected 0 option groups for unsupported address, got %d", len(groups)) } } func TestProviderError(t *testing.T) { ctx := context.Background() p := NewInMemoryProvider( "SE", "17173", []DeliveryOptionGroup{ { Type: "home", Name: "Home delivery", DefaultOption: DeliveryOption{ ID: "home-1", Name: "Home delivery", }, }, }, ) injected := errors.New("api timeout") p.SetError(injected) _, err := p.GetShippingOptions(ctx, GetOptionsRequest{ Country: "SE", PostCode: "17173", }) if err == nil { t.Fatal("expected injected error, got nil") } if !errors.Is(err, injected) { t.Fatalf("expected injected error %v, got %v", injected, err) } } func TestProviderAdditionalOptions(t *testing.T) { ctx := context.Background() additionalPoints := make([]DeliveryOption, 2) additionalPoints[0] = DeliveryOption{ ID: "pickup-2", Name: "ICA Maxi Solna", PickupLocation: &PickupLocation{ ID: "loc-2", Name: "ICA Maxi Solna", Address: Address{ Street: "Solna Centrum 5", City: "Solna", PostCode: "17173", Country: "SE", }, }, } additionalPoints[1] = DeliveryOption{ ID: "pickup-3", Name: "Coop Solna", PickupLocation: &PickupLocation{ ID: "loc-3", Name: "Coop Solna", Address: Address{ Street: "Solna Torg 2", City: "Solna", PostCode: "17173", Country: "SE", }, }, } p := NewInMemoryProvider( "SE", "17173", []DeliveryOptionGroup{ { Type: "pickup", Name: "Pickup point", DefaultOption: DeliveryOption{ ID: "pickup-1", Name: "PostNord Storgatan", }, AdditionalOptions: additionalPoints, }, }, ) groups, err := p.GetShippingOptions(ctx, GetOptionsRequest{ Country: "SE", PostCode: "17173", }) if err != nil { t.Fatalf("GetShippingOptions: %v", err) } if len(groups) != 1 { t.Fatalf("expected 1 option group, got %d", len(groups)) } addl := groups[0].AdditionalOptions if len(addl) != 2 { t.Fatalf("expected 2 additional options, got %d", len(addl)) } if addl[0].ID != "pickup-2" { t.Fatalf("additional[0]: expected ID=pickup-2, got %q", addl[0].ID) } } func TestRoundTripEncodeDecode(t *testing.T) { original := []DeliveryOptionGroup{ { Type: "home", Name: "Home delivery", DefaultOption: DeliveryOption{ ID: "home-1", Name: "Home delivery", DeliveryDays: "2-3 working days", }, }, } encoded, err := Encode(original) if err != nil { t.Fatalf("Encode: %v", err) } decoded, err := Decode(encoded) if err != nil { t.Fatalf("Decode: %v", err) } if len(decoded) != 1 { t.Fatalf("round-trip: expected 1 group, got %d", len(decoded)) } if decoded[0].DefaultOption.ID != "home-1" { t.Fatalf("round-trip: expected ID=home-1, got %q", decoded[0].DefaultOption.ID) } } func TestNoOptionsSentinelNotReturnedBySingleProvider(t *testing.T) { // ErrNoOptions is for multi-provider orchestrators. A single provider // returns an empty slice for unsupported addresses. Verify that the // in-memory provider follows this contract. ctx := context.Background() p := NewInMemoryProvider() groups, err := p.GetShippingOptions(ctx, GetOptionsRequest{ Country: "FI", PostCode: "00100", }) if err != nil { t.Fatalf("expected nil for unsupported address, got %v", err) } if groups == nil { t.Fatal("expected non-nil empty slice, got nil") } }