more platform
This commit is contained in:
1 parent
8a71e32149
commit
2a66754f9e
27 files changed
+2435
-540
No files matched your search
@@ -0,0 +1,140 @@
|
||||
// Package shipping is the platform's shared shipping seam: the cross-module
|
||||
// vocabulary for delivery options and the Provider interface. It holds only the
|
||||
// wire value types plus the interface, so the producer (a shipping adapter like
|
||||
// PostNord) and every consumer (checkout, cart, backoffice) agree on the types.
|
||||
// It imports only the stdlib — never an app module, never a transport library.
|
||||
//
|
||||
// Dependency rule: packages under platform/ import only third-party libraries
|
||||
// and other platform/ packages — never an application module. Application
|
||||
// modules depend downward onto platform. This is what keeps it a stable seam.
|
||||
//
|
||||
// See platform/inventory for the sibling pattern (value types + interface in
|
||||
// one stdlib-only package, test adapter in _test.go).
|
||||
package shipping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ── Provider interface ─────────────────────────────────────────────────────
|
||||
|
||||
// Provider is the cross-module seam for shipping delivery options. Checkout
|
||||
// calls GetShippingOptions to present available delivery methods to the
|
||||
// customer. Every implementation behind this interface is free to choose its
|
||||
// internal provider (PostNord, DHL, Bring, etc.) — the consumer never needs
|
||||
// to know.
|
||||
//
|
||||
// This is the policy-first seam: shipping selection policy lives here; the
|
||||
// provider-specific HTTP/REST details live behind it.
|
||||
type Provider interface {
|
||||
// GetShippingOptions returns available delivery options for a recipient
|
||||
// address. Returns an empty slice (not an error) when the provider has no
|
||||
// matching options. Returns ErrNoOptions when no provider in a multi-provider
|
||||
// setup could serve the address.
|
||||
GetShippingOptions(ctx context.Context, req GetOptionsRequest) ([]DeliveryOptionGroup, error)
|
||||
}
|
||||
|
||||
// ── Request types ─────────────────────────────────────────────────────────
|
||||
|
||||
// GetOptionsRequest is what a caller provides to get shipping options.
|
||||
type GetOptionsRequest struct {
|
||||
// Country is the ISO 3166-1 alpha-2 recipient country (e.g. "SE", "NO").
|
||||
Country string `json:"country"`
|
||||
// PostCode is the recipient postal code.
|
||||
PostCode string `json:"post_code"`
|
||||
// Locale is the IETF BCP 47 language tag for descriptions (e.g. "sv", "en").
|
||||
// Empty means the provider chooses a default.
|
||||
Locale string `json:"locale,omitempty"`
|
||||
// Items optionally describes the cart items for dimension/weight-based
|
||||
// filtering. May be nil or empty for providers that don't need item data.
|
||||
Items []OptionItem `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// OptionItem describes one line item for shipping calculations (weight, volume,
|
||||
// category-based restrictions).
|
||||
type OptionItem struct {
|
||||
SKU string `json:"sku"`
|
||||
Quantity int `json:"quantity"`
|
||||
// WeightGrams is the item weight in grams, if known.
|
||||
WeightGrams int `json:"weight_grams,omitempty"`
|
||||
}
|
||||
|
||||
// ── Response types ────────────────────────────────────────────────────────
|
||||
|
||||
// DeliveryOptionGroup groups shipping options by fulfilment method (e.g. home
|
||||
// delivery, pickup point, parcel locker). A provider returns one or more
|
||||
// groups; each group has one default option and optional alternatives (e.g.
|
||||
// different pickup point locations).
|
||||
type DeliveryOptionGroup struct {
|
||||
// Type is the fulfilment-method category: "home", "pickup", "locker",
|
||||
// "mailbox", etc.
|
||||
Type string `json:"type"`
|
||||
// Name is the display name for the group (e.g. "Home delivery",
|
||||
// "Pickup point"). Provider-localised to the request Locale.
|
||||
Name string `json:"name"`
|
||||
// DefaultOption is the recommended option within this group.
|
||||
DefaultOption DeliveryOption `json:"default_option"`
|
||||
// AdditionalOptions are alternatives within the same group (e.g. other
|
||||
// pickup point locations, different time slots).
|
||||
AdditionalOptions []DeliveryOption `json:"additional_options,omitempty"`
|
||||
}
|
||||
|
||||
// DeliveryOption is one selectable shipping method or service point.
|
||||
type DeliveryOption struct {
|
||||
// ID is the provider-scoped unique identifier for this option. Used when
|
||||
// the caller confirms the selection (e.g. SetCartDelivery).
|
||||
ID string `json:"id"`
|
||||
// Name is a short display name (e.g. "Home delivery", "PostNord Storgatan").
|
||||
Name string `json:"name"`
|
||||
// Description is a longer description for the checkout UI.
|
||||
Description string `json:"description,omitempty"`
|
||||
// DeliveryDays is a human-readable estimate (e.g. "2-3 working days",
|
||||
// "1-2 days"). Provider-localised.
|
||||
DeliveryDays string `json:"delivery_days,omitempty"`
|
||||
// PickupLocation is set when this option is a physical pickup point.
|
||||
PickupLocation *PickupLocation `json:"pickup_location,omitempty"`
|
||||
}
|
||||
|
||||
// PickupLocation describes a physical pickup point (store, locker, post office).
|
||||
type PickupLocation struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address Address `json:"address"`
|
||||
}
|
||||
|
||||
// Address is a simplified postal address for delivery or pickup.
|
||||
type Address struct {
|
||||
Street string `json:"street"`
|
||||
Street2 string `json:"street2,omitempty"`
|
||||
City string `json:"city"`
|
||||
PostCode string `json:"post_code"`
|
||||
Country string `json:"country"` // ISO 3166-1 alpha-2
|
||||
}
|
||||
|
||||
// ── Sentinels ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ErrNoOptions is returned by a multi-provider orchestrator when no registered
|
||||
// provider could serve the given address. A single provider returns an empty
|
||||
// slice (not this error) for an unsupported address.
|
||||
var ErrNoOptions = errors.New("shipping: no delivery options available")
|
||||
|
||||
// ErrProviderUnavailable is returned when a provider's API is unreachable or
|
||||
// returns a non-retryable error. The caller may fall back to another provider
|
||||
// or degrade gracefully.
|
||||
var ErrProviderUnavailable = errors.New("shipping: provider unavailable")
|
||||
|
||||
// ── Wire helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
// Encode serialises a DeliveryOptionGroup slice for caching or event payloads.
|
||||
func Encode(groups []DeliveryOptionGroup) ([]byte, error) {
|
||||
return json.Marshal(groups)
|
||||
}
|
||||
|
||||
// Decode parses a DeliveryOptionGroup slice from Encode output.
|
||||
func Decode(raw []byte) ([]DeliveryOptionGroup, error) {
|
||||
var out []DeliveryOptionGroup
|
||||
err := json.Unmarshal(raw, &out)
|
||||
return out, err
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user