more platform

This commit is contained in:
mats committed 2026-06-29 12:35:32 +02:00
1 parent 8a71e32149
commit 2a66754f9e
27 files changed
+2435 -540

No files matched your search

+140
View File
@@ -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
}
+306
View File
@@ -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")
}
}