Compare commits
38
Commits
3df95eac90
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c150ac635c | ||
|
|
80a1e6d05e | ||
|
|
9f1eca07e9 | ||
|
|
e20793a6b3 | ||
|
|
781c1bd171 | ||
|
|
e2f835c01b | ||
|
|
eed53c96fb | ||
|
|
6180706140 | ||
|
|
31530be28f | ||
|
|
dddb5c699a | ||
|
|
8bb0a7a786 | ||
|
|
b6a67e709d | ||
|
|
cecf4abe76 | ||
|
|
f339b60351 | ||
|
|
2e2060da5c | ||
|
|
26fd6dc970 | ||
|
|
1c97a4bdb0 | ||
|
|
4a37cb479c | ||
|
|
83986e7a35 | ||
|
|
b1e99891e9 | ||
|
|
75db64ce75 | ||
|
|
9495c654fa | ||
|
|
7b657901ac | ||
|
|
2e0204a287 | ||
|
|
a469b5ea97 | ||
|
|
d7acfc422c | ||
|
|
98e2fd8d41 | ||
|
|
de47ef4f14 | ||
|
|
46be260fcc | ||
|
|
d711348863 | ||
|
|
a3eab70de8 | ||
|
|
e9e9700a09 | ||
|
|
63b0112cc7 | ||
|
|
aa8b2bdedc | ||
|
|
7db0d236c7 | ||
|
|
528c59bfd3 | ||
|
|
492f54ff45 | ||
|
|
ae66ee162f |
+2
-1
@@ -47,10 +47,11 @@ ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
|
||||
# because the renamed git.k6n.net/mats/* modules are not yet published.
|
||||
COPY --from=slaskfinder . ./slask-finder
|
||||
COPY --from=redisinventory . ./go-redis-inventory
|
||||
COPY --from=platform . ./platform
|
||||
|
||||
# This module's source (relay on .dockerignore to prune).
|
||||
COPY . ./go-cart-actor
|
||||
RUN printf 'go 1.26.2\n\nuse (\n\t.\n\t../slask-finder\n\t../go-redis-inventory\n)\n' > ./go-cart-actor/go.work
|
||||
RUN printf 'go 1.26.2\n\nuse (\n\t.\n\t../slask-finder\n\t../go-redis-inventory\n\t../platform\n)\n' > ./go-cart-actor/go.work
|
||||
WORKDIR /src/go-cart-actor
|
||||
|
||||
# (Optional) If you do NOT check in generated protobuf code, uncomment generation:
|
||||
|
||||
@@ -19,11 +19,12 @@
|
||||
|
||||
MODULE_PATH := git.k6n.net/mats/go-cart-actor
|
||||
PROTO_DIR := proto
|
||||
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto $(PROTO_DIR)/order.proto
|
||||
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto $(PROTO_DIR)/order.proto $(PROTO_DIR)/profile.proto
|
||||
CART_PROTO_DIR := $(PROTO_DIR)/cart
|
||||
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
|
||||
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
|
||||
ORDER_PROTO_DIR := $(PROTO_DIR)/order
|
||||
PROFILE_PROTO_DIR := $(PROTO_DIR)/profile
|
||||
|
||||
# Allow override: make PROTOC=/path/to/protoc
|
||||
PROTOC ?= protoc
|
||||
@@ -88,6 +89,9 @@ protogen: check_tools
|
||||
--go_out=./proto/order --go_opt=paths=source_relative \
|
||||
--go-grpc_out=./proto/order --go-grpc_opt=paths=source_relative \
|
||||
$(PROTO_DIR)/order.proto
|
||||
$(PROTOC) -I $(PROTO_DIR) \
|
||||
--go_out=./proto/profile --go_opt=paths=source_relative \
|
||||
$(PROTO_DIR)/profile.proto
|
||||
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
||||
|
||||
clean_proto:
|
||||
@@ -95,6 +99,8 @@ clean_proto:
|
||||
@rm -f $(PROTO_DIR)/cart/*_grpc.pb.go $(PROTO_DIR)/cart/*.pb.go
|
||||
@rm -f $(PROTO_DIR)/control/*_grpc.pb.go $(PROTO_DIR)/control/*.pb.go
|
||||
@rm -f $(PROTO_DIR)/checkout/*_grpc.pb.go $(PROTO_DIR)/checkout/*.pb.go
|
||||
@rm -f $(PROTO_DIR)/order/*_grpc.pb.go $(PROTO_DIR)/order/*.pb.go
|
||||
@rm -f $(PROTO_DIR)/profile/*.pb.go
|
||||
@echo "$(GREEN)Clean complete.$(RESET)"
|
||||
|
||||
verify_proto:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# go-cart-actor — agents map
|
||||
|
||||
The **commercial heart** of the platform. Orleans-style grain actors for cart,
|
||||
checkout, order, profile, inventory, plus a **UCP (Universal Commerce
|
||||
Protocol) HTTP adapter** at `internal/ucp/`. Deploys multiple binaries in
|
||||
parallel (one per concern) coordinated via k8s and an internal actor
|
||||
discovery/RPC layer. See `docs/checkout-order-handoff.md` for the role split
|
||||
with `go-order-manager`.
|
||||
|
||||
## Where to start
|
||||
|
||||
- `cmd/cart/main.go` — the one most commonly run in dev.
|
||||
- `pkg/actor/` — the grain framework (start with `grain.go`, then
|
||||
`simple_grain_pool.go` and `grain_pool.go`).
|
||||
- `pkg/cart/cart-grain.go` — a concrete grain, the cleanest example.
|
||||
- `internal/ucp/handler.go` and `ucp/{cart,checkout,order,profile}.go` —
|
||||
the protocol adapter over the grains.
|
||||
- `go-cart-actor/README.md`, `TODO.md`, `NEW_MUTATIONS_SPEC.md` — current
|
||||
intent and known gaps.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Grain model**: each domain entity (cart, order, checkout…) is a grain
|
||||
keyed by an opaque ID. Per-aggregate state machine lives in the grain's
|
||||
`state.go`; mutations are individual `mutation_*.go` files under
|
||||
`pkg/<domain>/` and registered through `mutation_registry.go`.
|
||||
- **RPC**: gRPC over [`proto/`](proto/) — `cart.proto`, `checkout.proto`,
|
||||
`order.proto`, `profile.proto`, `control_plane.proto`. Pod discovery via
|
||||
`pkg/discovery/` + `pkg/proxy/remotehost.go` (k8s host discovery in
|
||||
`cmd/<bin>/k8s-host-discovery.go`).
|
||||
- **Binaries** (`cmd/`):
|
||||
- `cart/`, `checkout/`, `order/`, `profile/`, `inventory/` — one each.
|
||||
- `backoffice/` — the admin UI host, plugged into `backoffice` via
|
||||
`pkg/backofficeadmin/`.
|
||||
- `ucp-artifacts/` — emits the protocol artefacts (signing, handler
|
||||
bindings).
|
||||
- **UCP**: `internal/ucp/` is the protocol adapter — it talks to the grains
|
||||
via RPC, not directly. Separate from `pkg/{cart,checkout,…}`.
|
||||
- **Cross-cutting**: `pkg/outbox/`, `pkg/idempotency/`, `pkg/flow/`,
|
||||
`pkg/promotions/`, `pkg/voucher/`, `pkg/telemetry/`.
|
||||
- **Auth**: `internal/customerauth/` — password hashing (`password.go`),
|
||||
rate limit, mail notifier, redis store, opaque customer session tokens.
|
||||
|
||||
## Important files by area
|
||||
|
||||
| Area | File(s) |
|
||||
| -------------------------- | ------------------------------------------------------ |
|
||||
| Grain framework | `pkg/actor/{grain,grain_pool,simple_grain_pool,state,disk_storage}.go` |
|
||||
| Grain RPC | `pkg/actor/{grpc_server,grpc_server_test}.go` |
|
||||
| Cart mutations | `pkg/cart/mutation_*.go` (+ `cart-grain.go`) |
|
||||
| Order mutations | `pkg/order/mutation_*.go` + `pkg/order/order-grain.go` |
|
||||
| Order outbox / payment | `pkg/order/{emit_created,payment,stripe,klarna}_*.go` |
|
||||
| Promotions / vouchers | `pkg/promotions/`, `pkg/voucher/` |
|
||||
| Idempotency | `pkg/idempotency/store.go` |
|
||||
| Outbox (event publish) | `pkg/outbox/outbox.go` |
|
||||
| UCP protocol adapter | `internal/ucp/{handler,cart,checkout,order,customer,profile}.go`, `signing.go` |
|
||||
| Backoffice admin | `pkg/backofficeadmin/{app,hub,fileserver,customer}.go` |
|
||||
| Customer auth | `internal/customerauth/{server,redis,password,tokens,mail_notifier,ratelimit}.go` |
|
||||
| Telemetry / OTel | `pkg/telemetry/{otel,logs}.go` |
|
||||
| k8s service discovery | `pkg/discovery/discovery.go`, `pkg/proxy/remotehost.go`, `cmd/<bin>/k8s-host-discovery.go` |
|
||||
| Tests integration scaffold | `cmd/cart/projection_*_test.go`, `pkg/promotions/apply_test.go` |
|
||||
|
||||
## Cross-project seams
|
||||
|
||||
- **`platform/`**: uses `platform/auth`, `platform/catalog`, `platform/event`,
|
||||
`platform/inventory`, `platform/order`, `platform/tax`, `platform/rabbit`.
|
||||
- **`go-order-manager`**: subscribes to order events off the RabbitMQ
|
||||
topology (`docs/checkout-order-handoff.md`).
|
||||
- **`go-shipping`**: pulled via HTTP from `cart` (the cart client).
|
||||
- **`slask-tracking`**: independent consumer of order events for analytics.
|
||||
- **`backoffice`** (commerce tag): exposes the `pkg/backofficeadmin/` host.
|
||||
|
||||
## Improvement suggestions
|
||||
|
||||
- **Mutation file explosion**: each behavior of a grain lives in its own
|
||||
`mutation_*.go`. This is shallow — the deletion test says removing one just
|
||||
moves the same code to its sibling file. Consider grouping related
|
||||
mutations per feature slice (e.g. one `cart_subscription.go` for
|
||||
add/remove/upsert subscriptiondetails), so the test surface and locality
|
||||
live together.
|
||||
- **`pkg/cart` vs `internal/ucp` vs `pkg/checkout`** — three layers touch the
|
||||
same conceptual "cart" entity. The UCP adapter depends on the grain, the
|
||||
grain depends on the protocol-specific mutations, and the checkout layer
|
||||
brokers a different view of the cart. Drop the conceptual split here:
|
||||
consider promoting one domain (`cart`) as the seam and treating the others
|
||||
as adapters.
|
||||
- **Inconsistent layout**: most binaries are at `cmd/<name>/main.go`, but
|
||||
`internal/ucp/`, `internal/customerauth/`, and `pkg/voucher/`, `pkg/outbox/`
|
||||
sit at different depths. A short module-layout doc would prevent the next
|
||||
contributor from guessing where new helpers go.
|
||||
- **`TODO.md` and `NEW_MUTATIONS_SPEC.md` live in the repo root.** Both belong
|
||||
in `docs/` so the README is the only thing newcomers hit first.
|
||||
- **Two proto variants**: `proto/checkout.proto` and
|
||||
`proto/checkout/checkout.proto`. Likely a historical artefact (see
|
||||
`proto/README` if it exists, otherwise grep `proto/` for `checkout` to see
|
||||
which is actually imported).
|
||||
- **Test coverage is uneven across `pkg/*`** — vendor-friendly modules like
|
||||
`pkg/promotions/` have `_test.go`, others like `pkg/voucher/` rely on
|
||||
`parser_test.go` only. Worth a pass to seed unit tests at each public
|
||||
function entry.
|
||||
- **`cookies.txt` at repo root** is not a build artefact and shouldn't be
|
||||
committed.
|
||||
+132
-19
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# UCP API test suite
|
||||
# Tests the Universal Commerce Protocol REST endpoints (cart, order) via curl.
|
||||
# Tests the Universal Commerce Protocol REST endpoints (cart, checkout, order)
|
||||
# via curl.
|
||||
#
|
||||
# Prerequisites:
|
||||
# make up-infra (infra compose up — order :8092, cart :8090)
|
||||
@@ -17,6 +18,7 @@
|
||||
# # Single service:
|
||||
# bash api-tests/test_ucp.sh --order
|
||||
# bash api-tests/test_ucp.sh --cart
|
||||
# bash api-tests/test_ucp.sh --checkout
|
||||
#
|
||||
# # Verify signatures (requires signing key mounted):
|
||||
# bash api-tests/test_ucp.sh --verify-sig
|
||||
@@ -36,16 +38,20 @@ elif [ "$MODE" = "--order" ]; then
|
||||
ORDER_URL="http://localhost:8092"
|
||||
elif [ "$MODE" = "--cart" ]; then
|
||||
CART_URL="http://localhost:8090"
|
||||
elif [ "$MODE" = "--checkout" ]; then
|
||||
CART_URL="http://localhost:8090"
|
||||
CHECKOUT_URL="http://localhost:8094"
|
||||
elif [ "$MODE" = "--verify-sig" ]; then
|
||||
# If signing is enabled (key mounted), you need the full stack or direct with
|
||||
# env var — try both common ports.
|
||||
ORDER_URL="${ORDER_URL:-http://localhost:8092}"
|
||||
CART_URL="${CART_URL:-http://localhost:8090}"
|
||||
CHECKOUT_URL="${CHECKOUT_URL:-http://localhost:8094}"
|
||||
else
|
||||
# Default: infra stack, direct to Docker ports
|
||||
CART_URL="${CART_URL:-http://localhost:8090}"
|
||||
ORDER_URL="${ORDER_URL:-http://localhost:8092}"
|
||||
CHECKOUT_URL="${CHECKOUT_URL:-}"
|
||||
CHECKOUT_URL="${CHECKOUT_URL:-http://localhost:8094}"
|
||||
fi
|
||||
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
@@ -210,20 +216,25 @@ if [ -n "${CART_URL:-}" ]; then
|
||||
echo " response: $(echo "$cart_body" | head -c 400)"
|
||||
fi
|
||||
|
||||
# ── Add item to cart ────────────────────────────────────────────────────────
|
||||
echo "--- 2.3 POST /ucp/v1/carts/{id}/items ---"
|
||||
add_resp=$(curl -s -w '\n%{http_code}' -X POST "$CART_URL/ucp/v1/carts/$CART_ID/items" \
|
||||
# ── Replace cart contents via UCP ───────────────────────────────────────────
|
||||
echo "--- 2.3 PUT /ucp/v1/carts/{id} ---"
|
||||
add_resp=$(curl -s -w '\n%{http_code}' -X PUT "$CART_URL/ucp/v1/carts/$CART_ID" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"country": "se",
|
||||
"items": [
|
||||
{
|
||||
"sku": "TEST-SKU-1",
|
||||
"name": "Test Product",
|
||||
"quantity": 1,
|
||||
"unitPrice": 19900,
|
||||
"taxRate": 2500
|
||||
"price": 19900,
|
||||
"taxRate": 25
|
||||
}
|
||||
]
|
||||
}')
|
||||
add_code=$(echo "$add_resp" | tail -1)
|
||||
add_body=$(echo "$add_resp" | sed '$d')
|
||||
assert_status "Add item" 201 "$add_code" "$add_body"
|
||||
assert_status "Update cart items" 200 "$add_code" "$add_body"
|
||||
|
||||
# ── Get cart ────────────────────────────────────────────────────────────────
|
||||
echo "--- 2.4 GET /ucp/v1/carts/{id} ---"
|
||||
@@ -231,21 +242,24 @@ if [ -n "${CART_URL:-}" ]; then
|
||||
get_cart_code=$(echo "$get_cart_resp" | tail -1)
|
||||
get_cart_body=$(echo "$get_cart_resp" | sed '$d')
|
||||
assert_status "Get cart" 200 "$get_cart_code" "$get_cart_body"
|
||||
echo " total: $(echo "$get_cart_body" | sed -n 's/.*"total"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')"
|
||||
echo " totalIncVat: $(echo "$get_cart_body" | sed -n 's/.*"totalIncVat"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')"
|
||||
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# SECTION 3: Full flow — cart → checkout → order (via edge/nginx only)
|
||||
# SECTION 3: Checkout service tests
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
if [ "$MODE" = "--edge" ] && [ -n "${CHECKOUT_URL:-}" ]; then
|
||||
header "3. Full Flow (via nginx)"
|
||||
if [ -n "${CHECKOUT_URL:-}" ] && [ -n "${CART_ID:-}" ]; then
|
||||
header "3. Checkout Service → $CHECKOUT_URL"
|
||||
|
||||
# ── This section needs the full compose stack with nginx on :8080,
|
||||
# and checkout must be reachable. Skip for infra/direct modes.
|
||||
echo "--- 3.1 Create checkout session ---"
|
||||
checkout_resp=$(curl -s -w '\n%{http_code}' -X POST "$CHECKOUT_URL/ucp/v1/checkout-sessions" \
|
||||
# ── Health ──────────────────────────────────────────────────────────────────
|
||||
echo "--- 3.1 Health check ---"
|
||||
resp=$(curl -sf "$CHECKOUT_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp"
|
||||
|
||||
# ── Create checkout session ────────────────────────────────────────────────
|
||||
echo "--- 3.2 POST /ucp/v1/checkout-sessions/ ---"
|
||||
checkout_resp=$(curl -s -w '\n%{http_code}' -X POST "$CHECKOUT_URL/ucp/v1/checkout-sessions/" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"cartId": "'"$CART_ID"'",
|
||||
@@ -255,17 +269,116 @@ if [ "$MODE" = "--edge" ] && [ -n "${CHECKOUT_URL:-}" ]; then
|
||||
checkout_code=$(echo "$checkout_resp" | tail -1)
|
||||
checkout_body=$(echo "$checkout_resp" | sed '$d')
|
||||
assert_status "Create checkout session" 201 "$checkout_code" "$checkout_body"
|
||||
CHECKOUT_ID=$(echo "$checkout_body" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$CHECKOUT_ID" ]; then
|
||||
pass "Checkout ID: $CHECKOUT_ID"
|
||||
else
|
||||
fail "Could not extract checkout ID"
|
||||
echo " response: $(echo "$checkout_body" | head -c 400)"
|
||||
fi
|
||||
|
||||
if [ -n "${CHECKOUT_ID:-}" ]; then
|
||||
# ── Read checkout session ────────────────────────────────────────────────
|
||||
echo "--- 3.3 GET /ucp/v1/checkout-sessions/{id} ---"
|
||||
get_checkout_resp=$(curl -s -w '\n%{http_code}' "$CHECKOUT_URL/ucp/v1/checkout-sessions/$CHECKOUT_ID")
|
||||
get_checkout_code=$(echo "$get_checkout_resp" | tail -1)
|
||||
get_checkout_body=$(echo "$get_checkout_resp" | sed '$d')
|
||||
assert_status "Get checkout session" 200 "$get_checkout_code" "$get_checkout_body"
|
||||
echo " checkout: $(maybe_jq "$get_checkout_body" '.id + " status=" + .status')"
|
||||
|
||||
# ── Complete checkout session ────────────────────────────────────────────
|
||||
echo "--- 3.4 POST /ucp/v1/checkout-sessions/{id}/complete ---"
|
||||
complete_resp=$(curl -s -w '\n%{http_code}' -X POST "$CHECKOUT_URL/ucp/v1/checkout-sessions/$CHECKOUT_ID/complete" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"provider": "test",
|
||||
"paymentToken": "ucp-test-payment-token",
|
||||
"currency": "SEK",
|
||||
"country": "se"
|
||||
}')
|
||||
complete_code=$(echo "$complete_resp" | tail -1)
|
||||
complete_body=$(echo "$complete_resp" | sed '$d')
|
||||
assert_status "Complete checkout session" 200 "$complete_code" "$complete_body"
|
||||
COMPLETE_ORDER_ID=$(echo "$complete_body" | sed -n 's/.*"orderId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
if [ -n "$COMPLETE_ORDER_ID" ]; then
|
||||
pass "Checkout produced order ID: $COMPLETE_ORDER_ID"
|
||||
else
|
||||
fail "Checkout completion did not return an orderId"
|
||||
echo " response: $(echo "$complete_body" | head -c 400)"
|
||||
fi
|
||||
|
||||
# ── Signature headers (if enabled) ──────────────────────────────────────
|
||||
echo "--- 3.5 Check signature headers ---"
|
||||
checkout_sig_resp=$(curl -s -i "$CHECKOUT_URL/ucp/v1/checkout-sessions/$CHECKOUT_ID" 2>/dev/null | head -30)
|
||||
if echo "$checkout_sig_resp" | grep -qi "signature-input"; then
|
||||
pass "Checkout Signature-Input header present"
|
||||
echo " $(echo "$checkout_sig_resp" | grep -i "signature-input" | head -1)"
|
||||
else
|
||||
echo " ℹ️ No Signature-Input header"
|
||||
fi
|
||||
if echo "$checkout_sig_resp" | grep -qi "^signature:"; then
|
||||
pass "Checkout Signature header present"
|
||||
else
|
||||
echo " ℹ️ No Signature header"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# SECTION 4: Signature verification (standalone)
|
||||
# SECTION 4: Edge-only discovery artifact tests
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
if [ "$MODE" = "--edge" ]; then
|
||||
header "4. Edge Discovery → $BASE"
|
||||
|
||||
echo "--- 4.1 GET /.well-known/ucp ---"
|
||||
profile_resp=$(curl -s -w '\n%{http_code}' "$BASE/.well-known/ucp")
|
||||
profile_code=$(echo "$profile_resp" | tail -1)
|
||||
profile_body=$(echo "$profile_resp" | sed '$d')
|
||||
assert_status "Get UCP profile" 200 "$profile_code" "$profile_body"
|
||||
if echo "$profile_body" | grep -q '/.well-known/ucp/openapi/shopping-rest.openapi.json'; then
|
||||
pass "Profile advertises self-hosted REST OpenAPI"
|
||||
else
|
||||
fail "Profile is missing self-hosted REST OpenAPI URL"
|
||||
fi
|
||||
|
||||
echo "--- 4.2 GET /.well-known/ucp/openapi/shopping-rest.openapi.json ---"
|
||||
openapi_resp=$(curl -s -w '\n%{http_code}' "$BASE/.well-known/ucp/openapi/shopping-rest.openapi.json")
|
||||
openapi_code=$(echo "$openapi_resp" | tail -1)
|
||||
openapi_body=$(echo "$openapi_resp" | sed '$d')
|
||||
assert_status "Get UCP REST OpenAPI" 200 "$openapi_code" "$openapi_body"
|
||||
if echo "$openapi_body" | grep -q '"openapi"'; then
|
||||
pass "OpenAPI document returned"
|
||||
else
|
||||
fail "OpenAPI document body missing openapi field"
|
||||
fi
|
||||
|
||||
echo "--- 4.3 GET self-hosted UCP schemas ---"
|
||||
for schema_path in \
|
||||
"/.well-known/ucp/schemas/shopping/customer.json" \
|
||||
"/.well-known/ucp/schemas/shopping/authentication.json" \
|
||||
"/.well-known/ucp/schemas/payments/processor_tokenizer.json"
|
||||
do
|
||||
schema_resp=$(curl -s -w '\n%{http_code}' "$BASE$schema_path")
|
||||
schema_code=$(echo "$schema_resp" | tail -1)
|
||||
schema_body=$(echo "$schema_resp" | sed '$d')
|
||||
assert_status "Get $schema_path" 200 "$schema_code" "$schema_body"
|
||||
if echo "$schema_body" | grep -q '"$schema"'; then
|
||||
pass "$schema_path is valid schema JSON"
|
||||
else
|
||||
fail "$schema_path body missing \$schema"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# SECTION 5: Signature verification (standalone)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
if [ "$MODE" = "--verify-sig" ]; then
|
||||
header "4. Signature Verification"
|
||||
header "5. Signature Verification"
|
||||
|
||||
# Verify that the response includes RFC 9421 HTTP Message Signatures.
|
||||
# Requires the service to have UCP_SIGNING_KEY_PATH set and the key mounted.
|
||||
echo "--- 4.1 Check Signed Response ---"
|
||||
echo "--- 5.1 Check Signed Response ---"
|
||||
sig_output=$(curl -s -D - "$ORDER_URL/ucp/v1/orders/$ORDER_ID" 2>/dev/null | head -40)
|
||||
|
||||
echo "$sig_output" | while IFS= read -r line; do
|
||||
|
||||
+7
-15
@@ -9,25 +9,17 @@ import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/backofficeadmin"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"git.k6n.net/mats/platform/config"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
)
|
||||
|
||||
func envOrDefault(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := envOrDefault("ADDR", ":8080")
|
||||
addr := config.EnvString("ADDR", ":8080")
|
||||
amqpURL := os.Getenv("AMQP_URL")
|
||||
|
||||
app, err := backofficeadmin.New(backofficeadmin.Config{
|
||||
DataDir: envOrDefault("CART_DIR", "data"),
|
||||
CheckoutDataDir: envOrDefault("CHECKOUT_DIR", "checkout-data"),
|
||||
RedisAddress: os.Getenv("REDIS_ADDRESS"),
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
DataDir: config.EnvString("CART_DIR", "data"),
|
||||
CheckoutDataDir: config.EnvString("CHECKOUT_DIR", "checkout-data"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating backoffice: %v", err)
|
||||
@@ -72,9 +64,9 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var conn *amqp.Connection
|
||||
var conn *rabbit.Conn
|
||||
if amqpURL != "" {
|
||||
conn, err = amqp.Dial(amqpURL)
|
||||
conn, err = rabbit.Dial(amqpURL, "cart-backoffice")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,63 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
func GetDiscovery() discovery.Discovery {
|
||||
if podIp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, kerr := rest.InClusterConfig()
|
||||
|
||||
if kerr != nil {
|
||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating client: %v\n", err)
|
||||
}
|
||||
timeout := int64(30)
|
||||
return discovery.NewK8sDiscovery(client, v1.ListOptions{
|
||||
LabelSelector: "actor-pool=cart",
|
||||
TimeoutSeconds: &timeout,
|
||||
})
|
||||
}
|
||||
|
||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
||||
|
||||
go func(hw discovery.Discovery) {
|
||||
if hw == nil {
|
||||
log.Print("No discovery service available")
|
||||
return
|
||||
}
|
||||
ch, err := hw.Watch()
|
||||
if err != nil {
|
||||
log.Printf("Discovery error: %v", err)
|
||||
return
|
||||
}
|
||||
for evt := range ch {
|
||||
if evt.Host == "" {
|
||||
continue
|
||||
}
|
||||
switch evt.IsReady {
|
||||
case false:
|
||||
if pool.IsKnown(evt.Host) {
|
||||
log.Printf("Host %s is not ready, removing", evt.Host)
|
||||
pool.RemoveHost(evt.Host)
|
||||
}
|
||||
default:
|
||||
if !pool.IsKnown(evt.Host) {
|
||||
log.Printf("Discovered host %s", evt.Host)
|
||||
pool.AddRemoteHost(evt.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(GetDiscovery())
|
||||
discovery.StartK8sDiscovery(podIp, "actor-pool=cart", 30, pool)
|
||||
}
|
||||
|
||||
+282
-100
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
@@ -12,28 +13,31 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart/recovery"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
promotionmcp "git.k6n.net/mats/go-cart-actor/pkg/promotions/mcp"
|
||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"git.k6n.net/mats/platform/catalog"
|
||||
"git.k6n.net/mats/platform/config"
|
||||
"git.k6n.net/mats/platform/event"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_spawned_total",
|
||||
Help: "The total number of spawned grains",
|
||||
})
|
||||
// cartMetrics is the shared prometheus instrumentation for the
|
||||
// cart actor pool and event log. Built once at process start; nil
|
||||
// is not expected in production but the actor package's
|
||||
// touch sites are nil-safe so a test binary can pass nil.
|
||||
cartMetrics = actor.NewMetrics("cart")
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -51,14 +55,6 @@ var amqpUrl = os.Getenv("AMQP_URL")
|
||||
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||
|
||||
// getEnv returns the value of the environment variable named by key, or def if unset/empty.
|
||||
func getEnv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// normalizeListenAddr accepts either a bare port ("8080") or a full listen
|
||||
// address (":8080", "0.0.0.0:8080") and returns a value usable by http.Server.Addr.
|
||||
func normalizeListenAddr(v string) string {
|
||||
@@ -107,19 +103,60 @@ type CartChangeEvent struct {
|
||||
Mutations []actor.ApplyResult `json:"mutations"`
|
||||
}
|
||||
|
||||
func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem) bool {
|
||||
if string(update.SKU) == item.Sku {
|
||||
if update.LocationID == "se" && item.StoreId == nil {
|
||||
return true
|
||||
// catalogProjectionCache is the cart's per-pod, cold-start-ready catalog
|
||||
// projection cache, backed by platform/catalog.ProjectionStore. It consumes the
|
||||
// catalog.projection_published wire — a full snapshot (begin/chunk/end, framed by
|
||||
// epoch) plus incremental deltas — into its OWN local .bin and serves catalog
|
||||
// facts by SKU (price, tax_class, inventory_tracked, image, …) at add-to-cart /
|
||||
// checkout-reserve decisions. Stock is NOT cached — queried synchronously from
|
||||
// inventory per docs/inventory-shape.md.
|
||||
//
|
||||
// Clustering: the cart runs N replicas. Each pod has its OWN exclusive bus queue
|
||||
// (rabbit.ListenToPattern declares an exclusive server-named queue, so every pod
|
||||
// receives the full stream) and its OWN pod-local .bin. It's a replicated read
|
||||
// cache — no leader, no shared volume; N pods writing one file would corrupt it.
|
||||
// See docs/catalog-projection.md.
|
||||
//
|
||||
// The cart stores the full catalog.Projection (identity transform): its
|
||||
// add-to-cart overlay (projection_overlay.go) consumes nearly every field, so
|
||||
// there's nothing to subset. Deletes ride as tombstones in the store's overlay
|
||||
// (IsDeleted) so a removed SKU isn't re-fetched before the next snapshot folds
|
||||
// the delete into the base. Cold-grace: an SKU not yet in the base/overlay
|
||||
// resolves via the existing PRODUCT_BASE_URL HTTP fetch + overlay.
|
||||
type catalogProjectionCache struct {
|
||||
store *catalog.ProjectionStore[catalog.Projection]
|
||||
}
|
||||
|
||||
// identityProjection is the cart's mandatory transform — it stores the full
|
||||
// Projection because the overlay uses almost all of it.
|
||||
func identityProjection(p catalog.Projection) catalog.Projection { return p }
|
||||
|
||||
// newCatalogProjectionCache opens the per-pod store under dir. dir MUST be
|
||||
// pod-local (container fs / an emptyDir in k8s) — never a shared volume.
|
||||
func newCatalogProjectionCache(dir string) (*catalogProjectionCache, error) {
|
||||
s, err := catalog.NewProjectionStore(dir, "cart-projection.bin", identityProjection, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if item.StoreId == nil {
|
||||
return false
|
||||
}
|
||||
if *item.StoreId == string(update.LocationID) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return &catalogProjectionCache{store: s}, nil
|
||||
}
|
||||
|
||||
// HandleFrame feeds one catalog.projection_published event (snapshot frame or
|
||||
// delta) into the store; framing/epoch ordering live in platform/catalog.
|
||||
func (c *catalogProjectionCache) HandleFrame(meta map[string]string, payload []byte) error {
|
||||
return c.store.HandleFrame(meta, payload)
|
||||
}
|
||||
|
||||
func (c *catalogProjectionCache) Get(sku string) (catalog.Projection, bool) { return c.store.Get(sku) }
|
||||
|
||||
// IsDeleted reports whether the bus has explicitly deleted sku (overlay
|
||||
// tombstone) — call sites skip the HTTP fallback for it.
|
||||
func (c *catalogProjectionCache) IsDeleted(sku string) bool { return c.store.IsDeleted(sku) }
|
||||
|
||||
// Len reports cached entries (base + overlay) — debug/log gauge only.
|
||||
func (c *catalogProjectionCache) Len() int {
|
||||
_, base, overlay := c.store.Stats()
|
||||
return base + overlay
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -127,14 +164,18 @@ func main() {
|
||||
// cartPort is the bare HTTP port. It drives both the local listener and the
|
||||
// port used to proxy to peer pods, so a cluster must run all pods on the
|
||||
// same CART_PORT.
|
||||
cartPort := getEnv("CART_PORT", "8080")
|
||||
cartPort := config.EnvString("CART_PORT", "8080")
|
||||
if i := strings.LastIndex(cartPort, ":"); i >= 0 {
|
||||
cartPort = cartPort[i+1:]
|
||||
}
|
||||
|
||||
controlPlaneConfig := actor.DefaultServerConfig()
|
||||
|
||||
promotionStore, err := promotions.NewStore("data/promotions.json")
|
||||
promotionsPath := os.Getenv("PROMOTIONS_FILE")
|
||||
if promotionsPath == "" {
|
||||
promotionsPath = "data/promotions.json"
|
||||
}
|
||||
promotionStore, err := promotions.NewStore(promotionsPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Error loading promotions: %v\n", err)
|
||||
}
|
||||
@@ -142,53 +183,50 @@ func main() {
|
||||
log.Printf("loaded %d promotions", len(promotionStore.List()))
|
||||
|
||||
promotionService := promotions.NewPromotionService(nil)
|
||||
promotionMCP := promotionmcp.New(promotionStore, promotionService)
|
||||
|
||||
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
||||
|
||||
// rdb := redis.NewClient(&redis.Options{
|
||||
// Addr: redisAddress,
|
||||
// Password: redisPassword,
|
||||
// DB: 0,
|
||||
// })
|
||||
// inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
||||
// if err != nil {
|
||||
// log.Fatalf("Error creating inventory service: %v\n", err)
|
||||
// }
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddress,
|
||||
Password: redisPassword,
|
||||
DB: 0,
|
||||
})
|
||||
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
||||
}
|
||||
reservationPolicy := inventory.NewReservationPolicyAdapter(inventoryReservationService)
|
||||
|
||||
// inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
||||
// if err != nil {
|
||||
// log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
||||
// }
|
||||
|
||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(reservationPolicy))
|
||||
reg.RegisterProcessor(
|
||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
||||
defer span.End()
|
||||
g.UpdateTotals()
|
||||
// Evaluate active promotions against the freshly-totalled cart and apply
|
||||
// any matched actions (e.g. the Volymrabatt volume discount), which adjust
|
||||
// TotalPrice/TotalDiscount on top of line items and vouchers.
|
||||
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||
// ApplyResults applies qualifying actions in priority order and records
|
||||
// every effect — both applied discounts and pending "spend X more for ..."
|
||||
// nudges with their progress — in g.AppliedPromotions.
|
||||
promotionService.ApplyResults(g, results, promotionCtx)
|
||||
// Canonical promotion pipeline — same call site as the
|
||||
// /promotions/evaluate-with-cart preview handler in
|
||||
// promotions_evaluate.go. Going through the shared
|
||||
// PromotionService.EvaluateAndApply means the bypass
|
||||
// loop, the re-eval trigger, and ApplyResults stay in
|
||||
// lockstep with the preview so a what-if always matches
|
||||
// post-add behavior (including the coupon+promotion-
|
||||
// overlap edge case). If you find yourself adding
|
||||
// promotion logic here, add it to EvaluateAndApply
|
||||
// instead — the preview must follow.
|
||||
promotionService.EvaluateAndApply(promotionStore.Snapshot(), g, promotions.WithNow(time.Now()))
|
||||
g.Version++
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
|
||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](getEnv("CART_DIR", "data"), reg)
|
||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](config.EnvString("CART_DIR", "data"), reg)
|
||||
diskStorage.SetMetrics(cartMetrics)
|
||||
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
||||
MutationRegistry: reg,
|
||||
Storage: diskStorage,
|
||||
Metrics: cartMetrics,
|
||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
|
||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn cart id %d", id))
|
||||
defer span.End()
|
||||
grainSpawns.Inc()
|
||||
ret := cart.NewCartGrain(id, time.Now())
|
||||
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
||||
|
||||
@@ -245,29 +283,54 @@ func main() {
|
||||
|
||||
cartMCP := cartmcp.New(pool)
|
||||
|
||||
// Publish each applied mutation to the "cart"/"mutation" RabbitMQ topic so the
|
||||
// backoffice /commerce live feed (and other consumers) see cart activity.
|
||||
// Best-effort: if AMQP is unreachable the cart still serves; the feed stays empty.
|
||||
// Stream each applied mutation to the shared "mutations" exchange (routing key
|
||||
// mutation.cart) so the backoffice /commerce live feed (and other consumers)
|
||||
// see cart activity. Best-effort: if AMQP is unreachable the cart still serves.
|
||||
if amqpUrl != "" {
|
||||
if conn, derr := amqp.Dial(amqpUrl); derr != nil {
|
||||
if conn, derr := rabbit.Dial(amqpUrl, "cart"); derr != nil {
|
||||
log.Printf("cart: AMQP connect failed, mutation feed disabled: %v", derr)
|
||||
} else {
|
||||
if ch, cerr := conn.Channel(); cerr == nil {
|
||||
_ = messaging.DefineTopic(ch, "cart", "mutation") // idempotent; non-fatal
|
||||
_ = ch.Close()
|
||||
}
|
||||
pool.AddListener(actor.NewAmqpListener(conn, func(id uint64, results []actor.ApplyResult) (any, error) {
|
||||
types := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
types = append(types, r.Type)
|
||||
}
|
||||
return map[string]any{"cartId": id, "mutations": types}, nil
|
||||
}))
|
||||
log.Printf("cart: mutation feed enabled (cart/mutation)")
|
||||
listener := actor.NewAmqpListener(conn.Connection(), "cart", actor.MutationSummary)
|
||||
listener.DefineTopics()
|
||||
pool.AddListener(listener)
|
||||
log.Printf("cart: mutation feed enabled (mutation.cart)")
|
||||
}
|
||||
}
|
||||
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp)) //inventoryService, inventoryReservationService)
|
||||
// catalog.projection_published consumer (the cart is the canonical
|
||||
// consumer of the projection wire per docs/inventory-shape.md; inventory
|
||||
// emits level crossings back to finder only, never the reverse). Own
|
||||
// connection keeps the cart's mutation-feed lifecycle decoupled from a
|
||||
// read-write-consume connection that could grow event handlers later
|
||||
// (e.g. promotions reacting to a price drop on a watched SKU).
|
||||
// Pod-local store dir — container fs / an emptyDir in k8s. NEVER a shared
|
||||
// volume: each replica owns its own .bin (replicated read cache, no leader).
|
||||
projectionDir := config.EnvString("CART_PROJECTION_DIR", "data/projection")
|
||||
projectionCache, err := newCatalogProjectionCache(projectionDir)
|
||||
if err != nil {
|
||||
log.Fatalf("Error opening catalog projection cache: %v", err)
|
||||
}
|
||||
if amqpUrl != "" {
|
||||
if pConn, perr := rabbit.Dial(amqpUrl, "cart-catalog-projection"); perr != nil {
|
||||
log.Printf("cart: catalog projection consumer DISABLED (dial failed): %v", perr)
|
||||
} else {
|
||||
pConn.NotifyOnReconnect(func() {
|
||||
ch, err := pConn.Channel()
|
||||
if err != nil {
|
||||
log.Printf("cart: channel on projection reconnect: %v", err)
|
||||
return
|
||||
}
|
||||
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogProjectionPublished), catalog.MakeAmqpHandler(projectionCache.store)); err != nil {
|
||||
log.Printf("cart: bind catalog.projection_published on reconnect: %v", err)
|
||||
_ = ch.Close()
|
||||
}
|
||||
})
|
||||
defer func() { _ = pConn.Close() }()
|
||||
log.Printf("cart: catalog projection consumer ENABLED (catalog.projection_published on 'catalog' exchange)")
|
||||
}
|
||||
}
|
||||
|
||||
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), projectionCache) //inventoryService, inventoryReservationService)
|
||||
|
||||
app := &App{
|
||||
pool: pool,
|
||||
@@ -283,29 +346,82 @@ func main() {
|
||||
}
|
||||
defer grpcSrv.GracefulStop()
|
||||
|
||||
// go diskStorage.SaveLoop(10 * time.Second)
|
||||
go diskStorage.SaveLoop(20 * time.Second)
|
||||
UseDiscovery(pool)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
otelShutdown, err := setupOTelSDK(ctx)
|
||||
// Data Retention (C5) GDPR Purge
|
||||
retentionDays := config.EnvInt("CART_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
|
||||
if retentionDays > 0 {
|
||||
purgeInterval := config.EnvDuration("CART_PURGE_INTERVAL", 24*time.Hour)
|
||||
log.Printf("cart: data retention enabled. Retaining carts for %d days, purging every %v", retentionDays, purgeInterval)
|
||||
go func() {
|
||||
ticker := time.NewTicker(purgeInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run immediately on startup
|
||||
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||
}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
log.Print("cart: data retention / GDPR purge disabled (CART_RETENTION_DAYS not set or <= 0)")
|
||||
}
|
||||
|
||||
// Abandoned-cart recovery check (C3-notifications seam, dry-run by default).
|
||||
// Scans local pod event-log files for carts whose modtime falls into the
|
||||
// "[now - delay - window, now - delay]" window and that have items + a
|
||||
// contact (email and/or push tokens). Hands each candidate to the
|
||||
// configured Notifier (LoggingNotifier v0). The scanner reads local shard
|
||||
// files only — the control plane doesn't fan out between pods on purpose
|
||||
// (each pod owns the carts it is the owner of). Idempotency is the next
|
||||
// pass; the v0 window size keeps duplicate notifications minute-scoped.
|
||||
abandonedDelay := config.EnvDuration("CART_ABANDONED_DELAY", time.Hour)
|
||||
if abandonedDelay > 0 {
|
||||
abandonedWindow := config.EnvDuration("CART_ABANDONED_WINDOW", time.Hour)
|
||||
abandonedInterval := config.EnvDuration("CART_ABANDONED_SCAN_INTERVAL", 15*time.Minute)
|
||||
notifier := recovery.NewLoggingNotifier()
|
||||
scanner := recovery.NewScanner(config.EnvString("CART_DIR", "data"), diskStorage)
|
||||
log.Printf("cart: abandonment recovery ENABLED. Delay=%v window=%v scan_interval=%v (notifier=LoggingNotifier dry-run)", abandonedDelay, abandonedWindow, abandonedInterval)
|
||||
go func() {
|
||||
ticker := time.NewTicker(abandonedInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
runRecovery(ctx, scanner, notifier, abandonedDelay, abandonedWindow)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runRecovery(ctx, scanner, notifier, abandonedDelay, abandonedWindow)
|
||||
}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
log.Print("cart: abandonment recovery DISABLED (CART_ABANDONED_DELAY not set or <= 0)")
|
||||
}
|
||||
|
||||
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to start otel %v", err)
|
||||
}
|
||||
|
||||
syncedServer.Serve(mux)
|
||||
|
||||
// Promotion MCP edge: list/get/upsert/status/delete promotions and preview
|
||||
// discounts. Mutations persist to data/promotions.json and are picked up by
|
||||
// the cart's totals processor on the next mutation (it reads a fresh snapshot).
|
||||
mux.Handle("/mcp", promotionMCP.Handler())
|
||||
mux.Handle("/mcp/", promotionMCP.Handler())
|
||||
|
||||
// Cart MCP edge: inspect and mutate carts — get_cart, get_cart_items,
|
||||
// update_item_quantity, remove_cart_item, clear_cart, apply_voucher, etc.
|
||||
// Exposes 11 tools on the same grain pool, mounted at /cart-mcp so it does
|
||||
// not conflict with the Promotions MCP at /mcp.
|
||||
// not conflict with the Promotions MCP at /promotions-mcp.
|
||||
mux.Handle("/cart-mcp", cartMCP.Handler())
|
||||
mux.Handle("/cart-mcp/", cartMCP.Handler())
|
||||
|
||||
@@ -332,6 +448,19 @@ func main() {
|
||||
// without creating or mutating a real cart.
|
||||
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
|
||||
|
||||
// Cart-aware promotion evaluation: reads the cartid cookie the storefront
|
||||
// already maintains, fetches the actual cart grain (cross-pod via
|
||||
// GetAnywhere), merges with the request's items (the items the user is
|
||||
// considering adding), and runs the engine on the merged line list. The
|
||||
// cart is the source of truth (vouchers, customer segment, customer
|
||||
// lifetime value, total) so a serialised copy from the browser can race
|
||||
// the live cart — letting the backend read the cookie + fetch the grain
|
||||
// means exactly one source of truth and no race. Missing cookie or fetch
|
||||
// failure gracefully degrades to a stateless evaluation of the request
|
||||
// items alone. See newPromotionEvaluateWithCartHandler in
|
||||
// promotions_evaluate.go for the full contract.
|
||||
mux.HandleFunc("POST /promotions/evaluate-with-cart", newPromotionEvaluateWithCartHandler(promotionStore, promotionService, syncedServer))
|
||||
|
||||
// only for local
|
||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||
pool.AddRemote(r.PathValue("host"))
|
||||
@@ -343,6 +472,23 @@ func main() {
|
||||
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
debugMux.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
// Projection cache probe (dev/verification). GET /debug/projection/{sku} →
|
||||
// the resolved projection + the bus-fed map size. map_entries counts both
|
||||
// live projections and tombstones; a found=true response confirms the bus
|
||||
// consumer delivered the SKU to this pod.
|
||||
debugMux.HandleFunc("/debug/projection/", func(w http.ResponseWriter, r *http.Request) {
|
||||
sku := strings.TrimPrefix(r.URL.Path, "/debug/projection/")
|
||||
p, found := projectionCache.Get(sku)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"sku": sku,
|
||||
"found": found,
|
||||
"projection": p,
|
||||
"map_entries": projectionCache.Len(),
|
||||
"deleted": projectionCache.IsDeleted(sku),
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||
grainCount, capacity := app.pool.LocalUsage()
|
||||
@@ -376,7 +522,7 @@ func main() {
|
||||
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
|
||||
|
||||
httpAddr := normalizeListenAddr(cartPort)
|
||||
debugAddr := normalizeListenAddr(getEnv("CART_DEBUG_PORT", "8081"))
|
||||
debugAddr := normalizeListenAddr(config.EnvString("CART_DEBUG_PORT", "8081"))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: httpAddr,
|
||||
@@ -403,19 +549,11 @@ func main() {
|
||||
srvErr <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
// listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) {
|
||||
// for _, change := range changes {
|
||||
// log.Printf("inventory change: %v", change)
|
||||
// inventoryPubSub.Publish(change)
|
||||
// }
|
||||
// })
|
||||
|
||||
// go func() {
|
||||
// err := listener.Start()
|
||||
// if err != nil {
|
||||
// log.Fatalf("Unable to start inventory listener: %v", err)
|
||||
// }
|
||||
// }()
|
||||
// Inventory change consumption used to live here over the bare Redis
|
||||
// `inventory_changed` channel; it is now owned by the cart-inventory
|
||||
// service, which translates quantity changes into inventory.level_changed
|
||||
// bus crossings. The cart reads exact stock synchronously when it needs it.
|
||||
// See docs/inventory-shape.md.
|
||||
|
||||
log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr)
|
||||
|
||||
@@ -432,3 +570,47 @@ func main() {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[cart.CartGrain], pool *actor.SimpleGrainPool[cart.CartGrain], retentionDays int) {
|
||||
log.Printf("cart: starting data retention purge...")
|
||||
retention := time.Duration(retentionDays) * 24 * time.Hour
|
||||
|
||||
localIds := make(map[uint64]bool)
|
||||
for _, id := range pool.GetLocalIds() {
|
||||
localIds[id] = true
|
||||
}
|
||||
isGrainActive := func(id uint64) bool {
|
||||
return localIds[id]
|
||||
}
|
||||
|
||||
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
|
||||
if err != nil {
|
||||
log.Printf("cart: data retention purge failed: %v", err)
|
||||
} else {
|
||||
log.Printf("cart: data retention purge completed. Purged %d expired cart logs", purged)
|
||||
}
|
||||
}
|
||||
|
||||
// runRecovery is the abandoned-cart scanner pass. It finds carts that crossed
|
||||
// the abandonment threshold in the recent window and hands each to the
|
||||
// configured Notifier. Each pass is independent; idempotency across ticks is
|
||||
// the next pass (logged duplicates are not catastrophic for the dry-run
|
||||
// notifier, but a real MailerSend/FCM impl will need a SETNX side-key).
|
||||
func runRecovery(ctx context.Context, scanner *recovery.Scanner, notifier recovery.Notifier, delay, window time.Duration) {
|
||||
candidates, err := scanner.Scan(ctx, delay, window, time.Now())
|
||||
if err != nil {
|
||||
log.Printf("cart: recovery scan failed: %v", err)
|
||||
return
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
log.Printf("cart: recovery scan complete, 0 candidates (window delay=%v width=%v)", delay, window)
|
||||
return
|
||||
}
|
||||
log.Printf("cart: recovery scan complete, %d candidates", len(candidates))
|
||||
for _, c := range candidates {
|
||||
if err := notifier.NotifyAbandoned(ctx, c); err != nil {
|
||||
log.Printf("cart: recovery notify (cart %d) failed: %v", c.CartID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/log/global"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
||||
// If it does not return an error, make sure to call shutdown for proper cleanup.
|
||||
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||
var shutdownFuncs []func(context.Context) error
|
||||
var err error
|
||||
|
||||
// shutdown calls cleanup functions registered via shutdownFuncs.
|
||||
// The errors from the calls are joined.
|
||||
// Each registered cleanup will be invoked once.
|
||||
shutdown := func(ctx context.Context) error {
|
||||
var err error
|
||||
for _, fn := range shutdownFuncs {
|
||||
err = errors.Join(err, fn(ctx))
|
||||
}
|
||||
shutdownFuncs = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
|
||||
handleErr := func(inErr error) {
|
||||
err = errors.Join(inErr, shutdown(ctx))
|
||||
}
|
||||
|
||||
// Set up propagator.
|
||||
prop := newPropagator()
|
||||
otel.SetTextMapPropagator(prop)
|
||||
|
||||
// Set up trace provider.
|
||||
tracerProvider, err := newTracerProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
|
||||
otel.SetTracerProvider(tracerProvider)
|
||||
|
||||
// Set up meter provider.
|
||||
meterProvider, err := newMeterProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
||||
otel.SetMeterProvider(meterProvider)
|
||||
|
||||
// Set up logger provider.
|
||||
loggerProvider, err := newLoggerProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
||||
global.SetLoggerProvider(loggerProvider)
|
||||
|
||||
return shutdown, err
|
||||
}
|
||||
|
||||
func newPropagator() propagation.TextMapPropagator {
|
||||
return propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
)
|
||||
}
|
||||
|
||||
func newTracerProvider() (*trace.TracerProvider, error) {
|
||||
traceExporter, err := otlptracegrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracerProvider := trace.NewTracerProvider(
|
||||
trace.WithBatcher(traceExporter,
|
||||
// Default is 5s. Set to 1s for demonstrative purposes.
|
||||
trace.WithBatchTimeout(time.Second)),
|
||||
)
|
||||
return tracerProvider, nil
|
||||
}
|
||||
|
||||
func newMeterProvider() (*metric.MeterProvider, error) {
|
||||
exporter, err := otlpmetricgrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func newLoggerProvider() (*log.LoggerProvider, error) {
|
||||
logExporter, err := otlploggrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loggerProvider := log.NewLoggerProvider(
|
||||
log.WithProcessor(log.NewBatchProcessor(logExporter)),
|
||||
)
|
||||
return loggerProvider, nil
|
||||
}
|
||||
+167
-24
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -15,9 +16,6 @@ import (
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
@@ -28,26 +26,30 @@ import (
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_mutations_total",
|
||||
Help: "The total number of mutations",
|
||||
})
|
||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "cart_grain_lookups_total",
|
||||
Help: "The total number of lookups",
|
||||
})
|
||||
)
|
||||
// Pool metrics (cart_grain_spawned_total, cart_grain_lookups_total,
|
||||
// cart_mutations_total, cart_remote_negotiation_total,
|
||||
// cart_connected_remotes, …) are bumped centrally by SimpleGrainPool
|
||||
// — see pkg/actor/simple_grain_pool.go. The cart Metrics is
|
||||
// registered once in cmd/cart/main.go and shared with DiskStorage
|
||||
// via SetMetrics, so the per-handler counters this file used to
|
||||
// maintain are no longer needed.
|
||||
|
||||
type PoolServer struct {
|
||||
actor.GrainPool[cart.CartGrain]
|
||||
pod_name string
|
||||
// idx is the bus-fed catalog projection cache, consulted at HTTP-fetch
|
||||
// sites (AddSkuToCartHandler, buildItemGroups) to overlay authoritative
|
||||
// price / tax_class / display fields onto AddItem before mutation. nil is
|
||||
// tolerated so handlers still serve if the bus consumer is unavailable
|
||||
// (orphan / quarantine / CI), with a plain cache-miss fall-through.
|
||||
idx *catalogProjectionCache
|
||||
}
|
||||
|
||||
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string) *PoolServer {
|
||||
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, idx *catalogProjectionCache) *PoolServer {
|
||||
srv := &PoolServer{
|
||||
GrainPool: pool,
|
||||
pod_name: pod_name,
|
||||
idx: idx,
|
||||
}
|
||||
|
||||
return srv
|
||||
@@ -68,19 +70,64 @@ func (s *PoolServer) GetCartHandler(w http.ResponseWriter, r *http.Request, id c
|
||||
|
||||
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||
sku := r.PathValue("sku")
|
||||
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
|
||||
country := getCountryFromHost(r.Host)
|
||||
if s.idx == nil {
|
||||
log.Printf("No index present")
|
||||
}
|
||||
if s.idx != nil && s.idx.IsDeleted(sku) {
|
||||
// Bus-deleted SKU — skip the HTTP fetch and reject the add. Mirrors the
|
||||
// product-fetcher's "product service returned %d for sku %s" shape so
|
||||
// upstream code that pattern-matches that string still works, AND we
|
||||
// publish StatusNotFound so the HTTP response carries the right code
|
||||
// (the handler's error-return path otherwise renders as 500). http.Error
|
||||
// is the idiomatic stdlib call here — it does the encoding-safe text body
|
||||
// (no fmt.Sprintf/JSON-string-escaping fragility).
|
||||
http.Error(w, fmt.Sprintf("product service returned %d for sku %s (bus-deleted)", 404, sku), http.StatusNotFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cache-only fast path: AddSkuToCartHandler is a SINGLE top-level add
|
||||
// (qty=1, no children). When the projection carries full authoritative
|
||||
// fields, we skip PRODUCT_BASE_URL entirely and build the AddItem
|
||||
// directly from the cache. This eliminates one HTTP round-trip per
|
||||
// add-to-cart under steady-state bus-fed conditions.
|
||||
var msg *messages.AddItem
|
||||
if s.idx != nil {
|
||||
if p, ok := s.idx.Get(sku); ok && HasRequiredFields(p) {
|
||||
msg = BuildAddItemFromCache(sku, 1, country, nil, p)
|
||||
}
|
||||
}
|
||||
if msg == nil {
|
||||
if s.idx == nil {
|
||||
log.Printf("No item found in index %s", sku)
|
||||
}
|
||||
var err error
|
||||
msg, err = GetItemAddMessage(r.Context(), sku, 1, country, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.idx != nil {
|
||||
if p, ok := s.idx.Get(sku); ok {
|
||||
ApplyProjectionOverlay(msg, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := s.ApplyLocal(r.Context(), id, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grainMutations.Add(float64(len(data.Mutations)))
|
||||
return s.WriteResult(w, data)
|
||||
}
|
||||
|
||||
// Pool metrics (cart_grain_spawned_total, cart_grain_lookups_total,
|
||||
// cart_mutations_total, cart_remote_negotiation_total,
|
||||
// cart_connected_remotes, …) are bumped centrally by SimpleGrainPool
|
||||
// — see pkg/actor/simple_grain_pool.go. The cart Metrics is
|
||||
// registered once in cmd/cart/main.go and shared with DiskStorage
|
||||
// via SetMetrics, so the per-handler counters this file used to
|
||||
// maintain are no longer needed.
|
||||
|
||||
func (s *PoolServer) WriteResult(w http.ResponseWriter, result any) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
@@ -155,16 +202,58 @@ type itemGroup struct {
|
||||
// drive child pricing, then children are fetched concurrently. Input order is
|
||||
// preserved. Items that fail to fetch are skipped (logged), matching the prior
|
||||
// best-effort behavior.
|
||||
func buildItemGroups(ctx context.Context, items []Item, country string) []itemGroup {
|
||||
//
|
||||
// idx is the bus-fed projection cache; when non-nil, every AddItem built here
|
||||
// has its cache-covered fields (price/tax_class/display/inventory_tracked/
|
||||
// drop_ship) overlaid onto the HTTP-fetched answer. Cache-hit calls naturally
|
||||
// become authoritative for what the projection carries; HTTP stays for
|
||||
// dimensions (parent width/height for child pricing), seller/orgPrice and the
|
||||
// dynamic ExtraJson product data. Skipped cleanly when idx is nil.
|
||||
//
|
||||
// Cache-only fast path: a parent with NO children AND a cache hit that
|
||||
// satisfies HasRequiredFields skips PRODUCT_BASE_URL entirely — BuildAddItem-
|
||||
// FromCache constructs the line directly from the Projection. This eliminates
|
||||
// the per-add HTTP round-trip under steady-state bus-fed conditions for the
|
||||
// common top-level add case. The HTTP path continues to handle:
|
||||
// - cache miss (cold-grace; a SKU not yet bus-fed)
|
||||
// - HasRequiredFields==false (partial bus delivery, e.g. TaxClass missing
|
||||
// because the producer didn't classify)
|
||||
// - parents WITH children (configurator behavior preservation — child
|
||||
// price = child per-m² rate × parent area, and parent area comes from
|
||||
// HTTP-fetched width/height that the Projection doesn't carry yet; a
|
||||
// future schema extension can unblock a child cache-only path).
|
||||
func buildItemGroups(ctx context.Context, items []Item, country string, idx *catalogProjectionCache) []itemGroup {
|
||||
groups := make([]itemGroup, len(items))
|
||||
wg := sync.WaitGroup{}
|
||||
for i, itm := range items {
|
||||
wg.Go(func() {
|
||||
parentMsg, parentProduct, err := BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil)
|
||||
if idx != nil && idx.IsDeleted(itm.Sku) {
|
||||
log.Printf("error adding item %s: bus-deleted (skipping)", itm.Sku)
|
||||
return
|
||||
}
|
||||
|
||||
// Cache-only fast path: see package doc above for the eligibility
|
||||
// contract.
|
||||
var parentMsg *messages.AddItem
|
||||
var parentProduct *ProductItem
|
||||
if len(itm.Children) == 0 && idx != nil {
|
||||
if p, ok := idx.Get(itm.Sku); ok && HasRequiredFields(p) {
|
||||
parentMsg = BuildAddItemFromCache(itm.Sku, itm.Quantity, country, itm.StoreId, p)
|
||||
}
|
||||
}
|
||||
if parentMsg == nil {
|
||||
var err error
|
||||
parentMsg, parentProduct, err = BuildItemMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId, nil)
|
||||
if err != nil {
|
||||
log.Printf("error adding item %s: %v", itm.Sku, err)
|
||||
return
|
||||
}
|
||||
if idx != nil {
|
||||
if p, ok := idx.Get(itm.Sku); ok {
|
||||
ApplyProjectionOverlay(parentMsg, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
parentMsg.CustomFields = itm.CustomFields
|
||||
groups[i].parent = parentMsg
|
||||
|
||||
@@ -175,11 +264,20 @@ func buildItemGroups(ctx context.Context, items []Item, country string) []itemGr
|
||||
cwg := sync.WaitGroup{}
|
||||
for j, child := range itm.Children {
|
||||
cwg.Go(func() {
|
||||
if idx != nil && idx.IsDeleted(child.Sku) {
|
||||
log.Printf("error adding child %s of %s: bus-deleted (skipping)", child.Sku, itm.Sku)
|
||||
return
|
||||
}
|
||||
childMsg, _, err := BuildItemMessage(ctx, child.Sku, child.Quantity, country, child.StoreId, parentProduct)
|
||||
if err != nil {
|
||||
log.Printf("error adding child %s of %s: %v", child.Sku, itm.Sku, err)
|
||||
return
|
||||
}
|
||||
if idx != nil {
|
||||
if p, ok := idx.Get(child.Sku); ok {
|
||||
ApplyProjectionOverlay(childMsg, p)
|
||||
}
|
||||
}
|
||||
childMsg.CustomFields = child.CustomFields
|
||||
children[j] = childMsg
|
||||
})
|
||||
@@ -264,7 +362,7 @@ func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request,
|
||||
if _, err := s.ApplyLocal(r.Context(), id, &messages.ClearCartRequest{}); err != nil {
|
||||
return err
|
||||
}
|
||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country)
|
||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx)
|
||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -287,7 +385,7 @@ func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Reque
|
||||
return err
|
||||
}
|
||||
|
||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country)
|
||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx)
|
||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -327,7 +425,7 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
|
||||
Children: addRequest.Children,
|
||||
CustomFields: addRequest.CustomFields,
|
||||
}
|
||||
groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country)
|
||||
groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country, s.idx)
|
||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -399,11 +497,9 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
|
||||
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
||||
hostAttr := attribute.String("other host", ownerHost.Name())
|
||||
span.SetAttributes(hostAttr)
|
||||
logger.InfoContext(ctx, "cart proxyed", "result", ownerHost.Name())
|
||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
|
||||
|
||||
grainLookups.Inc()
|
||||
if err == nil && handled {
|
||||
return nil
|
||||
}
|
||||
@@ -419,7 +515,6 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
|
||||
var (
|
||||
tracer = otel.Tracer(name)
|
||||
meter = otel.Meter(name)
|
||||
logger = otelslog.NewLogger(name)
|
||||
proxyCalls metric.Int64Counter
|
||||
)
|
||||
|
||||
@@ -511,6 +606,49 @@ func (s *PoolServer) RemoveVoucherHandler(w http.ResponseWriter, r *http.Request
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRecoveryContactRequest is the wire shape clients send to
|
||||
// PUT /cart/recovery-contact. It mirrors proto SetRecoveryContact with
|
||||
// JSON-friendly types; sending an empty JSON object clears all contact info.
|
||||
type SetRecoveryContactRequest struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
PushTokens []cart.PushToken `json:"pushTokens,omitempty"`
|
||||
}
|
||||
|
||||
// toMessage builds the proto message from the JSON request body.
|
||||
func (s *SetRecoveryContactRequest) toMessage() *messages.SetRecoveryContact {
|
||||
out := &messages.SetRecoveryContact{Email: s.Email}
|
||||
if len(s.PushTokens) > 0 {
|
||||
out.PushTokens = make([]*messages.PushToken, 0, len(s.PushTokens))
|
||||
for _, t := range s.PushTokens {
|
||||
out.PushTokens = append(out.PushTokens, &messages.PushToken{
|
||||
Platform: t.Platform,
|
||||
Token: t.Token,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetRecoveryContactHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return nil
|
||||
}
|
||||
req := SetRecoveryContactRequest{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
reply, err := s.ApplyLocal(r.Context(), cartId, req.toMessage())
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return err
|
||||
}
|
||||
return s.WriteResult(w, reply)
|
||||
}
|
||||
|
||||
func (s *PoolServer) SetUserIdHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||
setUserId := messages.SetUserId{}
|
||||
err := json.NewDecoder(r.Body).Decode(&setUserId)
|
||||
@@ -653,6 +791,10 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
|
||||
handleFunc("PUT /cart/subscription-details", CookieCartIdHandler(s.ProxyHandler(s.SubscriptionDetailsHandler)))
|
||||
handleFunc("DELETE /cart/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||
handleFunc("PUT /cart/user", CookieCartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
||||
// PUT only — SetRecoveryContact is PUT/replace semantics. We deliberately
|
||||
// do NOT allow POST here to avoid conflicts with any future POST→create
|
||||
// semantics on /cart paths.
|
||||
handleFunc("PUT /cart/recovery-contact", CookieCartIdHandler(s.ProxyHandler(s.SetRecoveryContactHandler)))
|
||||
|
||||
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
||||
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
||||
@@ -669,6 +811,7 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
|
||||
handleFunc("PUT /cart/byid/{id}/voucher", CookieCartIdHandler(s.ProxyHandler(s.AddVoucherHandler)))
|
||||
handleFunc("DELETE /cart/byid/{id}/voucher/{voucherId}", CookieCartIdHandler(s.ProxyHandler(s.RemoveVoucherHandler)))
|
||||
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
||||
handleFunc("PUT /cart/byid/{id}/recovery-contact", CartIdHandler(s.ProxyHandler(s.SetRecoveryContactHandler)))
|
||||
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
||||
handleFunc("DELETE /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
||||
handleFunc("PUT /cart/byid/{id}/item/{itemId}/custom-fields", CartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
)
|
||||
|
||||
// TestWriteResult_CartGrainCarriesEvaluatedItems verifies that the per-line
|
||||
// promotion breakdown lives on CartGrain.EvaluatedItems (so the canonical
|
||||
// promotion pipeline populates it once per mutation, not at every read
|
||||
// path) and naturally serialises through WriteResult alongside the rest
|
||||
// of the grain's tagged fields. There is no envelope wrapper anymore —
|
||||
// the grain's own JSON tag carries the field. See pkg/cart/evaluated_item.go
|
||||
// for EvaluatedItem semantics and pkg/cart/cart-grain.go for the EvaluatedItems
|
||||
// field declaration.
|
||||
func TestWriteResult_CartGrainCarriesEvaluatedItems(t *testing.T) {
|
||||
grain := &cart.CartGrain{
|
||||
Id: 1,
|
||||
Currency: "SEK",
|
||||
Items: []*cart.CartItem{
|
||||
{
|
||||
Sku: "promo-1",
|
||||
Quantity: 2,
|
||||
Price: *cart.NewPriceFromIncVat(10000, 2500),
|
||||
Discount: cart.NewPriceFromIncVat(2000, 500),
|
||||
Meta: &cart.ItemMeta{Name: "Promo Item"},
|
||||
},
|
||||
},
|
||||
TotalPrice: cart.NewPriceFromIncVat(20000, 5000),
|
||||
TotalDiscount: cart.NewPriceFromIncVat(4000, 1000),
|
||||
EvaluatedItems: []cart.EvaluatedItem{{
|
||||
Sku: "promo-1",
|
||||
Name: "Promo Item",
|
||||
Quantity: 2,
|
||||
PriceIncVat: 10000,
|
||||
OrgPriceIncVat: 12000,
|
||||
DiscountIncVat: 2000,
|
||||
EffectivePriceIncVat: 9000,
|
||||
EffectiveTotalIncVat: 18000,
|
||||
}},
|
||||
}
|
||||
|
||||
s := &PoolServer{}
|
||||
w := httptest.NewRecorder()
|
||||
if err := s.WriteResult(w, grain); err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v (body=%q)", err, w.Body.String())
|
||||
}
|
||||
|
||||
for _, key := range []string{"id", "currency", "items", "totalPrice", "totalDiscount", "evaluatedItems"} {
|
||||
if _, ok := resp[key]; !ok {
|
||||
t.Fatalf("expected top-level %q in grain serialisation, got keys %v", key, mapKeys(resp))
|
||||
}
|
||||
}
|
||||
|
||||
var ev []struct {
|
||||
Sku string `json:"sku"`
|
||||
DiscountIncVat int64 `json:"discountIncVat"`
|
||||
EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"`
|
||||
EffectivePriceIncVat int64 `json:"effectivePriceIncVat"`
|
||||
}
|
||||
if err := json.Unmarshal(resp["evaluatedItems"], &ev); err != nil {
|
||||
t.Fatalf("evaluatedItems parse: %v", err)
|
||||
}
|
||||
if len(ev) != 1 || ev[0].Sku != "promo-1" {
|
||||
t.Fatalf("expected one promo-1 evaluated item, got %+v", ev)
|
||||
}
|
||||
if ev[0].DiscountIncVat != 2000 || ev[0].EffectiveTotalIncVat != 18000 || ev[0].EffectivePriceIncVat != 9000 {
|
||||
t.Fatalf("evaluatedItem math off: %+v", ev[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteResult_MutationResultNestsEvaluatedItems verifies the natural
|
||||
// post-mutation wire shape: actor.MutationResult[cart.CartGrain] serialises
|
||||
// Result (the grain) and Mutations (applied-change metadata) at the existing
|
||||
// top-level positions, and CartGrain's EvaluatedItems rides inside Result
|
||||
// because the grain owns the field. Previously the envelope wrapper pulled
|
||||
// evaluatedItems out as a sibling of {result, mutations}; the natural
|
||||
// nesting is what the user wanted ("no strange patterns on a global level").
|
||||
// Internal/admin tools that read response.evaluatedItems will need to read
|
||||
// response.result.evaluatedItems instead. Wrap-around breakage acknowledged.
|
||||
func TestWriteResult_MutationResultNestsEvaluatedItems(t *testing.T) {
|
||||
grain := &cart.CartGrain{
|
||||
Id: 1,
|
||||
Currency: "SEK",
|
||||
Items: []*cart.CartItem{
|
||||
{
|
||||
Sku: "promo-1",
|
||||
Quantity: 1,
|
||||
Price: *cart.NewPriceFromIncVat(10000, 2500),
|
||||
Discount: cart.NewPriceFromIncVat(3000, 750),
|
||||
},
|
||||
},
|
||||
EvaluatedItems: []cart.EvaluatedItem{{
|
||||
Sku: "promo-1",
|
||||
Quantity: 1,
|
||||
PriceIncVat: 10000,
|
||||
DiscountIncVat: 3000,
|
||||
EffectiveTotalIncVat: 7000,
|
||||
EffectivePriceIncVat: 7000,
|
||||
}},
|
||||
}
|
||||
|
||||
mr := &actor.MutationResult[cart.CartGrain]{
|
||||
Result: *grain,
|
||||
Mutations: []actor.ApplyResult{{Type: "cart.AddItem"}},
|
||||
}
|
||||
|
||||
s := &PoolServer{}
|
||||
w := httptest.NewRecorder()
|
||||
if err := s.WriteResult(w, mr); err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
// {result, mutations} are the existing envelope — preserved.
|
||||
for _, key := range []string{"result", "mutations"} {
|
||||
if _, ok := resp[key]; !ok {
|
||||
t.Fatalf("expected top-level %q in mutation response, got %v", key, mapKeys(resp))
|
||||
}
|
||||
}
|
||||
// evaluatedItems no longer rises to the top level (the envelope is
|
||||
// gone). Confirm absence at the top — it's now nested under result.
|
||||
if _, hasTop := resp["evaluatedItems"]; hasTop {
|
||||
t.Fatalf("evaluatedItems leaked to top level — envelope wrapper returned somehow")
|
||||
}
|
||||
|
||||
// Result nests the grain, including EvaluatedItems naturally.
|
||||
var inner map[string]json.RawMessage
|
||||
if err := json.Unmarshal(resp["result"], &inner); err != nil {
|
||||
t.Fatalf("result parse: %v", err)
|
||||
}
|
||||
if _, ok := inner["items"]; !ok {
|
||||
t.Fatalf("result.items missing — Result did not carry the grain")
|
||||
}
|
||||
evRaw, ok := inner["evaluatedItems"]
|
||||
if !ok {
|
||||
t.Fatalf("result.evaluatedItems missing — grain field should nest under result")
|
||||
}
|
||||
var ev []struct {
|
||||
Sku string `json:"sku"`
|
||||
DiscountIncVat int64 `json:"discountIncVat"`
|
||||
}
|
||||
if err := json.Unmarshal(evRaw, &ev); err != nil {
|
||||
t.Fatalf("result.evaluatedItems parse: %v", err)
|
||||
}
|
||||
if len(ev) != 1 || ev[0].Sku != "promo-1" || ev[0].DiscountIncVat != 3000 {
|
||||
t.Fatalf("result.evaluatedItems wrong: %+v", ev)
|
||||
}
|
||||
|
||||
// Mutations are still a sibling of result.
|
||||
var muts []map[string]any
|
||||
if err := json.Unmarshal(resp["mutations"], &muts); err != nil {
|
||||
t.Fatalf("mutations parse: %v", err)
|
||||
}
|
||||
if len(muts) != 1 || muts[0]["type"] != "cart.AddItem" {
|
||||
t.Fatalf("mutations wrong: %+v", muts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteResult_NoEvaluatedItemsOmitsField verifies the EvaluatedItems
|
||||
// omitempty on CartGrain drops the field entirely when the canonical
|
||||
// pipeline hasn't run (synthetic grain in a fixture, fresh spawn before
|
||||
// any promotion-aware mutation). Clients reading evaluatedItems can
|
||||
// rely on absence to mean "no promotion compute yet".
|
||||
func TestWriteResult_NoEvaluatedItemsOmitsField(t *testing.T) {
|
||||
grain := &cart.CartGrain{
|
||||
Id: 1,
|
||||
Currency: "SEK",
|
||||
}
|
||||
|
||||
s := &PoolServer{}
|
||||
w := httptest.NewRecorder()
|
||||
if err := s.WriteResult(w, grain); err != nil {
|
||||
t.Fatalf("WriteResult: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if _, has := resp["evaluatedItems"]; has {
|
||||
t.Fatalf("expected evaluatedItems omitted on a grain with nil breakdown")
|
||||
}
|
||||
}
|
||||
|
||||
// mapKeys returns the sorted set of top-level keys for nicer failure output.
|
||||
func mapKeys(m map[string]json.RawMessage) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"git.k6n.net/mats/platform/config"
|
||||
)
|
||||
|
||||
// consumedKeys are product-document keys that are mapped to typed AddItem
|
||||
@@ -24,7 +25,7 @@ var consumedKeys = []string{
|
||||
// PRODUCT_BASE_URL (the country argument is currently unused but kept for when
|
||||
// per-market routing returns).
|
||||
func getBaseUrl(country string) string {
|
||||
return getEnv("PRODUCT_BASE_URL", "http://localhost:8082")
|
||||
return config.EnvString("PRODUCT_BASE_URL", "http://localhost:8082")
|
||||
}
|
||||
|
||||
// ProductItem is the flat product document served by GET /api/get/{sku}.
|
||||
@@ -138,7 +139,7 @@ const minGlassArea = 0.4 // m²
|
||||
// areaFromItem derives the main article's *visible glass* surface in m² from a
|
||||
// resolved configurator selection. The catalog width/height are facet codes;
|
||||
// the outer (karmytter) size is `code × 100 − margin` mm and the visible glass
|
||||
// is that minus the frame border per axis, e.g. width 4, widthMargin 20,
|
||||
// is that minus the frame border per axis, e.g. width 4, marginWidth 20,
|
||||
// borderWidth 194 → 400 − 20 − 194 = 186 mm. Area = glassW(mm) × glassH(mm) /
|
||||
// 1e6, clamped up to the minGlassArea billing floor. Returns 0 when a dimension
|
||||
// is missing/non-numeric (non-window PDP, or no product resolved yet).
|
||||
@@ -200,6 +201,9 @@ func ToItemAddMessage(item *ProductItem, parent *ProductItem, storeId *string, q
|
||||
Name: item.Title,
|
||||
Image: item.Img,
|
||||
Stock: stock,
|
||||
// item.Vat is the product's VAT as a raw integer percent (e.g. 25);
|
||||
// ×100 converts to the platform basis-point scale (2500). This is the
|
||||
// single conversion boundary — everything downstream is basis points.
|
||||
Tax: int32(item.Vat * 100),
|
||||
SellerId: strconv.Itoa(item.SupplierId),
|
||||
SellerName: item.SupplierName,
|
||||
|
||||
@@ -56,7 +56,7 @@ func newTestPoolServer(t *testing.T) *PoolServer {
|
||||
if err != nil {
|
||||
t.Fatalf("new pool: %v", err)
|
||||
}
|
||||
return NewPoolServer(pool, "test")
|
||||
return NewPoolServer(pool, "test", nil)
|
||||
}
|
||||
|
||||
// exampleId is the catalog item the fetcher rework was validated against.
|
||||
@@ -338,7 +338,7 @@ func TestChildren_ParentLinkage(t *testing.T) {
|
||||
Children: []Item{{Sku: exampleId, Quantity: 1, StoreId: &childStore}},
|
||||
}}
|
||||
|
||||
groups := buildItemGroups(ctx, items, "se")
|
||||
groups := buildItemGroups(ctx, items, "se", nil)
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("groups = %d, want 1", len(groups))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/platform/catalog"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// TestProjectionCache_ApplyUpserts covers the happy path: a batch of N
|
||||
// projections reaches the cache via Apply, all are visible via Get.
|
||||
func TestProjectionCache_ApplyUpserts(t *testing.T) {
|
||||
c := mustCache(t)
|
||||
updates := []catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard"}},
|
||||
{Projection: catalog.Projection{ID: "id-B", SKU: "B", PriceIncVat: money.Cents(150_00), TaxClass: "reduced"}},
|
||||
{Projection: catalog.Projection{ID: "id-C", SKU: "C", PriceIncVat: money.Cents(0), TaxClass: ""}},
|
||||
}
|
||||
upserts, deletes := c.Apply(updates)
|
||||
if upserts != 3 || deletes != 0 {
|
||||
t.Fatalf("counts: upserts=%d deletes=%d, want 3/0", upserts, deletes)
|
||||
}
|
||||
for _, want := range []struct {
|
||||
sku string
|
||||
price money.Cents
|
||||
taxCls string
|
||||
}{
|
||||
{"A", money.Cents(100_00), "standard"},
|
||||
{"B", money.Cents(150_00), "reduced"},
|
||||
{"C", money.Cents(0), ""},
|
||||
} {
|
||||
got, ok := c.Get(want.sku)
|
||||
if !ok {
|
||||
t.Fatalf("Get(%q): missing", want.sku)
|
||||
}
|
||||
if got.PriceIncVat != want.price || got.TaxClass != want.taxCls {
|
||||
t.Fatalf("Get(%q) = %+v, want price=%d taxCls=%q", want.sku, got, want.price, want.taxCls)
|
||||
}
|
||||
}
|
||||
if c.Len() != 3 {
|
||||
t.Fatalf("Len = %d, want 3", c.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectionCache_DeleteTombstone verifies that a delete update sets a
|
||||
// tombstone (not removes the entry), so:
|
||||
// - Get returns (_, false) on a deleted SKU
|
||||
// - IsDeleted returns true
|
||||
// - the same entry continues to count toward Len (size accounting)
|
||||
func TestProjectionCache_DeleteTombstone(t *testing.T) {
|
||||
c := mustCache(t)
|
||||
c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{ID: "id-A", SKU: "A", PriceIncVat: money.Cents(100_00)}},
|
||||
})
|
||||
if _, ok := c.Get("A"); !ok {
|
||||
t.Fatalf("A: expected hit before delete")
|
||||
}
|
||||
|
||||
upserts, deletes := c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "A"}, Deleted: true},
|
||||
})
|
||||
if upserts != 0 || deletes != 1 {
|
||||
t.Fatalf("delete counts: upserts=%d deletes=%d, want 0/1", upserts, deletes)
|
||||
}
|
||||
|
||||
if got, ok := c.Get("A"); ok {
|
||||
t.Fatalf("A after delete: got %+v ok=true, want miss", got)
|
||||
}
|
||||
if !c.IsDeleted("A") {
|
||||
t.Fatalf("A after delete: IsDeleted false")
|
||||
}
|
||||
if c.IsDeleted("UNKNOWN") {
|
||||
t.Fatalf("UNKNOWN: IsDeleted should be false (entry doesn't exist)")
|
||||
}
|
||||
if c.Len() != 1 {
|
||||
t.Fatalf("Len after tombstone: %d, want 1 (tombstone still in map)", c.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectionCache_BusResurrect verifies that a fresh upsert after a delete
|
||||
// clears the tombstone (live entry replaces it): Get returns the projection,
|
||||
// IsDeleted flips to false. Mirrors how the live catalog keeps flipping state.
|
||||
func TestProjectionCache_BusResurrect(t *testing.T) {
|
||||
c := mustCache(t)
|
||||
c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}},
|
||||
{Projection: catalog.Projection{SKU: "A"}, Deleted: true},
|
||||
})
|
||||
if !c.IsDeleted("A") {
|
||||
t.Fatalf("A: not tombstoned after delete")
|
||||
}
|
||||
|
||||
c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(75_00)}},
|
||||
})
|
||||
|
||||
got, ok := c.Get("A")
|
||||
if !ok {
|
||||
t.Fatalf("A: expected live after re-upsert")
|
||||
}
|
||||
if got.PriceIncVat != money.Cents(75_00) {
|
||||
t.Fatalf("A re-upsert price = %d, want 7500", got.PriceIncVat)
|
||||
}
|
||||
if c.IsDeleted("A") {
|
||||
t.Fatalf("A: still tombstoned after re-upsert")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectionCache_EmptySKUIgnored validates that an upsert with an empty
|
||||
// SKU is a no-op (defensive — bus should never publish "" but cheaper to log
|
||||
// + drop than to populate an ambiguous cache slot).
|
||||
func TestProjectionCache_EmptySKUIgnored(t *testing.T) {
|
||||
c := mustCache(t)
|
||||
upserts, _ := c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "", PriceIncVat: money.Cents(100_00)}},
|
||||
})
|
||||
if upserts != 0 {
|
||||
t.Fatalf("empty-SKU upsert: %d, want 0", upserts)
|
||||
}
|
||||
if c.Len() != 0 {
|
||||
t.Fatalf("Len after empty-SKU: %d, want 0", c.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectionCache_BusRaceSafe runs Apply + Get concurrently under -race
|
||||
// to lock down the lock order. Run via `go test -race ./cmd/cart/...`.
|
||||
func TestProjectionCache_BusRaceSafe(t *testing.T) {
|
||||
c := mustCache(t)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < 500; i++ {
|
||||
c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "X", PriceIncVat: money.Cents(int64(i * 100))}},
|
||||
})
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 2000; i++ {
|
||||
_, _ = c.Get("X")
|
||||
_ = c.IsDeleted("X")
|
||||
_ = c.Len()
|
||||
}
|
||||
<-done
|
||||
if _, ok := c.Get("X"); !ok {
|
||||
t.Fatalf("final Get(X): miss after concurrent writes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApply_MixedUpsertDelete verifies the realistic per-batch sequence a
|
||||
// publisher emits: a single Apply call interleaves upserts and deletes, and
|
||||
// the per-batch counters report the right split. Without this the cache could
|
||||
// drift on count semantics across batched events.
|
||||
func TestApply_MixedUpsertDelete(t *testing.T) {
|
||||
c := mustCache(t)
|
||||
// Order matters: prior in-cache state for A is upserted twice (latest wins),
|
||||
// B is tombstoned, C cold-upserted, D cold-tombstoned.
|
||||
updates := []catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(50_00)}},
|
||||
{Projection: catalog.Projection{SKU: "B"}, Deleted: true},
|
||||
{Projection: catalog.Projection{SKU: "C", PriceIncVat: money.Cents(75_00)}},
|
||||
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(60_00)}}, // later wins
|
||||
{Projection: catalog.Projection{SKU: "D"}, Deleted: true},
|
||||
}
|
||||
upserts, deletes := c.Apply(updates)
|
||||
if upserts != 3 || deletes != 2 {
|
||||
t.Fatalf("counts: upserts=%d deletes=%d, want 3/2 (A,C live-upserts; B,D tombstones)", upserts, deletes)
|
||||
}
|
||||
if got, ok := c.Get("A"); !ok || got.PriceIncVat != money.Cents(60_00) {
|
||||
t.Fatalf("A latest-wins: got %+v ok=%v, want 6000", got, ok)
|
||||
}
|
||||
if _, ok := c.Get("B"); ok {
|
||||
t.Fatalf("B: tombstoned but Get returned a value")
|
||||
}
|
||||
if !c.IsDeleted("B") {
|
||||
t.Fatalf("B: IsDeleted not true")
|
||||
}
|
||||
if got, ok := c.Get("C"); !ok || got.PriceIncVat != money.Cents(75_00) {
|
||||
t.Fatalf("C cold-upsert: got %+v ok=%v, want 7500", got, ok)
|
||||
}
|
||||
if !c.IsDeleted("D") {
|
||||
t.Fatalf("D: IsDeleted not true")
|
||||
}
|
||||
if c.Len() != 4 {
|
||||
t.Fatalf("Len() after mixed batch: %d, want 4 (A live + B tombstone + C live + D tombstone)", c.Len())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaxResolveBp covers the static TaxClass→bp mapping used by
|
||||
// ApplyProjectionOverlay. Adds a regression net for the common Nordic rates.
|
||||
func TestTaxResolveBp(t *testing.T) {
|
||||
cases := []struct {
|
||||
class string
|
||||
want int
|
||||
}{
|
||||
{"standard", 2500},
|
||||
{"reduced", 1200},
|
||||
{"lowered", 600},
|
||||
{"zero", 0},
|
||||
{"exempt", 0},
|
||||
{"", 2500}, // missing class defaults to standard (matches mutation registry)
|
||||
{"unknown-class", 2500},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := resolveTaxBp(tc.class)
|
||||
if got != tc.want {
|
||||
t.Errorf("resolveTaxBp(%q) = %d, want %d", tc.class, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"git.k6n.net/mats/platform/catalog"
|
||||
)
|
||||
|
||||
// taxClassToBp maps the canonical TaxClass string identifiers published on
|
||||
// catalog.Projection to basis-points-of-a-percent (rate × 100) — the single
|
||||
// platform-wide rate scale used by AddItem.Tax (int32 basis points), the
|
||||
// mutation registry, and the tax.Provider Compute formula.
|
||||
//
|
||||
// Values reflect the standard tariff names used by the Nordic configuration.
|
||||
// Per-country / per-tenant numeric resolution (e.g. Finland 25.5%, Ireland
|
||||
// 13.5%) is delegated to platform/tax and is a follow-up: this static map
|
||||
// gets the common case right (2500 / 1200 / 600 / 0) and lets the cache path
|
||||
// ship while the platform/tax lookup is wired into the cart pool server.
|
||||
var taxClassToBp = map[string]int{
|
||||
"standard": 2500,
|
||||
"reduced": 1200, // common reduced rate (e.g. SE/NO food)
|
||||
"lowered": 600, // super-reduced
|
||||
"zero": 0, // zero-rated (export, healthcare, ...)
|
||||
"exempt": 0, // similarly untaxed, separate nominal class
|
||||
}
|
||||
|
||||
// resolveTaxBp returns the basis-point rate for a TaxClass string, falling
|
||||
// back to platform "standard" (25%) when the class is unrecognised. The
|
||||
// default matches the grain's existing behaviour (AddItem falls back to 2500
|
||||
// when m.Tax == 0 — see pkg/cart/mutation_add_item.go).
|
||||
func resolveTaxBp(class string) int {
|
||||
if bp, ok := taxClassToBp[class]; ok {
|
||||
return bp
|
||||
}
|
||||
return 2500
|
||||
}
|
||||
|
||||
// HasRequiredFields asserts the catalog Projection carries enough authoritative
|
||||
// fields for the cart to build a complete AddItem WITHOUT an HTTP fallback to
|
||||
// PRODUCT_BASE_URL. Required:
|
||||
//
|
||||
// PriceIncVat > 0 (treats zero as "unbus-fed", not "free"; positive-only
|
||||
// matches every other cache-authoritative field)
|
||||
// TaxClass != "" (string identifier; numeric rate resolved via
|
||||
// resolveTaxBp at the same scale as the HTTP path)
|
||||
// ItemID != 0 (uint32 dedup key; zero means the producer didn't
|
||||
// publish one, so we don't claim we know the id)
|
||||
//
|
||||
// Display fields (Title, Image) and fulfillment flags (InventoryTracked,
|
||||
// DropShip) are optional — when present they populate the line, when absent
|
||||
// the proto-zero value is acceptable.
|
||||
func HasRequiredFields(p catalog.Projection) bool {
|
||||
return p.PriceIncVat.Int64() > 0 &&
|
||||
p.TaxClass != "" &&
|
||||
p.ItemID != 0
|
||||
}
|
||||
|
||||
// BuildAddItemFromCache constructs a complete AddItem message directly from a
|
||||
// cache hit that satisfies HasRequiredFields. The caller has already confirmed
|
||||
// the projection is well-formed; we map every cache-visible carrier onto the
|
||||
// proto fields the existing HTTP path would have constructed.
|
||||
//
|
||||
// Fields mapped:
|
||||
//
|
||||
// Sku ← input
|
||||
// ItemId ← p.ItemID (uint32 dedup key)
|
||||
// Quantity ← input
|
||||
// Country ← input
|
||||
// StoreId ← input
|
||||
// Price ← p.PriceIncVat.Int64()
|
||||
// Tax ← int32(resolveTaxBp(p.TaxClass))
|
||||
// Name ← p.Title (may be empty)
|
||||
// Image ← p.Image (may be empty)
|
||||
// InventoryTracked ← p.InventoryTracked
|
||||
// DropShip ← p.DropShip
|
||||
//
|
||||
// Fields intentionally left blank (cache-only gap; the schema doesn't yet
|
||||
// carry them and the audience for those fields is downstream of the cart line
|
||||
// — they are NOT required for line dedup or pricing):
|
||||
//
|
||||
// SellerId / SellerName — marketplace split; not on Projection yet.
|
||||
// Stock — queried synchronously from inventory at
|
||||
// decision points (per docs/inventory-shape.md).
|
||||
// OrgPrice / Discount — pre-discount price; a future Projection
|
||||
// extension when promotion previews need it.
|
||||
// ExtraJson — dynamic product data (configurator
|
||||
// width/height etc.); a future schema extension
|
||||
// for cache-only-skip-HTTP for groups with
|
||||
// configurator children.
|
||||
//
|
||||
// IMPORTANT: this helper is for TOP-LEVEL lines only. Configurator children
|
||||
// re-price based on parent dimensions (ToItemAddMessage's parent != nil
|
||||
// path); since the Projection does not yet carry width/height, callers must
|
||||
// keep the existing HTTP-fetch flow for groups with children.
|
||||
//
|
||||
// Tombstoned SKUs never reach this helper; callers check idx.IsDeleted(sku)
|
||||
// first and short-circuit.
|
||||
func BuildAddItemFromCache(sku string, qty int, country string, storeId *string, p catalog.Projection) *messages.AddItem {
|
||||
return &messages.AddItem{
|
||||
Sku: sku,
|
||||
ItemId: p.ItemID,
|
||||
Quantity: int32(qty),
|
||||
Country: country,
|
||||
StoreId: storeId,
|
||||
Price: p.PriceIncVat.Int64(),
|
||||
Tax: int32(resolveTaxBp(p.TaxClass)),
|
||||
Name: p.Title,
|
||||
Image: p.Image,
|
||||
InventoryTracked: p.InventoryTracked,
|
||||
DropShip: p.DropShip,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyProjectionOverlay overlays the cache's authoritative catalog facts onto
|
||||
// an AddItem message that was just produced by a PRODUCT_BASE_URL HTTP fetch.
|
||||
//
|
||||
// Authoritative-from-cache fields (these WIN over the HTTP-fetched value when
|
||||
// both are present, because the bus is the live stream):
|
||||
//
|
||||
// Price (bus has post-class-overrides; product service has static)
|
||||
// Tax (TaxClass → bp via resolveTaxBp; only when TaxClass is set —
|
||||
// an empty TaxClass means the producer didn't classify,
|
||||
// so we leave the HTTP-fetched rate intact rather than
|
||||
// quietly swapping in a default 2500 and miscategorising)
|
||||
// Title (canonical, post-trim)
|
||||
// Image (canonical)
|
||||
// ItemID (positive bus values override HTTP; zero is the
|
||||
// documented "no id" signal, so a tombstone's zero or an
|
||||
// unbus-fed cache value won't clobber a real HTTP id.)
|
||||
// InventoryTracked (only flips to true; false is the zero value in proto
|
||||
// and may be unset on the wire, so we don't second-guess)
|
||||
// DropShip (only flips to true; same reasoning)
|
||||
//
|
||||
// The HTTP-fetched values remain authoritative for fields the cache does not
|
||||
// carry: SellerId / SellerName (marketplace split), Stock (per docs/inventory-
|
||||
// shape.md stock is queried synchronously from inventory, not cached),
|
||||
// OrgPrice / Discount (not yet on the projection schema), and the dynamic
|
||||
// ExtraJson product data (configurator options — width/height used by
|
||||
// accessory child-pricing).
|
||||
//
|
||||
// SKUs that satisfy HasRequiredFields AND have no configurator children
|
||||
// route through BuildAddItemFromCache instead — see the cache-only fast path
|
||||
// in buildItemGroups / AddSkuToCartHandler. ApplyProjectionOverlay remains
|
||||
// for the cold-grace case (unbus-fed cache entry) AND for groups with
|
||||
// children (the parent's HTTP fetch is required for child dimension-driven
|
||||
// pricing; Overlay is applied after the fetch).
|
||||
//
|
||||
// Tombstoned SKUs (catalog.projection_published with deleted=true) → the
|
||||
// caller checks idx.IsDeleted(sku) before this helper and short-circuits.
|
||||
func ApplyProjectionOverlay(msg *messages.AddItem, p catalog.Projection) {
|
||||
if msg == nil {
|
||||
return
|
||||
}
|
||||
// Price: positive bus values override HTTP. A zero PriceIncVat is treated
|
||||
// as "no value" rather than "free".
|
||||
if p.PriceIncVat.Int64() > 0 {
|
||||
msg.Price = p.PriceIncVat.Int64()
|
||||
}
|
||||
// Tax: only override when the bus has classified the SKU. An empty TaxClass
|
||||
// means the producer had nothing to say about VAT; the HTTP rate is more
|
||||
// trustworthy in that case than a guessed 2500.
|
||||
if p.TaxClass != "" {
|
||||
msg.Tax = int32(resolveTaxBp(p.TaxClass))
|
||||
}
|
||||
// ItemId: positive bus values override HTTP. Zero means the producer
|
||||
// didn't publish an id (some sources have no integer id) — we leave the
|
||||
// HTTP-fetched id intact in that case rather than zeroing out a real id.
|
||||
if p.ItemID != 0 {
|
||||
msg.ItemId = p.ItemID
|
||||
}
|
||||
// Display fields: only override if non-empty so the HTTP-fanned value
|
||||
// remains in place when the cache hasn't populated them yet.
|
||||
if p.Title != "" {
|
||||
msg.Name = p.Title
|
||||
}
|
||||
if p.Image != "" {
|
||||
msg.Image = p.Image
|
||||
}
|
||||
// Flags: only flip to true (positive boolean signals). A false is the
|
||||
// proto-zero value and not worth overriding.
|
||||
if p.InventoryTracked {
|
||||
msg.InventoryTracked = true
|
||||
}
|
||||
if p.DropShip {
|
||||
msg.DropShip = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"git.k6n.net/mats/platform/catalog"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// TestApplyProjectionOverlay_PricePositiveOnly covers the contract that a cache
|
||||
// hit with a positive PriceIncVat overrides HTTP-fetched price, but a zero
|
||||
// (unbroadcast) PriceIncVat must NOT overwrite a valid HTTP price — it would
|
||||
// be a silent corruption.
|
||||
func TestApplyProjectionOverlay_PricePositiveOnly(t *testing.T) {
|
||||
startPrice := int64(95_00)
|
||||
base := func() *messages.AddItem {
|
||||
return &messages.AddItem{Sku: "X", Price: startPrice, Name: "http-name", Image: "http.jpg", Tax: 2500}
|
||||
}
|
||||
|
||||
// Positive cache value wins.
|
||||
m := base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "cache-name", Image: "cache.jpg", PriceIncVat: money.Cents(120_00), TaxClass: "reduced"})
|
||||
if m.Price != 120_00 {
|
||||
t.Errorf("positive cache price: got %d, want 12000", m.Price)
|
||||
}
|
||||
|
||||
// Zero cache value must NOT overwrite (treat as "no value").
|
||||
m = base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", PriceIncVat: money.Cents(0)})
|
||||
if m.Price != startPrice {
|
||||
t.Errorf("zero cache price overwrote HTTP price: got %d, want %d (HTTP)", m.Price, startPrice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyProjectionOverlay_TaxOnlyWhenClassSet verifies the TaxClass-skip on
|
||||
// an unclassified cache entry: HTTP rate is more trustworthy than a guessed
|
||||
// 2500 default, so msg.Tax stays untouched.
|
||||
func TestApplyProjectionOverlay_TaxOnlyWhenClassSet(t *testing.T) {
|
||||
base := func() *messages.AddItem {
|
||||
return &messages.AddItem{Sku: "X", Tax: 600} // HTTP-fetched lowered 6%
|
||||
}
|
||||
|
||||
// Empty TaxClass: HTTP rate preserved.
|
||||
m := base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: ""})
|
||||
if m.Tax != 600 {
|
||||
t.Errorf("empty TaxClass overwrote HTTP tax: got %d, want 600", m.Tax)
|
||||
}
|
||||
|
||||
// Non-empty: cache rate wins.
|
||||
m = base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: "reduced"})
|
||||
if m.Tax != 1200 {
|
||||
t.Errorf("TaxClass=reduced: got %d, want 1200", m.Tax)
|
||||
}
|
||||
|
||||
m = base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", TaxClass: "zero"})
|
||||
if m.Tax != 0 {
|
||||
t.Errorf("TaxClass=zero: got %d, want 0", m.Tax)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyProjectionOverlay_DisplayFields verifies Title/Image override only
|
||||
// when the cache actually carries a value (an unbus-fed "" isn't propagated
|
||||
// over a valid HTTP value).
|
||||
func TestApplyProjectionOverlay_DisplayFields(t *testing.T) {
|
||||
base := func() *messages.AddItem {
|
||||
return &messages.AddItem{Sku: "X", Name: "http-name", Image: "http.jpg"}
|
||||
}
|
||||
|
||||
m := base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "cache-name", Image: "cache.jpg"})
|
||||
if m.Name != "cache-name" {
|
||||
t.Errorf("Title overlay: got %q, want %q", m.Name, "cache-name")
|
||||
}
|
||||
if m.Image != "cache.jpg" {
|
||||
t.Errorf("Image overlay: got %q, want %q", m.Image, "cache.jpg")
|
||||
}
|
||||
|
||||
// Empty cache Title/Image must NOT overwrite.
|
||||
m = base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", Title: "", Image: ""})
|
||||
if m.Name != "http-name" {
|
||||
t.Errorf("empty cache Title wiped HTTP name: got %q", m.Name)
|
||||
}
|
||||
if m.Image != "http.jpg" {
|
||||
t.Errorf("empty cache Image wiped HTTP image: got %q", m.Image)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyProjectionOverlay_FlagFlipToTrueOnly locks down the
|
||||
// InventoryTracked / DropShip positive-flip semantics. False in the cache
|
||||
// is the proto zero value and may be unset on the wire; we don't second-guess.
|
||||
func TestApplyProjectionOverlay_FlagFlipToTrueOnly(t *testing.T) {
|
||||
// HTTP set both true (e.g. live SKU). Cache also has them true. Should
|
||||
// stay true.
|
||||
m := &messages.AddItem{InventoryTracked: true, DropShip: true}
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: true, DropShip: true})
|
||||
if !m.InventoryTracked || !m.DropShip {
|
||||
t.Fatalf("true-from-cache over a true-from-HTTP: %+v", m)
|
||||
}
|
||||
|
||||
// HTTP set false (default), cache says true → upgrade to true.
|
||||
m = &messages.AddItem{InventoryTracked: false, DropShip: false}
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: true, DropShip: true})
|
||||
if !m.InventoryTracked || !m.DropShip {
|
||||
t.Errorf("true-from-cache should flip false-from-HTTP: %+v", m)
|
||||
}
|
||||
|
||||
// HTTP already true, cache says false → must NOT downgrade (false would
|
||||
// be the proto zero value; trust the live HTTP signal here).
|
||||
m = &messages.AddItem{InventoryTracked: true, DropShip: true}
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", InventoryTracked: false, DropShip: false})
|
||||
if !m.InventoryTracked || !m.DropShip {
|
||||
t.Errorf("false-from-cache must NOT overwrite true-from-HTTP: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyProjectionOverlay_NilMsgSafe confirms the helper tolerates a nil
|
||||
// pointer (defensive — caller already validates, but a future caller may not).
|
||||
func TestApplyProjectionOverlay_NilMsgSafe(t *testing.T) {
|
||||
// Must not panic.
|
||||
ApplyProjectionOverlay(nil, catalog.Projection{SKU: "X"})
|
||||
}
|
||||
|
||||
// TestAddSkuToCartHandler_TombstoneReturns404 verifies the bus-deleted
|
||||
// rejection path: HTTP 404 with the product-fetcher-shaped error string, no
|
||||
// HTTP fetch attempted. Locks down the regression where the handler returned
|
||||
// an error and the framework rendered it as 500.
|
||||
//
|
||||
// Why a real mux: `r.PathValue("sku")` only resolves when the request is
|
||||
// dispatched through a mux that registered the path pattern (e.g.
|
||||
// `GET /cart/add/{sku}` in pool-server.go's Serve()). Calling the handler
|
||||
// directly with httptest.NewRequest produces an empty PathValue, which would
|
||||
// bypass the tombstone branch and fall through to the HTTP fetch — failing
|
||||
// the test for a wiring reason rather than the contract under test.
|
||||
func TestAddSkuToCartHandler_TombstoneReturns404(t *testing.T) {
|
||||
idx := mustCache(t)
|
||||
idx.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "DEL", PriceIncVat: money.Cents(10_00)}},
|
||||
{Projection: catalog.Projection{SKU: "DEL"}, Deleted: true},
|
||||
})
|
||||
// Use a stub PoolServer with nil grain pool: the tombstone path returns
|
||||
// BEFORE ApplyLocal, so the embedded actor.GrainPool is never touched. If a
|
||||
// future edit adds an early s.IsHealthy() / pool-touch on this handler, the
|
||||
// test will panic with a nil-pointer deref and the wiring fault will be
|
||||
// obvious.
|
||||
s := &PoolServer{pod_name: "test", idx: idx}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /cart/add/{sku}", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.AddSkuToCartHandler(w, r, cart.CartId(1))
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/cart/add/DEL", nil))
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d (bus-deleted SKU). body=%s",
|
||||
rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
// http.Error-style exact shape: trailing newline, text/plain. Pinning the
|
||||
// string catches regressions either way (body too loose AND format flips).
|
||||
wantCT := "text/plain; charset=utf-8"
|
||||
if got := rec.Header().Get("Content-Type"); got != wantCT {
|
||||
t.Errorf("Content-Type = %q, want %q", got, wantCT)
|
||||
}
|
||||
wantBody := "product service returned 404 for sku DEL (bus-deleted)\n"
|
||||
if got := rec.Body.String(); got != wantBody {
|
||||
t.Errorf("body mismatch:\n got %q\n want %q", got, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsDeletedVsGet asserts the two methods don't conflict: a tombstoned SKU
|
||||
// is reported by both IsDeleted (true) and Get (miss).
|
||||
func TestIsDeletedVsGet(t *testing.T) {
|
||||
c := mustCache(t)
|
||||
c.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "DL", PriceIncVat: money.Cents(10_00)}},
|
||||
{Projection: catalog.Projection{SKU: "DL"}, Deleted: true},
|
||||
})
|
||||
if _, ok := c.Get("DL"); ok {
|
||||
t.Fatalf("Get on tombstone: ok=true")
|
||||
}
|
||||
if !c.IsDeleted("DL") {
|
||||
t.Fatalf("IsDeleted on tombstone: false")
|
||||
}
|
||||
if c.IsDeleted("DL-fresh") {
|
||||
t.Fatalf("IsDeleted on absent entry: true")
|
||||
}
|
||||
if _, ok := c.Get("DL-fresh"); ok {
|
||||
t.Fatalf("Get on absent entry: ok=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyProjectionOverlay_ItemIdPositiveOnly locks the contract that a
|
||||
// positive bus ItemID overrides HTTP, but ItemID=0 must NOT clobber a real
|
||||
// HTTP id (e.g. a producer that omits an integer id, or a tombstone's zero
|
||||
// residual). This is the wire that future-unblocks the cache-only-skip-HTTP
|
||||
// path; regression here would silently drop cart-line dedup info.
|
||||
func TestApplyProjectionOverlay_ItemIdPositiveOnly(t *testing.T) {
|
||||
base := func() *messages.AddItem {
|
||||
return &messages.AddItem{Sku: "X", ItemId: 12345}
|
||||
}
|
||||
|
||||
// Positive cache id wins.
|
||||
m := base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", ItemID: 67890})
|
||||
if m.ItemId != 67890 {
|
||||
t.Errorf("positive cache ItemID: got %d, want 67890", m.ItemId)
|
||||
}
|
||||
|
||||
// Zero cache id must NOT overwrite HTTP.
|
||||
m = base()
|
||||
ApplyProjectionOverlay(m, catalog.Projection{SKU: "X", ItemID: 0})
|
||||
if m.ItemId != 12345 {
|
||||
t.Errorf("zero cache ItemID wiped HTTP id: got %d, want 12345", m.ItemId)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache-only-skip-HTTP path tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestHasRequiredFields covers the eligibility contract for the cache-only
|
||||
// fast path: positive PriceIncVat, non-empty TaxClass, non-zero ItemID. Any
|
||||
// one missing → HTTP fallback.
|
||||
func TestHasRequiredFields(t *testing.T) {
|
||||
full := catalog.Projection{
|
||||
SKU: "S", PriceIncVat: money.Cents(100), TaxClass: "standard", ItemID: 1,
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
p catalog.Projection
|
||||
want bool
|
||||
}{
|
||||
{"all required fields set", full, true},
|
||||
{"zero price → ineligible", catalog.Projection{SKU: "S", TaxClass: "standard", ItemID: 1}, false},
|
||||
{"empty tax → ineligible", catalog.Projection{SKU: "S", PriceIncVat: money.Cents(100), ItemID: 1}, false},
|
||||
{"zero id → ineligible", catalog.Projection{SKU: "S", PriceIncVat: money.Cents(100), TaxClass: "standard"}, false},
|
||||
{"only sku set", catalog.Projection{SKU: "S"}, false},
|
||||
{"empty zero value", catalog.Projection{}, false},
|
||||
{"reduced-rate still eligible", catalog.Projection{SKU: "S", PriceIncVat: money.Cents(99), TaxClass: "reduced", ItemID: 7}, true},
|
||||
{"zero-rate still eligible (zero TaxClass not required, only non-empty)",
|
||||
catalog.Projection{SKU: "S", PriceIncVat: money.Cents(0), TaxClass: "zero", ItemID: 7}, false}, // price=0 still ineligible
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := HasRequiredFields(tc.p); got != tc.want {
|
||||
t.Errorf("%s: HasRequiredFields=%v, want %v (p=%+v)", tc.name, got, tc.want, tc.p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAddItemFromCache verifies the cache-only fast-path builder maps
|
||||
// every cache-visible carrier onto the proto fields. ItemID, Price, Tax
|
||||
// (via TaxClass resolution), Title/Image, quantity/country/storeId all
|
||||
// populated; SellerId/SellerName/Stock/OrgPrice/ExtraJson left blank (those
|
||||
// fields live outside the Projection for now).
|
||||
func TestBuildAddItemFromCache(t *testing.T) {
|
||||
p := catalog.Projection{
|
||||
SKU: "X",
|
||||
Title: "Cache Title",
|
||||
Image: "cache.jpg",
|
||||
PriceIncVat: money.Cents(123_45),
|
||||
TaxClass: "reduced",
|
||||
ItemID: 42,
|
||||
InventoryTracked: true,
|
||||
DropShip: true,
|
||||
}
|
||||
msg := BuildAddItemFromCache("X", 2, "no", nil, p)
|
||||
if msg == nil {
|
||||
t.Fatalf("BuildAddItemFromCache returned nil")
|
||||
}
|
||||
if msg.Sku != "X" {
|
||||
t.Errorf("Sku: got %q, want %q", msg.Sku, "X")
|
||||
}
|
||||
if msg.ItemId != 42 {
|
||||
t.Errorf("ItemId: got %d, want 42", msg.ItemId)
|
||||
}
|
||||
if msg.Quantity != 2 {
|
||||
t.Errorf("Quantity: got %d, want 2", msg.Quantity)
|
||||
}
|
||||
if msg.Country != "no" {
|
||||
t.Errorf("Country: got %q, want %q", msg.Country, "no")
|
||||
}
|
||||
if msg.StoreId != nil {
|
||||
t.Errorf("StoreId: got %v, want nil", msg.StoreId)
|
||||
}
|
||||
if msg.Price != 123_45 {
|
||||
t.Errorf("Price: got %d, want 12345", msg.Price)
|
||||
}
|
||||
if msg.Tax != 1200 {
|
||||
t.Errorf("Tax: got %d, want 1200 (reduced)", msg.Tax)
|
||||
}
|
||||
if msg.Name != "Cache Title" {
|
||||
t.Errorf("Name: got %q, want %q", msg.Name, "Cache Title")
|
||||
}
|
||||
if msg.Image != "cache.jpg" {
|
||||
t.Errorf("Image: got %q, want %q", msg.Image, "cache.jpg")
|
||||
}
|
||||
if !msg.InventoryTracked {
|
||||
t.Errorf("InventoryTracked: got false, want true")
|
||||
}
|
||||
if !msg.DropShip {
|
||||
t.Errorf("DropShip: got false, want true")
|
||||
}
|
||||
// Cache-only gaps (intentionally blank):
|
||||
if msg.SellerId != "" || msg.SellerName != "" {
|
||||
t.Errorf("SellerId/SellerName should be blank: %q/%q", msg.SellerId, msg.SellerName)
|
||||
}
|
||||
if msg.Stock != 0 {
|
||||
t.Errorf("Stock should be 0 (queried sync from inventory): got %d", msg.Stock)
|
||||
}
|
||||
if msg.OrgPrice != 0 {
|
||||
t.Errorf("OrgPrice should be 0 (not on Projection): got %d", msg.OrgPrice)
|
||||
}
|
||||
if len(msg.ExtraJson) != 0 {
|
||||
t.Errorf("ExtraJson should be empty (not on Projection): got %s", msg.ExtraJson)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAddItemFromCache_WithStoreId covers the *string storeId path.
|
||||
// nil pointer is the default; non-nil must propagate through.
|
||||
func TestBuildAddItemFromCache_WithStoreId(t *testing.T) {
|
||||
sid := "store-42"
|
||||
p := catalog.Projection{
|
||||
SKU: "X", PriceIncVat: money.Cents(100),
|
||||
TaxClass: "standard", ItemID: 1,
|
||||
}
|
||||
msg := BuildAddItemFromCache("X", 1, "se", &sid, p)
|
||||
if msg.StoreId == nil {
|
||||
t.Fatalf("StoreId is nil; want propagation")
|
||||
}
|
||||
if *msg.StoreId != "store-42" {
|
||||
t.Errorf("StoreId: got %q, want %q", *msg.StoreId, "store-42")
|
||||
}
|
||||
}
|
||||
|
||||
// productServer is a fixture that returns a minimal product document so
|
||||
// BuildItemMessage / FetchItem can complete without depending on a real
|
||||
// product service. Each invocation records the SKU path so tests can assert
|
||||
// "the cache-only path did NOT call the product service".
|
||||
type productProduct struct {
|
||||
Id uint64 `json:"id"`
|
||||
Sku string `json:"sku"`
|
||||
Title string `json:"title"`
|
||||
Img string `json:"img"`
|
||||
Price float64 `json:"price"`
|
||||
Vat int `json:"vat"`
|
||||
InStock int32 `json:"inStock"`
|
||||
SupplierId int `json:"supplierId"`
|
||||
SupplierName string `json:"supplierName"`
|
||||
}
|
||||
|
||||
// fakeProductServer is a goroutine-safe mock that records each call.
|
||||
type fakeProductServer struct {
|
||||
srv *httptest.Server
|
||||
called chan string
|
||||
}
|
||||
|
||||
func newFakeProductServer(t *testing.T) *fakeProductServer {
|
||||
t.Helper()
|
||||
called := make(chan string, 16)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case called <- r.URL.Path:
|
||||
default:
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
sku := r.URL.Path
|
||||
_ = json.NewEncoder(w).Encode(productProduct{
|
||||
Id: 100, Sku: sku, Title: "http-name",
|
||||
Img: "http.jpg", Price: 50.0, Vat: 25,
|
||||
InStock: 5, SupplierId: 1, SupplierName: "test",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
return &fakeProductServer{srv: srv, called: called}
|
||||
}
|
||||
|
||||
// TestBuildItemGroups_CacheOnlySkipsHTTP locks the core contract of the
|
||||
// cache-only fast path: a parent with NO children AND a cache hit with
|
||||
// HasRequiredFields returns an AddItem populated from the Projection
|
||||
// WITHOUT making any HTTP round-trip. The mock product server's handler
|
||||
// calls t.Errorf if invoked, so a non-empty channel would also fail the
|
||||
// test.
|
||||
func TestBuildItemGroups_CacheOnlySkipsHTTP(t *testing.T) {
|
||||
prod := newFakeProductServer(t)
|
||||
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
|
||||
|
||||
idx := mustCache(t)
|
||||
idx.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{
|
||||
SKU: "TOP",
|
||||
PriceIncVat: money.Cents(99_00),
|
||||
TaxClass: "standard",
|
||||
ItemID: 7,
|
||||
Title: "Cache Top",
|
||||
Image: "cache.jpg",
|
||||
InventoryTracked: true,
|
||||
}},
|
||||
})
|
||||
|
||||
groups := buildItemGroups(context.Background(), []Item{
|
||||
{Sku: "TOP", Quantity: 1},
|
||||
}, "se", idx)
|
||||
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("len(groups) = %d, want 1", len(groups))
|
||||
}
|
||||
g := groups[0]
|
||||
if g.parent == nil {
|
||||
t.Fatalf("parent nil; want a BuildAddItemFromCache-built message")
|
||||
}
|
||||
if g.parent.Sku != "TOP" {
|
||||
t.Errorf("Sku: got %q, want %q", g.parent.Sku, "TOP")
|
||||
}
|
||||
if g.parent.Price != 99_00 {
|
||||
t.Errorf("Price: got %d, want 9900", g.parent.Price)
|
||||
}
|
||||
if g.parent.Tax != 2500 {
|
||||
t.Errorf("Tax: got %d, want 2500", g.parent.Tax)
|
||||
}
|
||||
if g.parent.ItemId != 7 {
|
||||
t.Errorf("ItemId: got %d, want 7", g.parent.ItemId)
|
||||
}
|
||||
if g.parent.Name != "Cache Top" {
|
||||
t.Errorf("Name: got %q, want %q", g.parent.Name, "Cache Top")
|
||||
}
|
||||
if g.parent.Image != "cache.jpg" {
|
||||
t.Errorf("Image: got %q, want %q", g.parent.Image, "cache.jpg")
|
||||
}
|
||||
if !g.parent.InventoryTracked {
|
||||
t.Errorf("InventoryTracked: got false (cache said true)")
|
||||
}
|
||||
if len(g.children) != 0 {
|
||||
t.Errorf("children: got %d, want 0", len(g.children))
|
||||
}
|
||||
|
||||
// Crucially: the mock product service must NOT have been called.
|
||||
select {
|
||||
case path := <-prod.called:
|
||||
t.Errorf("product service called at %s; cache-only path should skip HTTP", path)
|
||||
default:
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildItemGroups_HTTPFallbackWhenChildrenPresent locks the configurator
|
||||
// safety: a parent with children CANNOT take the cache-only fast path
|
||||
// because price derivation in ToItemAddMessage's parent != nil branch uses
|
||||
// areaFromItem(parent) which reads width/height from the HTTP-fetched
|
||||
// ProductItem.Extra — not on Projection yet. So the HTTP path must run for
|
||||
// BOTH the parent and the child.
|
||||
func TestBuildItemGroups_HTTPFallbackWhenChildrenPresent(t *testing.T) {
|
||||
prod := newFakeProductServer(t)
|
||||
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
|
||||
|
||||
idx := mustCache(t)
|
||||
idx.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{
|
||||
SKU: "PARENT", PriceIncVat: money.Cents(99_00),
|
||||
TaxClass: "standard", ItemID: 7,
|
||||
}},
|
||||
})
|
||||
|
||||
groups := buildItemGroups(context.Background(), []Item{
|
||||
{Sku: "PARENT", Quantity: 1, Children: []Item{
|
||||
{Sku: "CHILD", Quantity: 1},
|
||||
}},
|
||||
}, "se", idx)
|
||||
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("len(groups) = %d, want 1", len(groups))
|
||||
}
|
||||
if groups[0].parent == nil {
|
||||
t.Fatalf("parent nil; expect HTTP-built message")
|
||||
}
|
||||
// Drain the channel and pin BOTH parent and child fetches. A regression
|
||||
// that fetches the parent twice or skips the child will surface loudly:
|
||||
// we capture the SKU paths the mock sees, so missing/duplicate calls
|
||||
// fail the assertion.
|
||||
close(prod.called)
|
||||
paths := make(map[string]int)
|
||||
for path := range prod.called {
|
||||
paths[path]++
|
||||
}
|
||||
if paths["/api/get/PARENT"] < 1 {
|
||||
t.Errorf("expected /api/get/PARENT fetch (HTTP fallback for parent); got %v", paths)
|
||||
}
|
||||
if paths["/api/get/CHILD"] < 1 {
|
||||
t.Errorf("expected /api/get/CHILD fetch (HTTP fallback for child); got %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSkuToCartHandler_CacheOnlySkipsHTTP exercises the AddSkuToCartHandler
|
||||
// cache-only path end-to-end through the mux. The same fail-on-call mock
|
||||
// product service proves no HTTP round-trip happens on the cache-only fast
|
||||
// path. ApplyLocal will fail on a nil grain pool — that's fine, we recover()
|
||||
// inside the mux wrapper; the only assertion that matters is "the mock was
|
||||
// not called".
|
||||
//
|
||||
// Why a real mux: PathValue("sku") only resolves when the mux has registered
|
||||
// the path pattern (see TestAddSkuToCartHandler_TombstoneReturns404 for the
|
||||
// same rationale).
|
||||
func TestAddSkuToCartHandler_CacheOnlySkipsHTTP(t *testing.T) {
|
||||
failSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("product service called at %s on cache-only path; want zero HTTP fetches", r.URL.Path)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer failSrv.Close()
|
||||
t.Setenv("PRODUCT_BASE_URL", failSrv.URL)
|
||||
|
||||
idx := mustCache(t)
|
||||
idx.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{
|
||||
SKU: "OK", PriceIncVat: money.Cents(99_00),
|
||||
TaxClass: "standard", ItemID: 7,
|
||||
Title: "Cache OK", Image: "cache.jpg",
|
||||
}},
|
||||
})
|
||||
s := &PoolServer{pod_name: "test", idx: idx}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /cart/add/{sku}", func(w http.ResponseWriter, r *http.Request) {
|
||||
// ApplyLocal calls into the embedded GrainPool which is nil here;
|
||||
// the cache-only build-the-message path will reach ApplyLocal and
|
||||
// then panic on a nil deref. Recover so the test framework can
|
||||
// observe a clean pass; what matters is the cache-only short-circuit
|
||||
// RAN (the mock product service would have been hit otherwise).
|
||||
defer func() { _ = recover() }()
|
||||
_ = s.AddSkuToCartHandler(w, r, cart.CartId(1))
|
||||
})
|
||||
|
||||
mux.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/cart/add/OK", nil))
|
||||
// No explicit assertion needed: failSrv's t.Errorf would already be
|
||||
// recorded as a test failure if the cache-only short-circuit missed.
|
||||
}
|
||||
|
||||
// TestBuildItemGroups_HTTPFallbackWhenCacheMiss locks the cold-grace path:
|
||||
// when the cache has NO entry for the SKU, the HTTP fetch must run.
|
||||
func TestBuildItemGroups_HTTPFallbackWhenCacheMiss(t *testing.T) {
|
||||
prod := newFakeProductServer(t)
|
||||
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
|
||||
|
||||
idx := mustCache(t) // empty
|
||||
groups := buildItemGroups(context.Background(), []Item{
|
||||
{Sku: "MISS", Quantity: 1},
|
||||
}, "se", idx)
|
||||
if groups[0].parent == nil {
|
||||
t.Fatalf("parent nil; cold-grace should have HTTP-fetched")
|
||||
}
|
||||
close(prod.called)
|
||||
count := 0
|
||||
for range prod.called {
|
||||
count++
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected exactly 1 product service call; got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildItemGroups_HTTPFallbackWhenHasRequiredFieldsFalse locks the
|
||||
// partial-cache case: a cache entry with PriceIncVat > 0 but TaxClass=""
|
||||
// fails HasRequiredFields → HTTP fallback. This keeps the existing cold-grace
|
||||
// behavior for SKUs whose producer hasn't published a TaxClass yet.
|
||||
func TestBuildItemGroups_HTTPFallbackWhenHasRequiredFieldsFalse(t *testing.T) {
|
||||
prod := newFakeProductServer(t)
|
||||
t.Setenv("PRODUCT_BASE_URL", prod.srv.URL)
|
||||
|
||||
idx := mustCache(t)
|
||||
idx.Apply([]catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{
|
||||
SKU: "PARTIAL", PriceIncVat: money.Cents(99_00),
|
||||
TaxClass: "" /* missing → HasRequiredFields=false */,
|
||||
ItemID: 7,
|
||||
}},
|
||||
})
|
||||
|
||||
groups := buildItemGroups(context.Background(), []Item{
|
||||
{Sku: "PARTIAL", Quantity: 1},
|
||||
}, "se", idx)
|
||||
if groups[0].parent == nil {
|
||||
t.Fatalf("parent nil; partial cache should have HTTP-fetched")
|
||||
}
|
||||
close(prod.called)
|
||||
count := 0
|
||||
for range prod.called {
|
||||
count++
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected exactly 1 product service call; got %d (HasRequiredFields=false should fail the cache-only gate)", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/platform/catalog"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// frameMeta builds the event.Meta a producer stamps for a snapshot frame / delta.
|
||||
func frameMeta(phase string, epoch int64) map[string]string {
|
||||
m := map[string]string{catalog.MetaEpoch: strconv.FormatInt(epoch, 10), catalog.MetaTenant: "se"}
|
||||
if phase != "" {
|
||||
m[catalog.MetaSnapshotPhase] = phase
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func enc(t *testing.T, ups []catalog.ProjectionUpdate) []byte {
|
||||
t.Helper()
|
||||
b, err := catalog.EncodeUpdates(ups)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestCartCache_SnapshotThenDelta exercises the cart's cache through the real bus
|
||||
// framing: a full begin→chunk→end snapshot makes SKUs Get-able cold (no Apply),
|
||||
// then an epoch-matched delta overrides one and tombstones another.
|
||||
func TestCartCache_SnapshotThenDelta(t *testing.T) {
|
||||
c := mustCache(t)
|
||||
|
||||
const epoch = int64(1000)
|
||||
if err := c.HandleFrame(frameMeta(catalog.SnapshotBegin, epoch), enc(t, nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.HandleFrame(frameMeta(catalog.SnapshotChunk, epoch), enc(t, []catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{ID: "sku:A", SKU: "A", PriceIncVat: money.Cents(100_00), TaxClass: "standard", ItemID: 1}},
|
||||
{Projection: catalog.Projection{ID: "sku:B", SKU: "B", PriceIncVat: money.Cents(50_00), TaxClass: "reduced", ItemID: 2}},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// end carries the total count for the completeness guard.
|
||||
endMeta := frameMeta(catalog.SnapshotEnd, epoch)
|
||||
endMeta[catalog.MetaCount] = "2"
|
||||
if err := c.HandleFrame(endMeta, enc(t, nil)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got, ok := c.Get("A"); !ok || got.PriceIncVat != 100_00 {
|
||||
t.Fatalf("A from snapshot = %+v ok=%v, want 10000", got, ok)
|
||||
}
|
||||
|
||||
// Epoch-matched delta: bump A's price, delete B.
|
||||
if err := c.HandleFrame(frameMeta("", epoch), enc(t, []catalog.ProjectionUpdate{
|
||||
{Projection: catalog.Projection{SKU: "A", PriceIncVat: money.Cents(90_00), TaxClass: "standard", ItemID: 1}},
|
||||
{Projection: catalog.Projection{SKU: "B"}, Deleted: true},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := c.Get("A"); got.PriceIncVat != 90_00 {
|
||||
t.Fatalf("A after delta = %d, want 9000", got.PriceIncVat)
|
||||
}
|
||||
if _, ok := c.Get("B"); ok {
|
||||
t.Fatalf("B after delete should miss")
|
||||
}
|
||||
if !c.IsDeleted("B") {
|
||||
t.Fatalf("B should be tombstoned (skip HTTP fetch)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/platform/catalog"
|
||||
)
|
||||
|
||||
// mustCache opens a per-pod projection cache backed by a temp dir (pod-local).
|
||||
func mustCache(t *testing.T) *catalogProjectionCache {
|
||||
t.Helper()
|
||||
c, err := newCatalogProjectionCache(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("newCatalogProjectionCache: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = c.store.Close() })
|
||||
return c
|
||||
}
|
||||
|
||||
// Apply is a TEST-ONLY convenience that routes a delta batch through the store at
|
||||
// the initial epoch (0), reproducing the pre-ProjectionStore cache API so the
|
||||
// existing cart tests keep exercising Get/IsDeleted/Len. Production code feeds the
|
||||
// store via HandleFrame with real bus framing (snapshot + epoch-stamped deltas).
|
||||
func (c *catalogProjectionCache) Apply(updates []catalog.ProjectionUpdate) (upserts, deletes int) {
|
||||
for _, u := range updates {
|
||||
if u.Deleted {
|
||||
deletes++
|
||||
} else if u.SKU != "" {
|
||||
upserts++
|
||||
}
|
||||
}
|
||||
payload, err := catalog.EncodeUpdates(updates)
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
// No MetaSnapshotPhase ⇒ delta; epoch 0 matches a fresh store's base epoch.
|
||||
_ = c.store.HandleFrame(map[string]string{catalog.MetaEpoch: "0"}, payload)
|
||||
return upserts, deletes
|
||||
}
|
||||
@@ -4,8 +4,11 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
)
|
||||
|
||||
@@ -13,6 +16,7 @@ import (
|
||||
// (possibly partial) evaluation context and returns the totals plus the
|
||||
// applied/pending promotion effects, without creating or mutating a real cart.
|
||||
// An empty body is treated as an empty context (evaluates against no items).
|
||||
// Pure stateless — callers compose the line list themselves.
|
||||
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req promotions.EvaluateRequest
|
||||
@@ -27,3 +31,179 @@ func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.Promot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// newPromotionEvaluateWithCartHandler serves POST /promotions/evaluate-with-cart.
|
||||
// Reads the `cartid` cookie the storefront already maintains (set by
|
||||
// GET /cart on first visit), fetches the actual cart from the grain pool
|
||||
// (cross-pod via GetAnywhere so a cart owned by another pod is fetched
|
||||
// transparently), deep-copies the grain, merges the PDP request items
|
||||
// into the copy (replace-by-sku semantic so the PDP qty wins for any
|
||||
// product already in the cart), and runs the canonical
|
||||
// promotions.PromotionService.EvaluateAndApply pipeline the live cart
|
||||
// processor uses — so the preview sees the cart's vouchers, customer
|
||||
// segment, customer-lifetime-value, and computed total, not just the
|
||||
// line list, and the coupon+promotion-overlap edge case is handled
|
||||
// the same way on both sides. Returns the same EvaluateResponse shape
|
||||
// as the stateless endpoint.
|
||||
//
|
||||
// Why this exists: the storefront's "I kundkorgen" preview needs the
|
||||
// engine's verdict for "what would the cart look like if I add this
|
||||
// product". The cart is the source of truth (it has vouchers, customer
|
||||
// segment, customer-lifetime-value, total, etc. that the engine needs),
|
||||
// and a serialised copy sent from the browser can race the live cart.
|
||||
// Letting the backend read the cookie + fetch the grain means there is
|
||||
// exactly one source of truth and no race. The grain is deep-copied via
|
||||
// JSON marshal/unmarshal so the preview never mutates the live state —
|
||||
// every read is purely a what-if computation.
|
||||
//
|
||||
// Missing/invalid cookie (new visitor, preview-only tab): the handler
|
||||
// treats the request as a pure stateless evaluation of the request
|
||||
// items alone — the "what would I get just for the product I'm
|
||||
// viewing" answer is still useful on its own.
|
||||
//
|
||||
// Cart fetch failure (the grain is gone, the pool is degraded, etc.):
|
||||
// the handler logs and falls through to the same stateless evaluation
|
||||
// of the request items. The preview degrades gracefully rather than
|
||||
// erroring the whole block.
|
||||
//
|
||||
// "Replace" merge semantic: if the same sku is in both the cart and
|
||||
// the request items, the request item wins (with its qty). This
|
||||
// matches the PDP's intent ("I am previewing adding this product
|
||||
// at this qty") and avoids double-counting the same product.
|
||||
func newPromotionEvaluateWithCartHandler(store *promotions.Store, svc *promotions.PromotionService, server *PoolServer) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req promotions.EvaluateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var resp promotions.EvaluateResponse
|
||||
if g := cloneCartForPreview(server, r); g != nil {
|
||||
mergeRequestItemsIntoGrain(g, req.Items, svc.DefaultTaxProvider)
|
||||
// One call to the canonical pipeline — same code path
|
||||
// the live cart processor's reg.RegisterProcessor in
|
||||
// main.go uses. Going through the shared
|
||||
// PromotionService.EvaluateAndApply means the bypass
|
||||
// loop, the re-eval trigger, and ApplyResults stay
|
||||
// in lockstep with post-add behavior. EvaluateAndApply
|
||||
// populates g.EvaluatedItems via cart.MapEvaluatedItems
|
||||
// on its final step, so the preview response reuses the
|
||||
// same per-line projection the live cart renders — math
|
||||
// and rounding stay in lockstep across both paths.
|
||||
svc.EvaluateAndApply(store.Snapshot(), g, previewContextOptions(req)...)
|
||||
resp = promotions.EvaluateResponse{
|
||||
TotalPrice: g.TotalPrice,
|
||||
TotalDiscount: g.TotalDiscount,
|
||||
AppliedPromotions: g.AppliedPromotions,
|
||||
Items: g.EvaluatedItems,
|
||||
}
|
||||
} else {
|
||||
// No live cart (missing/invalid cookie, fetch failed, or
|
||||
// deep-copy failed). Fall back to the pure stateless
|
||||
// evaluation of the request items alone — the user still
|
||||
// gets a useful preview of "what would the engine do for
|
||||
// just the items I'm about to add".
|
||||
resp = svc.Evaluate(store.Snapshot(), req)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cloneCartForPreview reads the cartid cookie, fetches the live grain
|
||||
// (cross-pod via GetAnywhere), and returns a deep copy safe to mutate
|
||||
// for the what-if preview. Returns nil (and logs) when the cookie is
|
||||
// missing/invalid OR the grain fetch fails OR the deep-copy fails —
|
||||
// the caller falls back to a stateless evaluation of the request items
|
||||
// alone.
|
||||
//
|
||||
// The deep copy is via JSON marshal/unmarshal, which is intentional: it
|
||||
// produces a fully independent *cart.CartGrain (re-allocating every
|
||||
// pointer — Items, Vouchers, ItemMeta, Price, etc.) so the live grain
|
||||
// is never touched, even if the live grain holds non-serializable
|
||||
// unexported state like an in-memory event-log channel. Unexported
|
||||
// fields are silently dropped by the json package, which is exactly
|
||||
// what we want here — the engine only reads the grain's exported
|
||||
// state (Items, Vouchers, TotalPrice, etc.), and the live grain's
|
||||
// in-memory event log / channels must not be cloned or shared.
|
||||
func cloneCartForPreview(server *PoolServer, r *http.Request) *cart.CartGrain {
|
||||
cookie, err := r.Cookie("cartid")
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil
|
||||
}
|
||||
id, ok := cart.ParseCartId(cookie.Value)
|
||||
if !ok {
|
||||
log.Printf("promotions/evaluate-with-cart: invalid cartid cookie, falling back to stateless")
|
||||
return nil
|
||||
}
|
||||
live, err := server.GetAnywhere(r.Context(), id)
|
||||
if err != nil {
|
||||
log.Printf("promotions/evaluate-with-cart: cart fetch failed for %s, falling back to stateless: %v", id, err)
|
||||
return nil
|
||||
}
|
||||
if live == nil {
|
||||
return nil
|
||||
}
|
||||
buf, err := json.Marshal(live)
|
||||
if err != nil {
|
||||
log.Printf("promotions/evaluate-with-cart: cart marshal failed for %s, falling back to stateless: %v", id, err)
|
||||
return nil
|
||||
}
|
||||
var clone *cart.CartGrain
|
||||
if err := json.Unmarshal(buf, &clone); err != nil {
|
||||
log.Printf("promotions/evaluate-with-cart: cart unmarshal failed for %s, falling back to stateless: %v", id, err)
|
||||
return nil
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// mergeRequestItemsIntoGrain merges reqItems into the grain's items with
|
||||
// "replace" semantic: any cart line whose sku also appears in the
|
||||
// request is dropped (the request item wins, so the PDP qty replaces
|
||||
// the cart qty for that product). Remaining cart lines are kept
|
||||
// verbatim, then the converted request items are appended. The grain
|
||||
// is mutated in place — caller is expected to be holding a deep copy
|
||||
// from cloneCartForPreview. The EvalItem→CartItem conversion uses the
|
||||
// shared promotions.EvalItem.ToCartItem helper so the math (VAT rate
|
||||
// rounding, qty default, ItemMeta, Price) matches the synthetic-cart
|
||||
// conversion in the stateless endpoint exactly.
|
||||
func mergeRequestItemsIntoGrain(g *cart.CartGrain, reqItems []promotions.EvalItem, tp cart.TaxProvider) {
|
||||
requestSkus := make(map[string]struct{}, len(reqItems))
|
||||
for _, it := range reqItems {
|
||||
if it.Sku != "" {
|
||||
requestSkus[it.Sku] = struct{}{}
|
||||
}
|
||||
}
|
||||
merged := make([]*cart.CartItem, 0, len(g.Items)+len(reqItems))
|
||||
for _, it := range g.Items {
|
||||
if _, taken := requestSkus[it.Sku]; taken {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, it)
|
||||
}
|
||||
for _, it := range reqItems {
|
||||
merged = append(merged, it.ToCartItem(tp))
|
||||
}
|
||||
g.Items = merged
|
||||
}
|
||||
|
||||
// previewContextOptions builds the ContextOption list for the
|
||||
// cart-aware preview. The shared EvaluateRequest.ContextOptions
|
||||
// already includes WithNow when req.Now is set, so we only prepend a
|
||||
// default WithNow(time.Now()) when the request didn't specify one —
|
||||
// avoiding a duplicate WithNow in the opts list (the engine's
|
||||
// NewContextFromCart applies options in order, but the last-one-wins
|
||||
// rule for duplicates is not a contract we want to rely on). The
|
||||
// other context options (CustomerSegment, CLV, OrderCount) come from
|
||||
// req.ContextOptions unchanged.
|
||||
func previewContextOptions(req promotions.EvaluateRequest) []promotions.ContextOption {
|
||||
opts := req.ContextOptions()
|
||||
if req.Now == nil {
|
||||
opts = append([]promotions.ContextOption{promotions.WithNow(time.Now())}, opts...)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req
|
||||
// Adyen analogue of the Klarna push. Create the event-sourced
|
||||
// order now (mirrors KlarnaPushHandler). Idempotent on
|
||||
// "checkout-{checkoutId}", so a retried CAPTURE won't duplicate.
|
||||
if isSuccess && s.orderClient != nil {
|
||||
if isSuccess {
|
||||
s.createAdyenOrder(r.Context(), *checkoutId, item)
|
||||
}
|
||||
|
||||
@@ -231,18 +231,9 @@ func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId ca
|
||||
return
|
||||
}
|
||||
|
||||
// Reserve inventory once, guarded by the grain flag so a retried CAPTURE
|
||||
// does not decrement stock twice. (Same as the Klarna path.)
|
||||
if s.inventoryService != nil && grain.CartState != nil && !grain.InventoryReserved {
|
||||
if rerr := s.inventoryService.ReserveInventory(ctx, getInventoryRequests(grain.CartState.Items)...); rerr != nil {
|
||||
log.Printf("from-checkout: inventory reservation failed for %s: %v", checkoutId.String(), rerr)
|
||||
} else {
|
||||
s.Apply(ctx, uint64(grain.Id), &messages.InventoryReserved{
|
||||
Id: grain.Id.String(),
|
||||
Status: "success",
|
||||
})
|
||||
}
|
||||
}
|
||||
// Inventory is NOT committed here — see the note in KlarnaPushHandler. The
|
||||
// order saga emits order.created and the inventory service reacts
|
||||
// (commit + release + level crossing), idempotently and off the revenue path.
|
||||
|
||||
country := getCountryFromCurrency(item.Amount.Currency)
|
||||
if _, oerr := createOrderFromSettledCheckout(ctx, s, grain, settledPayment{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
"git.k6n.net/mats/platform/tax"
|
||||
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||
)
|
||||
@@ -64,7 +65,9 @@ type CheckoutMeta struct {
|
||||
//
|
||||
// If you later need to support different tax rates per line, you can extend
|
||||
// CartItem / Delivery to expose that data and propagate it here.
|
||||
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||
// tp is an optional TaxProvider; when non-nil, its DefaultTaxRate is used for
|
||||
// delivery lines instead of the hardcoded 2500 (25 % as CartItem format).
|
||||
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp tax.Provider) ([]byte, *CheckoutOrder, error) {
|
||||
if grain == nil {
|
||||
return nil, nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
@@ -106,12 +109,32 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
||||
})
|
||||
}
|
||||
|
||||
hasFreeShipping := false
|
||||
if grain.CartState != nil {
|
||||
for _, ap := range grain.CartState.AppliedPromotions {
|
||||
if ap.Type == "free_shipping" && !ap.Pending {
|
||||
hasFreeShipping = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total := cart.NewPrice()
|
||||
total.Add(*grain.CartState.TotalPrice)
|
||||
|
||||
// Delivery lines
|
||||
// Delivery lines — use the configured default tax rate for the purchase country.
|
||||
defaultKlarnaRate := defaultKlarnaTaxRate(tp, country)
|
||||
for _, d := range grain.Deliveries {
|
||||
if d == nil || d.Price.IncVat <= 0 {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
unitPrice := d.Price.IncVat
|
||||
taxAmount := d.Price.TotalVat()
|
||||
if hasFreeShipping {
|
||||
unitPrice = 0
|
||||
taxAmount = 0
|
||||
}
|
||||
if unitPrice <= 0 && !hasFreeShipping {
|
||||
continue
|
||||
}
|
||||
//total.Add(d.Price)
|
||||
@@ -120,14 +143,41 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
||||
Reference: d.Provider,
|
||||
Name: "Delivery",
|
||||
Quantity: 1,
|
||||
UnitPrice: int(d.Price.IncVat),
|
||||
TaxRate: 2500,
|
||||
UnitPrice: int(unitPrice),
|
||||
TaxRate: defaultKlarnaRate,
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(d.Price.IncVat),
|
||||
TotalTaxAmount: int(d.Price.TotalVat()),
|
||||
TotalAmount: int(unitPrice),
|
||||
TotalTaxAmount: int(taxAmount),
|
||||
})
|
||||
}
|
||||
|
||||
// Reflected applied promotions as negative discount lines
|
||||
if grain.CartState != nil {
|
||||
for _, ap := range grain.CartState.AppliedPromotions {
|
||||
if ap.Pending {
|
||||
continue
|
||||
}
|
||||
discountVal := int64(0)
|
||||
if ap.Discount != nil {
|
||||
discountVal = ap.Discount.IncVat.Int64()
|
||||
}
|
||||
if discountVal <= 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, &Line{
|
||||
Type: "discount",
|
||||
Reference: "promo-" + ap.PromotionId,
|
||||
Name: "Promotion: " + ap.Name,
|
||||
Quantity: 1,
|
||||
UnitPrice: int(-discountVal),
|
||||
QuantityUnit: "st",
|
||||
TotalAmount: int(-discountVal),
|
||||
TaxRate: 2500,
|
||||
TotalTaxAmount: int(-discountVal * 2500 / 12500),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
order := &CheckoutOrder{
|
||||
PurchaseCountry: country,
|
||||
PurchaseCurrency: currency,
|
||||
@@ -154,6 +204,27 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
||||
return payload, order, nil
|
||||
}
|
||||
|
||||
// defaultKlarnaTaxRate returns the default tax rate for the purchase country, in Klarna
|
||||
// format (percent x 100, e.g. 2500 = 25.00 %). This matches the platform basis-point
|
||||
// scale exactly, so DefaultTaxRate is passed through. When tp is nil, falls back to 2500.
|
||||
func defaultKlarnaTaxRate(tp tax.Provider, country string) int {
|
||||
if tp == nil {
|
||||
return 2500
|
||||
}
|
||||
return tp.DefaultTaxRate(country)
|
||||
}
|
||||
|
||||
// defaultAdyenTaxRate returns the default tax rate for the purchase country, in Adyen
|
||||
// format (taxPercentage in basis points, e.g. 2500 = 25 %). This matches the platform
|
||||
// basis-point scale exactly, so DefaultTaxRate is passed through. When tp is nil, falls
|
||||
// back to 2500.
|
||||
func defaultAdyenTaxRate(tp tax.Provider, country string) int64 {
|
||||
if tp == nil {
|
||||
return 2500
|
||||
}
|
||||
return int64(tp.DefaultTaxRate(country))
|
||||
}
|
||||
|
||||
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
|
||||
host := getOriginalHost(r)
|
||||
country := getCountryFromHost(host)
|
||||
@@ -171,7 +242,7 @@ func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
|
||||
}
|
||||
}
|
||||
|
||||
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
|
||||
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp tax.Provider) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
|
||||
if grain == nil {
|
||||
return nil, fmt.Errorf("nil grain")
|
||||
}
|
||||
@@ -197,34 +268,78 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
||||
}
|
||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||
Quantity: common.PtrInt64(int64(it.Quantity)),
|
||||
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat),
|
||||
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat.Int64()),
|
||||
Description: common.PtrString(it.Meta.Name),
|
||||
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat()),
|
||||
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat()),
|
||||
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat().Int64()),
|
||||
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat().Int64()),
|
||||
TaxPercentage: common.PtrInt64(int64(it.Tax)),
|
||||
})
|
||||
}
|
||||
hasFreeShipping := false
|
||||
if grain.CartState != nil {
|
||||
for _, ap := range grain.CartState.AppliedPromotions {
|
||||
if ap.Type == "free_shipping" && !ap.Pending {
|
||||
hasFreeShipping = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total := cart.NewPrice()
|
||||
total.Add(*grain.CartState.TotalPrice)
|
||||
|
||||
// Delivery lines
|
||||
// Delivery lines — use the configured default tax rate for the purchase country.
|
||||
defaultAdyenRate := defaultAdyenTaxRate(tp, country)
|
||||
for _, d := range grain.Deliveries {
|
||||
if d == nil || d.Price.IncVat <= 0 {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
amountIncTax := d.Price.IncVat.Int64()
|
||||
amountExTax := d.Price.ValueExVat().Int64()
|
||||
if hasFreeShipping {
|
||||
amountIncTax = 0
|
||||
amountExTax = 0
|
||||
}
|
||||
if amountIncTax <= 0 && !hasFreeShipping {
|
||||
continue
|
||||
}
|
||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||
Quantity: common.PtrInt64(1),
|
||||
AmountIncludingTax: common.PtrInt64(d.Price.IncVat),
|
||||
AmountIncludingTax: common.PtrInt64(amountIncTax),
|
||||
Description: common.PtrString("Delivery"),
|
||||
AmountExcludingTax: common.PtrInt64(d.Price.ValueExVat()),
|
||||
TaxPercentage: common.PtrInt64(25),
|
||||
AmountExcludingTax: common.PtrInt64(amountExTax),
|
||||
TaxPercentage: common.PtrInt64(defaultAdyenRate),
|
||||
})
|
||||
}
|
||||
|
||||
// Reflected applied promotions as negative discount lines
|
||||
if grain.CartState != nil {
|
||||
for _, ap := range grain.CartState.AppliedPromotions {
|
||||
if ap.Pending {
|
||||
continue
|
||||
}
|
||||
discountVal := int64(0)
|
||||
if ap.Discount != nil {
|
||||
discountVal = ap.Discount.IncVat.Int64()
|
||||
}
|
||||
if discountVal <= 0 {
|
||||
continue
|
||||
}
|
||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||
Quantity: common.PtrInt64(1),
|
||||
AmountIncludingTax: common.PtrInt64(-discountVal),
|
||||
Description: common.PtrString("Promotion: " + ap.Name),
|
||||
AmountExcludingTax: common.PtrInt64(-discountVal * 10000 / 12500),
|
||||
TaxAmount: common.PtrInt64(-discountVal * 2500 / 12500),
|
||||
TaxPercentage: common.PtrInt64(2500),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &adyenCheckout.CreateCheckoutSessionRequest{
|
||||
Reference: grain.Id.String(),
|
||||
Amount: adyenCheckout.Amount{
|
||||
Value: total.IncVat,
|
||||
Value: total.IncVat.Int64(),
|
||||
Currency: currency,
|
||||
},
|
||||
CountryCode: common.PtrString(country),
|
||||
|
||||
@@ -1,63 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
)
|
||||
|
||||
func GetDiscovery() discovery.Discovery {
|
||||
if podIp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, kerr := rest.InClusterConfig()
|
||||
|
||||
if kerr != nil {
|
||||
log.Fatalf("Error creating kubernetes client: %v\n", kerr)
|
||||
}
|
||||
client, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating client: %v\n", err)
|
||||
}
|
||||
timeout := int64(30)
|
||||
return discovery.NewK8sDiscovery(client, v1.ListOptions{
|
||||
LabelSelector: "actor-pool=checkout",
|
||||
TimeoutSeconds: &timeout,
|
||||
})
|
||||
}
|
||||
|
||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
||||
|
||||
go func(hw discovery.Discovery) {
|
||||
if hw == nil {
|
||||
log.Print("No discovery service available")
|
||||
return
|
||||
}
|
||||
ch, err := hw.Watch()
|
||||
if err != nil {
|
||||
log.Printf("Discovery error: %v", err)
|
||||
return
|
||||
}
|
||||
for evt := range ch {
|
||||
if evt.Host == "" {
|
||||
continue
|
||||
}
|
||||
switch evt.IsReady {
|
||||
case false:
|
||||
if pool.IsKnown(evt.Host) {
|
||||
log.Printf("Host %s is not ready, removing", evt.Host)
|
||||
pool.RemoveHost(evt.Host)
|
||||
}
|
||||
default:
|
||||
if !pool.IsKnown(evt.Host) {
|
||||
log.Printf("Discovered host %s", evt.Host)
|
||||
pool.AddRemoteHost(evt.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}(GetDiscovery())
|
||||
discovery.StartK8sDiscovery(podIp, "actor-pool=checkout", 30, pool)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/platform/inventory"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
@@ -181,24 +181,12 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
if s.inventoryService != nil {
|
||||
inventoryRequests := getInventoryRequests(grain.CartState.Items)
|
||||
invStatus := "success"
|
||||
if err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...); err != nil {
|
||||
// The payment is already settled at Klarna (checkout_complete), so the
|
||||
// order MUST be created. Inventory is best-effort at this point — a
|
||||
// reservation failure (unstocked / drop-ship / non-tracked items)
|
||||
// must not block order creation. Log it and flag the grain so
|
||||
// fulfillment can reconcile.
|
||||
logger.WarnContext(r.Context(), "klarna push: inventory reservation failed; creating order anyway",
|
||||
"err", err, "checkoutId", grain.Id.String())
|
||||
invStatus = "failed"
|
||||
}
|
||||
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
|
||||
Id: grain.Id.String(),
|
||||
Status: invStatus,
|
||||
})
|
||||
}
|
||||
// Inventory is NOT committed here. Checkout announces a completed sale; the
|
||||
// order saga emits order.created and the inventory service reacts (commit +
|
||||
// release the cart hold + emit a level crossing), idempotently. This keeps a
|
||||
// down inventory service from blocking the revenue path — the payment is
|
||||
// already settled at Klarna, so the sale is a fact, not a request. See
|
||||
// docs/inventory-shape.md.
|
||||
|
||||
s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{
|
||||
PaymentId: orderId,
|
||||
@@ -209,23 +197,13 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
|
||||
CompletedAt: timestamppb.Now(),
|
||||
})
|
||||
|
||||
// ── Create the event-sourced order grain (dual-write with AMQP) ────
|
||||
if s.orderClient != nil {
|
||||
// ── Create the event-sourced order grain (HTTP with AMQP fallback) ────
|
||||
if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil {
|
||||
// Non-fatal: the checkout grain is updated, the order can be
|
||||
// created on retry (idempotency key guards against duplicates).
|
||||
logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push",
|
||||
"err", orderErr, "checkoutId", grain.Id.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dual-write: AMQP order-queue for legacy consumers ───────────────
|
||||
if s.orderHandler != nil {
|
||||
legacyBody, _ := buildLegacyOrderJSON(grain, order, "klarna", orderId)
|
||||
if pubErr := s.orderHandler.OrderCompleted(legacyBody); pubErr != nil {
|
||||
logger.WarnContext(r.Context(), "order-queue publish failed", "err", pubErr)
|
||||
}
|
||||
}
|
||||
|
||||
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
|
||||
if err != nil {
|
||||
@@ -264,82 +242,21 @@ func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *
|
||||
})
|
||||
}
|
||||
|
||||
// buildLegacyOrderJSON builds the go-order-manager compatible JSON for AMQP
|
||||
// dual-write, so existing downstream consumers on the order-queue still receive
|
||||
// order events during the migration period.
|
||||
func buildLegacyOrderJSON(grain *checkout.CheckoutGrain, klarnaOrder *CheckoutOrder, provider string, orderId string) ([]byte, error) {
|
||||
if grain.CartState == nil {
|
||||
return nil, fmt.Errorf("buildLegacyOrderJSON: checkout %s has no cart state", grain.Id)
|
||||
}
|
||||
type legacyLine struct {
|
||||
Reference string `json:"reference"`
|
||||
Name string `json:"name"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice int `json:"unit_price"`
|
||||
TaxRate int `json:"tax_rate"`
|
||||
TotalAmount int `json:"total_amount"`
|
||||
TotalTaxAmount int `json:"total_tax_amount"`
|
||||
}
|
||||
type legacyOrder struct {
|
||||
ID string `json:"order_id"`
|
||||
PurchaseCountry string `json:"purchase_country"`
|
||||
PurchaseCurrency string `json:"purchase_currency"`
|
||||
Locale string `json:"locale"`
|
||||
OrderAmount int `json:"order_amount"`
|
||||
OrderTaxAmount int `json:"order_tax_amount"`
|
||||
OrderLines []legacyLine `json:"order_lines"`
|
||||
}
|
||||
|
||||
lines := make([]legacyLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
|
||||
for _, it := range grain.CartState.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, legacyLine{
|
||||
Reference: it.Sku,
|
||||
Name: it.Meta.Name,
|
||||
Quantity: int(it.Quantity),
|
||||
UnitPrice: int(it.Price.IncVat),
|
||||
TaxRate: it.Tax,
|
||||
TotalAmount: int(it.TotalPrice.IncVat),
|
||||
TotalTaxAmount: int(it.TotalPrice.TotalVat()),
|
||||
})
|
||||
}
|
||||
|
||||
lo := legacyOrder{
|
||||
ID: orderId,
|
||||
PurchaseCountry: klarnaOrder.PurchaseCountry,
|
||||
PurchaseCurrency: klarnaOrder.PurchaseCurrency,
|
||||
Locale: klarnaOrder.Locale,
|
||||
OrderAmount: klarnaOrder.OrderAmount,
|
||||
OrderTaxAmount: klarnaOrder.OrderTaxAmount,
|
||||
OrderLines: lines,
|
||||
}
|
||||
return json.Marshal(lo)
|
||||
}
|
||||
|
||||
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
||||
if item.StoreId == nil || *item.StoreId == "" {
|
||||
return "se"
|
||||
return inventory.LocationID("se")
|
||||
}
|
||||
return inventory.LocationID(*item.StoreId)
|
||||
}
|
||||
|
||||
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
||||
var requests []inventory.ReserveRequest
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
func shouldTrackInventory(item *cart.CartItem) bool {
|
||||
if item.DropShip {
|
||||
return false
|
||||
}
|
||||
requests = append(requests, inventory.ReserveRequest{
|
||||
InventoryReference: &inventory.InventoryReference{
|
||||
SKU: inventory.SKU(item.Sku),
|
||||
LocationID: getLocationId(item),
|
||||
},
|
||||
Quantity: uint32(item.Quantity),
|
||||
})
|
||||
if item.InventoryTracked {
|
||||
return true
|
||||
}
|
||||
return requests
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, error) {
|
||||
|
||||
+101
-17
@@ -14,21 +14,23 @@ import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||
redisinv "git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/platform/config"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"git.k6n.net/mats/platform/tax"
|
||||
"github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
var (
|
||||
grainSpawns = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "checkout_grain_spawned_total",
|
||||
Help: "The total number of spawned checkout grains",
|
||||
})
|
||||
// checkoutMetrics is the shared prometheus instrumentation for the
|
||||
// checkout actor pool and event log. Built once at process start;
|
||||
// the actor package's touch sites are nil-safe so a test binary
|
||||
// could pass nil.
|
||||
checkoutMetrics = actor.NewMetrics("checkout")
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -49,6 +51,20 @@ var redisAddress = os.Getenv("REDIS_ADDRESS")
|
||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
|
||||
|
||||
// selectTaxProvider picks the tax provider from the environment.
|
||||
// Default is NordicTaxProvider with SE as the default country.
|
||||
// Set TAX_PROVIDER=static for dev and tests (no country awareness).
|
||||
func selectTaxProvider() tax.Provider {
|
||||
switch os.Getenv("TAX_PROVIDER") {
|
||||
case "static":
|
||||
return tax.NewStatic()
|
||||
case "nordic":
|
||||
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
||||
default:
|
||||
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
||||
}
|
||||
}
|
||||
|
||||
// loadUCPCheckoutSigner loads the UCP ECDSA signing key from the path specified in
|
||||
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
||||
func loadUCPCheckoutSigner() *ucp.SigningConfig {
|
||||
@@ -85,24 +101,27 @@ func main() {
|
||||
Password: redisPassword,
|
||||
DB: 0,
|
||||
})
|
||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
||||
|
||||
reservationService, err := redisinv.NewRedisCartReservationService(rdb)
|
||||
if err != nil {
|
||||
log.Fatalf("Error creating inventory service: %v\n", err)
|
||||
log.Fatalf("Error creating reservation service: %v\n", err)
|
||||
}
|
||||
reservationPolicy := redisinv.NewReservationPolicyAdapter(reservationService)
|
||||
|
||||
checkoutDir := os.Getenv("CHECKOUT_DIR")
|
||||
if checkoutDir == "" {
|
||||
checkoutDir = "data"
|
||||
}
|
||||
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
|
||||
|
||||
diskStorage.SetMetrics(checkoutMetrics)
|
||||
var syncedServer *CheckoutPoolServer
|
||||
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
|
||||
MutationRegistry: reg,
|
||||
Storage: diskStorage,
|
||||
Metrics: checkoutMetrics,
|
||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[checkout.CheckoutGrain], error) {
|
||||
_, span := tracer.Start(ctx, fmt.Sprintf("Spawn checkout id %d", id))
|
||||
defer span.End()
|
||||
grainSpawns.Inc()
|
||||
|
||||
ret := checkout.NewCheckoutGrain(id, 0, 0, time.Now(), nil) // version to be set later
|
||||
// Load persisted events/state for this checkout if present
|
||||
@@ -113,6 +132,11 @@ func main() {
|
||||
return ret, nil
|
||||
},
|
||||
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
|
||||
ctx := context.Background()
|
||||
state, err := grain.GetCurrentState()
|
||||
if err == nil && state != nil {
|
||||
syncedServer.releaseCartReservations(ctx, state)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
|
||||
@@ -144,17 +168,31 @@ func main() {
|
||||
}
|
||||
|
||||
var orderHandler *AmqpOrderHandler
|
||||
conn, err := amqp.Dial(amqpUrl)
|
||||
conn, err := rabbit.Dial(amqpUrl, "cart-checkout")
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
orderHandler = NewAmqpOrderHandler(conn)
|
||||
orderHandler = NewAmqpOrderHandler(conn.Connection())
|
||||
if err := orderHandler.DefineQueue(); err != nil {
|
||||
log.Fatalf("failed to declare order queue: %v", err)
|
||||
}
|
||||
|
||||
syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
|
||||
syncedServer.inventoryService = inventoryService
|
||||
// Reconnecting order queue consumer: re-define queue and re-consume on reconnect.
|
||||
conn.NotifyOnReconnect(func() {
|
||||
if err := orderHandler.DefineQueue(); err != nil {
|
||||
log.Printf("checkout: define queue on reconnect: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Stream applied checkout mutations to the shared mutations exchange
|
||||
// (routing key mutation.checkout) for the backoffice live feed.
|
||||
checkoutFeed := actor.NewAmqpListener(conn.Connection(), "checkout", actor.MutationSummary)
|
||||
checkoutFeed.DefineTopics()
|
||||
pool.AddListener(checkoutFeed)
|
||||
|
||||
syncedServer = NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient, orderClient, orderHandler)
|
||||
syncedServer.reservationPolicy = reservationPolicy
|
||||
syncedServer.taxProvider = selectTaxProvider()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
debugMux := http.NewServeMux()
|
||||
@@ -174,7 +212,32 @@ func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
otelShutdown, err := setupOTelSDK(ctx)
|
||||
// Data Retention (C5) GDPR Purge
|
||||
retentionDays := config.EnvInt("CHECKOUT_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
|
||||
if retentionDays > 0 {
|
||||
purgeInterval := config.EnvDuration("CHECKOUT_PURGE_INTERVAL", 24*time.Hour)
|
||||
log.Printf("checkout: data retention enabled. Retaining checkouts for %d days, purging every %v", retentionDays, purgeInterval)
|
||||
go func() {
|
||||
ticker := time.NewTicker(purgeInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run immediately on startup
|
||||
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||
}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
log.Print("checkout: data retention / GDPR purge disabled (CHECKOUT_RETENTION_DAYS not set or <= 0)")
|
||||
}
|
||||
|
||||
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to start otel %v", err)
|
||||
}
|
||||
@@ -261,3 +324,24 @@ func main() {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[checkout.CheckoutGrain], pool *actor.SimpleGrainPool[checkout.CheckoutGrain], retentionDays int) {
|
||||
log.Printf("checkout: starting data retention purge...")
|
||||
retention := time.Duration(retentionDays) * 24 * time.Hour
|
||||
|
||||
localIds := make(map[uint64]bool)
|
||||
for _, id := range pool.GetLocalIds() {
|
||||
localIds[id] = true
|
||||
}
|
||||
isGrainActive := func(id uint64) bool {
|
||||
return localIds[id]
|
||||
}
|
||||
|
||||
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
|
||||
if err != nil {
|
||||
log.Printf("checkout: data retention purge failed: %v", err)
|
||||
} else {
|
||||
log.Printf("checkout: data retention purge completed. Purged %d expired checkout logs", purged)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ type OrderLine struct {
|
||||
Quantity int32 `json:"quantity"`
|
||||
UnitPrice int64 `json:"unitPrice"`
|
||||
TaxRate int32 `json:"taxRate"`
|
||||
DropShip bool `json:"drop_ship,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
}
|
||||
|
||||
// CreateOrderResult is the success response from POST /api/orders/from-checkout.
|
||||
|
||||
+102
-10
@@ -2,10 +2,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
profile "git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||
profileMessages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
@@ -27,22 +30,49 @@ type settledPayment struct {
|
||||
// selections into from-checkout order lines. Shared by every provider so the
|
||||
// order payload is built the same way no matter who settled the payment.
|
||||
func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
||||
hasFreeShipping := false
|
||||
if grain.CartState != nil {
|
||||
for _, ap := range grain.CartState.AppliedPromotions {
|
||||
if ap.Type == "free_shipping" && !ap.Pending {
|
||||
hasFreeShipping = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines := make([]OrderLine, 0, len(grain.CartState.Items)+len(grain.Deliveries))
|
||||
for _, it := range grain.CartState.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
// Carry the per-item store as the inventory commit location; empty when
|
||||
// no store (commit then falls back to the order country / central stock).
|
||||
location := ""
|
||||
if it.StoreId != nil {
|
||||
location = *it.StoreId
|
||||
}
|
||||
lines = append(lines, OrderLine{
|
||||
Reference: it.Sku,
|
||||
Sku: it.Sku,
|
||||
Name: it.Meta.Name,
|
||||
Quantity: int32(it.Quantity),
|
||||
UnitPrice: it.Price.IncVat,
|
||||
UnitPrice: it.Price.IncVat.Int64(),
|
||||
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
|
||||
// (2500 = 25%), so the rate passes through unchanged.
|
||||
TaxRate: int32(it.Tax),
|
||||
DropShip: it.DropShip,
|
||||
Location: location,
|
||||
})
|
||||
}
|
||||
for _, d := range grain.Deliveries {
|
||||
if d == nil || d.Price.IncVat <= 0 {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
unitPrice := d.Price.IncVat.Int64()
|
||||
if hasFreeShipping {
|
||||
unitPrice = 0
|
||||
}
|
||||
if unitPrice <= 0 && !hasFreeShipping {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, OrderLine{
|
||||
@@ -50,10 +80,35 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
||||
Sku: d.Provider,
|
||||
Name: "Delivery",
|
||||
Quantity: 1,
|
||||
UnitPrice: d.Price.IncVat,
|
||||
TaxRate: 2500,
|
||||
UnitPrice: unitPrice,
|
||||
TaxRate: 2500, // 25% in basis points
|
||||
})
|
||||
}
|
||||
|
||||
// Reflected applied promotions as negative discount lines
|
||||
if grain.CartState != nil {
|
||||
for _, ap := range grain.CartState.AppliedPromotions {
|
||||
if ap.Pending {
|
||||
continue
|
||||
}
|
||||
discountVal := int64(0)
|
||||
if ap.Discount != nil {
|
||||
discountVal = ap.Discount.IncVat.Int64()
|
||||
}
|
||||
if discountVal <= 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, OrderLine{
|
||||
Reference: "promo-" + ap.PromotionId,
|
||||
Sku: "promo-" + ap.PromotionId,
|
||||
Name: "Promotion: " + ap.Name,
|
||||
Quantity: 1,
|
||||
UnitPrice: -discountVal,
|
||||
TaxRate: 2500, // default standard tax rate (25%) in basis points
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -64,8 +119,10 @@ func buildOrderLinesFromGrain(grain *checkout.CheckoutGrain) []OrderLine {
|
||||
//
|
||||
// It is idempotent: the order service dedupes on "checkout-{checkoutId}", so
|
||||
// repeated provider callbacks (Klarna re-push, retried Adyen CAPTURE) return the
|
||||
// existing order rather than creating a duplicate. Callers should treat a
|
||||
// non-nil error as non-fatal and retry on the next callback.
|
||||
// existing order rather than creating a duplicate.
|
||||
//
|
||||
// If the HTTP call fails or is not configured, it falls back to publishing the
|
||||
// exact same payload to RabbitMQ via s.orderHandler.OrderCompleted(body).
|
||||
func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, pay settledPayment) (*CreateOrderResult, error) {
|
||||
if grain.CartState == nil {
|
||||
return nil, fmt.Errorf("from-checkout: checkout %s has no cart state", grain.Id)
|
||||
@@ -96,15 +153,50 @@ func createOrderFromSettledCheckout(ctx context.Context, s *CheckoutPoolServer,
|
||||
req.Payment.Reference = pay.Reference
|
||||
req.Payment.Amount = pay.Amount
|
||||
|
||||
var createErr error
|
||||
if s.orderClient != nil {
|
||||
result, err := s.orderClient.CreateOrder(ctx, req, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
_ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{
|
||||
OrderId: result.OrderId,
|
||||
Status: "completed",
|
||||
CreatedAt: timestamppb.Now(),
|
||||
})
|
||||
if grain.CartState != nil && grain.CartState.UserId != "" {
|
||||
if pid, ok := profile.ParseProfileId(grain.CartState.UserId); ok {
|
||||
_ = s.ApplyAnywhere(ctx, checkout.CheckoutId(pid), &profileMessages.LinkOrder{
|
||||
OrderReference: result.OrderId,
|
||||
CartId: uint64(grain.CartId),
|
||||
Status: "completed",
|
||||
})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
createErr = err
|
||||
logger.WarnContext(ctx, "from-checkout HTTP call failed; falling back to AMQP", "err", err, "checkoutId", grain.Id.String())
|
||||
}
|
||||
|
||||
// Fallback to AMQP
|
||||
if s.orderHandler != nil {
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal order fallback: %w", err)
|
||||
}
|
||||
if pubErr := s.orderHandler.OrderCompleted(body); pubErr != nil {
|
||||
if createErr != nil {
|
||||
return nil, fmt.Errorf("AMQP fallback failed: %w (HTTP error: %v)", pubErr, createErr)
|
||||
}
|
||||
return nil, fmt.Errorf("AMQP fallback failed: %w", pubErr)
|
||||
}
|
||||
logger.InfoContext(ctx, "order request published to AMQP successfully as fallback", "checkoutId", grain.Id.String())
|
||||
return &CreateOrderResult{
|
||||
OrderId: "queued-" + grain.Id.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
return nil, fmt.Errorf("neither order client nor AMQP order handler configured")
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/log/global"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
||||
// If it does not return an error, make sure to call shutdown for proper cleanup.
|
||||
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||
var shutdownFuncs []func(context.Context) error
|
||||
var err error
|
||||
|
||||
// shutdown calls cleanup functions registered via shutdownFuncs.
|
||||
// The errors from the calls are joined.
|
||||
// Each registered cleanup will be invoked once.
|
||||
shutdown := func(ctx context.Context) error {
|
||||
var err error
|
||||
for _, fn := range shutdownFuncs {
|
||||
err = errors.Join(err, fn(ctx))
|
||||
}
|
||||
shutdownFuncs = nil
|
||||
return err
|
||||
}
|
||||
|
||||
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
|
||||
handleErr := func(inErr error) {
|
||||
err = errors.Join(inErr, shutdown(ctx))
|
||||
}
|
||||
|
||||
// Set up propagator.
|
||||
prop := newPropagator()
|
||||
otel.SetTextMapPropagator(prop)
|
||||
|
||||
// Set up trace provider.
|
||||
tracerProvider, err := newTracerProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
|
||||
otel.SetTracerProvider(tracerProvider)
|
||||
|
||||
// Set up meter provider.
|
||||
meterProvider, err := newMeterProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
||||
otel.SetMeterProvider(meterProvider)
|
||||
|
||||
// Set up logger provider.
|
||||
loggerProvider, err := newLoggerProvider()
|
||||
if err != nil {
|
||||
handleErr(err)
|
||||
return shutdown, err
|
||||
}
|
||||
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
||||
global.SetLoggerProvider(loggerProvider)
|
||||
|
||||
return shutdown, err
|
||||
}
|
||||
|
||||
func newPropagator() propagation.TextMapPropagator {
|
||||
return propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
)
|
||||
}
|
||||
|
||||
func newTracerProvider() (*trace.TracerProvider, error) {
|
||||
traceExporter, err := otlptracegrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tracerProvider := trace.NewTracerProvider(
|
||||
trace.WithBatcher(traceExporter,
|
||||
// Default is 5s. Set to 1s for demonstrative purposes.
|
||||
trace.WithBatchTimeout(time.Second)),
|
||||
)
|
||||
return tracerProvider, nil
|
||||
}
|
||||
|
||||
func newMeterProvider() (*metric.MeterProvider, error) {
|
||||
exporter, err := otlpmetricgrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter)))
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
func newLoggerProvider() (*log.LoggerProvider, error) {
|
||||
logExporter, err := otlploggrpc.New(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loggerProvider := log.NewLoggerProvider(
|
||||
log.WithProcessor(log.NewBatchProcessor(logExporter)),
|
||||
)
|
||||
return loggerProvider, nil
|
||||
}
|
||||
+35
-20
@@ -13,13 +13,12 @@ import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
"git.k6n.net/mats/platform/tax"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||
|
||||
"git.k6n.net/mats/platform/inventory"
|
||||
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"google.golang.org/protobuf/proto"
|
||||
@@ -32,16 +31,13 @@ import (
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
grainMutations = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "checkout_grain_mutations_total",
|
||||
Help: "The total number of mutations",
|
||||
})
|
||||
grainLookups = promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: "checkout_grain_lookups_total",
|
||||
Help: "The total number of lookups",
|
||||
})
|
||||
)
|
||||
// Pool metrics (checkout_grain_spawned_total, checkout_grain_lookups_total,
|
||||
// checkout_mutations_total, checkout_remote_negotiation_total,
|
||||
// checkout_connected_remotes, …) are bumped centrally by
|
||||
// SimpleGrainPool — see pkg/actor/simple_grain_pool.go. The checkout
|
||||
// Metrics is registered once in cmd/checkout/main.go and shared with
|
||||
// DiskStorage via SetMetrics, so the per-handler counters this file
|
||||
// used to maintain are no longer needed.
|
||||
|
||||
type CheckoutPoolServer struct {
|
||||
actor.GrainPool[checkout.CheckoutGrain]
|
||||
@@ -51,7 +47,8 @@ type CheckoutPoolServer struct {
|
||||
cartClient *CartClient
|
||||
orderClient *OrderClient
|
||||
orderHandler *AmqpOrderHandler
|
||||
inventoryService *inventory.RedisInventoryService
|
||||
reservationPolicy inventory.ReservationPolicy
|
||||
taxProvider tax.Provider
|
||||
}
|
||||
|
||||
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient, orderClient *OrderClient, orderHandler *AmqpOrderHandler) *CheckoutPoolServer {
|
||||
@@ -207,6 +204,10 @@ func (s *CheckoutPoolServer) CancelPaymentHandler(w http.ResponseWriter, r *http
|
||||
return err
|
||||
}
|
||||
|
||||
if grain, gerr := s.Get(r.Context(), uint64(checkoutId)); gerr == nil && grain != nil {
|
||||
s.releaseCartReservations(r.Context(), grain)
|
||||
}
|
||||
|
||||
return s.WriteResult(w, result)
|
||||
}
|
||||
|
||||
@@ -295,7 +296,7 @@ func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *chec
|
||||
|
||||
meta := GetCheckoutMetaFromRequest(r)
|
||||
|
||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta, s.taxProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -431,7 +432,7 @@ func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http.
|
||||
switch payload.Provider {
|
||||
case "adyen":
|
||||
meta := GetCheckoutMetaFromRequest(r)
|
||||
sessionData, err := BuildAdyenCheckoutSession(grain, meta)
|
||||
sessionData, err := BuildAdyenCheckoutSession(grain, meta, s.taxProvider)
|
||||
if err != nil {
|
||||
logger.Error("unable to build adyen session", "error", err)
|
||||
return err
|
||||
@@ -536,10 +537,10 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
||||
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
|
||||
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
|
||||
handleFunc("DELETE /payment/{id}", CookieCheckoutIdHandler(s.ProxyHandler(s.CancelPaymentHandler)))
|
||||
// handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
|
||||
// handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
|
||||
// handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
|
||||
// handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
|
||||
handleFunc("POST /api/checkout/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
|
||||
handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
|
||||
handleFunc("POST /api/checkout/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
|
||||
handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
|
||||
|
||||
//handleFunc("GET /payment/klarna/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
|
||||
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
|
||||
@@ -547,3 +548,17 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
||||
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
|
||||
|
||||
}
|
||||
|
||||
func (s *CheckoutPoolServer) releaseCartReservations(ctx context.Context, grain *checkout.CheckoutGrain) {
|
||||
if s.reservationPolicy == nil || grain.CartState == nil {
|
||||
return
|
||||
}
|
||||
for _, item := range grain.CartState.Items {
|
||||
if item == nil || !shouldTrackInventory(item) {
|
||||
continue
|
||||
}
|
||||
if err := s.reservationPolicy.Release(ctx, inventory.SKU(item.Sku), getLocationId(item), inventory.CartID(grain.CartId.String())); err != nil {
|
||||
logger.WarnContext(ctx, "failed to release cart reservation", "sku", item.Sku, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-6
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
"git.k6n.net/mats/platform/inventory"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
)
|
||||
@@ -77,12 +79,23 @@ func getCountryFromHost(host string) string {
|
||||
}
|
||||
|
||||
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
|
||||
if a.inventoryService != nil {
|
||||
inventoryRequests := getInventoryRequests(grain.CartState.Items)
|
||||
_, err := a.inventoryService.ReservationCheck(ctx, inventoryRequests...)
|
||||
if a.reservationPolicy == nil {
|
||||
return nil
|
||||
}
|
||||
for _, item := range grain.CartState.Items {
|
||||
if item == nil || !shouldTrackInventory(item) {
|
||||
continue
|
||||
}
|
||||
loc := inventory.LocationID("se")
|
||||
if item.StoreId != nil && *item.StoreId != "" {
|
||||
loc = inventory.LocationID(*item.StoreId)
|
||||
}
|
||||
avail, err := a.reservationPolicy.Available(ctx, inventory.SKU(item.Sku), loc)
|
||||
if err != nil {
|
||||
logger.WarnContext(ctx, "placeorder inventory check failed")
|
||||
return err
|
||||
return fmt.Errorf("inventory check failed for SKU %s: %w", item.Sku, err)
|
||||
}
|
||||
if int64(item.Quantity) > avail {
|
||||
return fmt.Errorf("insufficient inventory for SKU %s: have %d, need %d", item.Sku, avail, item.Quantity)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -154,7 +167,6 @@ func (s *CheckoutPoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http
|
||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||
handled, err := ownerHost.Proxy(uint64(checkoutId), w, r, nil)
|
||||
|
||||
grainLookups.Inc()
|
||||
if err == nil && handled {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
)
|
||||
|
||||
// batchItem is one stock set in a batch request. LocationID is optional; an
|
||||
// empty value defaults to the service's country location (the same location the
|
||||
// catalog-feed listener writes to).
|
||||
type batchItem struct {
|
||||
SKU string `json:"sku"`
|
||||
LocationID string `json:"locationId,omitempty"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// batchResult mirrors one input item with the location actually written and an
|
||||
// optional per-item error (empty on success).
|
||||
type batchResult struct {
|
||||
SKU string `json:"sku"`
|
||||
LocationID string `json:"locationId"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// batchInventoryHandler SETS stock to absolute values for many SKUs in one
|
||||
// pipelined call — for re-seeding/import and for testing the inventory + reserve
|
||||
// paths directly. Same write as the catalog-feed listener (UpdateInventory +
|
||||
// level crossing), so a value set here is authoritative until the next
|
||||
// catalog.item_changed for that SKU overwrites it (catalog is the source of
|
||||
// truth). Body is a JSON array of {sku, locationId?, quantity}.
|
||||
func (srv *Server) batchInventoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 16<<20)) // 16 MiB
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var items []batchItem
|
||||
if err := json.Unmarshal(body, &items); err != nil {
|
||||
http.Error(w, "body must be a JSON array of {sku, locationId?, quantity}: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(items) == 0 {
|
||||
http.Error(w, "empty batch", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
results := make([]batchResult, len(items))
|
||||
pipe := srv.rdb.Pipeline()
|
||||
for i, it := range items {
|
||||
loc := it.LocationID
|
||||
if loc == "" {
|
||||
loc = country
|
||||
}
|
||||
results[i] = batchResult{SKU: it.SKU, LocationID: loc, Quantity: it.Quantity}
|
||||
if it.SKU == "" {
|
||||
results[i].Error = "sku is required"
|
||||
continue
|
||||
}
|
||||
srv.inventoryService.UpdateInventory(ctx, pipe, inventory.SKU(it.SKU), inventory.LocationID(loc), it.Quantity)
|
||||
}
|
||||
|
||||
hadError := false
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
// Exec reports the first failing command; flag the whole batch rather than
|
||||
// guess which items committed.
|
||||
hadError = true
|
||||
for i := range results {
|
||||
if results[i].Error == "" {
|
||||
results[i].Error = err.Error()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Emit level crossings for the items that were actually written, so the
|
||||
// finder/level listeners react the same way they do for catalog feeds.
|
||||
for i := range results {
|
||||
if results[i].Error == "" {
|
||||
emitLevelIfChanged(ctx, srv.rdb, srv.conn, country, lowWatermark, results[i].SKU, results[i].LocationID, results[i].Quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range results {
|
||||
if results[i].Error != "" {
|
||||
hadError = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
status := http.StatusOK
|
||||
if hadError {
|
||||
status = http.StatusMultiStatus // 207
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]any{"count": len(results), "results": results})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
contract "git.k6n.net/mats/platform/order"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// commitOrder applies an order.created event to inventory. The sale already
|
||||
// happened, so this is an unconditional permanent decrement (it may drive stock
|
||||
// negative — visible oversell, reconciled downstream as backorder), NOT a
|
||||
// check-and-reserve that could refuse. It then releases the cart's TTL hold and
|
||||
// emits the resulting level crossing. Idempotent per order via a Redis marker,
|
||||
// since the bus is at-least-once.
|
||||
func commitOrder(ctx context.Context, rdb *redis.Client, svc *inventory.RedisInventoryService, resv *inventory.RedisCartReservationService, conn *rabbit.Conn, country string, low int64, c contract.Created) {
|
||||
if c.OrderID == "" {
|
||||
return
|
||||
}
|
||||
// Idempotency: first writer wins; redelivery is a no-op.
|
||||
fresh, err := rdb.SetNX(ctx, "invcommit:"+c.OrderID, "1", 30*24*time.Hour).Result()
|
||||
if err != nil {
|
||||
log.Printf("inventory: commit idempotency order=%s: %v", c.OrderID, err)
|
||||
return
|
||||
}
|
||||
if !fresh {
|
||||
return // already committed
|
||||
}
|
||||
// Default location for lines that don't carry their own: the order country
|
||||
// (central stock), falling back to the service's configured country.
|
||||
defaultLoc := c.Country
|
||||
if defaultLoc == "" {
|
||||
defaultLoc = country
|
||||
}
|
||||
for _, line := range c.Lines {
|
||||
if line.SKU == "" || line.Quantity <= 0 {
|
||||
continue
|
||||
}
|
||||
// Drop-ship items are fulfilled by the supplier — never decrement
|
||||
// local stock for them.
|
||||
if line.DropShip {
|
||||
continue
|
||||
}
|
||||
loc := inventory.LocationID(defaultLoc)
|
||||
if line.Location != "" {
|
||||
loc = inventory.LocationID(line.Location)
|
||||
}
|
||||
sku := inventory.SKU(line.SKU)
|
||||
pipe := rdb.Pipeline()
|
||||
if err := svc.DecrementInventory(ctx, pipe, sku, loc, int64(line.Quantity)); err != nil {
|
||||
log.Printf("inventory: commit decrement sku=%s order=%s: %v", sku, c.OrderID, err)
|
||||
continue
|
||||
}
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
log.Printf("inventory: commit exec sku=%s order=%s: %v", sku, c.OrderID, err)
|
||||
continue
|
||||
}
|
||||
// Release the cart's TTL hold now the sale is committed (best-effort —
|
||||
// it would expire on its own anyway).
|
||||
if c.CartID != "" && resv != nil {
|
||||
if err := resv.ReleaseForCart(ctx, sku, loc, inventory.CartID(c.CartID)); err != nil {
|
||||
log.Printf("inventory: commit release sku=%s cart=%s: %v", sku, c.CartID, err)
|
||||
}
|
||||
}
|
||||
// Emit the level crossing from the new on-hand.
|
||||
if newQty, err := svc.GetInventory(ctx, sku, loc); err == nil {
|
||||
emitLevelIfChanged(ctx, rdb, conn, country, low, string(sku), string(loc), newQty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/platform/event"
|
||||
invlevel "git.k6n.net/mats/platform/inventory"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// emitLevelIfChanged classifies qty into a Level and emits an
|
||||
// inventory.level_changed bus event only when it differs from the level last
|
||||
// emitted for this SKU+location (the levelKey marker). Exact quantity never
|
||||
// leaves the service; only the coarse crossing does. Shared by every write path
|
||||
// (catalog feed, order.created commit). A nil conn (no bus) makes it a no-op.
|
||||
func emitLevelIfChanged(ctx context.Context, rdb *redis.Client, conn *rabbit.Conn, country string, low int64, sku, loc string, qty int64) {
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
lvl := invlevel.LevelFor(qty, low)
|
||||
// Atomic read-old + write-new of the level marker; redis.Nil means the
|
||||
// marker didn't exist yet (first observation), which we treat as a crossing.
|
||||
prev, err := rdb.SetArgs(ctx, levelKey(sku, loc), string(lvl), redis.SetArgs{Get: true}).Result()
|
||||
if err != nil && err != redis.Nil {
|
||||
log.Printf("inventory: level marker sku=%s: %v", sku, err)
|
||||
return
|
||||
}
|
||||
if prev == string(lvl) {
|
||||
return // no threshold crossing — stay off the bus
|
||||
}
|
||||
payload, err := invlevel.LevelChanged{SKU: sku, Location: loc, Level: lvl}.Encode()
|
||||
if err != nil {
|
||||
log.Printf("inventory: encode level payload sku=%s: %v", sku, err)
|
||||
return
|
||||
}
|
||||
ev := event.Event{
|
||||
ID: "invlvl-" + sku + "-" + loc + "-" + strconv.FormatInt(time.Now().UnixNano(), 36),
|
||||
Type: event.InventoryLevelChanged,
|
||||
Source: "inventory",
|
||||
Subject: sku,
|
||||
Payload: payload,
|
||||
Time: time.Now(),
|
||||
Meta: map[string]string{"country": country},
|
||||
}
|
||||
if err := rabbit.PublishEvent(conn.Connection(), "inventory", ev); err != nil {
|
||||
log.Printf("inventory: publish level event sku=%s: %v", sku, err)
|
||||
}
|
||||
}
|
||||
+131
-15
@@ -6,20 +6,29 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/platform/event"
|
||||
orderevent "git.k6n.net/mats/platform/order"
|
||||
"git.k6n.net/mats/slask-finder/pkg/index"
|
||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/redis/go-redis/v9/maintnotifications"
|
||||
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
inventoryService *inventory.RedisInventoryService
|
||||
reservationService *inventory.RedisCartReservationService
|
||||
|
||||
// rdb + conn back the batch write path (pipelined UpdateInventory + level
|
||||
// crossing). conn is nil when RABBIT_HOST is unset; emit is then skipped.
|
||||
rdb *redis.Client
|
||||
conn *rabbit.Conn
|
||||
}
|
||||
|
||||
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -66,6 +75,12 @@ var country = "se"
|
||||
var redisAddress = "10.10.3.18:6379"
|
||||
var redisPassword = "slaskredis"
|
||||
|
||||
// lowWatermark is the cutoff between "low" and "high" stock for the level
|
||||
// crossings emitted on the bus (0 < qty <= lowWatermark ⇒ low). Single global
|
||||
// number for now; per-SKU / per-tenant thresholds can come later behind the
|
||||
// same inventory.LevelChanged payload.
|
||||
var lowWatermark int64 = 5
|
||||
|
||||
func init() {
|
||||
// Override redis config from environment variables if set
|
||||
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
|
||||
@@ -77,6 +92,19 @@ func init() {
|
||||
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
|
||||
country = ctry
|
||||
}
|
||||
if v, ok := os.LookupEnv("INVENTORY_LOW_WATERMARK"); ok {
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
lowWatermark = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// levelKey is the Redis key holding the last-emitted Level for a SKU+location.
|
||||
// It is the crossing-detection marker: the level listener only emits when the
|
||||
// freshly computed level differs from this stored value, so the bus carries
|
||||
// crossings, not every quantity tick.
|
||||
func levelKey(sku, loc string) string {
|
||||
return "invlevel:" + sku + ":" + loc
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -101,52 +129,140 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
server := &Server{inventoryService: s, reservationService: r}
|
||||
server := &Server{inventoryService: s, reservationService: r, rdb: rdb}
|
||||
|
||||
// Set up HTTP routes
|
||||
http.HandleFunc("/livez", server.livezHandler)
|
||||
http.HandleFunc("/readyz", server.readyzHandler)
|
||||
http.HandleFunc("/inventory/{sku}/{locationId}", server.getInventoryHandler)
|
||||
http.HandleFunc("/reservations/{sku}/{locationId}", server.getReservationHandler)
|
||||
http.HandleFunc("POST /reservations", server.reserveInventoryHandler)
|
||||
http.HandleFunc("POST /reservations/release", server.releaseReservationHandler)
|
||||
http.HandleFunc("POST /inventory/batch", server.batchInventoryHandler)
|
||||
|
||||
stockhandler := &StockHandler{
|
||||
MainStockLocationID: inventory.LocationID(country),
|
||||
rdb: rdb,
|
||||
ctx: ctx,
|
||||
svc: *s,
|
||||
country: country,
|
||||
low: lowWatermark,
|
||||
}
|
||||
|
||||
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
|
||||
if ok {
|
||||
log.Printf("Connecting to rabbitmq")
|
||||
conn, err := amqp.DialConfig(amqpUrl, amqp.Config{
|
||||
Properties: amqp.NewConnectionProperties(),
|
||||
})
|
||||
//a.conn = conn
|
||||
conn, err := rabbit.Dial(amqpUrl, "cart-inventory")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
|
||||
}
|
||||
// The catalog-feed handler emits inventory.level_changed crossings
|
||||
// directly on this connection as it writes stock.
|
||||
stockhandler.conn = conn
|
||||
// Share the bus connection with the batch handler so its writes emit
|
||||
// inventory.level_changed crossings too.
|
||||
server.conn = conn
|
||||
// Reconnecting consumer: re-listen on reconnect.
|
||||
conn.NotifyOnReconnect(func() {
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open a channel: %v", err)
|
||||
log.Printf("inventory: channel on reconnect: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Producer side: declare the "inventory" topic exchange we publish
|
||||
// inventory.level_changed crossings to. Durable + idempotent, so
|
||||
// re-declaring on each reconnect is safe.
|
||||
if err := ch.ExchangeDeclare("inventory", "topic", true, false, false, false, nil); err != nil {
|
||||
log.Printf("inventory: declare inventory exchange: %v", err)
|
||||
}
|
||||
|
||||
// 1) LEGACY raw `catalog.item_changed` wire — payload is a raw item
|
||||
// JSON array. Consumed by the historical pricewatcher +
|
||||
// writeradmin item-changed paths. Carries RawDataItem bodies
|
||||
// with full finder-style metadata (including the stock
|
||||
// field that the new projection wire does not).
|
||||
//
|
||||
// This listener is the one that mutates Redis stock state via
|
||||
// StockHandler.HandleItem.
|
||||
if err := rabbit.ListenToPattern(ch, "catalog", string(event.CatalogItemChanged), func(d amqp.Delivery) error {
|
||||
var ev event.Event
|
||||
if err := json.Unmarshal(d.Body, &ev); err != nil {
|
||||
log.Printf("inventory: decode catalog event: %v", err)
|
||||
return nil
|
||||
}
|
||||
if ev.Meta["country"] != "" && ev.Meta["country"] != country {
|
||||
return nil // not for our country
|
||||
}
|
||||
// items listener
|
||||
err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error {
|
||||
wg := &sync.WaitGroup{}
|
||||
err = index.ForEachRawDataItemInJSONArray(d.Body, func(item *index.RawDataItem) error {
|
||||
if err := index.ForEachRawDataItemInJSONArray(ev.Payload, func(item *index.RawDataItem) error {
|
||||
stockhandler.HandleItem(item, wg)
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Printf("inventory: apply items: %v", err)
|
||||
}
|
||||
wg.Wait()
|
||||
log.Print("Batch done...")
|
||||
return err
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to listen to item_added topic: %v", err)
|
||||
}); err != nil {
|
||||
log.Printf("inventory: listen on reconnect: %v", err)
|
||||
}
|
||||
|
||||
// 2) (REMOVED) `catalog.projection_published` listener — moved to
|
||||
// the cart service. Per docs/inventory-shape.md, inventory's
|
||||
// bus role is emit-only (inventory.level_changed crossings back
|
||||
// to finder on the inventory exchange). The catalog projection
|
||||
// is read by the cart at checkout-time lookups via its own
|
||||
// in-memory CatalogCache, not by inventory. Stock state stays
|
||||
// sourced from the legacy raw wire (block 1 below) until
|
||||
// inventory itself emits level_changed on a stock mutation.
|
||||
|
||||
// 3) order.created → commit stock (permanent decrement), release the
|
||||
// cart hold, emit the level crossing. This is the async commit
|
||||
// path: checkout no longer decrements synchronously; it announces
|
||||
// a completed sale and inventory reacts. Idempotent per order.
|
||||
if err := rabbit.ListenToPattern(ch, "order", string(event.OrderCreated), func(d amqp.Delivery) error {
|
||||
var ev event.Event
|
||||
if err := json.Unmarshal(d.Body, &ev); err != nil {
|
||||
log.Printf("inventory: decode order event: %v", err)
|
||||
return nil
|
||||
}
|
||||
if target := countryForEvent(&ev); target != "" && target != country {
|
||||
return nil // not for our country/tenant
|
||||
}
|
||||
created, err := orderevent.Decode(ev.Payload)
|
||||
if err != nil {
|
||||
log.Printf("inventory: decode order.created: %v", err)
|
||||
return nil
|
||||
}
|
||||
commitOrder(ctx, rdb, s, r, conn, country, lowWatermark, created)
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Printf("inventory: listen order.created on reconnect: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
log.Println("Starting HTTP server on :8080")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
}
|
||||
|
||||
// countryForEvent extracts the best-available country/tenant identifier
|
||||
// from an envelope. Lookup order: Meta["country"], Meta["tenant"], then
|
||||
// the Source prefix "catalog:<x>". An empty string means "no decoration" —
|
||||
// callers treat that as "for everyone" (same as the legacy listener).
|
||||
func countryForEvent(ev *event.Event) string {
|
||||
if t := strings.TrimSpace(ev.Meta["country"]); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := strings.TrimSpace(ev.Meta["tenant"]); t != "" {
|
||||
return t
|
||||
}
|
||||
if strings.HasPrefix(ev.Source, "catalog:") {
|
||||
if t := strings.TrimSpace(strings.TrimPrefix(ev.Source, "catalog:")); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
)
|
||||
|
||||
type reservationLineReq struct {
|
||||
SKU string `json:"sku"`
|
||||
LocationID string `json:"locationId"`
|
||||
Quantity uint32 `json:"quantity"`
|
||||
}
|
||||
|
||||
type reservationBatchReq struct {
|
||||
HolderID string `json:"holderId"`
|
||||
TTLSeconds int64 `json:"ttlSeconds,omitempty"`
|
||||
Lines []reservationLineReq `json:"lines"`
|
||||
}
|
||||
|
||||
func (srv *Server) reserveInventoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req reservationBatchReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.HolderID == "" {
|
||||
http.Error(w, "missing holderId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Lines) == 0 {
|
||||
http.Error(w, "missing lines", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ttl := 15 * time.Minute
|
||||
if req.TTLSeconds > 0 {
|
||||
ttl = time.Duration(req.TTLSeconds) * time.Second
|
||||
}
|
||||
for _, line := range req.Lines {
|
||||
if line.SKU == "" || line.LocationID == "" || line.Quantity == 0 {
|
||||
http.Error(w, "each line must include sku, locationId, and quantity", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := srv.reservationService.ReserveForCart(r.Context(), inventory.CartReserveRequest{
|
||||
InventoryReference: &inventory.InventoryReference{
|
||||
SKU: inventory.SKU(line.SKU),
|
||||
LocationID: inventory.LocationID(line.LocationID),
|
||||
},
|
||||
CartID: inventory.CartID(req.HolderID),
|
||||
Quantity: line.Quantity,
|
||||
TTL: ttl,
|
||||
}); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, inventory.ErrInsufficientInventory) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
http.Error(w, err.Error(), status)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"status": "reserved", "holderId": req.HolderID})
|
||||
}
|
||||
|
||||
func (srv *Server) releaseReservationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req reservationBatchReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.HolderID == "" {
|
||||
http.Error(w, "missing holderId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Lines) == 0 {
|
||||
http.Error(w, "missing lines", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, line := range req.Lines {
|
||||
if line.SKU == "" || line.LocationID == "" {
|
||||
http.Error(w, "each line must include sku and locationId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := srv.reservationService.ReleaseForCart(r.Context(),
|
||||
inventory.SKU(line.SKU),
|
||||
inventory.LocationID(line.LocationID),
|
||||
inventory.CartID(req.HolderID),
|
||||
); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"status": "released", "holderId": req.HolderID})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"git.k6n.net/mats/slask-finder/pkg/types"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
@@ -15,30 +16,35 @@ type StockHandler struct {
|
||||
ctx context.Context
|
||||
svc inventory.RedisInventoryService
|
||||
MainStockLocationID inventory.LocationID
|
||||
|
||||
// Bus producer config. conn is nil when RABBIT_HOST is unset (no bus); the
|
||||
// handler still updates Redis and just skips emitting.
|
||||
conn *rabbit.Conn
|
||||
country string
|
||||
low int64
|
||||
}
|
||||
|
||||
func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
|
||||
wg.Go(func() {
|
||||
ctx := s.ctx
|
||||
pipe := s.rdb.Pipeline()
|
||||
centralStock, ok := item.GetNumberFieldValue("inStock")
|
||||
if !ok {
|
||||
centralStock = 0
|
||||
}
|
||||
|
||||
sku, ok := item.GetStringFieldValue("sku")
|
||||
if !ok {
|
||||
log.Printf("unable to parse central stock for item %s: %v", sku)
|
||||
centralStock = 0
|
||||
} else {
|
||||
log.Printf("unable to parse central stock for item: sku missing")
|
||||
return
|
||||
}
|
||||
// One linear pass: write the authoritative quantity, then derive and
|
||||
// emit the level crossing from the same value. No Redis pub/sub
|
||||
// round-trip — Redis stays the internal store, the bus carries levels.
|
||||
pipe := s.rdb.Pipeline()
|
||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock))
|
||||
}
|
||||
// for id, value := range item.GetStock() {
|
||||
// s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), inventory.LocationID(id), int64(value))
|
||||
// }
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
log.Printf("unable to update stock: %v", err)
|
||||
return
|
||||
}
|
||||
emitLevelIfChanged(ctx, s.rdb, s.conn, s.country, s.low, sku, string(s.MainStockLocationID), int64(centralStock))
|
||||
})
|
||||
}
|
||||
|
||||
+29
-111
@@ -3,13 +3,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
@@ -29,7 +26,7 @@ func newRabbitPublisher(url string) *rabbitPublisher {
|
||||
}
|
||||
|
||||
func (p *rabbitPublisher) connect() error {
|
||||
conn, err := amqp.Dial(p.url)
|
||||
conn, err := rabbit.Connect(p.url, "cart-order-publisher")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -73,137 +70,57 @@ func (p *rabbitPublisher) Publish(exchange, routingKey string, body []byte) erro
|
||||
return err
|
||||
}
|
||||
|
||||
// This ingester lets the order service supersede the legacy go-order-manager:
|
||||
// it consumes the same "order-queue" the Klarna/Adyen checkout publishes to and
|
||||
// records each completed order as an event-sourced grain. Those orders are
|
||||
// already paid by the external processor, so we record payment with provider
|
||||
// "legacy" (place -> authorize -> capture) rather than charging again.
|
||||
|
||||
// legacyOrder is the subset of go-order-manager's Order JSON we need.
|
||||
type legacyOrder struct {
|
||||
ID string `json:"order_id"`
|
||||
PurchaseCountry string `json:"purchase_country"`
|
||||
PurchaseCurrency string `json:"purchase_currency"`
|
||||
Locale string `json:"locale"`
|
||||
OrderAmount int64 `json:"order_amount"`
|
||||
OrderTaxAmount int64 `json:"order_tax_amount"`
|
||||
OrderLines []legacyLine `json:"order_lines"`
|
||||
Customer *legacyPerson `json:"customer,omitempty"`
|
||||
BillingAddress *legacyAddr `json:"billing_address,omitempty"`
|
||||
}
|
||||
|
||||
type legacyLine struct {
|
||||
Reference string `json:"reference"`
|
||||
Name string `json:"name"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
TaxRate int32 `json:"tax_rate"`
|
||||
TotalAmount int64 `json:"total_amount"`
|
||||
TotalTaxAmount int64 `json:"total_tax_amount"`
|
||||
}
|
||||
|
||||
type legacyPerson struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
}
|
||||
type legacyAddr struct {
|
||||
Email string `json:"email,omitempty"`
|
||||
GivenName string `json:"given_name,omitempty"`
|
||||
FamilyName string `json:"family_name,omitempty"`
|
||||
}
|
||||
|
||||
// legacyOrderID maps a legacy string order id to a stable grain id, so a
|
||||
// redelivered message resolves to the same grain (PlaceOrder then no-ops).
|
||||
func legacyOrderID(ref string) uint64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(ref))
|
||||
id := h.Sum64()
|
||||
if id == 0 {
|
||||
id = 1
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// ingestLegacyOrder records one legacy order as an event-sourced grain. It is
|
||||
// idempotent: if the order is already placed, PlaceOrder is rejected and we
|
||||
// stop (the order already exists). Testable without a broker.
|
||||
func ingestLegacyOrder(ctx context.Context, app *orderedApplier, body []byte) error {
|
||||
var lo legacyOrder
|
||||
if err := json.Unmarshal(body, &lo); err != nil {
|
||||
// ingestOrderFromQueue records one order from the queue. It uses the exact same
|
||||
// idempotent business logic as the HTTP endpoint.
|
||||
func ingestOrderFromQueue(ctx context.Context, s *server, body []byte) error {
|
||||
var req fromCheckoutReq
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
if lo.ID == "" || len(lo.OrderLines) == 0 {
|
||||
if len(req.Lines) == 0 {
|
||||
return nil // nothing actionable
|
||||
}
|
||||
id := legacyOrderID(lo.ID)
|
||||
ms := time.Now().UnixMilli()
|
||||
|
||||
po := &messages.PlaceOrder{
|
||||
OrderReference: lo.ID,
|
||||
Currency: lo.PurchaseCurrency,
|
||||
Locale: lo.Locale,
|
||||
Country: lo.PurchaseCountry,
|
||||
TotalAmount: lo.OrderAmount,
|
||||
TotalTax: lo.OrderTaxAmount,
|
||||
PlacedAtMs: ms,
|
||||
_, _, _, _, runErr, err := s.createOrderFromCheckoutInternal(ctx, &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lo.Customer != nil {
|
||||
po.CustomerEmail = lo.Customer.Email
|
||||
if runErr != nil {
|
||||
return runErr
|
||||
}
|
||||
if po.CustomerEmail == "" && lo.BillingAddress != nil {
|
||||
po.CustomerEmail = lo.BillingAddress.Email
|
||||
po.CustomerName = lo.BillingAddress.GivenName + " " + lo.BillingAddress.FamilyName
|
||||
}
|
||||
for _, l := range lo.OrderLines {
|
||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
||||
Reference: l.Reference, Sku: l.Reference, Name: l.Name,
|
||||
Quantity: l.Quantity, UnitPrice: l.UnitPrice, TaxRate: l.TaxRate,
|
||||
TotalAmount: l.TotalAmount, TotalTax: l.TotalTaxAmount,
|
||||
})
|
||||
}
|
||||
|
||||
if err := applyOne(ctx, app, id, po); err != nil {
|
||||
// Already placed (redelivery) or invalid — nothing more to do.
|
||||
return nil
|
||||
}
|
||||
// Record the externally-settled payment so the grain reaches "captured".
|
||||
ref := "legacy-" + lo.ID
|
||||
_ = applyOne(ctx, app, id, &messages.AuthorizePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
|
||||
_ = applyOne(ctx, app, id, &messages.CapturePayment{Provider: "legacy", Amount: lo.OrderAmount, Reference: ref, AtMs: ms})
|
||||
return nil
|
||||
}
|
||||
|
||||
// startLegacyIngest connects to AMQP and consumes "order-queue" until ctx is
|
||||
// done. Best-effort: a connection failure is logged and ingestion is skipped
|
||||
// (the HTTP checkout path keeps working regardless).
|
||||
func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier, logger *slog.Logger) {
|
||||
conn, err := amqp.Dial(amqpURL)
|
||||
// startOrderIngest connects to AMQP and consumes "order-queue" until ctx is
|
||||
// done. The connection is reconnecting — on broker blips the caller re-declares
|
||||
// the queue and re-consumes automatically.
|
||||
func startOrderIngest(ctx context.Context, amqpURL string, s *server) {
|
||||
conn, err := rabbit.Dial(amqpURL, "cart-order-ingest")
|
||||
if err != nil {
|
||||
logger.Warn("legacy order ingest disabled: amqp dial failed", "err", err)
|
||||
s.logger.Warn("order ingest disabled: amqp dial failed", "err", err)
|
||||
return
|
||||
}
|
||||
// Reconnecting consumer: re-declare queue and re-consume on reconnect.
|
||||
conn.NotifyOnReconnect(func() {
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
logger.Warn("legacy order ingest disabled: channel failed", "err", err)
|
||||
_ = conn.Close()
|
||||
s.logger.Warn("order ingest: channel on reconnect", "err", err)
|
||||
return
|
||||
}
|
||||
q, err := ch.QueueDeclare("order-queue", false, false, false, false, nil)
|
||||
if err != nil {
|
||||
logger.Warn("legacy order ingest disabled: queue declare failed", "err", err)
|
||||
s.logger.Warn("order ingest: queue declare on reconnect", "err", err)
|
||||
_ = ch.Close()
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
|
||||
if err != nil {
|
||||
logger.Warn("legacy order ingest disabled: consume failed", "err", err)
|
||||
s.logger.Warn("order ingest: consume on reconnect", "err", err)
|
||||
_ = ch.Close()
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
logger.Info("ingesting legacy orders from order-queue")
|
||||
s.logger.Info("order ingest: consuming from order-queue (reconnect)")
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
defer ch.Close()
|
||||
for {
|
||||
select {
|
||||
@@ -211,13 +128,14 @@ func startLegacyIngest(ctx context.Context, amqpURL string, app *orderedApplier,
|
||||
return
|
||||
case d, ok := <-msgs:
|
||||
if !ok {
|
||||
logger.Warn("legacy order ingest: channel closed")
|
||||
s.logger.Warn("order ingest: channel closed (will reconnect)")
|
||||
return
|
||||
}
|
||||
if err := ingestLegacyOrder(ctx, app, d.Body); err != nil {
|
||||
logger.Error("legacy order ingest failed", "err", err)
|
||||
if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil {
|
||||
s.logger.Error("order queue ingest failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
+47
-12
@@ -3,14 +3,19 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
)
|
||||
|
||||
func testApplier(t *testing.T) *orderedApplier {
|
||||
func testServer(t *testing.T) *server {
|
||||
t.Helper()
|
||||
reg := actor.NewMutationRegistry()
|
||||
order.RegisterMutations(reg)
|
||||
@@ -32,23 +37,53 @@ func testApplier(t *testing.T) *orderedApplier {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return &orderedApplier{pool: pool, storage: storage}
|
||||
|
||||
applier := &orderedApplier{pool: pool, storage: storage}
|
||||
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
|
||||
|
||||
freg := buildFlowRegistry(applier, provider, nil, nil, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
engine := flow.NewEngine(freg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
|
||||
def, err := order.EmbeddedFlow("place-and-pay")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idem, err := idempotency.Open(filepath.Join(t.TempDir(), "idem.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &server{
|
||||
pool: pool, applier: applier, storage: storage, reg: reg,
|
||||
engine: engine, freg: freg,
|
||||
flows: &flowStore{defs: map[string]*flow.Definition{def.Name: def}},
|
||||
provider: provider, taxProvider: order.NewStaticTaxProvider(),
|
||||
defaultFlow: def.Name, idem: idem, logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestLegacyOrder(t *testing.T) {
|
||||
app := testApplier(t)
|
||||
func TestIngestOrderFromQueue(t *testing.T) {
|
||||
s := testServer(t)
|
||||
ctx := context.Background()
|
||||
body := []byte(`{
|
||||
"order_id":"K-1","purchase_currency":"SEK","purchase_country":"se","locale":"sv-SE",
|
||||
"order_amount":15000,"order_tax_amount":3000,
|
||||
"order_lines":[{"reference":"l1","name":"Widget","quantity":1,"unit_price":15000,"tax_rate":25,"total_amount":15000,"total_tax_amount":3000}]
|
||||
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
|
||||
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
|
||||
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
|
||||
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
|
||||
}`)
|
||||
|
||||
if err := ingestLegacyOrder(ctx, app, body); err != nil {
|
||||
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
id := legacyOrderID("K-1")
|
||||
g, err := app.Get(ctx, id)
|
||||
|
||||
// Retrieve the created order grain. The new order ID is recorded in idempotency.Store
|
||||
id, ok := s.idem.Get("checkout-K-1")
|
||||
if !ok {
|
||||
t.Fatalf("expected order ID to be stored in idempotency cache")
|
||||
}
|
||||
|
||||
g, err := s.applier.Get(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -60,10 +95,10 @@ func TestIngestLegacyOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
// Redelivery must be idempotent — no double capture, no new payment.
|
||||
if err := ingestLegacyOrder(ctx, app, body); err != nil {
|
||||
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
|
||||
t.Fatalf("re-ingest: %v", err)
|
||||
}
|
||||
g2, _ := app.Get(ctx, id)
|
||||
g2, _ := s.applier.Get(ctx, id)
|
||||
if g2.CapturedAmount != 15000 {
|
||||
t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
)
|
||||
|
||||
type projectFulfillmentHookParams struct {
|
||||
Integration string `json:"integration,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Dropship bool `json:"dropship,omitempty"`
|
||||
}
|
||||
|
||||
// registerProjectFlowHooks is the project-layer seam for customer-specific flow
|
||||
// integrations. These belong here rather than pkg/order so core order logic
|
||||
// stays generic while deployments can register their own fulfillment tools.
|
||||
func registerProjectFlowHooks(reg *flow.Registry, app *orderedApplier, logger *slog.Logger) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
reg.HookWithMeta("project_fulfillment_integration", func(ctx context.Context, st *flow.State, info flow.HookInfo, params json.RawMessage) error {
|
||||
var p projectFulfillmentHookParams
|
||||
if len(params) > 0 {
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return fmt.Errorf("project_fulfillment_integration: bad params: %w", err)
|
||||
}
|
||||
}
|
||||
if p.Integration == "" {
|
||||
p.Integration = "order-integration"
|
||||
}
|
||||
|
||||
o, err := app.Get(ctx, st.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(o.Fulfillments) == 0 {
|
||||
return fmt.Errorf("project_fulfillment_integration: order %s has no fulfillment yet", order.OrderId(st.ID).String())
|
||||
}
|
||||
last := o.Fulfillments[len(o.Fulfillments)-1]
|
||||
|
||||
log := st.Logger
|
||||
if log == nil {
|
||||
log = logger
|
||||
}
|
||||
log.Info("project fulfillment integration",
|
||||
"orderId", order.OrderId(st.ID).String(),
|
||||
"orderReference", o.OrderReference,
|
||||
"customerEmail", o.CustomerEmail,
|
||||
"integration", p.Integration,
|
||||
"target", p.Target,
|
||||
"dropship", p.Dropship,
|
||||
"step", info.Step,
|
||||
"phase", info.Phase,
|
||||
"fulfillmentId", last.ID,
|
||||
"carrier", last.Carrier,
|
||||
"trackingNumber", last.TrackingNumber,
|
||||
)
|
||||
return nil
|
||||
}, flow.CapabilityMeta{
|
||||
Description: "Project-specific fulfillment integration seam for local or testing adapters.",
|
||||
ExampleParams: json.RawMessage(`{"integration":"dropship-test","target":"erp-sandbox","dropship":true}`),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||
)
|
||||
|
||||
func TestProjectFulfillmentIntegrationHookIsRegistered(t *testing.T) {
|
||||
s := testServer(t)
|
||||
if !strings.Contains(strings.Join(s.freg.Capabilities().Hooks, ","), "project_fulfillment_integration") {
|
||||
t.Fatalf("hooks = %v, want project_fulfillment_integration", s.freg.Capabilities().Hooks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectFulfillmentIntegrationLogs(t *testing.T) {
|
||||
s := testServer(t)
|
||||
var logs bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&logs, nil))
|
||||
|
||||
provider := order.NewPassthroughProvider("legacy", "ref", 25000)
|
||||
freg := buildFlowRegistry(s.applier, provider, nil, nil, nil, nil, nil, logger)
|
||||
engine := flow.NewEngine(freg, logger)
|
||||
|
||||
st := flow.NewState(801, logger)
|
||||
po := orderTestPlaceMsg()
|
||||
po.CustomerEmail = "dropship@example.com"
|
||||
st.Vars[order.PlaceOrderVar] = po
|
||||
|
||||
def, err := order.EmbeddedFlow("place-and-pay")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := engine.Run(context.Background(), def, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ship := &flow.Definition{Name: "ship", Steps: []flow.Step{{
|
||||
Name: "fulfill",
|
||||
Action: "create_fulfillment",
|
||||
Params: json.RawMessage(`{"carrier":"dropshipper","trackingNumber":"DS-123","lines":[{"reference":"l1","quantity":1}]}`),
|
||||
Hooks: flow.Hooks{After: []flow.HookRef{{
|
||||
Type: "project_fulfillment_integration",
|
||||
Params: json.RawMessage(`{"integration":"dropship-test","target":"erp-sandbox","dropship":true}`),
|
||||
}}},
|
||||
}}}
|
||||
if _, err := engine.Run(context.Background(), ship, flow.NewState(801, logger)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := logs.String()
|
||||
for _, want := range []string{
|
||||
`"msg":"project fulfillment integration"`,
|
||||
`"integration":"dropship-test"`,
|
||||
`"target":"erp-sandbox"`,
|
||||
`"dropship":true`,
|
||||
`"trackingNumber":"DS-123"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("log output missing %s: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func orderTestPlaceMsg() *messages.PlaceOrder {
|
||||
return &messages.PlaceOrder{
|
||||
OrderReference: "ref-1",
|
||||
CartId: "cart-1",
|
||||
Currency: "SEK",
|
||||
Country: "SE",
|
||||
CustomerName: "Dropship Customer",
|
||||
TotalAmount: 25000,
|
||||
TotalTax: 5000,
|
||||
PlacedAtMs: 1718877600000,
|
||||
Lines: []*messages.OrderLine{
|
||||
{Reference: "l1", Sku: "ABC", Name: "Widget", Quantity: 2, UnitPrice: 10000, TaxRate: 25, TotalAmount: 20000},
|
||||
{Reference: "l2", Sku: "DEF", Name: "Gizmo", Quantity: 1, UnitPrice: 5000, TaxRate: 25, TotalAmount: 5000},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
)
|
||||
|
||||
// emailTemplateInfo is the metadata returned by the list endpoint.
|
||||
type emailTemplateInfo struct {
|
||||
Name string `json:"name"`
|
||||
Embedded bool `json:"embedded,omitempty"`
|
||||
}
|
||||
|
||||
// emailTemplateHandler serves CRUD operations for email templates stored in the
|
||||
// ORDER_EMAIL_TEMPLATES directory. Embedded templates are read-only; on-disk
|
||||
// copies override them and can be edited via PUT.
|
||||
type emailTemplateHandler struct {
|
||||
dir string // the on-disk templates directory, from ORDER_EMAIL_TEMPLATES env
|
||||
}
|
||||
|
||||
func newEmailTemplateHandler(dir string) *emailTemplateHandler {
|
||||
if dir == "" {
|
||||
dir = "data/order-email-templates"
|
||||
}
|
||||
return &emailTemplateHandler{dir: dir}
|
||||
}
|
||||
|
||||
// list returns all known email templates (embedded + on-disk) with their source.
|
||||
func (h *emailTemplateHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
embedded := knownEmbeddedTemplates()
|
||||
onDisk := h.listOnDisk()
|
||||
|
||||
seen := map[string]bool{}
|
||||
var infos []emailTemplateInfo
|
||||
|
||||
// List on-disk first (they take precedence).
|
||||
for _, name := range onDisk {
|
||||
infos = append(infos, emailTemplateInfo{Name: name})
|
||||
seen[name] = true
|
||||
}
|
||||
// Then add embedded templates not overridden by disk.
|
||||
for _, name := range embedded {
|
||||
if !seen[name] {
|
||||
infos = append(infos, emailTemplateInfo{Name: name, Embedded: true})
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
if infos == nil {
|
||||
infos = []emailTemplateInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, infos)
|
||||
}
|
||||
|
||||
// get returns the template content for the given name.
|
||||
func (h *emailTemplateHandler) get(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if name == "" {
|
||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
|
||||
return
|
||||
}
|
||||
store, err := order.LoadEmailTemplates(h.dir)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("load templates: %w", err))
|
||||
return
|
||||
}
|
||||
tmpl, err := store.Get(name)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found", name))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tmpl)
|
||||
}
|
||||
|
||||
// save persists a template to the on-disk directory. Returns the saved template.
|
||||
func (h *emailTemplateHandler) save(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if name == "" || !validEmailTemplateName(name) {
|
||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid template name %q", name))
|
||||
return
|
||||
}
|
||||
var tmpl order.EmailTemplate
|
||||
if err := json.NewDecoder(r.Body).Decode(&tmpl); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid template body: %w", err))
|
||||
return
|
||||
}
|
||||
if err := tmpl.Validate(name); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(h.dir, 0o755); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("create templates dir: %w", err))
|
||||
return
|
||||
}
|
||||
data, err := json.MarshalIndent(tmpl, "", " ")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("marshal template: %w", err))
|
||||
return
|
||||
}
|
||||
path := filepath.Join(h.dir, name+".json")
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("write template: %w", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tmpl)
|
||||
}
|
||||
|
||||
// delete removes an on-disk template. Embedded templates cannot be deleted.
|
||||
func (h *emailTemplateHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if name == "" {
|
||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
|
||||
return
|
||||
}
|
||||
// Check if it's embedded (read-only).
|
||||
if isEmbeddedTemplate(name) {
|
||||
writeErr(w, http.StatusForbidden, fmt.Errorf("template %q is built-in and cannot be deleted; save an override instead", name))
|
||||
return
|
||||
}
|
||||
path := filepath.Join(h.dir, name+".json")
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found on disk", name))
|
||||
return
|
||||
}
|
||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("delete template: %w", err))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// preview renders a template with sample data and returns the result.
|
||||
func (h *emailTemplateHandler) preview(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if name == "" {
|
||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("template name is required"))
|
||||
return
|
||||
}
|
||||
store, err := order.LoadEmailTemplates(h.dir)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("load templates: %w", err))
|
||||
return
|
||||
}
|
||||
tmpl, err := store.Get(name)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, fmt.Errorf("template %q not found", name))
|
||||
return
|
||||
}
|
||||
|
||||
// Build sample data for preview.
|
||||
sampleData := order.SampleTemplateData()
|
||||
subject, _ := order.RenderTextTemplate("preview-subject", tmpl.Subject, sampleData)
|
||||
textBody, _ := order.RenderTextTemplate("preview-text", tmpl.Text, sampleData)
|
||||
htmlBody, _ := order.RenderOptionalHTMLTemplate("preview-html", tmpl.HTML, sampleData)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"subject": subject,
|
||||
"text": textBody,
|
||||
"html": htmlBody,
|
||||
})
|
||||
}
|
||||
|
||||
// listOnDisk returns template names from the on-disk directory.
|
||||
func (h *emailTemplateHandler) listOnDisk() []string {
|
||||
matches, err := filepath.Glob(filepath.Join(h.dir, "*.json"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, m := range matches {
|
||||
name := strings.TrimSuffix(filepath.Base(m), ".json")
|
||||
if validEmailTemplateName(name) {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// knownEmbeddedTemplates returns the names of templates embedded in the binary.
|
||||
// This must be kept in sync with the files in pkg/order/email_templates/.
|
||||
func knownEmbeddedTemplates() []string {
|
||||
return []string{
|
||||
"fulfillment-shipped",
|
||||
"order-confirmation",
|
||||
"payment-receipt",
|
||||
"shipment-delivered",
|
||||
"return-confirmed",
|
||||
"refund-issued",
|
||||
}
|
||||
}
|
||||
|
||||
// isEmbeddedTemplate reports whether name matches one of the embedded templates.
|
||||
func isEmbeddedTemplate(name string) bool {
|
||||
for _, e := range knownEmbeddedTemplates() {
|
||||
if e == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validEmailTemplateName validates a template name (alphanumeric + hyphens).
|
||||
func validEmailTemplateName(s string) bool {
|
||||
if s == "" || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if !(r == '-' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
)
|
||||
|
||||
func buildFlowRegistry(
|
||||
applier *orderedApplier,
|
||||
provider order.PaymentProvider,
|
||||
inventoryReservations order.InventoryReservationService,
|
||||
emitPub order.Publisher,
|
||||
emailSender order.EmailSender,
|
||||
emailTemplates order.EmailTemplateStore,
|
||||
prefChecker order.EmailPreferenceChecker,
|
||||
logger *slog.Logger,
|
||||
) *flow.Registry {
|
||||
freg := flow.NewRegistry()
|
||||
flow.RegisterBuiltinHooks(freg)
|
||||
order.RegisterFlowActions(freg, applier, provider, prefChecker)
|
||||
order.RegisterInventoryReservationActions(freg, applier, inventoryReservations)
|
||||
order.RegisterEmitHook(freg, emitPub)
|
||||
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates, prefChecker)
|
||||
order.RegisterFulfillmentWebhookHook(freg, applier, nil)
|
||||
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
|
||||
registerProjectFlowHooks(freg, applier, logger)
|
||||
return freg
|
||||
}
|
||||
+223
-11
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
@@ -65,9 +66,33 @@ func (s *server) loadOrder(w http.ResponseWriter, r *http.Request) (order.OrderI
|
||||
return id, g, true
|
||||
}
|
||||
|
||||
// checkIdempotency checks if the request is an idempotent retry.
|
||||
// It returns (idemKey, exists, unlockFn).
|
||||
// If exists is true, the caller should write the current order grain status and return immediately.
|
||||
// If exists is false, the caller must defer unlockFn() and continue.
|
||||
func (s *server) checkIdempotency(w http.ResponseWriter, r *http.Request, id order.OrderId) (string, bool, func()) {
|
||||
idemKey := r.Header.Get("Idempotency-Key")
|
||||
if idemKey == "" {
|
||||
return "", false, func() {}
|
||||
}
|
||||
unlock := s.idem.Lock(idemKey)
|
||||
if _, ok := s.idem.Get(idemKey); ok {
|
||||
unlock() // Release lock immediately since we are returning cached response
|
||||
s.logger.Info("idempotency hit for lifecycle endpoint", "key", idemKey, "orderId", id.String())
|
||||
grain, err := s.applier.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return idemKey, true, nil
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
|
||||
return idemKey, true, nil
|
||||
}
|
||||
return idemKey, false, unlock
|
||||
}
|
||||
|
||||
// applyAndRespond applies a mutation and returns the updated order. A rejected
|
||||
// state transition (handler error) maps to 409 Conflict.
|
||||
func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message) {
|
||||
func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id order.OrderId, msg proto.Message, idemKey string) {
|
||||
res, err := s.applier.Apply(r.Context(), uint64(id), msg)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
@@ -79,6 +104,11 @@ func (s *server) applyAndRespond(w http.ResponseWriter, r *http.Request, id orde
|
||||
return
|
||||
}
|
||||
}
|
||||
if idemKey != "" {
|
||||
if err := s.idem.Put(idemKey, uint64(id)); err != nil {
|
||||
s.logger.Error("idempotency record failed", "key", idemKey, "orderId", id.String(), "err", err)
|
||||
}
|
||||
}
|
||||
grain, err := s.applier.Get(r.Context(), uint64(id))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
@@ -100,27 +130,185 @@ type fulfillReq struct {
|
||||
}
|
||||
|
||||
func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := s.loadOrder(w, r)
|
||||
id, g, ok := s.loadOrder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||
if exists {
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
var req fulfillReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
fid, _ := order.NewOrderId()
|
||||
|
||||
carrier := req.Carrier
|
||||
trackingNumber := req.TrackingNumber
|
||||
trackingURI := req.TrackingURI
|
||||
|
||||
// Integration with go-shipping:
|
||||
if carrier == "" && g.CartId != "" {
|
||||
shippingURL := os.Getenv("SHIPPING_URL")
|
||||
if shippingURL == "" {
|
||||
shippingURL = "http://localhost:8080"
|
||||
}
|
||||
// Query go-shipping for cached options
|
||||
url := fmt.Sprintf("%s/api/shipping-options/%s", shippingURL, g.CartId)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err == nil {
|
||||
resp, err := http.DefaultClient.Do(httpReq)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
var groups []struct {
|
||||
Type string `json:"type"`
|
||||
DefaultOption *struct {
|
||||
BookingInstructions struct {
|
||||
DeliveryOptionID string `json:"deliveryOptionId"`
|
||||
ServiceCode string `json:"serviceCode"`
|
||||
} `json:"bookingInstructions"`
|
||||
DescriptiveTexts struct {
|
||||
Checkout struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"checkout"`
|
||||
} `json:"descriptiveTexts"`
|
||||
} `json:"defaultOption"`
|
||||
}
|
||||
if json.NewDecoder(resp.Body).Decode(&groups) == nil && len(groups) > 0 {
|
||||
g0 := groups[0]
|
||||
carrier = g0.Type
|
||||
if g0.DefaultOption != nil {
|
||||
opt := g0.DefaultOption
|
||||
if opt.DescriptiveTexts.Checkout.Title != "" {
|
||||
carrier = opt.DescriptiveTexts.Checkout.Title
|
||||
}
|
||||
trackingNumber = "SE-" + opt.BookingInstructions.DeliveryOptionID
|
||||
trackingURI = "https://www.postnord.se/en/our-tools/track-and-trace?shipmentId=" + trackingNumber
|
||||
}
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if carrier == "" {
|
||||
carrier = "postnord"
|
||||
}
|
||||
if trackingNumber == "" {
|
||||
trackingNumber = "mock-track-" + fid.String()
|
||||
}
|
||||
if trackingURI == "" {
|
||||
trackingURI = "https://tracking.postnord.com/?id=" + trackingNumber
|
||||
}
|
||||
|
||||
msg := &messages.CreateFulfillment{
|
||||
Id: "f_" + fid.String(),
|
||||
Carrier: req.Carrier,
|
||||
TrackingNumber: req.TrackingNumber,
|
||||
TrackingUri: req.TrackingURI,
|
||||
Carrier: carrier,
|
||||
TrackingNumber: trackingNumber,
|
||||
TrackingUri: trackingURI,
|
||||
AtMs: nowMs(),
|
||||
}
|
||||
for _, l := range req.Lines {
|
||||
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
||||
}
|
||||
s.applyAndRespond(w, r, id, msg)
|
||||
s.applyAndRespond(w, r, id, msg, idemKey)
|
||||
}
|
||||
|
||||
type exchangeReq struct {
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ReturnLines []struct {
|
||||
Reference string `json:"reference"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
} `json:"returnLines"`
|
||||
NewLines []struct {
|
||||
Reference string `json:"reference"`
|
||||
Sku string `json:"sku"`
|
||||
Name string `json:"name"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
UnitPrice int64 `json:"unitPrice"`
|
||||
TaxRate int32 `json:"taxRate"`
|
||||
TotalAmount int64 `json:"totalAmount"`
|
||||
TotalTax int64 `json:"totalTax"`
|
||||
} `json:"newLines"`
|
||||
}
|
||||
|
||||
func (s *server) handleExchange(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := s.loadOrder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||
if exists {
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
var req exchangeReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
exId, _ := order.NewOrderId()
|
||||
retId, _ := order.NewOrderId()
|
||||
msg := &messages.RequestExchange{
|
||||
Id: "ex_" + exId.String(),
|
||||
ReturnId: "r_" + retId.String(),
|
||||
Reason: req.Reason,
|
||||
AtMs: nowMs(),
|
||||
}
|
||||
for _, l := range req.ReturnLines {
|
||||
msg.ReturnLines = append(msg.ReturnLines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
||||
}
|
||||
for _, l := range req.NewLines {
|
||||
msg.NewLines = append(msg.NewLines, &messages.OrderLine{
|
||||
Reference: l.Reference,
|
||||
Sku: l.Sku,
|
||||
Name: l.Name,
|
||||
Quantity: l.Quantity,
|
||||
UnitPrice: l.UnitPrice,
|
||||
TaxRate: l.TaxRate,
|
||||
TotalAmount: l.TotalAmount,
|
||||
TotalTax: l.TotalTax,
|
||||
})
|
||||
}
|
||||
s.applyAndRespond(w, r, id, msg, idemKey)
|
||||
}
|
||||
|
||||
type editDetailsReq struct {
|
||||
ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"`
|
||||
BillingAddress json.RawMessage `json:"billingAddress,omitempty"`
|
||||
ShippingPrice int64 `json:"shippingPrice,omitempty"`
|
||||
}
|
||||
|
||||
func (s *server) handleEditDetails(w http.ResponseWriter, r *http.Request) {
|
||||
id, _, ok := s.loadOrder(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||
if exists {
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
var req editDetailsReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
msg := &messages.EditOrderDetails{
|
||||
ShippingAddress: req.ShippingAddress,
|
||||
BillingAddress: req.BillingAddress,
|
||||
ShippingPrice: req.ShippingPrice,
|
||||
AtMs: nowMs(),
|
||||
}
|
||||
s.applyAndRespond(w, r, id, msg, idemKey)
|
||||
}
|
||||
|
||||
func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -128,7 +316,13 @@ func (s *server) handleComplete(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()})
|
||||
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||
if exists {
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
s.applyAndRespond(w, r, id, &messages.CompleteOrder{AtMs: nowMs()}, idemKey)
|
||||
}
|
||||
|
||||
func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -136,11 +330,17 @@ func (s *server) handleCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||
if exists {
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()})
|
||||
s.applyAndRespond(w, r, id, &messages.CancelOrder{Reason: req.Reason, AtMs: nowMs()}, idemKey)
|
||||
}
|
||||
|
||||
type returnReq struct {
|
||||
@@ -156,6 +356,12 @@ func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||
if exists {
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
var req returnReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err)
|
||||
@@ -166,7 +372,7 @@ func (s *server) handleReturn(w http.ResponseWriter, r *http.Request) {
|
||||
for _, l := range req.Lines {
|
||||
msg.Lines = append(msg.Lines, &messages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
||||
}
|
||||
s.applyAndRespond(w, r, id, msg)
|
||||
s.applyAndRespond(w, r, id, msg, idemKey)
|
||||
}
|
||||
|
||||
func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -174,13 +380,19 @@ func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idemKey, exists, unlock := s.checkIdempotency(w, r, id)
|
||||
if exists {
|
||||
return
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
var req struct {
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
amount := req.Amount
|
||||
if amount <= 0 {
|
||||
amount = g.CapturedAmount - g.RefundedAmount // default: full remaining
|
||||
amount = (g.CapturedAmount - g.RefundedAmount).Int64() // default: full remaining
|
||||
}
|
||||
captureRef := capturedRef(g)
|
||||
ref, err := s.provider.Refund(r.Context(), captureRef, amount)
|
||||
@@ -193,7 +405,7 @@ func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
|
||||
Amount: ref.Amount,
|
||||
Reference: ref.Reference,
|
||||
AtMs: nowMs(),
|
||||
})
|
||||
}, idemKey)
|
||||
}
|
||||
|
||||
// --- saga / flow admin (a) ------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
)
|
||||
|
||||
// fromCheckoutReq is the payload the checkout service sends to create an order
|
||||
@@ -55,101 +57,91 @@ func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// ── Idempotency check ─────────────────────────────────────────────
|
||||
// Lock the key for the whole check→create→record sequence so a concurrent
|
||||
// retry with the same key can't race past the check and create a second
|
||||
// order. The store is durable, so this also holds across a restart.
|
||||
if req.IdempotencyKey != "" {
|
||||
unlock := s.idem.Lock(req.IdempotencyKey)
|
||||
defer unlock()
|
||||
if existingID, ok := s.idem.Get(req.IdempotencyKey); ok {
|
||||
// The order already exists — return it.
|
||||
g, err := s.applier.Get(r.Context(), existingID)
|
||||
ordID, flowRes, grain, exists, runErr, err := s.createOrderFromCheckoutInternal(r.Context(), &req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("idempotent lookup: %w", err))
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
if exists {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"orderId": order.OrderId(existingID).String(),
|
||||
"order": g,
|
||||
"orderId": order.OrderId(ordID).String(),
|
||||
"order": grain,
|
||||
"existing": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
status := http.StatusCreated
|
||||
if runErr != nil {
|
||||
status = http.StatusPaymentRequired
|
||||
}
|
||||
|
||||
writeJSON(w, status, map[string]any{
|
||||
"orderId": order.OrderId(ordID).String(),
|
||||
"flow": flowRes,
|
||||
"order": grain,
|
||||
})
|
||||
}
|
||||
|
||||
// createOrderFromCheckoutInternal runs the core idempotent checkout-to-order logic.
|
||||
// It returns (orderID, flowResult, grain, exists, runErr, err).
|
||||
// - exists is true if the order already existed (idempotency hit).
|
||||
// - runErr is the flow run error (payment flow failed, e.g. compensating was run).
|
||||
// - err is a structural/storage error.
|
||||
func (s *server) createOrderFromCheckoutInternal(ctx context.Context, req *fromCheckoutReq) (uint64, *flow.Result, *order.OrderGrain, bool, error, error) {
|
||||
if req.IdempotencyKey != "" {
|
||||
unlock := s.idem.Lock(req.IdempotencyKey)
|
||||
defer unlock()
|
||||
if existingID, ok := s.idem.Get(req.IdempotencyKey); ok {
|
||||
g, err := s.applier.Get(ctx, existingID)
|
||||
if err != nil {
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("idempotent lookup: %w", err)
|
||||
}
|
||||
return existingID, nil, g, true, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ── Create the order grain ────────────────────────────────────────
|
||||
id, err := order.NewOrderId()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, fmt.Errorf("new order id: %w", err))
|
||||
return
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("new order id: %w", err)
|
||||
}
|
||||
ordID := uint64(id)
|
||||
|
||||
// Build PlaceOrder from the same lineReq format the direct checkout uses.
|
||||
po := buildFromCheckoutPlaceOrder(id, &req)
|
||||
|
||||
// Build a passthrough provider for the externally-settled payment.
|
||||
po := buildFromCheckoutPlaceOrder(id, req)
|
||||
provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount)
|
||||
|
||||
// Run the place-and-pay flow with the passthrough provider. Since the
|
||||
// payment is already settled, this records the authorization and capture
|
||||
// without any external call.
|
||||
flowName := req.Flow
|
||||
if flowName == "" {
|
||||
flowName = s.defaultFlow
|
||||
}
|
||||
def, ok := s.flows.get(flowName)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("unknown flow %q", flowName))
|
||||
return
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
|
||||
}
|
||||
|
||||
// Create a per-request flow registry so the authorize/capture actions use
|
||||
// the passthrough provider for this specific order. RegisterFlowActions
|
||||
// registers all order lifecycle actions, overriding the default provider.
|
||||
freg := flow.NewRegistry()
|
||||
flow.RegisterBuiltinHooks(freg)
|
||||
order.RegisterFlowActions(freg, s.applier, provider)
|
||||
order.RegisterEmitHook(freg, nil)
|
||||
|
||||
freg := buildFlowRegistry(s.applier, provider, s.inventoryReservations, nil, nil, nil, nil, s.logger)
|
||||
engine := flow.NewEngine(freg, s.logger)
|
||||
|
||||
st := flow.NewState(ordID, s.logger)
|
||||
st.Vars[order.PlaceOrderVar] = po
|
||||
|
||||
res, runErr := engine.Run(r.Context(), def, st)
|
||||
grain, getErr := s.applier.Get(r.Context(), ordID)
|
||||
res, runErr := engine.Run(ctx, def, st)
|
||||
grain, getErr := s.applier.Get(ctx, ordID)
|
||||
if getErr != nil {
|
||||
writeErr(w, http.StatusInternalServerError, getErr)
|
||||
return
|
||||
return 0, nil, nil, false, nil, fmt.Errorf("get order grain: %w", getErr)
|
||||
}
|
||||
|
||||
// On failure the flow should have compensated (voided + cancelled). Return
|
||||
// the failed order with the flow trace so the checkout service can decide
|
||||
// how to proceed (e.g. alert operator, retry with a new idempotency key).
|
||||
status := http.StatusCreated
|
||||
if runErr != nil {
|
||||
status = http.StatusPaymentRequired
|
||||
s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr)
|
||||
}
|
||||
|
||||
// Record the idempotency key durably only on success, so retries (incl. after
|
||||
// a restart) return the captured order. A failed flow (402) is deliberately
|
||||
// NOT recorded: the failed grain is terminal, and a retry with the same key
|
||||
// should re-attempt rather than be handed back a dead order as a 409 hit
|
||||
// (which the checkout client treats as a successful, existing order).
|
||||
if req.IdempotencyKey != "" && runErr == nil {
|
||||
if err := s.idem.Put(req.IdempotencyKey, ordID); err != nil {
|
||||
s.logger.Error("idempotency record failed", "key", req.IdempotencyKey, "orderId", id.String(), "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, status, map[string]any{
|
||||
"orderId": id.String(),
|
||||
"flow": res,
|
||||
"order": grain,
|
||||
})
|
||||
return ordID, res, grain, false, runErr, nil
|
||||
}
|
||||
|
||||
// buildFromCheckoutPlaceOrder converts a from-checkout request into a PlaceOrder
|
||||
@@ -169,11 +161,7 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
|
||||
var total, totalTax int64
|
||||
for _, l := range req.Lines {
|
||||
lineTotal := l.UnitPrice * int64(l.Quantity)
|
||||
var lineTax int64
|
||||
if l.TaxRate > 0 {
|
||||
// inc-vat -> tax portion = total * rate / (100 + rate)
|
||||
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
|
||||
}
|
||||
lineTax := order.ComputeTax(money.Cents(lineTotal), int(l.TaxRate)).Int64()
|
||||
total += lineTotal
|
||||
totalTax += lineTax
|
||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
||||
@@ -185,6 +173,8 @@ func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messag
|
||||
TaxRate: l.TaxRate,
|
||||
TotalAmount: lineTotal,
|
||||
TotalTax: lineTax,
|
||||
Location: l.Location,
|
||||
DropShip: l.DropShip,
|
||||
})
|
||||
}
|
||||
po.TotalAmount = total
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
)
|
||||
|
||||
func TestLifecycleIdempotencyRefund(t *testing.T) {
|
||||
s := testServer(t)
|
||||
s.provider = order.NewMockProvider()
|
||||
ctx := context.Background()
|
||||
|
||||
// Ingest an order to get it into Captured status
|
||||
body := []byte(`{
|
||||
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
|
||||
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
|
||||
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
|
||||
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
|
||||
}`)
|
||||
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
|
||||
orderID, ok := s.idem.Get("checkout-K-1")
|
||||
if !ok {
|
||||
t.Fatalf("expected order ID to be stored in idempotency cache")
|
||||
}
|
||||
|
||||
// ── First refund request (with idempotency key) ────────────────────────
|
||||
reqBody := []byte(`{"amount": 5000}`)
|
||||
req1 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
|
||||
req1.Header.Set("Idempotency-Key", "refund-key-123")
|
||||
req1.SetPathValue("id", order.OrderId(orderID).String())
|
||||
w1 := httptest.NewRecorder()
|
||||
|
||||
s.handleRefund(w1, req1)
|
||||
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("first refund status = %d, want 200", w1.Code)
|
||||
}
|
||||
|
||||
g1, err := s.applier.Get(ctx, orderID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g1.RefundedAmount != 5000 {
|
||||
t.Fatalf("refunded amount after first call = %d, want 5000", g1.RefundedAmount)
|
||||
}
|
||||
|
||||
// ── Second refund request (with the same idempotency key) ──────────────
|
||||
req2 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
|
||||
req2.Header.Set("Idempotency-Key", "refund-key-123")
|
||||
req2.SetPathValue("id", order.OrderId(orderID).String())
|
||||
w2 := httptest.NewRecorder()
|
||||
|
||||
s.handleRefund(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("second refund status = %d, want 200", w2.Code)
|
||||
}
|
||||
|
||||
g2, err := s.applier.Get(ctx, orderID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// It should NOT have refunded another 5000 (total refunded remains 5000)
|
||||
if g2.RefundedAmount != 5000 {
|
||||
t.Fatalf("refunded amount after second call = %d, want 5000 (idempotency hit failed, did double refund)", g2.RefundedAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleWithoutIdempotencyRefund(t *testing.T) {
|
||||
s := testServer(t)
|
||||
s.provider = order.NewMockProvider()
|
||||
ctx := context.Background()
|
||||
|
||||
// Ingest an order to get it into Captured status
|
||||
body := []byte(`{
|
||||
"checkoutId":"K-1","idempotencyKey":"checkout-K-1","cartId":"cart-1",
|
||||
"currency":"SEK","country":"SE","customerEmail":"customer@example.com",
|
||||
"lines":[{"reference":"l1","sku":"l1","name":"Widget","quantity":1,"unitPrice":15000,"taxRate":25}],
|
||||
"payment":{"provider":"klarna","reference":"ref-123","amount":15000}
|
||||
}`)
|
||||
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
|
||||
orderID, ok := s.idem.Get("checkout-K-1")
|
||||
if !ok {
|
||||
t.Fatalf("expected order ID to be stored in idempotency cache")
|
||||
}
|
||||
|
||||
// First refund request (no idempotency key)
|
||||
reqBody := []byte(`{"amount": 5000}`)
|
||||
req1 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
|
||||
req1.SetPathValue("id", order.OrderId(orderID).String())
|
||||
w1 := httptest.NewRecorder()
|
||||
|
||||
s.handleRefund(w1, req1)
|
||||
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("first refund status = %d, want 200", w1.Code)
|
||||
}
|
||||
|
||||
// Second refund request (no idempotency key)
|
||||
req2 := httptest.NewRequest("POST", "/api/orders/"+order.OrderId(orderID).String()+"/refunds", bytes.NewReader(reqBody))
|
||||
req2.SetPathValue("id", order.OrderId(orderID).String())
|
||||
w2 := httptest.NewRecorder()
|
||||
|
||||
s.handleRefund(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("second refund status = %d, want 200", w2.Code)
|
||||
}
|
||||
|
||||
g2, err := s.applier.Get(ctx, orderID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Without idempotency, it SHOULD have refunded twice (total 10000)
|
||||
if g2.RefundedAmount != 10000 {
|
||||
t.Fatalf("refunded amount after second call without idempotency = %d, want 10000", g2.RefundedAmount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
||||
)
|
||||
|
||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
||||
discovery.StartK8sDiscovery(podIp, "actor-pool=order", 30, pool)
|
||||
}
|
||||
+347
-32
@@ -12,10 +12,12 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -24,13 +26,21 @@ import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/idempotency"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
ordermcp "git.k6n.net/mats/go-cart-actor/pkg/order/mcp"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/outbox"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||
"git.k6n.net/mats/platform/config"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"git.k6n.net/mats/platform/tax"
|
||||
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var podIp = os.Getenv("POD_IP")
|
||||
var name = os.Getenv("POD_NAME")
|
||||
|
||||
type server struct {
|
||||
pool *actor.SimpleGrainPool[order.OrderGrain]
|
||||
applier *orderedApplier
|
||||
@@ -40,17 +50,19 @@ type server struct {
|
||||
freg *flow.Registry
|
||||
flows *flowStore
|
||||
provider order.PaymentProvider
|
||||
taxProvider tax.Provider
|
||||
defaultFlow string
|
||||
dataDir string
|
||||
idem *idempotency.Store
|
||||
logger *slog.Logger
|
||||
inventoryReservations order.InventoryReservationService
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := envOr("ORDER_ADDR", ":8092") // :8090 cart, :8091 redirector
|
||||
dataDir := envOr("ORDER_DATA", "data/orders")
|
||||
flowsDir := envOr("ORDER_FLOWS", "data/order-flows")
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
addr := config.EnvString("ORDER_ADDR", ":8092") // :8090 cart, :8091 redirector
|
||||
dataDir := config.EnvString("ORDER_DATA", "data/orders")
|
||||
flowsDir := config.EnvString("ORDER_FLOWS", "data/order-flows")
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
log.Fatalf("create data dir: %v", err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
@@ -59,8 +71,13 @@ func main() {
|
||||
order.RegisterMutations(reg)
|
||||
storage := actor.NewDiskStorage[order.OrderGrain](dataDir, reg)
|
||||
|
||||
hostname := podIp
|
||||
if hostname == "" {
|
||||
hostname = "order-1"
|
||||
}
|
||||
|
||||
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
|
||||
Hostname: "order-1",
|
||||
Hostname: hostname,
|
||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
|
||||
g := order.NewOrderGrain(id, time.Now())
|
||||
// Replay any persisted history so the resident grain is current.
|
||||
@@ -69,8 +86,8 @@ func main() {
|
||||
}
|
||||
return g, nil
|
||||
},
|
||||
SpawnHost: func(string) (actor.Host[order.OrderGrain], error) {
|
||||
return nil, fmt.Errorf("order service is single-node")
|
||||
SpawnHost: func(host string) (actor.Host[order.OrderGrain], error) {
|
||||
return proxy.NewRemoteHost[order.OrderGrain](host)
|
||||
},
|
||||
Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
|
||||
TTL: time.Hour,
|
||||
@@ -88,6 +105,15 @@ func main() {
|
||||
log.Fatalf("create pool: %v", err)
|
||||
}
|
||||
|
||||
controlPlaneConfig := actor.DefaultServerConfig()
|
||||
grpcSrv, err := actor.NewControlServer[order.OrderGrain](controlPlaneConfig, pool)
|
||||
if err != nil {
|
||||
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
||||
}
|
||||
defer grpcSrv.GracefulStop()
|
||||
|
||||
UseDiscovery(pool)
|
||||
|
||||
applier := &orderedApplier{pool: pool, storage: storage}
|
||||
provider := selectProvider(logger)
|
||||
|
||||
@@ -96,6 +122,18 @@ func main() {
|
||||
// the broker (publisher is nil without AMQP — the hook then errors at run
|
||||
// time, which is logged, not fatal).
|
||||
amqpURL := os.Getenv("AMQP_URL")
|
||||
emailTemplates, err := order.LoadEmailTemplates(config.EnvString("ORDER_EMAIL_TEMPLATES", "data/order-email-templates"))
|
||||
if err != nil {
|
||||
log.Fatalf("load email templates: %v", err)
|
||||
}
|
||||
emailSender, err := selectEmailSender(logger)
|
||||
if err != nil {
|
||||
log.Fatalf("configure email sender: %v", err)
|
||||
}
|
||||
inventoryReservations, err := selectInventoryReservationService()
|
||||
if err != nil {
|
||||
log.Fatalf("configure inventory reservation service: %v", err)
|
||||
}
|
||||
var emitPub order.Publisher
|
||||
if amqpURL != "" {
|
||||
// The amqp_emit hook writes to a durable outbox rather than the broker
|
||||
@@ -109,18 +147,53 @@ func main() {
|
||||
}
|
||||
go box.Run(context.Background(), newRabbitPublisher(amqpURL), 5*time.Second, logger)
|
||||
emitPub = box
|
||||
|
||||
// Stream applied order mutations to the shared mutations exchange (routing
|
||||
// key mutation.order) for the backoffice live feed. Best-effort, separate
|
||||
// from the durable outbox above.
|
||||
if conn, derr := rabbit.Dial(amqpURL, "order"); derr != nil {
|
||||
logger.Warn("order: mutation feed disabled", "err", derr)
|
||||
} else {
|
||||
feed := actor.NewAmqpListener(conn.Connection(), "order", actor.MutationSummary)
|
||||
feed.DefineTopics()
|
||||
pool.AddListener(feed)
|
||||
|
||||
// Declare the "order" topic exchange the outbox relay publishes
|
||||
// order.created to, so a publish can't hit a missing exchange before
|
||||
// a consumer declares it. Durable + idempotent.
|
||||
if ch, cerr := conn.Channel(); cerr != nil {
|
||||
logger.Warn("order: declare order exchange: channel", "err", cerr)
|
||||
} else {
|
||||
if eerr := ch.ExchangeDeclare("order", "topic", true, false, false, false, nil); eerr != nil {
|
||||
logger.Warn("order: declare order exchange", "err", eerr)
|
||||
}
|
||||
freg := flow.NewRegistry()
|
||||
flow.RegisterBuiltinHooks(freg)
|
||||
order.RegisterFlowActions(freg, applier, provider)
|
||||
order.RegisterEmitHook(freg, emitPub)
|
||||
_ = ch.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
// Email preference checker: when a profile service URL is configured, look up
|
||||
// customer email preferences before sending transactional emails.
|
||||
var prefChecker order.EmailPreferenceChecker
|
||||
if profileURL := os.Getenv("PROFILE_URL"); profileURL != "" {
|
||||
prefChecker = order.NewPreferencesChecker(profileURL + "/ucp/v1/customers")
|
||||
logger.Info("email preference checking enabled", "profile_url", profileURL)
|
||||
}
|
||||
|
||||
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, prefChecker, logger)
|
||||
engine := flow.NewEngine(freg, logger)
|
||||
|
||||
def, err := order.EmbeddedFlow("place-and-pay")
|
||||
if err != nil {
|
||||
log.Fatalf("load flow: %v", err)
|
||||
}
|
||||
flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{def.Name: def})
|
||||
fulfillDef, err := order.EmbeddedFlow("fulfill-order")
|
||||
if err != nil {
|
||||
log.Fatalf("load fulfill flow: %v", err)
|
||||
}
|
||||
flows, err := newFlowStore(flowsDir, engine, map[string]*flow.Definition{
|
||||
def.Name: def,
|
||||
fulfillDef.Name: fulfillDef,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("load flows: %v", err)
|
||||
}
|
||||
@@ -133,10 +206,14 @@ func main() {
|
||||
log.Fatalf("open idempotency store: %v", err)
|
||||
}
|
||||
|
||||
taxProvider := selectTaxProvider()
|
||||
|
||||
s := &server{
|
||||
pool: pool, applier: applier, storage: storage, reg: reg,
|
||||
engine: engine, freg: freg, flows: flows, provider: provider,
|
||||
taxProvider: taxProvider,
|
||||
defaultFlow: def.Name, dataDir: dataDir, idem: idem, logger: logger,
|
||||
inventoryReservations: inventoryReservations,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
@@ -146,6 +223,7 @@ func main() {
|
||||
mux.HandleFunc("POST /checkout", s.handleCheckout)
|
||||
mux.HandleFunc("POST /api/orders/from-checkout", s.handleFromCheckout)
|
||||
mux.HandleFunc("GET /api/orders", s.handleList)
|
||||
mux.HandleFunc("GET /api/orders/stats", s.handleStats)
|
||||
mux.HandleFunc("GET /api/orders/{id}", s.handleGet)
|
||||
mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
|
||||
|
||||
@@ -155,6 +233,8 @@ func main() {
|
||||
mux.HandleFunc("POST /api/orders/{id}/cancel", s.handleCancel)
|
||||
mux.HandleFunc("POST /api/orders/{id}/returns", s.handleReturn)
|
||||
mux.HandleFunc("POST /api/orders/{id}/refunds", s.handleRefund)
|
||||
mux.HandleFunc("POST /api/orders/{id}/exchanges", s.handleExchange)
|
||||
mux.HandleFunc("POST /api/orders/{id}/edit", s.handleEditDetails)
|
||||
|
||||
// UCP REST adapter — Universal Commerce Protocol order lifecycle.
|
||||
orderUCP := ucp.OrderHandler(s.applier)
|
||||
@@ -169,16 +249,30 @@ func main() {
|
||||
// for why the exact-match pattern is omitted).
|
||||
mux.Handle("/ucp/v1/orders/", http.StripPrefix("/ucp/v1/orders", orderUCP))
|
||||
|
||||
// Order MCP streamable-HTTP server (inspect + mutate orders via tools).
|
||||
// Mounted at /order-mcp to avoid conflicting with the backoffice CMS MCP at
|
||||
// /mcp (which is routed by the edge).
|
||||
orderMCP := ordermcp.New(applier)
|
||||
mux.Handle("/order-mcp", orderMCP.Handler())
|
||||
mux.Handle("/order-mcp/", orderMCP.Handler())
|
||||
|
||||
// Saga / flow admin (backoffice editor).
|
||||
mux.HandleFunc("GET /sagas/capabilities", s.handleCapabilities)
|
||||
mux.HandleFunc("GET /sagas/flows", s.handleListFlows)
|
||||
mux.HandleFunc("GET /sagas/flows/{name}", s.handleGetFlow)
|
||||
mux.HandleFunc("PUT /sagas/flows/{name}", s.handleSaveFlow)
|
||||
|
||||
// Ingest legacy checkout orders (Klarna/Adyen → order-queue) so this service
|
||||
// is the single source of orders, superseding go-order-manager.
|
||||
// Email template admin API (used by the backoffice Email Template Manager).
|
||||
eth := newEmailTemplateHandler(config.EnvString("ORDER_EMAIL_TEMPLATES", "data/order-email-templates"))
|
||||
mux.HandleFunc("GET /api/order-email-templates", eth.list)
|
||||
mux.HandleFunc("GET /api/order-email-templates/{name}", eth.get)
|
||||
mux.HandleFunc("PUT /api/order-email-templates/{name}", eth.save)
|
||||
mux.HandleFunc("DELETE /api/order-email-templates/{name}", eth.delete)
|
||||
mux.HandleFunc("POST /api/order-email-templates/{name}/preview", eth.preview)
|
||||
|
||||
// Ingest checkout orders from order-queue (Klarna/Adyen fallback).
|
||||
if amqpURL != "" {
|
||||
startLegacyIngest(context.Background(), amqpURL, applier, logger)
|
||||
startOrderIngest(context.Background(), amqpURL, s)
|
||||
}
|
||||
|
||||
logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir)
|
||||
@@ -235,7 +329,9 @@ type lineReq struct {
|
||||
Name string `json:"name"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
|
||||
TaxRate int32 `json:"taxRate"` // percent
|
||||
TaxRate int32 `json:"taxRate"` // basis points (2500 = 25%)
|
||||
DropShip bool `json:"drop_ship,omitempty"`
|
||||
Location string `json:"location,omitempty"` // inventory commit location / store id
|
||||
}
|
||||
|
||||
type checkoutReq struct {
|
||||
@@ -284,7 +380,7 @@ func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
po := buildPlaceOrder(id, &req)
|
||||
po := s.buildPlaceOrder(id, &req)
|
||||
|
||||
st := flow.NewState(uint64(id), s.logger)
|
||||
st.Vars[order.PlaceOrderVar] = po
|
||||
@@ -312,7 +408,8 @@ func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// buildPlaceOrder converts a checkout request into the PlaceOrder event,
|
||||
// computing per-line and order totals (inc-vat) from the lines.
|
||||
func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
||||
// Tax amounts are computed using the configured TaxProvider.
|
||||
func (s *server) buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
||||
po := &messages.PlaceOrder{
|
||||
OrderReference: req.OrderReference,
|
||||
CartId: req.CartId,
|
||||
@@ -328,11 +425,7 @@ func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
||||
var total, totalTax int64
|
||||
for _, l := range req.Lines {
|
||||
lineTotal := l.UnitPrice * int64(l.Quantity)
|
||||
var lineTax int64
|
||||
if l.TaxRate > 0 {
|
||||
// inc-vat -> tax portion = total * rate / (100 + rate)
|
||||
lineTax = lineTotal * int64(l.TaxRate) / int64(100+l.TaxRate)
|
||||
}
|
||||
lineTax := s.taxProvider.Compute(lineTotal, int(l.TaxRate))
|
||||
total += lineTotal
|
||||
totalTax += lineTax
|
||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
||||
@@ -344,6 +437,7 @@ func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
||||
TaxRate: l.TaxRate,
|
||||
TotalAmount: lineTotal,
|
||||
TotalTax: lineTax,
|
||||
DropShip: l.DropShip,
|
||||
})
|
||||
}
|
||||
po.TotalAmount = total
|
||||
@@ -351,6 +445,160 @@ func buildPlaceOrder(id order.OrderId, req *checkoutReq) *messages.PlaceOrder {
|
||||
return po
|
||||
}
|
||||
|
||||
// --- dashboard stats ------------------------------------------------------
|
||||
|
||||
type orderAlert struct {
|
||||
Severity string `json:"severity"` // warn | error
|
||||
Message string `json:"message"`
|
||||
OrderId string `json:"orderId,omitempty"`
|
||||
}
|
||||
|
||||
type statusBucket struct {
|
||||
Status order.Status `json:"status"`
|
||||
Count int `json:"count"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type revenueSnapshot struct {
|
||||
Total int64 `json:"total"`
|
||||
Captured int64 `json:"captured"`
|
||||
Refunded int64 `json:"refunded"`
|
||||
Pending int64 `json:"pending"`
|
||||
}
|
||||
|
||||
type dailyRevenue struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type orderStatsResponse struct {
|
||||
OrdersTotal int `json:"ordersTotal"`
|
||||
Revenue revenueSnapshot `json:"revenue"`
|
||||
ByStatus []statusBucket `json:"byStatus"`
|
||||
RecentOrders []orderSummary `json:"recentOrders"`
|
||||
DailyRevenue30 []dailyRevenue `json:"dailyRevenue30,omitempty"`
|
||||
Alerts []orderAlert `json:"alerts"`
|
||||
}
|
||||
|
||||
// handleStats scans all order logs and returns aggregated dashboard stats.
|
||||
func (s *server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
matches, _ := filepath.Glob(filepath.Join(s.dataDir, "*.events.log"))
|
||||
now := time.Now()
|
||||
var totalOrders int
|
||||
var totalRevenue, capturedRevenue, refundedRevenue int64
|
||||
byStatus := map[order.Status]int{}
|
||||
statusTotal := map[order.Status]int64{} // total amount per status
|
||||
|
||||
// Daily revenue for the last 30 days (keys are "2026-06-01" sortable strings).
|
||||
dailyByDay := map[string]int64{}
|
||||
for i := 0; i < 30; i++ {
|
||||
day := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||
dailyByDay[day] = 0
|
||||
}
|
||||
|
||||
// Collect all order summaries, then sort by placedAt and take top 10.
|
||||
var allOrders []orderSummary
|
||||
var alerts []orderAlert
|
||||
|
||||
for _, m := range matches {
|
||||
base := strings.TrimSuffix(filepath.Base(m), ".events.log")
|
||||
raw, err := strconv.ParseUint(base, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
g, err := s.pool.Get(r.Context(), raw)
|
||||
if err != nil || g.Status == order.StatusNew {
|
||||
continue
|
||||
}
|
||||
totalOrders++
|
||||
byStatus[g.Status]++
|
||||
statusTotal[g.Status] += g.TotalAmount.Int64()
|
||||
|
||||
totalRevenue += g.TotalAmount.Int64()
|
||||
capturedRevenue += g.CapturedAmount.Int64()
|
||||
refundedRevenue += g.RefundedAmount.Int64()
|
||||
|
||||
// Daily revenue — parse placedAt to extract date.
|
||||
if g.PlacedAt != "" {
|
||||
if placed, parseErr := time.Parse(time.RFC3339, g.PlacedAt); parseErr == nil {
|
||||
dayKey := placed.Format("2006-01-02")
|
||||
if _, ok := dailyByDay[dayKey]; ok {
|
||||
dailyByDay[dayKey] += g.TotalAmount.Int64()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
allOrders = append(allOrders, orderSummary{
|
||||
OrderId: order.OrderId(raw).String(),
|
||||
Reference: g.OrderReference,
|
||||
Status: g.Status,
|
||||
TotalAmount: g.TotalAmount.Int64(),
|
||||
Currency: g.Currency,
|
||||
PlacedAt: g.PlacedAt,
|
||||
})
|
||||
|
||||
// Alerts.
|
||||
if g.Status == order.StatusPending {
|
||||
alerts = append(alerts, orderAlert{
|
||||
Severity: "warn",
|
||||
Message: "Payment still pending",
|
||||
OrderId: order.OrderId(raw).String(),
|
||||
})
|
||||
}
|
||||
if g.Status == order.StatusCancelled {
|
||||
alerts = append(alerts, orderAlert{
|
||||
Severity: "warn",
|
||||
Message: "Order was cancelled",
|
||||
OrderId: order.OrderId(raw).String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort ALL orders by placedAt descending, take top 10 as "recent".
|
||||
sort.Slice(allOrders, func(i, j int) bool {
|
||||
return allOrders[i].PlacedAt > allOrders[j].PlacedAt
|
||||
})
|
||||
recent := allOrders
|
||||
if len(recent) > 10 {
|
||||
recent = recent[:10]
|
||||
}
|
||||
|
||||
// Cap alerts
|
||||
if len(alerts) > 50 {
|
||||
alerts = alerts[:50]
|
||||
}
|
||||
|
||||
// Build status buckets array with totals.
|
||||
buckets := make([]statusBucket, 0, len(byStatus))
|
||||
for st, cnt := range byStatus {
|
||||
buckets = append(buckets, statusBucket{Status: st, Count: cnt, Total: statusTotal[st]})
|
||||
}
|
||||
sort.Slice(buckets, func(i, j int) bool {
|
||||
return buckets[i].Count > buckets[j].Count
|
||||
})
|
||||
|
||||
// Daily revenue as an ordered slice.
|
||||
var dailyRev []dailyRevenue
|
||||
for i := 29; i >= 0; i-- {
|
||||
day := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||
dailyRev = append(dailyRev, dailyRevenue{Date: day, Total: dailyByDay[day]})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, orderStatsResponse{
|
||||
OrdersTotal: totalOrders,
|
||||
Revenue: revenueSnapshot{
|
||||
Total: totalRevenue,
|
||||
Captured: capturedRevenue,
|
||||
Refunded: refundedRevenue,
|
||||
Pending: totalRevenue - capturedRevenue - refundedRevenue,
|
||||
},
|
||||
ByStatus: buckets,
|
||||
RecentOrders: recent,
|
||||
DailyRevenue30: dailyRev,
|
||||
Alerts: alerts,
|
||||
})
|
||||
}
|
||||
|
||||
// --- reads ----------------------------------------------------------------
|
||||
|
||||
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -431,8 +679,8 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
OrderId: order.OrderId(raw).String(),
|
||||
Reference: g.OrderReference,
|
||||
Status: g.Status,
|
||||
TotalAmount: g.TotalAmount,
|
||||
CapturedAmount: g.CapturedAmount,
|
||||
TotalAmount: g.TotalAmount.Int64(),
|
||||
CapturedAmount: g.CapturedAmount.Int64(),
|
||||
Currency: g.Currency,
|
||||
PlacedAt: g.PlacedAt,
|
||||
})
|
||||
@@ -442,6 +690,22 @@ func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// --- helpers --------------------------------------------------------------
|
||||
|
||||
// selectTaxProvider picks the tax provider from the environment. Default is
|
||||
// NordicTaxProvider with SE as the default country (matching the current
|
||||
// Nordic-only footprint). Set TAX_PROVIDER=static for dev and tests
|
||||
// (no country awareness).
|
||||
func selectTaxProvider() tax.Provider {
|
||||
provider := os.Getenv("TAX_PROVIDER")
|
||||
switch provider {
|
||||
case "static":
|
||||
return tax.NewStatic()
|
||||
case "nordic":
|
||||
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
||||
default:
|
||||
return tax.NewNordic(config.EnvString("TAX_DEFAULT_COUNTRY", "SE"))
|
||||
}
|
||||
}
|
||||
|
||||
// selectProvider picks the payment provider from the environment. Default is
|
||||
// the dependency-free mock; set PAYMENT_PROVIDER=stripe + STRIPE_SECRET_KEY for
|
||||
// real Stripe (STRIPE_API_BASE / STRIPE_PAYMENT_METHOD optionally override).
|
||||
@@ -458,6 +722,64 @@ func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
||||
return order.NewMockProvider()
|
||||
}
|
||||
|
||||
// selectEmailSender picks the flow-email transport. Supports SMTP and
|
||||
// MailerSend. When no sender is configured the hook still exists in
|
||||
// capabilities but errors at run time if a flow tries to use it.
|
||||
func selectEmailSender(logger *slog.Logger) (order.EmailSender, error) {
|
||||
kind := config.EnvString("ORDER_EMAIL_SENDER", "")
|
||||
if kind == "" && os.Getenv("ORDER_EMAIL_SMTP_HOST") == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if kind == "" {
|
||||
kind = "smtp"
|
||||
}
|
||||
|
||||
fromAddr := strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_ADDRESS"))
|
||||
if fromAddr == "" {
|
||||
return nil, fmt.Errorf("ORDER_EMAIL_FROM_ADDRESS is required when email sender is configured")
|
||||
}
|
||||
from := mail.Address{
|
||||
Name: strings.TrimSpace(os.Getenv("ORDER_EMAIL_FROM_NAME")),
|
||||
Address: fromAddr,
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case "smtp":
|
||||
sender, err := order.NewSMTPEmailSender(order.SMTPEmailSenderConfig{
|
||||
Host: config.EnvString("ORDER_EMAIL_SMTP_HOST", ""),
|
||||
Port: config.EnvInt("ORDER_EMAIL_SMTP_PORT", 587, func(n int) bool { return n > 0 }),
|
||||
Username: os.Getenv("ORDER_EMAIL_SMTP_USERNAME"),
|
||||
Password: os.Getenv("ORDER_EMAIL_SMTP_PASSWORD"),
|
||||
DefaultFrom: from,
|
||||
Timeout: config.EnvDuration("ORDER_EMAIL_SMTP_TIMEOUT", 10*time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("email sender enabled", "kind", "smtp", "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
|
||||
return sender, nil
|
||||
|
||||
case "mailersend":
|
||||
apiKey := os.Getenv("MAILERSEND_API_KEY")
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("MAILERSEND_API_KEY is required for mailersend sender")
|
||||
}
|
||||
sender, err := order.NewMailerSendEmailSender(apiKey, from)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("email sender enabled", "kind", "mailersend", "from", from.Address)
|
||||
return sender, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q (supported: smtp, mailersend)", kind)
|
||||
}
|
||||
}
|
||||
|
||||
func selectInventoryReservationService() (order.InventoryReservationService, error) {
|
||||
return order.NewHTTPInventoryReservationService(config.EnvString("INVENTORY_URL", "http://localhost:8080"), nil)
|
||||
}
|
||||
|
||||
// loadUCPOrderSigner loads the UCP ECDSA signing key from the path specified in
|
||||
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
||||
func loadUCPOrderSigner() *ucp.SigningConfig {
|
||||
@@ -478,13 +800,6 @@ func loadUCPOrderSigner() *ucp.SigningConfig {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
)
|
||||
|
||||
// clusterAwareApplier is a ProfileApplier that chooses between an
|
||||
// in-process pool call and a remote forward based on which replica
|
||||
// currently owns the grain. It is the single seam at which the auth
|
||||
// server (`customerauth.Server`) and the UCP customer handler
|
||||
// (`ucp.CustomerServer`) ever speak to the grain pool — keeping the
|
||||
// decision of "go to the authoritative owner" vs "spawn locally" in
|
||||
// one place avoids the split-brain hazard introduced when multiple
|
||||
// code paths (HTTP middleware + the handlers' own pool.Get) could each
|
||||
// independently decide to spawn a stale or empty grain and broadcast
|
||||
// conflicting ownership for it.
|
||||
//
|
||||
// The decision rule:
|
||||
//
|
||||
// - pool.OwnerHost(id) returns a remote host → forward Get/Apply
|
||||
// to that host. The host holds the authoritative in-memory state.
|
||||
// - pool.OwnerHost(id) returns no host → delegate to
|
||||
// pool.Get / pool.Apply. On a local cache miss pool.Get spawns
|
||||
// the grain from disk on this pod, broadcasts the new ownership,
|
||||
// and returns the grain; this is the only code path that ever
|
||||
// claims a grain on this pod.
|
||||
//
|
||||
// pool.Get's spawn path is unchanged on purpose: the disk-backed event
|
||||
// log is the source of truth, and the local cache is just a projection
|
||||
// rebuilt from it. The risk surface that the applicr layer removes is
|
||||
// the HTTP-middleware fall-through that re-entered the same pool.Get
|
||||
// from a different code path and duplicated the decision.
|
||||
type clusterAwareApplier struct {
|
||||
pool *actor.SimpleGrainPool[profile.ProfileGrain]
|
||||
}
|
||||
|
||||
// Get returns the current state of grain id, preferring the
|
||||
// authoritative remote owner when one is registered. With no remote
|
||||
// owner the pool spawns the grain locally (from the disk-backed event
|
||||
// log), takes ownership, and returns the grain.
|
||||
func (a *clusterAwareApplier) Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error) {
|
||||
if owner, ok := a.pool.OwnerHost(id); ok {
|
||||
return owner.Get(ctx, id)
|
||||
}
|
||||
return a.pool.Get(ctx, id)
|
||||
}
|
||||
|
||||
// Apply mutates grain id with messages, forwarding to the authoritative
|
||||
// remote owner when one is registered. With no remote owner the pool
|
||||
// spawns the grain locally, applies the mutation, takes ownership, and
|
||||
// returns the mutated state — its listeners (including the AMQP
|
||||
// mutation feed) fire from this pod.
|
||||
func (a *clusterAwareApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) {
|
||||
if owner, ok := a.pool.OwnerHost(id); ok {
|
||||
return owner.Apply(ctx, id, msgs...)
|
||||
}
|
||||
return a.pool.Apply(ctx, id, msgs...)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
||||
)
|
||||
|
||||
func UseDiscovery(pool discovery.DiscoveryTarget) {
|
||||
discovery.StartK8sDiscovery(podIp, "actor-pool=profile", 30, pool)
|
||||
}
|
||||
+196
-19
@@ -8,21 +8,81 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/internal/customerauth"
|
||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/telemetry"
|
||||
"git.k6n.net/mats/platform/config"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
)
|
||||
|
||||
// buildAuthStorage selects the credential store and login limiter that back the
|
||||
// customer-auth surface. With REDIS_ADDRESS set it uses shared Redis storage so
|
||||
// the service scales horizontally; otherwise it falls back to the file-backed
|
||||
// store and an in-memory limiter (single-instance / local dev). A Redis failure
|
||||
// at startup is fatal rather than silently degrading to per-replica state.
|
||||
func buildAuthStorage(ctx context.Context, profileDir string) (customerauth.Credentials, customerauth.Limiter) {
|
||||
if addr := os.Getenv("REDIS_ADDRESS"); addr != "" {
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: os.Getenv("REDIS_PASSWORD"),
|
||||
DB: 0,
|
||||
})
|
||||
store, err := customerauth.NewRedisCredentialStore(ctx, rdb)
|
||||
if err != nil {
|
||||
log.Fatalf("Error connecting customer-auth credential store to Redis at %s: %v", addr, err)
|
||||
}
|
||||
log.Printf("customer-auth: using shared Redis storage at %s", addr)
|
||||
return store, customerauth.NewRedisLoginLimiter(rdb, 0, 0)
|
||||
}
|
||||
credStore, err := customerauth.LoadCredentialStore(filepath.Join(profileDir, "credentials.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("Error loading credential store: %v", err)
|
||||
}
|
||||
log.Print("customer-auth: REDIS_ADDRESS unset — using file credential store + in-memory limiter (single-instance only)")
|
||||
return credStore, customerauth.NewLoginLimiter(0, 0)
|
||||
}
|
||||
|
||||
// authSecret returns the HMAC key for customer session cookies. It reads
|
||||
// CUSTOMER_AUTH_SECRET; when unset it generates an ephemeral random key and
|
||||
// warns that issued sessions will not survive a restart (fine for dev).
|
||||
// selectAuthNotifier picks the transactional auth-message sender. When
|
||||
// MAILERSEND_API_KEY is set it returns a MailerSend-backed notifier; otherwise
|
||||
// it returns nil (the server defaults to LogNotifier, which is fine for dev).
|
||||
func selectAuthNotifier() customerauth.Notifier {
|
||||
apiKey := os.Getenv("MAILERSEND_API_KEY")
|
||||
if apiKey == "" {
|
||||
return nil
|
||||
}
|
||||
fromAddr := strings.TrimSpace(os.Getenv("PROFILE_EMAIL_FROM_ADDRESS"))
|
||||
if fromAddr == "" {
|
||||
log.Print("warning: MAILERSEND_API_KEY set but PROFILE_EMAIL_FROM_ADDRESS is empty — falling back to LogNotifier")
|
||||
return nil
|
||||
}
|
||||
from := mail.Address{
|
||||
Name: strings.TrimSpace(os.Getenv("PROFILE_EMAIL_FROM_NAME")),
|
||||
Address: fromAddr,
|
||||
}
|
||||
n, err := customerauth.NewMailerSendNotifier(apiKey, from)
|
||||
if err != nil {
|
||||
log.Printf("warning: mailersend notifier: %v — falling back to LogNotifier", err)
|
||||
return nil
|
||||
}
|
||||
log.Print("customer-auth: using MailerSend notifier for verification and reset emails")
|
||||
return n
|
||||
}
|
||||
|
||||
func authSecret() []byte {
|
||||
if v := os.Getenv("CUSTOMER_AUTH_SECRET"); v != "" {
|
||||
return []byte(v)
|
||||
@@ -38,6 +98,13 @@ func authSecret() []byte {
|
||||
var podIp = os.Getenv("POD_IP")
|
||||
var name = os.Getenv("POD_NAME")
|
||||
|
||||
// profileMetrics is the shared prometheus instrumentation for the
|
||||
// profile actor pool and event log. Built once at process start; the
|
||||
// actor package's touch sites are nil-safe so a test binary could pass
|
||||
// nil. Package-level so tests in this package can construct a pool
|
||||
// without re-registering (cart/checkout use the same pattern).
|
||||
var profileMetrics = actor.NewMetrics("profile")
|
||||
|
||||
// loadUCPProfileSigner loads the UCP ECDSA signing key from the path specified in
|
||||
// the UCP_SIGNING_KEY_PATH environment variable. Returns nil if unset or unreadable.
|
||||
func loadUCPProfileSigner() *ucp.SigningConfig {
|
||||
@@ -58,26 +125,20 @@ func loadUCPProfileSigner() *ucp.SigningConfig {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// getEnv returns the value of the environment variable named by key, or def if unset/empty.
|
||||
func getEnv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func main() {
|
||||
reg := profile.NewProfileMutationRegistry()
|
||||
|
||||
profileDir := getEnv("PROFILE_DIR", "data/profiles")
|
||||
profileDir := config.EnvString("PROFILE_DIR", "data/profiles")
|
||||
if err := os.MkdirAll(profileDir, 0755); err != nil {
|
||||
log.Printf("warning: could not create profile data directory %s: %v", profileDir, err)
|
||||
}
|
||||
diskStorage := actor.NewDiskStorage[profile.ProfileGrain](profileDir, reg)
|
||||
diskStorage.SetMetrics(profileMetrics)
|
||||
|
||||
poolConfig := actor.GrainPoolConfig[profile.ProfileGrain]{
|
||||
MutationRegistry: reg,
|
||||
Storage: diskStorage,
|
||||
Metrics: profileMetrics,
|
||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[profile.ProfileGrain], error) {
|
||||
ret := profile.NewProfileGrain(id, time.Now())
|
||||
err := diskStorage.LoadEvents(ctx, id, ret)
|
||||
@@ -86,6 +147,13 @@ func main() {
|
||||
// Destroy is an optional grain-eviction cleanup hook; profiles need none
|
||||
// (disk storage persists via its own loop), so it's left unset — purge()
|
||||
// skips a nil Destroy.
|
||||
// SpawnHost makes the pool cluster-aware: when a grain is owned by a peer
|
||||
// replica, the pool proxies to it over gRPC :1337 (control) + HTTP :8080
|
||||
// (requests). With POD_IP unset and no discovered peers this is never
|
||||
// invoked, so the service still runs fine single-node.
|
||||
SpawnHost: func(host string) (actor.Host[profile.ProfileGrain], error) {
|
||||
return proxy.NewRemoteHost[profile.ProfileGrain](host)
|
||||
},
|
||||
TTL: 10 * time.Minute,
|
||||
PoolSize: 65535,
|
||||
Hostname: podIp,
|
||||
@@ -96,21 +164,107 @@ func main() {
|
||||
log.Fatalf("Error creating profile pool: %v\n", err)
|
||||
}
|
||||
|
||||
// Stream applied profile mutations to the shared mutations exchange (routing
|
||||
// key mutation.profile) for the backoffice live feed. Best-effort: disabled
|
||||
// when AMQP_URL is unset or the broker is unreachable.
|
||||
if amqpURL := os.Getenv("AMQP_URL"); amqpURL != "" {
|
||||
if conn, derr := rabbit.Dial(amqpURL, "cart-profile"); derr != nil {
|
||||
log.Printf("profile: AMQP connect failed, mutation feed disabled: %v", derr)
|
||||
} else {
|
||||
feed := actor.NewAmqpListener(conn.Connection(), "profile", actor.MutationSummary)
|
||||
feed.DefineTopics()
|
||||
pool.AddListener(feed)
|
||||
log.Printf("profile: mutation feed enabled (mutation.profile)")
|
||||
}
|
||||
}
|
||||
|
||||
// Clustering control plane: gRPC server on :1337 serves peer pools'
|
||||
// ownership announcements and remote Apply/Get calls; UseDiscovery watches
|
||||
// for sibling profile pods (label actor-pool=profile) and wires them into the
|
||||
// pool. Both are no-ops in single-node mode (POD_IP unset → nil discovery).
|
||||
grpcSrv, err := actor.NewControlServer[profile.ProfileGrain](actor.DefaultServerConfig(), pool)
|
||||
if err != nil {
|
||||
log.Fatalf("Error starting control plane gRPC server: %v\n", err)
|
||||
}
|
||||
defer grpcSrv.GracefulStop()
|
||||
|
||||
UseDiscovery(pool)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
debugMux := http.NewServeMux()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
otelShutdown, err := setupOTelSDK(ctx)
|
||||
// Data Retention (C5) GDPR Purge
|
||||
retentionDays := config.EnvInt("PROFILE_RETENTION_DAYS", 0, func(n int) bool { return n >= 0 })
|
||||
if retentionDays > 0 {
|
||||
purgeInterval := config.EnvDuration("PROFILE_PURGE_INTERVAL", 24*time.Hour)
|
||||
log.Printf("profile: data retention enabled. Retaining profiles for %d days, purging every %v", retentionDays, purgeInterval)
|
||||
go func() {
|
||||
ticker := time.NewTicker(purgeInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run immediately on startup
|
||||
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runPurge(ctx, diskStorage, pool, retentionDays)
|
||||
}
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
log.Print("profile: data retention / GDPR purge disabled (PROFILE_RETENTION_DAYS not set or <= 0)")
|
||||
}
|
||||
|
||||
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to start otel %v", err)
|
||||
}
|
||||
|
||||
// Customer auth storage and failed-login limiter.
|
||||
// Credentials and the failed-login limiter use shared Redis storage when
|
||||
// REDIS_ADDRESS is set (so the service scales horizontally), and fall back to
|
||||
// the file store + in-memory limiter for single-instance / local dev. Session
|
||||
// and verify/reset tokens are stateless HMAC values, so a shared
|
||||
// CUSTOMER_AUTH_SECRET is all they need to work across replicas.
|
||||
credStore, limiter := buildAuthStorage(ctx, profileDir)
|
||||
|
||||
// Email index: in-memory email→profileID map so the order service can
|
||||
// look up email preferences without scanning all profiles. Built from
|
||||
// the event log on disk at startup, then kept live by the UCP handlers.
|
||||
emailIndex := profile.NewProfileEmailIndex()
|
||||
stateStorage := actor.NewState(reg)
|
||||
profile.BuildEmailIndex(profileDir, stateStorage, reg, emailIndex)
|
||||
|
||||
// UCP Customer REST adapter
|
||||
customerUCP := ucp.CustomerHandler(pool)
|
||||
if signer := loadUCPProfileSigner(); signer != nil {
|
||||
customerUCP = ucp.WithSigning(customerUCP, signer)
|
||||
auditLogPath := filepath.Join(profileDir, "audit.log")
|
||||
// Session signer is shared by the auth server (HMAC verifier for the
|
||||
// "sid" cookie); NewSigner is stateless once built so a single
|
||||
// instance can be reused across the auth server's NewServer call.
|
||||
sessionSigner := customerauth.NewSigner(authSecret())
|
||||
|
||||
// Cluster-aware ProfileApplier: routes Get/Apply to the replica that
|
||||
// currently owns the grain. With a remote owner we forward there so
|
||||
// the request reads the authoritative in-memory state; with no owner
|
||||
// we let pool.Get / pool.Apply spawn the grain locally from the
|
||||
// disk-backed event log and broadcast ownership. Centralising this
|
||||
// choice in the applicr (rather than HTTP middleware) prevents the
|
||||
// split-brain hazard where a non-owner pod reads a stale or empty
|
||||
// grain from disk and caches it under its own (conflicting)
|
||||
// ownership.
|
||||
applier := &clusterAwareApplier{pool: pool}
|
||||
|
||||
customerUCP := ucp.CustomerHandler(applier, auditLogPath,
|
||||
ucp.WithEmailIndex(emailIndex),
|
||||
ucp.WithCredentialDeleter(credStore),
|
||||
)
|
||||
if ucpSigner := loadUCPProfileSigner(); ucpSigner != nil {
|
||||
customerUCP = ucp.WithSigning(customerUCP, ucpSigner)
|
||||
log.Print("ucp customer signing enabled")
|
||||
}
|
||||
// StripPrefix is required because the sub-mux (CustomerHandler) registers
|
||||
@@ -119,11 +273,13 @@ func main() {
|
||||
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
|
||||
|
||||
// Customer auth: password signup/login + session cookies + identity linking.
|
||||
credStore, err := customerauth.LoadCredentialStore(filepath.Join(profileDir, "credentials.json"))
|
||||
if err != nil {
|
||||
log.Fatalf("Error loading credential store: %v\n", err)
|
||||
}
|
||||
authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0).Handler()
|
||||
authNotifier := selectAuthNotifier()
|
||||
authHandler := customerauth.NewServer(credStore, applier, sessionSigner, 0, customerauth.Options{
|
||||
Notifier: authNotifier,
|
||||
Limiter: limiter,
|
||||
BaseURL: os.Getenv("CUSTOMER_AUTH_BASE_URL"),
|
||||
RequireVerifiedEmail: os.Getenv("CUSTOMER_AUTH_REQUIRE_VERIFIED") == "true",
|
||||
}).Handler()
|
||||
mux.Handle("/ucp/v1/auth/", http.StripPrefix("/ucp/v1/auth", authHandler))
|
||||
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -156,7 +312,7 @@ func main() {
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: getEnv("PROFILE_ADDR", ":8080"),
|
||||
Addr: config.EnvString("PROFILE_ADDR", ":8080"),
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
@@ -186,3 +342,24 @@ func main() {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
func runPurge(ctx context.Context, diskStorage *actor.DiskStorage[profile.ProfileGrain], pool *actor.SimpleGrainPool[profile.ProfileGrain], retentionDays int) {
|
||||
log.Printf("profile: starting data retention purge...")
|
||||
retention := time.Duration(retentionDays) * 24 * time.Hour
|
||||
|
||||
localIds := make(map[uint64]bool)
|
||||
for _, id := range pool.GetLocalIds() {
|
||||
localIds[id] = true
|
||||
}
|
||||
isGrainActive := func(id uint64) bool {
|
||||
return localIds[id]
|
||||
}
|
||||
|
||||
purged, err := diskStorage.PurgeOlderThan(ctx, retention, isGrainActive)
|
||||
if err != nil {
|
||||
log.Printf("profile: data retention purge failed: %v", err)
|
||||
} else {
|
||||
log.Printf("profile: data retention purge completed. Purged %d expired profile logs", purged)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+102
-3
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"id": "volymrabatt",
|
||||
"name": "Volymrabatt",
|
||||
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). Belopp i öre: 20 000–40 000 kr → 5%, 40 000–60 000 kr → 9%, 60 000–90 000 kr → 14%.",
|
||||
"description": "Automatisk volymrabatt baserad på varukorgens totalsumma (inkl. moms). 20 000+ kr → 5%, 40 000+ kr → 9%, 60 000+ kr → 14%.",
|
||||
"status": "active",
|
||||
"priority": 100,
|
||||
"startDate": "2024-01-01",
|
||||
@@ -28,7 +28,7 @@
|
||||
"tiers": [
|
||||
{ "minTotal": 2000000, "maxTotal": 4000000, "percent": 5 },
|
||||
{ "minTotal": 4000000, "maxTotal": 6000000, "percent": 9 },
|
||||
{ "minTotal": 6000000, "maxTotal": 9000000, "percent": 14 }
|
||||
{ "minTotal": 6000000, "maxTotal": 0, "percent": 14 }
|
||||
]
|
||||
},
|
||||
"label": "Volymrabatt 5–14%"
|
||||
@@ -38,7 +38,106 @@
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"createdBy": "mats.tornberg@gmail.com",
|
||||
"tags": ["volume", "volymrabatt", "test"]
|
||||
"tags": ["volume", "volymrabatt"]
|
||||
},
|
||||
{
|
||||
"id": "fri-frakt",
|
||||
"name": "Fri frakt över 10 000 kr",
|
||||
"description": "Fri frakt när varukorgens totalsumma (inkl. moms) överstiger 10 000 kr.",
|
||||
"status": "active",
|
||||
"priority": 50,
|
||||
"startDate": "2024-01-01",
|
||||
"endDate": null,
|
||||
"conditions": [
|
||||
{
|
||||
"id": "min-cart-total",
|
||||
"type": "cart_total",
|
||||
"operator": ">=",
|
||||
"value": 1000000,
|
||||
"label": "Minst 10 000 kr i varukorgen"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "fri-frakt-action",
|
||||
"type": "free_shipping",
|
||||
"value": 0,
|
||||
"label": "Fri frakt"
|
||||
}
|
||||
],
|
||||
"usageCount": 0,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"createdBy": "mats.tornberg@gmail.com",
|
||||
"tags": ["shipping", "free-shipping"]
|
||||
},
|
||||
{
|
||||
"id": "fonster-10-pct",
|
||||
"name": "10% rabatt på fönster",
|
||||
"description": "10% rabatt på alla fönster i varukorgen.",
|
||||
"status": "active",
|
||||
"priority": 200,
|
||||
"startDate": "2024-01-01",
|
||||
"endDate": null,
|
||||
"conditions": [
|
||||
{
|
||||
"id": "cat-windows",
|
||||
"type": "product_category",
|
||||
"operator": "in",
|
||||
"value": ["Fönster"]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "fonster-pct",
|
||||
"type": "percentage_discount",
|
||||
"value": 10,
|
||||
"label": "10% rabatt på fönster"
|
||||
}
|
||||
],
|
||||
"usageCount": 0,
|
||||
"createdAt": "2024-01-01T00:00:00Z",
|
||||
"updatedAt": "2024-01-01T00:00:00Z",
|
||||
"createdBy": "mats.tornberg@gmail.com",
|
||||
"tags": ["windows", "category"]
|
||||
},
|
||||
{
|
||||
"id": "sommar15",
|
||||
"name": "15% rabatt med kod SOMMAR15",
|
||||
"description": "15% rabatt på hela köpet när rabattkoden SOMMAR15 används. Gäller vid köp över 5 000 kr under sommaren.",
|
||||
"status": "active",
|
||||
"priority": 150,
|
||||
"startDate": "2024-06-01",
|
||||
"endDate": null,
|
||||
"conditions": [
|
||||
{
|
||||
"id": "min-cart-total",
|
||||
"type": "cart_total",
|
||||
"operator": ">=",
|
||||
"value": 500000,
|
||||
"label": "Minst 5 000 kr i varukorgen"
|
||||
},
|
||||
{
|
||||
"id": "coupon-code",
|
||||
"type": "coupon_code",
|
||||
"operator": "=",
|
||||
"value": "SOMMAR15",
|
||||
"label": "Ange koden SOMMAR15"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "sommar15-pct",
|
||||
"type": "percentage_discount",
|
||||
"value": 15,
|
||||
"label": "15% rabatt med kod"
|
||||
}
|
||||
],
|
||||
"usageCount": 0,
|
||||
"createdAt": "2024-06-01T00:00:00Z",
|
||||
"updatedAt": "2024-06-01T00:00:00Z",
|
||||
"createdBy": "mats.tornberg@gmail.com",
|
||||
"tags": ["seasonal", "coupon", "summer"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -78,6 +78,8 @@ spec:
|
||||
env:
|
||||
- name: CART_DIR
|
||||
value: "/data/cart-actor"
|
||||
- name: PROMOTIONS_FILE
|
||||
value: "/data/cart-actor/promotions.json"
|
||||
- name: CHECKOUT_DIR
|
||||
value: "/data/checkout-actor"
|
||||
- name: TZ
|
||||
@@ -186,6 +188,8 @@ spec:
|
||||
env:
|
||||
- name: CART_DIR
|
||||
value: "/data/cart-actor"
|
||||
- name: PROMOTIONS_FILE
|
||||
value: "/data/promotions.json"
|
||||
- name: CHECKOUT_DIR
|
||||
value: "/data/checkout-actor"
|
||||
- name: TZ
|
||||
@@ -230,6 +234,10 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: CART_RETENTION_DAYS
|
||||
value: "30"
|
||||
- name: CART_PURGE_INTERVAL
|
||||
value: "24h"
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
@@ -308,6 +316,8 @@ spec:
|
||||
memory: "70Mi"
|
||||
cpu: "1200m"
|
||||
env:
|
||||
- name: PROMOTIONS_FILE
|
||||
value: "/data/promotions.json"
|
||||
- name: TZ
|
||||
value: "Europe/Stockholm"
|
||||
- name: REDIS_ADDRESS
|
||||
@@ -350,6 +360,10 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: CART_RETENTION_DAYS
|
||||
value: "30"
|
||||
- name: CART_PURGE_INTERVAL
|
||||
value: "24h"
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
@@ -484,6 +498,10 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: CHECKOUT_RETENTION_DAYS
|
||||
value: "30"
|
||||
- name: CHECKOUT_PURGE_INTERVAL
|
||||
value: "24h"
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
|
||||
@@ -3,6 +3,7 @@ module git.k6n.net/mats/go-cart-actor
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
git.k6n.net/mats/platform v0.0.0-00010101000000-000000000000
|
||||
github.com/adyen/adyen-go-api-library/v21 v21.1.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
@@ -62,6 +63,7 @@ require (
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mailersend/mailersend-go v1.4.0
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
|
||||
+11
@@ -127,7 +127,10 @@ github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwm
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
@@ -137,6 +140,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/lyft/protoc-gen-star/v2 v2.0.4/go.mod h1:amey7yeodaJhXSbf/TlLvWiqQfLOSpEk//mLlc+axEk=
|
||||
github.com/mailersend/mailersend-go v1.4.0 h1:8IXN3HXoyKh6qG0IyTESedEjlaYsZfT0XnV2k7XzjvE=
|
||||
github.com/mailersend/mailersend-go v1.4.0/go.mod h1:4MeiOnzmjWCsXRNdjg6NGzsijsVrmQ8E/T003/ystQU=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mdempsky/unconvert v0.0.0-20250216222326-4a038b3d31f5/go.mod h1:mVCHGHs8r8jnrZ2ammcv8ySbhG2+rEPXegFmdNA51GI=
|
||||
@@ -152,8 +157,14 @@ github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtX
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
|
||||
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"gridPos": { "x": 18, "y": 1, "w": 6, "h": 4 },
|
||||
"datasource": "${DS_PROMETHEUS}",
|
||||
"targets": [
|
||||
{ "refId": "A", "expr": "connected_remotes" }
|
||||
{ "refId": "A", "expr": "cart_connected_remotes" }
|
||||
],
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -73,11 +74,12 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if err := store.Register("User@Example.com", 42, "hash1", "ts"); err != nil {
|
||||
ctx := context.Background()
|
||||
if err := store.Register(ctx, "User@Example.com", 42, "hash1", "ts"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
// Duplicate (case-insensitive) is rejected.
|
||||
if err := store.Register("user@example.com", 99, "hash2", "ts"); err != ErrEmailExists {
|
||||
if err := store.Register(ctx, "user@example.com", 99, "hash2", "ts"); err != ErrEmailExists {
|
||||
t.Fatalf("duplicate: got %v, want ErrEmailExists", err)
|
||||
}
|
||||
// Reload from disk and confirm the record persisted.
|
||||
@@ -85,7 +87,7 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
rec, ok := reloaded.Get("USER@EXAMPLE.COM")
|
||||
rec, ok := reloaded.Get(ctx, "USER@EXAMPLE.COM")
|
||||
if !ok {
|
||||
t.Fatal("record not found after reload")
|
||||
}
|
||||
@@ -93,3 +95,39 @@ func TestStoreRegisterDuplicateAndReload(t *testing.T) {
|
||||
t.Fatalf("unexpected record: %+v", rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreDelete(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "credentials.json")
|
||||
store, err := LoadCredentialStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := store.Register(ctx, "user@example.com", 42, "hash1", "ts"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
// Delete
|
||||
ok, err := store.Delete(ctx, "USER@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected delete to report email was found")
|
||||
}
|
||||
|
||||
// Confirm not found
|
||||
if _, ok := store.Get(ctx, "user@example.com"); ok {
|
||||
t.Fatal("record still exists after delete")
|
||||
}
|
||||
|
||||
// Reload from disk and verify it's gone
|
||||
reloaded, err := LoadCredentialStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if _, ok := reloaded.Get(ctx, "user@example.com"); ok {
|
||||
t.Fatal("record still exists after reload")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPurposeTokenRoundTrip(t *testing.T) {
|
||||
s := NewSigner([]byte("test-secret"))
|
||||
tok := s.IssuePurpose(purposePasswordReset, "user@example.com", time.Hour)
|
||||
sub, err := s.ParsePurpose(purposePasswordReset, tok)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if sub != "user@example.com" {
|
||||
t.Fatalf("got subject %q, want user@example.com", sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurposeTokenWrongPurposeRejected(t *testing.T) {
|
||||
s := NewSigner([]byte("test-secret"))
|
||||
tok := s.IssuePurpose(purposeVerifyEmail, "user@example.com", time.Hour)
|
||||
// A verify token must not be accepted as a reset token.
|
||||
if _, err := s.ParsePurpose(purposePasswordReset, tok); err != ErrInvalidToken {
|
||||
t.Fatalf("got %v, want ErrInvalidToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurposeTokenExpiredAndTampered(t *testing.T) {
|
||||
s := NewSigner([]byte("test-secret"))
|
||||
expired := s.IssuePurpose(purposeVerifyEmail, "user@example.com", -time.Second)
|
||||
if _, err := s.ParsePurpose(purposeVerifyEmail, expired); err != ErrExpiredToken {
|
||||
t.Fatalf("expired: got %v, want ErrExpiredToken", err)
|
||||
}
|
||||
tok := s.IssuePurpose(purposeVerifyEmail, "user@example.com", time.Hour)
|
||||
if _, err := s.ParsePurpose(purposeVerifyEmail, tok+"x"); err != ErrInvalidToken {
|
||||
t.Fatalf("tampered: got %v, want ErrInvalidToken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterLocksOutAndResets(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
l := NewLoginLimiter(3, time.Minute)
|
||||
const key = "user@example.com"
|
||||
for i := range 3 {
|
||||
if ok, _ := l.Allowed(ctx, key); !ok {
|
||||
t.Fatalf("locked out early after %d failures", i)
|
||||
}
|
||||
l.RecordFailure(ctx, key)
|
||||
}
|
||||
ok, retry := l.Allowed(ctx, key)
|
||||
if ok {
|
||||
t.Fatal("expected lockout after 3 failures")
|
||||
}
|
||||
if retry <= 0 {
|
||||
t.Fatalf("expected positive retry-after, got %v", retry)
|
||||
}
|
||||
// A successful auth clears the history.
|
||||
l.Reset(ctx, key)
|
||||
if ok, _ := l.Allowed(ctx, key); !ok {
|
||||
t.Fatal("expected reset to clear lockout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterNilIsNoop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var l *LoginLimiter
|
||||
if ok, _ := l.Allowed(ctx, "x"); !ok {
|
||||
t.Fatal("nil limiter should allow")
|
||||
}
|
||||
l.RecordFailure(ctx, "x") // must not panic
|
||||
l.Reset(ctx, "x") // must not panic
|
||||
}
|
||||
|
||||
func TestStoreMarkVerifiedAndUpdateHash(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "credentials.json")
|
||||
store, err := LoadCredentialStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if err := store.Register(ctx, "u@example.com", 1, "hash1", "ts"); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
// Unknown email is a no-op, not an error.
|
||||
if found, err := store.MarkVerified(ctx, "missing@example.com", "now"); err != nil || found {
|
||||
t.Fatalf("MarkVerified(missing) = (%v, %v), want (false, nil)", found, err)
|
||||
}
|
||||
|
||||
if found, err := store.MarkVerified(ctx, "U@EXAMPLE.COM", "2026-01-01T00:00:00Z"); err != nil || !found {
|
||||
t.Fatalf("MarkVerified = (%v, %v), want (true, nil)", found, err)
|
||||
}
|
||||
if found, err := store.UpdateHash(ctx, "u@example.com", "hash2"); err != nil || !found {
|
||||
t.Fatalf("UpdateHash = (%v, %v), want (true, nil)", found, err)
|
||||
}
|
||||
|
||||
// Both changes survive a reload from disk.
|
||||
reloaded, err := LoadCredentialStore(path)
|
||||
if err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
rec, ok := reloaded.Get(ctx, "u@example.com")
|
||||
if !ok {
|
||||
t.Fatal("record missing after reload")
|
||||
}
|
||||
if rec.Hash != "hash2" {
|
||||
t.Fatalf("hash = %q, want hash2", rec.Hash)
|
||||
}
|
||||
if rec.VerifiedAt == "" {
|
||||
t.Fatal("verifiedAt not persisted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mailersend/mailersend-go"
|
||||
)
|
||||
|
||||
// MailerSendNotifier delivers transactional auth messages (email verification
|
||||
// and password reset) through the MailerSend API. It implements the Notifier
|
||||
// interface and is a drop-in replacement for LogNotifier in production.
|
||||
type MailerSendNotifier struct {
|
||||
client *mailersend.Mailersend
|
||||
from mail.Address
|
||||
}
|
||||
|
||||
// NewMailerSendNotifier creates a new MailerSend-backed notifier. The apiKey
|
||||
// is the MailerSend API token (MAILERSEND_API_KEY env var). The from address
|
||||
// must include at least an email; the name portion is optional.
|
||||
func NewMailerSendNotifier(apiKey string, from mail.Address) (*MailerSendNotifier, error) {
|
||||
if strings.TrimSpace(apiKey) == "" {
|
||||
return nil, fmt.Errorf("mailersend notifier: missing API key")
|
||||
}
|
||||
if strings.TrimSpace(from.Address) == "" {
|
||||
return nil, fmt.Errorf("mailersend notifier: missing from address")
|
||||
}
|
||||
return &MailerSendNotifier{
|
||||
client: mailersend.NewMailersend(apiKey),
|
||||
from: from,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SendEmailVerification sends the verification link to the customer's email
|
||||
// address. Errors are logged but not returned (best-effort delivery matches the
|
||||
// signature of the Notifier interface).
|
||||
func (n *MailerSendNotifier) SendEmailVerification(email, link string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := n.send(ctx, email,
|
||||
"Verify your email address",
|
||||
fmt.Sprintf("Click the link to verify your email address:\n\n%s", link),
|
||||
fmt.Sprintf(`<p>Click <a href="%s">here</a> to verify your email address.</p>`, link),
|
||||
); err != nil {
|
||||
log.Printf("customerauth: send verification to %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
// SendPasswordReset sends the password-reset link to the customer's email.
|
||||
// Errors are logged but not returned.
|
||||
func (n *MailerSendNotifier) SendPasswordReset(email, link string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := n.send(ctx, email,
|
||||
"Reset your password",
|
||||
fmt.Sprintf("Click the link to reset your password:\n\n%s\n\nIf you didn't request this, ignore this email.", link),
|
||||
fmt.Sprintf(`<p>Click <a href="%s">here</a> to reset your password.</p><p>If you didn't request this, ignore this email.</p>`, link),
|
||||
); err != nil {
|
||||
log.Printf("customerauth: send password reset to %s: %v", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *MailerSendNotifier) send(ctx context.Context, to, subject, textBody, htmlBody string) error {
|
||||
message := n.client.Email.NewMessage()
|
||||
message.SetFrom(mailersend.From{
|
||||
Name: n.from.Name,
|
||||
Email: n.from.Address,
|
||||
})
|
||||
message.SetRecipients([]mailersend.Recipient{
|
||||
{Email: to},
|
||||
})
|
||||
message.SetSubject(subject)
|
||||
message.SetText(textBody)
|
||||
if htmlBody != "" {
|
||||
message.SetHTML(htmlBody)
|
||||
}
|
||||
_, err := n.client.Email.Send(ctx, message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mailersend: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package customerauth
|
||||
|
||||
import "log"
|
||||
|
||||
// Notifier delivers transactional auth messages (email verification and
|
||||
// password reset). The auth server only builds the links; delivery is pluggable
|
||||
// so this is the seam where a real mailer — or the planned notification service
|
||||
// (commerce-maturity plan, section C3) — gets wired in. The default LogNotifier
|
||||
// just logs the link, which is enough for local development.
|
||||
type Notifier interface {
|
||||
SendEmailVerification(email, link string)
|
||||
SendPasswordReset(email, link string)
|
||||
}
|
||||
|
||||
// LogNotifier writes the links to the standard logger instead of sending them.
|
||||
type LogNotifier struct{}
|
||||
|
||||
func (LogNotifier) SendEmailVerification(email, link string) {
|
||||
log.Printf("customerauth: email verification for %s: %s", email, link)
|
||||
}
|
||||
|
||||
func (LogNotifier) SendPasswordReset(email, link string) {
|
||||
log.Printf("customerauth: password reset for %s: %s", email, link)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter throttles repeated failures for a key (login email or "reset:"-prefixed
|
||||
// email). It is satisfied by the in-memory LoginLimiter (single-instance / dev)
|
||||
// and by RedisLoginLimiter (shared, horizontally scalable).
|
||||
type Limiter interface {
|
||||
// Allowed reports whether an attempt for key may proceed, and when locked the
|
||||
// remaining lockout duration (for a Retry-After header).
|
||||
Allowed(ctx context.Context, key string) (bool, time.Duration)
|
||||
// RecordFailure registers a failed attempt for key.
|
||||
RecordFailure(ctx context.Context, key string)
|
||||
// Reset clears the failure history for key (call on a successful auth).
|
||||
Reset(ctx context.Context, key string)
|
||||
}
|
||||
|
||||
// LoginLimiter is an in-memory failed-attempt limiter that locks a key out after
|
||||
// too many failures inside a rolling window. It is per-process — matching the
|
||||
// credential store's single-instance scope; a horizontally-scaled deployment
|
||||
// would need a shared backing store. Keys are typically the normalized email
|
||||
// (login) or a "reset:"-prefixed email (reset requests).
|
||||
type LoginLimiter struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]*attemptState
|
||||
max int // failures allowed in the window before lockout
|
||||
window time.Duration // rolling window and lockout duration
|
||||
}
|
||||
|
||||
type attemptState struct {
|
||||
count int
|
||||
first time.Time
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
// NewLoginLimiter builds a limiter allowing max failures per window before a
|
||||
// lockout of window duration. Zero/negative values fall back to 5 per 15m.
|
||||
func NewLoginLimiter(max int, window time.Duration) *LoginLimiter {
|
||||
if max <= 0 {
|
||||
max = 5
|
||||
}
|
||||
if window <= 0 {
|
||||
window = 15 * time.Minute
|
||||
}
|
||||
return &LoginLimiter{attempts: make(map[string]*attemptState), max: max, window: window}
|
||||
}
|
||||
|
||||
// Allowed reports whether an attempt for key may proceed. When locked it also
|
||||
// returns the remaining lockout duration (suitable for a Retry-After header). A
|
||||
// nil limiter always allows (disabled).
|
||||
func (l *LoginLimiter) Allowed(_ context.Context, key string) (bool, time.Duration) {
|
||||
if l == nil {
|
||||
return true, 0
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
st := l.attempts[key]
|
||||
if st == nil {
|
||||
return true, 0
|
||||
}
|
||||
if now.Before(st.lockedUntil) {
|
||||
return false, st.lockedUntil.Sub(now)
|
||||
}
|
||||
// Window elapsed since the first failure: forget the history.
|
||||
if now.Sub(st.first) > l.window {
|
||||
delete(l.attempts, key)
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// RecordFailure registers a failed attempt for key, locking it for window once
|
||||
// max failures accumulate inside the window.
|
||||
func (l *LoginLimiter) RecordFailure(_ context.Context, key string) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
st := l.attempts[key]
|
||||
if st == nil || now.Sub(st.first) > l.window {
|
||||
l.attempts[key] = &attemptState{count: 1, first: now}
|
||||
return
|
||||
}
|
||||
st.count++
|
||||
if st.count >= l.max {
|
||||
st.lockedUntil = now.Add(l.window)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears the failure history for key. Call it on a successful auth.
|
||||
func (l *LoginLimiter) Reset(_ context.Context, key string) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
delete(l.attempts, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Redis-backed implementations of Credentials and Limiter. They let the profile
|
||||
// service run horizontally: every replica reads/writes the same credential
|
||||
// records and shares one failed-attempt counter, instead of each holding its own
|
||||
// file map and in-process counter. Session and verify/reset tokens are already
|
||||
// stateless HMAC values, so Redis + a shared CUSTOMER_AUTH_SECRET is all that
|
||||
// the auth surface needs to scale out.
|
||||
|
||||
const (
|
||||
credKeyPrefix = "customerauth:cred:" // hash per normalized email
|
||||
failKeyPrefix = "customerauth:fail:" // failure counter per key
|
||||
lockKeyPrefix = "customerauth:lock:" // lockout marker per key
|
||||
)
|
||||
|
||||
// Compile-time guarantees that both backings satisfy the server's interfaces.
|
||||
var (
|
||||
_ Credentials = (*CredentialStore)(nil)
|
||||
_ Credentials = (*RedisCredentialStore)(nil)
|
||||
_ Limiter = (*LoginLimiter)(nil)
|
||||
_ Limiter = (*RedisLoginLimiter)(nil)
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RedisCredentialStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RedisCredentialStore stores each credential as a Redis hash at
|
||||
// "customerauth:cred:<normalized-email>". Register/MarkVerified/UpdateHash are
|
||||
// atomic via small Lua scripts so concurrent replicas can't race a duplicate
|
||||
// registration or a lost update.
|
||||
type RedisCredentialStore struct {
|
||||
client *redis.Client
|
||||
luaRegister *redis.Script
|
||||
luaVerified *redis.Script
|
||||
luaUpdHash *redis.Script
|
||||
}
|
||||
|
||||
// registerScript creates the hash only if it does not already exist, returning 1
|
||||
// on create and 0 if the email is taken.
|
||||
const registerScript = `
|
||||
if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
|
||||
redis.call('HSET', KEYS[1], 'email', ARGV[1], 'profileId', ARGV[2], 'hash', ARGV[3], 'createdAt', ARGV[4])
|
||||
return 1`
|
||||
|
||||
// markVerifiedScript sets verifiedAt only when missing. Returns -1 if the email
|
||||
// is unknown, 1 otherwise (whether it set the field now or was already verified).
|
||||
const markVerifiedScript = `
|
||||
if redis.call('EXISTS', KEYS[1]) == 0 then return -1 end
|
||||
local v = redis.call('HGET', KEYS[1], 'verifiedAt')
|
||||
if v and v ~= '' then return 1 end
|
||||
redis.call('HSET', KEYS[1], 'verifiedAt', ARGV[1])
|
||||
return 1`
|
||||
|
||||
// updateHashScript replaces the password hash. Returns 0 if unknown, 1 on update.
|
||||
const updateHashScript = `
|
||||
if redis.call('EXISTS', KEYS[1]) == 0 then return 0 end
|
||||
redis.call('HSET', KEYS[1], 'hash', ARGV[1])
|
||||
return 1`
|
||||
|
||||
// NewRedisCredentialStore builds a Redis-backed credential store and verifies
|
||||
// connectivity with a ping.
|
||||
func NewRedisCredentialStore(ctx context.Context, client *redis.Client) (*RedisCredentialStore, error) {
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &RedisCredentialStore{
|
||||
client: client,
|
||||
luaRegister: redis.NewScript(registerScript),
|
||||
luaVerified: redis.NewScript(markVerifiedScript),
|
||||
luaUpdHash: redis.NewScript(updateHashScript),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *RedisCredentialStore) Get(ctx context.Context, email string) (Record, bool) {
|
||||
key := credKeyPrefix + NormalizeEmail(email)
|
||||
m, err := s.client.HGetAll(ctx, key).Result()
|
||||
if err != nil {
|
||||
log.Printf("customerauth: redis Get(%s): %v", key, err)
|
||||
return Record{}, false
|
||||
}
|
||||
if len(m) == 0 {
|
||||
return Record{}, false
|
||||
}
|
||||
pid, _ := strconv.ParseUint(m["profileId"], 10, 64)
|
||||
return Record{
|
||||
Email: m["email"],
|
||||
ProfileID: pid,
|
||||
Hash: m["hash"],
|
||||
CreatedAt: m["createdAt"],
|
||||
VerifiedAt: m["verifiedAt"],
|
||||
}, true
|
||||
}
|
||||
|
||||
func (s *RedisCredentialStore) Register(ctx context.Context, email string, profileID uint64, hash, createdAt string) error {
|
||||
norm := NormalizeEmail(email)
|
||||
created, err := s.luaRegister.Run(ctx, s.client,
|
||||
[]string{credKeyPrefix + norm},
|
||||
norm, strconv.FormatUint(profileID, 10), hash, createdAt,
|
||||
).Int()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if created == 0 {
|
||||
return ErrEmailExists
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RedisCredentialStore) MarkVerified(ctx context.Context, email, verifiedAt string) (bool, error) {
|
||||
res, err := s.luaVerified.Run(ctx, s.client,
|
||||
[]string{credKeyPrefix + NormalizeEmail(email)}, verifiedAt,
|
||||
).Int()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res >= 0, nil // -1 means unknown email
|
||||
}
|
||||
|
||||
func (s *RedisCredentialStore) UpdateHash(ctx context.Context, email, hash string) (bool, error) {
|
||||
res, err := s.luaUpdHash.Run(ctx, s.client,
|
||||
[]string{credKeyPrefix + NormalizeEmail(email)}, hash,
|
||||
).Int()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res == 1, nil
|
||||
}
|
||||
|
||||
func (s *RedisCredentialStore) Delete(ctx context.Context, email string) (bool, error) {
|
||||
key := credKeyPrefix + NormalizeEmail(email)
|
||||
res, err := s.client.Del(ctx, key).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return res > 0, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RedisLoginLimiter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RedisLoginLimiter is the shared-storage counterpart of LoginLimiter: the
|
||||
// failure counter and lockout marker live in Redis with native TTLs, so the
|
||||
// window/lockout are enforced across all replicas. It fails open (allows the
|
||||
// attempt) on a Redis error so an outage degrades to "no rate limiting" rather
|
||||
// than locking every customer out.
|
||||
type RedisLoginLimiter struct {
|
||||
client *redis.Client
|
||||
luaRecord *redis.Script
|
||||
max int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
// recordFailureScript increments the failure counter (setting the window TTL on
|
||||
// the first failure) and, once it reaches max, writes a lockout marker with the
|
||||
// same TTL.
|
||||
const recordFailureScript = `
|
||||
local cnt = redis.call('INCR', KEYS[1])
|
||||
if cnt == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
|
||||
if cnt >= tonumber(ARGV[1]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[2]) end
|
||||
return cnt`
|
||||
|
||||
// NewRedisLoginLimiter builds a Redis-backed limiter. Zero/negative values fall
|
||||
// back to 5 failures per 15 minutes (matching the in-memory default).
|
||||
func NewRedisLoginLimiter(client *redis.Client, max int, window time.Duration) *RedisLoginLimiter {
|
||||
if max <= 0 {
|
||||
max = 5
|
||||
}
|
||||
if window <= 0 {
|
||||
window = 15 * time.Minute
|
||||
}
|
||||
return &RedisLoginLimiter{
|
||||
client: client,
|
||||
luaRecord: redis.NewScript(recordFailureScript),
|
||||
max: max,
|
||||
window: window,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RedisLoginLimiter) Allowed(ctx context.Context, key string) (bool, time.Duration) {
|
||||
ttl, err := l.client.PTTL(ctx, lockKeyPrefix+key).Result()
|
||||
if err != nil {
|
||||
log.Printf("customerauth: redis limiter Allowed(%s): %v", key, err)
|
||||
return true, 0 // fail open
|
||||
}
|
||||
if ttl > 0 {
|
||||
return false, ttl
|
||||
}
|
||||
return true, 0
|
||||
}
|
||||
|
||||
func (l *RedisLoginLimiter) RecordFailure(ctx context.Context, key string) {
|
||||
if err := l.luaRecord.Run(ctx, l.client,
|
||||
[]string{failKeyPrefix + key, lockKeyPrefix + key},
|
||||
strconv.Itoa(l.max), strconv.Itoa(int(l.window.Seconds())),
|
||||
).Err(); err != nil {
|
||||
log.Printf("customerauth: redis limiter RecordFailure(%s): %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *RedisLoginLimiter) Reset(ctx context.Context, key string) {
|
||||
if err := l.client.Del(ctx, failKeyPrefix+key, lockKeyPrefix+key).Err(); err != nil {
|
||||
log.Printf("customerauth: redis limiter Reset(%s): %v", key, err)
|
||||
}
|
||||
}
|
||||
+233
-10
@@ -3,8 +3,11 @@ package customerauth
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,9 +17,24 @@ import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// minPasswordLen is the minimum accepted password length at registration.
|
||||
// minPasswordLen is the minimum accepted password length at registration and
|
||||
// password reset.
|
||||
const minPasswordLen = 8
|
||||
|
||||
// Token lifetimes for the email-verification and password-reset links.
|
||||
const (
|
||||
verifyTokenTTL = 24 * time.Hour
|
||||
resetTokenTTL = 1 * time.Hour
|
||||
)
|
||||
|
||||
// Storefront paths (appended to Options.BaseURL) that the verification and reset
|
||||
// links point at. The page reads the token from the query string and POSTs it
|
||||
// back to /verify or /reset.
|
||||
const (
|
||||
verifyLinkPath = "/account/verify"
|
||||
resetLinkPath = "/account/reset"
|
||||
)
|
||||
|
||||
// ProfileApplier is the subset of the profile grain pool the auth server needs.
|
||||
// The pool (and the UCP adapter's ProfileApplier) already satisfies it.
|
||||
type ProfileApplier interface {
|
||||
@@ -24,21 +42,60 @@ type ProfileApplier interface {
|
||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
|
||||
}
|
||||
|
||||
// Server exposes password signup/login and identity-linking over HTTP, backed
|
||||
// by the credential store (email→id+hash) and the profile grain pool.
|
||||
// Server exposes password signup/login, email verification, password reset and
|
||||
// identity-linking over HTTP, backed by the credential store (email→id+hash) and
|
||||
// the profile grain pool.
|
||||
type Server struct {
|
||||
store *CredentialStore
|
||||
store Credentials
|
||||
applier ProfileApplier
|
||||
signer *Signer
|
||||
ttl time.Duration
|
||||
limiter Limiter
|
||||
notifier Notifier
|
||||
baseURL string
|
||||
requireVerified bool
|
||||
}
|
||||
|
||||
// Options configures optional auth-server behavior. The zero value is valid: an
|
||||
// in-memory login limiter (5 failures / 15m), a logging notifier, and no
|
||||
// verified-email requirement for login.
|
||||
type Options struct {
|
||||
// Limiter throttles failed logins and reset requests. Nil installs a default
|
||||
// in-memory limiter (5 failures / 15m). Inject a RedisLoginLimiter for a
|
||||
// horizontally-scaled deployment.
|
||||
Limiter Limiter
|
||||
// Notifier delivers verification and reset links. Nil installs LogNotifier.
|
||||
Notifier Notifier
|
||||
// BaseURL is the public origin used to build links in messages, e.g.
|
||||
// "https://shop.tornberg.me". The verify/reset paths are appended to it.
|
||||
BaseURL string
|
||||
// RequireVerifiedEmail, when true, blocks login until the email is verified.
|
||||
// Off by default so pre-existing accounts (which carry no verification
|
||||
// record) keep working.
|
||||
RequireVerifiedEmail bool
|
||||
}
|
||||
|
||||
// NewServer builds an auth server. ttl<=0 falls back to DefaultSessionTTL.
|
||||
func NewServer(store *CredentialStore, applier ProfileApplier, signer *Signer, ttl time.Duration) *Server {
|
||||
func NewServer(store Credentials, applier ProfileApplier, signer *Signer, ttl time.Duration, opts Options) *Server {
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultSessionTTL
|
||||
}
|
||||
return &Server{store: store, applier: applier, signer: signer, ttl: ttl}
|
||||
if opts.Limiter == nil {
|
||||
opts.Limiter = NewLoginLimiter(0, 0)
|
||||
}
|
||||
if opts.Notifier == nil {
|
||||
opts.Notifier = LogNotifier{}
|
||||
}
|
||||
return &Server{
|
||||
store: store,
|
||||
applier: applier,
|
||||
signer: signer,
|
||||
ttl: ttl,
|
||||
limiter: opts.Limiter,
|
||||
notifier: opts.Notifier,
|
||||
baseURL: strings.TrimRight(opts.BaseURL, "/"),
|
||||
requireVerified: opts.RequireVerifiedEmail,
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the router for the auth endpoints. Mount it under /ucp/v1/auth
|
||||
@@ -49,6 +106,10 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /login", s.handleLogin)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /me", s.handleMe)
|
||||
mux.HandleFunc("POST /verify-request", s.handleVerifyRequest)
|
||||
mux.HandleFunc("POST /verify", s.handleVerify)
|
||||
mux.HandleFunc("POST /reset-request", s.handleResetRequest)
|
||||
mux.HandleFunc("POST /reset", s.handleResetComplete)
|
||||
mux.HandleFunc("POST /link-cart", s.handleLinkCart)
|
||||
mux.HandleFunc("POST /link-checkout", s.handleLinkCheckout)
|
||||
mux.HandleFunc("POST /link-order", s.handleLinkOrder)
|
||||
@@ -86,6 +147,29 @@ type linkOrderRequest struct {
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// tokenRequest carries a verification token.
|
||||
type tokenRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// resetRequest asks for a password-reset link to be sent to an email.
|
||||
type resetRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// resetCompleteRequest carries a reset token and the new password.
|
||||
type resetCompleteRequest struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// EmailPreferencesResponse mirrors the UCP email preferences type for the
|
||||
// customer-facing API.
|
||||
type EmailPreferencesResponse struct {
|
||||
OrderEmails *bool `json:"orderEmails,omitempty"`
|
||||
MarketingEmails *bool `json:"marketingEmails,omitempty"`
|
||||
}
|
||||
|
||||
// CustomerResponse mirrors the UCP customer projection (kept local to avoid an
|
||||
// import cycle with internal/ucp). The password hash is never included.
|
||||
type CustomerResponse struct {
|
||||
@@ -98,6 +182,12 @@ type CustomerResponse struct {
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Addresses []AddressResponse `json:"addresses"`
|
||||
Orders []OrderRef `json:"orders"`
|
||||
|
||||
// EmailPreferences carries the customer's current email opt-in/out state.
|
||||
EmailPreferences *EmailPreferencesResponse `json:"emailPreferences,omitempty"`
|
||||
|
||||
// EmailVerified reflects whether the email-verification flow has completed.
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
}
|
||||
|
||||
type AddressResponse struct {
|
||||
@@ -137,7 +227,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
if _, exists := s.store.Get(email); exists {
|
||||
if _, exists := s.store.Get(r.Context(), email); exists {
|
||||
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
||||
return
|
||||
}
|
||||
@@ -169,7 +259,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Persist the credential only after the profile is created. If this fails
|
||||
// the profile exists but is unreachable by login — acceptable and rare.
|
||||
if err := s.store.Register(email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
if err := s.store.Register(r.Context(), email, id, hash, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
if err == ErrEmailExists {
|
||||
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
||||
return
|
||||
@@ -178,6 +268,9 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send the verification link (best-effort, via the configured notifier).
|
||||
s.sendVerification(email)
|
||||
|
||||
s.issueSession(w, r, id)
|
||||
s.writeCustomer(w, r, http.StatusCreated, rawID.String(), id)
|
||||
}
|
||||
@@ -188,14 +281,26 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
rec, ok := s.store.Get(req.Email)
|
||||
key := NormalizeEmail(req.Email)
|
||||
if ok, retry := s.limiter.Allowed(r.Context(), key); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
|
||||
writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later")
|
||||
return
|
||||
}
|
||||
rec, ok := s.store.Get(r.Context(), req.Email)
|
||||
// Always run a verify (even on miss, against the stored hash if present) and
|
||||
// return a single generic error so the response does not reveal whether the
|
||||
// email exists.
|
||||
if !ok || !VerifyPassword(req.Password, rec.Hash) {
|
||||
s.limiter.RecordFailure(r.Context(), key)
|
||||
writeErr(w, http.StatusUnauthorized, "invalid email or password")
|
||||
return
|
||||
}
|
||||
if s.requireVerified && rec.VerifiedAt == "" {
|
||||
writeErr(w, http.StatusForbidden, "email not verified")
|
||||
return
|
||||
}
|
||||
s.limiter.Reset(r.Context(), key)
|
||||
s.issueSession(w, r, rec.ProfileID)
|
||||
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(rec.ProfileID).String(), rec.ProfileID)
|
||||
}
|
||||
@@ -213,6 +318,101 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
||||
}
|
||||
|
||||
// handleVerifyRequest re-sends the email-verification link for the logged-in
|
||||
// customer. Requires a session so it can't be used to enumerate addresses.
|
||||
func (s *Server) handleVerifyRequest(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := s.requireSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
g, err := s.applier.Get(r.Context(), id)
|
||||
if err != nil || g.Email == "" {
|
||||
writeErr(w, http.StatusInternalServerError, "could not read profile")
|
||||
return
|
||||
}
|
||||
s.sendVerification(NormalizeEmail(g.Email))
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
|
||||
}
|
||||
|
||||
// handleVerify consumes a verification token and marks the email verified. An
|
||||
// unknown subject still returns success so the endpoint reveals nothing.
|
||||
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
var req tokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
email, err := s.signer.ParsePurpose(purposeVerifyEmail, req.Token)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.MarkVerified(r.Context(), email, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "could not record verification")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "verified"})
|
||||
}
|
||||
|
||||
// handleResetRequest issues a password-reset link for a registered email. It
|
||||
// always returns 200 (whether or not the email exists) so it never reveals
|
||||
// account existence, and is rate-limited per email to prevent mail-bombing.
|
||||
func (s *Server) handleResetRequest(w http.ResponseWriter, r *http.Request) {
|
||||
var req resetRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
email := NormalizeEmail(req.Email)
|
||||
limiterKey := "reset:" + email
|
||||
if ok, retry := s.limiter.Allowed(r.Context(), limiterKey); !ok {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
|
||||
writeErr(w, http.StatusTooManyRequests, "too many requests, try again later")
|
||||
return
|
||||
}
|
||||
if _, exists := s.store.Get(r.Context(), email); exists {
|
||||
token := s.signer.IssuePurpose(purposePasswordReset, email, resetTokenTTL)
|
||||
s.notifier.SendPasswordReset(email, s.link(resetLinkPath, token))
|
||||
}
|
||||
s.limiter.RecordFailure(r.Context(), limiterKey)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleResetComplete consumes a reset token and replaces the password hash.
|
||||
func (s *Server) handleResetComplete(w http.ResponseWriter, r *http.Request) {
|
||||
var req resetCompleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < minPasswordLen {
|
||||
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
email, err := s.signer.ParsePurpose(purposePasswordReset, req.Token)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
hash, err := HashPassword(req.Password)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "could not hash password")
|
||||
return
|
||||
}
|
||||
found, err := s.store.UpdateHash(r.Context(), email, hash)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "could not update password")
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
// Token signed for an email no longer present.
|
||||
writeErr(w, http.StatusBadRequest, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
s.limiter.Reset(r.Context(), NormalizeEmail(email))
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleLinkCart(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := s.requireSession(w, r)
|
||||
if !ok {
|
||||
@@ -292,6 +492,19 @@ func (s *Server) issueSession(w http.ResponseWriter, r *http.Request, id uint64)
|
||||
SetSession(w, r, s.signer.Issue(id, s.ttl), s.ttl)
|
||||
}
|
||||
|
||||
// sendVerification mints a verification token for email and hands the link to
|
||||
// the notifier. email must already be normalized.
|
||||
func (s *Server) sendVerification(email string) {
|
||||
token := s.signer.IssuePurpose(purposeVerifyEmail, email, verifyTokenTTL)
|
||||
s.notifier.SendEmailVerification(email, s.link(verifyLinkPath, token))
|
||||
}
|
||||
|
||||
// link builds an absolute link to a storefront path carrying the token as a
|
||||
// query parameter. With no configured BaseURL it returns a relative link.
|
||||
func (s *Server) link(path, token string) string {
|
||||
return fmt.Sprintf("%s%s?token=%s", s.baseURL, path, url.QueryEscape(token))
|
||||
}
|
||||
|
||||
// requireSession reads and validates the session cookie, writing 401 on
|
||||
// failure. It returns the profile id and whether a valid session was present.
|
||||
func (s *Server) requireSession(w http.ResponseWriter, r *http.Request) (uint64, bool) {
|
||||
@@ -315,7 +528,11 @@ func (s *Server) writeCustomer(w http.ResponseWriter, r *http.Request, status in
|
||||
writeErr(w, http.StatusInternalServerError, "could not read profile")
|
||||
return
|
||||
}
|
||||
writeJSON(w, status, grainToCustomer(idStr, g))
|
||||
resp := grainToCustomer(idStr, g)
|
||||
if rec, ok := s.store.Get(r.Context(), g.Email); ok {
|
||||
resp.EmailVerified = rec.VerifiedAt != ""
|
||||
}
|
||||
writeJSON(w, status, resp)
|
||||
}
|
||||
|
||||
func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
|
||||
@@ -347,6 +564,12 @@ func grainToCustomer(id string, g *profile.ProfileGrain) CustomerResponse {
|
||||
for _, o := range g.Orders {
|
||||
resp.Orders = append(resp.Orders, OrderRef{OrderReference: o.OrderReference, Status: o.Status})
|
||||
}
|
||||
if g.EmailPreferences != nil {
|
||||
resp.EmailPreferences = &EmailPreferencesResponse{
|
||||
OrderEmails: g.EmailPreferences.OrderEmails,
|
||||
MarketingEmails: g.EmailPreferences.MarketingEmails,
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -14,6 +15,27 @@ import (
|
||||
// associated with a credential record.
|
||||
var ErrEmailExists = errors.New("customerauth: email already registered")
|
||||
|
||||
// Credentials is the email→credential index the auth server depends on. It is
|
||||
// satisfied by the file-backed CredentialStore (single-instance / dev) and by
|
||||
// RedisCredentialStore (shared, horizontally scalable). All methods normalize
|
||||
// the email key internally.
|
||||
type Credentials interface {
|
||||
// Get returns the record for email and whether it exists. A backing-store
|
||||
// failure is reported as "not found" so callers fail closed.
|
||||
Get(ctx context.Context, email string) (Record, bool)
|
||||
// Register adds a new credential, returning ErrEmailExists if taken.
|
||||
Register(ctx context.Context, email string, profileID uint64, hash, createdAt string) error
|
||||
// MarkVerified records email verification. The bool reports whether the
|
||||
// email was known; an unknown email is not an error (avoids enumeration).
|
||||
MarkVerified(ctx context.Context, email, verifiedAt string) (bool, error)
|
||||
// UpdateHash replaces the password hash. The bool reports whether the email
|
||||
// was known; an unknown email is not an error.
|
||||
UpdateHash(ctx context.Context, email, hash string) (bool, error)
|
||||
// Delete removes a credential record by email. The bool reports whether the email
|
||||
// was known.
|
||||
Delete(ctx context.Context, email string) (bool, error)
|
||||
}
|
||||
|
||||
// Record is a single stored credential: which profile an email belongs to and
|
||||
// its password hash. It deliberately does not embed any profile data — the
|
||||
// profile grain remains the source of truth for that.
|
||||
@@ -22,6 +44,7 @@ type Record struct {
|
||||
ProfileID uint64 `json:"profileId"`
|
||||
Hash string `json:"hash"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
VerifiedAt string `json:"verifiedAt,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialStore is a small email→credential index persisted to a JSON file.
|
||||
@@ -61,8 +84,9 @@ func NormalizeEmail(email string) string {
|
||||
return strings.ToLower(strings.TrimSpace(email))
|
||||
}
|
||||
|
||||
// Get returns the record for email and whether it exists.
|
||||
func (s *CredentialStore) Get(email string) (Record, bool) {
|
||||
// Get returns the record for email and whether it exists. The ctx is accepted
|
||||
// for interface parity with the Redis store; the file store ignores it.
|
||||
func (s *CredentialStore) Get(_ context.Context, email string) (Record, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
r, ok := s.byMail[NormalizeEmail(email)]
|
||||
@@ -71,7 +95,7 @@ func (s *CredentialStore) Get(email string) (Record, bool) {
|
||||
|
||||
// Register adds a new credential record and persists the store. It returns
|
||||
// ErrEmailExists if the email is already taken.
|
||||
func (s *CredentialStore) Register(email string, profileID uint64, hash, createdAt string) error {
|
||||
func (s *CredentialStore) Register(_ context.Context, email string, profileID uint64, hash, createdAt string) error {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -82,6 +106,54 @@ func (s *CredentialStore) Register(email string, profileID uint64, hash, created
|
||||
return s.persistLocked()
|
||||
}
|
||||
|
||||
// MarkVerified records that email completed email verification and persists the
|
||||
// store. It is a no-op if already verified. The bool reports whether the email
|
||||
// was known; an unknown email is not an error (callers avoid leaking which
|
||||
// emails exist).
|
||||
func (s *CredentialStore) MarkVerified(_ context.Context, email, verifiedAt string) (bool, error) {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rec, ok := s.byMail[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if rec.VerifiedAt != "" {
|
||||
return true, nil
|
||||
}
|
||||
rec.VerifiedAt = verifiedAt
|
||||
s.byMail[key] = rec
|
||||
return true, s.persistLocked()
|
||||
}
|
||||
|
||||
// UpdateHash replaces the stored password hash for email and persists the store.
|
||||
// The bool reports whether the email was known; an unknown email is not an error.
|
||||
func (s *CredentialStore) UpdateHash(_ context.Context, email, hash string) (bool, error) {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rec, ok := s.byMail[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
rec.Hash = hash
|
||||
s.byMail[key] = rec
|
||||
return true, s.persistLocked()
|
||||
}
|
||||
|
||||
// Delete removes the credential record for email and persists the store.
|
||||
// The bool reports whether the email was known.
|
||||
func (s *CredentialStore) Delete(_ context.Context, email string) (bool, error) {
|
||||
key := NormalizeEmail(email)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.byMail[key]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.byMail, key)
|
||||
return true, s.persistLocked()
|
||||
}
|
||||
|
||||
// persistLocked writes the whole store to disk atomically. Caller holds s.mu.
|
||||
func (s *CredentialStore) persistLocked() error {
|
||||
records := make([]Record, 0, len(s.byMail))
|
||||
@@ -102,6 +174,11 @@ func (s *CredentialStore) persistLocked() error {
|
||||
return fmt.Errorf("customerauth: temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
if err := tmp.Chmod(0644); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("customerauth: chmod temp: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package customerauth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Purpose-scoped tokens (email verification, password reset) reuse the session
|
||||
// Signer's HMAC secret but bind a purpose + subject, so a token minted for one
|
||||
// flow cannot be replayed in another. Format:
|
||||
//
|
||||
// base64url(<purpose> NUL <subject> NUL <expUnix>) "." base64url(hmac)
|
||||
//
|
||||
// subject is the normalized email. Like session tokens these are signed, not
|
||||
// encrypted: they carry no secret, only a purpose, an email and an expiry, and
|
||||
// the HMAC makes them tamper-evident.
|
||||
const (
|
||||
purposeVerifyEmail = "verify"
|
||||
purposePasswordReset = "reset"
|
||||
)
|
||||
|
||||
// IssuePurpose returns a signed, purpose-bound token for subject that expires
|
||||
// after ttl.
|
||||
func (s *Signer) IssuePurpose(purpose, subject string, ttl time.Duration) string {
|
||||
exp := time.Now().Add(ttl).Unix()
|
||||
payload := purpose + "\x00" + subject + "\x00" + strconv.FormatInt(exp, 10)
|
||||
b := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
return b + "." + s.sign(b)
|
||||
}
|
||||
|
||||
// ParsePurpose verifies a purpose token and returns its subject. It requires the
|
||||
// embedded purpose to match want and the token to be unexpired, returning
|
||||
// ErrInvalidToken for a bad signature/format/purpose and ErrExpiredToken when
|
||||
// the token has expired.
|
||||
func (s *Signer) ParsePurpose(want, token string) (string, error) {
|
||||
b, sig, ok := strings.Cut(token, ".")
|
||||
if !ok || b == "" || sig == "" {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
if !hmac.Equal([]byte(sig), []byte(s.sign(b))) {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(b)
|
||||
if err != nil {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
parts := strings.Split(string(raw), "\x00")
|
||||
if len(parts) != 3 || parts[0] != want {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
exp, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
return "", ErrInvalidToken
|
||||
}
|
||||
if time.Now().Unix() >= exp {
|
||||
return "", ErrExpiredToken
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
@@ -189,11 +189,22 @@ func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse {
|
||||
Items: make([]CartItem, 0, len(g.Items)),
|
||||
Totals: Totals{Currency: g.Currency},
|
||||
Status: "active",
|
||||
AppliedPromotions: g.AppliedPromotions,
|
||||
}
|
||||
if g.CheckoutStatus != nil && *g.CheckoutStatus != "" {
|
||||
resp.Status = string(*g.CheckoutStatus)
|
||||
}
|
||||
|
||||
// Per-line evaluation breakdown. The canonical promotion pipeline
|
||||
// (pkg/promotions.PromotionService.EvaluateAndApply) populates this
|
||||
// field on the grain itself after every successful mutation, so the
|
||||
// conversion just reads g.EvaluatedItems verbatim — no recompute at
|
||||
// response time. Same shape /promotions/evaluate-with-cart returns
|
||||
// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems),
|
||||
// so a line verified via GET /ucp/v1/carts/{id} byte-matches the
|
||||
// preview from /promotions/evaluate-with-cart.
|
||||
resp.EvaluatedItems = g.EvaluatedItems
|
||||
|
||||
for _, it := range g.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
|
||||
+103
-2
@@ -12,6 +12,7 @@ import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -228,9 +229,109 @@ func TestCartResponse_Conversion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCartResponse_EvaluatedItems verifies that the per-line breakdown the
|
||||
// canonical promotion pipeline populates on CartGrain.EvaluatedItems flows
|
||||
// verbatim into the UCP CartResponse's EvaluatedItems field. Conversion is a
|
||||
// straight pass-through now — we hand-construct the grain's EvaluatedItems
|
||||
// field with the expected values to lock down the wire shape independent of
|
||||
// the math MapEvaluatedItems applies (which has its own tests in pkg/cart).
|
||||
func TestCartResponse_EvaluatedItems(t *testing.T) {
|
||||
id := mustParseID("ABCD")
|
||||
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
|
||||
g.Currency = "SEK"
|
||||
g.Items = append(g.Items, &cart.CartItem{
|
||||
Sku: "promo-1",
|
||||
Quantity: 2,
|
||||
Price: *mustPrice(10000, 2500),
|
||||
TotalPrice: *mustPrice(20000, 5000),
|
||||
Tax: 2500,
|
||||
OrgPrice: mustPrice(12000, 3000),
|
||||
Discount: mustPrice(2000, 500),
|
||||
Meta: &cart.ItemMeta{Name: "Promo Item"},
|
||||
})
|
||||
g.TotalPrice = mustPrice(20000, 5000)
|
||||
|
||||
// In the live cart, pkg/promotions.PromotionService.EvaluateAndApply
|
||||
// populates g.EvaluatedItems via cart.MapEvaluatedItems after every
|
||||
// successful mutation. The conversion test only cares that whatever
|
||||
// the grain carries projects into resp.EvaluatedItems unchanged — so
|
||||
// the input is the canonical shape, not the math's output.
|
||||
g.EvaluatedItems = []cart.EvaluatedItem{{
|
||||
Sku: "promo-1",
|
||||
Name: "Promo Item",
|
||||
Quantity: 2,
|
||||
PriceIncVat: 10000,
|
||||
OrgPriceIncVat: 12000,
|
||||
DiscountIncVat: 2000,
|
||||
EffectivePriceIncVat: 9000,
|
||||
EffectiveTotalIncVat: 18000,
|
||||
}}
|
||||
|
||||
resp := cartGrainToResponse(g.Id.String(), g)
|
||||
if len(resp.EvaluatedItems) != 1 {
|
||||
t.Fatalf("expected 1 evaluated item, got %d", len(resp.EvaluatedItems))
|
||||
}
|
||||
ev := resp.EvaluatedItems[0]
|
||||
if ev.Sku != "promo-1" {
|
||||
t.Fatalf("expected sku 'promo-1', got %q", ev.Sku)
|
||||
}
|
||||
if ev.Name != "Promo Item" {
|
||||
t.Fatalf("expected name 'Promo Item', got %q", ev.Name)
|
||||
}
|
||||
if ev.Quantity != 2 {
|
||||
t.Fatalf("expected qty 2, got %d", ev.Quantity)
|
||||
}
|
||||
if ev.PriceIncVat != 10000 {
|
||||
t.Fatalf("expected priceIncVat 10000, got %d", ev.PriceIncVat)
|
||||
}
|
||||
if ev.OrgPriceIncVat != 12000 {
|
||||
t.Fatalf("expected orgPriceIncVat 12000, got %d", ev.OrgPriceIncVat)
|
||||
}
|
||||
if ev.DiscountIncVat != 2000 {
|
||||
t.Fatalf("expected discountIncVat 2000, got %d", ev.DiscountIncVat)
|
||||
}
|
||||
if ev.EffectiveTotalIncVat != 18000 {
|
||||
t.Fatalf("expected effectiveTotalIncVat 18000, got %d", ev.EffectiveTotalIncVat)
|
||||
}
|
||||
if ev.EffectivePriceIncVat != 9000 {
|
||||
t.Fatalf("expected effectivePriceIncVat 9000, got %d", ev.EffectivePriceIncVat)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCartResponse_EvaluatedItems_MapEquivalence confirms that
|
||||
// cart.MapEvaluatedItems produces the same shape the test's hand-built
|
||||
// []cart.EvaluatedItem entries contain, given matching inputs. This locks
|
||||
// the conversion path's output to the canonical pipeline's output — if
|
||||
// either side starts omitting or renaming a field, this fails.
|
||||
func TestCartResponse_EvaluatedItems_MapEquivalence(t *testing.T) {
|
||||
id := mustParseID("ABCD")
|
||||
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
|
||||
g.Currency = "SEK"
|
||||
g.Items = append(g.Items, &cart.CartItem{
|
||||
Sku: "promo-1",
|
||||
Quantity: 2,
|
||||
Price: *mustPrice(10000, 2500),
|
||||
TotalPrice: *mustPrice(20000, 5000),
|
||||
Tax: 2500,
|
||||
OrgPrice: mustPrice(12000, 3000),
|
||||
Discount: mustPrice(2000, 500),
|
||||
Meta: &cart.ItemMeta{Name: "Promo Item"},
|
||||
})
|
||||
g.TotalPrice = mustPrice(20000, 5000)
|
||||
|
||||
canonical := cart.MapEvaluatedItems(g)
|
||||
if len(canonical) != 1 {
|
||||
t.Fatalf("expected 1 evaluated item from MapEvaluatedItems, got %d", len(canonical))
|
||||
}
|
||||
e := canonical[0]
|
||||
if e.DiscountIncVat != 2000 || e.EffectiveTotalIncVat != 18000 || e.EffectivePriceIncVat != 9000 {
|
||||
t.Fatalf("MapEvaluatedItems math off: %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func mustPrice(incVat int64, totalVat int64) *cart.Price {
|
||||
return &cart.Price{
|
||||
IncVat: incVat,
|
||||
VatRates: map[float32]int64{25: totalVat},
|
||||
IncVat: money.Cents(incVat),
|
||||
VatRates: map[int]money.Cents{2500: money.Cents(totalVat)}, // 25% in basis points
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
profilePkg "git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
"google.golang.org/protobuf/types/known/anypb"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
@@ -448,14 +449,16 @@ type orderLine struct {
|
||||
Sku string `json:"sku"`
|
||||
Name string `json:"name"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
UnitPrice int64 `json:"unitPrice"`
|
||||
UnitPrice money.Cents `json:"unitPrice"`
|
||||
TaxRate int32 `json:"taxRate"`
|
||||
DropShip bool `json:"drop_ship,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
}
|
||||
|
||||
type orderPayment struct {
|
||||
Provider string `json:"provider"`
|
||||
Reference string `json:"reference"`
|
||||
Amount int64 `json:"amount"`
|
||||
Amount money.Cents `json:"amount"`
|
||||
}
|
||||
|
||||
// CreateOrder implements OrderApplier. It builds a from-checkout request from
|
||||
@@ -476,10 +479,10 @@ func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g
|
||||
}
|
||||
|
||||
// Compute total from cart items + deliveries.
|
||||
var totalAmount int64
|
||||
var totalAmount money.Cents
|
||||
lines := buildOrderLines(g)
|
||||
for _, l := range lines {
|
||||
totalAmount += l.UnitPrice * int64(l.Quantity)
|
||||
totalAmount += l.UnitPrice.Mul(int64(l.Quantity))
|
||||
}
|
||||
|
||||
req := fromCheckoutReq{
|
||||
@@ -539,6 +542,16 @@ func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g
|
||||
// buildOrderLines converts a checkout grain's cart items and delivery selections
|
||||
// into order lines, matching the format expected by the order service.
|
||||
func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
||||
hasFreeShipping := false
|
||||
if g.CartState != nil {
|
||||
for _, ap := range g.CartState.AppliedPromotions {
|
||||
if ap.Type == "free_shipping" && !ap.Pending {
|
||||
hasFreeShipping = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines := make([]orderLine, 0, len(g.CartState.Items)+len(g.Deliveries))
|
||||
for _, it := range g.CartState.Items {
|
||||
if it == nil {
|
||||
@@ -548,17 +561,32 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
||||
if it.Meta != nil {
|
||||
name = it.Meta.Name
|
||||
}
|
||||
location := ""
|
||||
if it.StoreId != nil {
|
||||
location = *it.StoreId
|
||||
}
|
||||
lines = append(lines, orderLine{
|
||||
Reference: it.Sku,
|
||||
Sku: it.Sku,
|
||||
Name: name,
|
||||
Quantity: int32(it.Quantity),
|
||||
UnitPrice: it.Price.IncVat,
|
||||
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
|
||||
// (2500 = 25%), so the rate passes through unchanged.
|
||||
TaxRate: int32(it.Tax),
|
||||
DropShip: it.DropShip,
|
||||
Location: location,
|
||||
})
|
||||
}
|
||||
for _, d := range g.Deliveries {
|
||||
if d == nil || d.Price.IncVat <= 0 {
|
||||
if d == nil {
|
||||
continue
|
||||
}
|
||||
unitPrice := d.Price.IncVat
|
||||
if hasFreeShipping {
|
||||
unitPrice = 0
|
||||
}
|
||||
if unitPrice <= 0 && !hasFreeShipping {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, orderLine{
|
||||
@@ -566,10 +594,35 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
|
||||
Sku: d.Provider,
|
||||
Name: "Delivery",
|
||||
Quantity: 1,
|
||||
UnitPrice: d.Price.IncVat,
|
||||
TaxRate: 2500,
|
||||
UnitPrice: unitPrice,
|
||||
TaxRate: 2500, // 25% in basis points
|
||||
})
|
||||
}
|
||||
|
||||
// Reflected applied promotions as negative discount lines
|
||||
if g.CartState != nil {
|
||||
for _, ap := range g.CartState.AppliedPromotions {
|
||||
if ap.Pending {
|
||||
continue
|
||||
}
|
||||
discountVal := int64(0)
|
||||
if ap.Discount != nil {
|
||||
discountVal = ap.Discount.IncVat.Int64()
|
||||
}
|
||||
if discountVal <= 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, orderLine{
|
||||
Reference: "promo-" + ap.PromotionId,
|
||||
Sku: "promo-" + ap.PromotionId,
|
||||
Name: "Promotion: " + ap.Name,
|
||||
Quantity: 1,
|
||||
UnitPrice: money.Cents(-discountVal),
|
||||
TaxRate: 2500, // default standard tax rate (25%) in basis points
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -671,7 +724,7 @@ func checkoutGrainToResponse(id string, g *checkout.CheckoutGrain) CheckoutRespo
|
||||
resp.Payments = append(resp.Payments, PaymentResponse{
|
||||
Id: p.PaymentId,
|
||||
Provider: p.Provider,
|
||||
Amount: p.Amount,
|
||||
Amount: money.Cents(p.Amount),
|
||||
Currency: p.Currency,
|
||||
Status: string(p.Status),
|
||||
})
|
||||
|
||||
+139
-2
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
@@ -16,11 +17,45 @@ import (
|
||||
// CustomerServer is the UCP REST adapter over the profile grain pool.
|
||||
type CustomerServer struct {
|
||||
applier ProfileApplier
|
||||
deleter CredentialDeleter
|
||||
auditLogPath string
|
||||
emailIndex *profile.ProfileEmailIndex // optional, maintained by mutations
|
||||
}
|
||||
|
||||
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
|
||||
func NewCustomerServer(applier ProfileApplier) *CustomerServer {
|
||||
return &CustomerServer{applier: applier}
|
||||
func NewCustomerServer(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) *CustomerServer {
|
||||
var del CredentialDeleter
|
||||
if len(deleter) > 0 {
|
||||
del = deleter[0]
|
||||
}
|
||||
return &CustomerServer{applier: applier, deleter: del, auditLogPath: auditLogPath}
|
||||
}
|
||||
|
||||
// SetEmailIndex attaches a ProfileEmailIndex that is updated by every
|
||||
// customer mutation (create, update, delete).
|
||||
func (s *CustomerServer) SetEmailIndex(ix *profile.ProfileEmailIndex) {
|
||||
s.emailIndex = ix
|
||||
}
|
||||
|
||||
// indexProfileAfterMutation reads the grain and updates the email index.
|
||||
func (s *CustomerServer) indexProfileAfterMutation(ctx context.Context, id uint64) {
|
||||
if s.emailIndex == nil {
|
||||
return
|
||||
}
|
||||
g, err := s.applier.Get(ctx, id)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
prefs := g.EmailPreferences
|
||||
// Copy so the index holds its own snapshot (the grain is pooled).
|
||||
if prefs != nil {
|
||||
cp := &profile.EmailPreferences{
|
||||
OrderEmails: prefs.OrderEmails,
|
||||
MarketingEmails: prefs.MarketingEmails,
|
||||
}
|
||||
prefs = cp
|
||||
}
|
||||
s.emailIndex.Set(id, g.Email, prefs)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -54,6 +89,11 @@ func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Req
|
||||
AvatarUrl: req.AvatarUrl,
|
||||
}
|
||||
|
||||
if req.EmailPreferences != nil {
|
||||
msg.OrderEmails = req.EmailPreferences.OrderEmails
|
||||
msg.MarketingEmails = req.EmailPreferences.MarketingEmails
|
||||
}
|
||||
|
||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create customer: "+err.Error())
|
||||
return
|
||||
@@ -65,6 +105,8 @@ func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
s.indexProfileAfterMutation(r.Context(), id)
|
||||
|
||||
writeJSON(w, http.StatusCreated, customerGrainToResponse(rawId.String(), g))
|
||||
}
|
||||
|
||||
@@ -125,6 +167,11 @@ func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Req
|
||||
AvatarUrl: req.AvatarUrl,
|
||||
}
|
||||
|
||||
if req.EmailPreferences != nil {
|
||||
msg.OrderEmails = req.EmailPreferences.OrderEmails
|
||||
msg.MarketingEmails = req.EmailPreferences.MarketingEmails
|
||||
}
|
||||
|
||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error())
|
||||
return
|
||||
@@ -136,9 +183,36 @@ func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
s.indexProfileAfterMutation(r.Context(), id)
|
||||
|
||||
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||
}
|
||||
|
||||
// handleGetCustomerByEmail handles GET /customers/by-email/{email} — looks up
|
||||
// the customer profile from the email index and returns the customer response.
|
||||
// Returns 404 when the email is not found.
|
||||
func (s *CustomerServer) handleGetCustomerByEmail(w http.ResponseWriter, r *http.Request) {
|
||||
email := r.PathValue("email")
|
||||
if email == "" || s.emailIndex == nil {
|
||||
writeError(w, http.StatusNotFound, "customer not found")
|
||||
return
|
||||
}
|
||||
|
||||
entry, ok := s.emailIndex.Lookup(email)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "customer not found")
|
||||
return
|
||||
}
|
||||
|
||||
g, err := s.applier.Get(r.Context(), entry.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "customer not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, customerGrainToResponse(profile.ProfileId(entry.ID).String(), g))
|
||||
}
|
||||
|
||||
// handleAddAddress handles POST /customers/{id}/addresses.
|
||||
func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseCustomerID(r)
|
||||
@@ -276,6 +350,61 @@ func (s *CustomerServer) handleRemoveAddress(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||
}
|
||||
|
||||
// handleDeleteCustomer handles DELETE /customers/{id}.
|
||||
func (s *CustomerServer) handleDeleteCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseCustomerID(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Fetch current profile grain to get the email address.
|
||||
g, err := s.applier.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "customer not found")
|
||||
return
|
||||
}
|
||||
|
||||
email := g.Email
|
||||
|
||||
// 2. Apply the AnonymizeProfile mutation to clear all PII.
|
||||
msg := &messages.AnonymizeProfile{}
|
||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to anonymize customer: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Delete the credentials from the auth store if we have a deleter.
|
||||
if email != "" && s.deleter != nil {
|
||||
if _, err := s.deleter.Delete(r.Context(), email); err != nil {
|
||||
fmt.Printf("ucp: failed to delete credentials for email %s: %v\n", email, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Log audit trail to file (GDPR requirement, append-only, readable, no PII)
|
||||
if s.auditLogPath != "" {
|
||||
logLine := fmt.Sprintf("[%s] ACTION=GDPR_ERASURE PROFILE_ID=%s STATUS=SUCCESS\n",
|
||||
time.Now().UTC().Format(time.RFC3339), r.PathValue("id"))
|
||||
|
||||
// Import "os" package is handled or we can open it directly.
|
||||
// Since we need to write to the file, let's open it in append-only mode.
|
||||
// To ensure directory exists, we write to the file.
|
||||
if f, err := os.OpenFile(s.auditLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
|
||||
_, _ = f.WriteString(logLine)
|
||||
_ = f.Close()
|
||||
} else {
|
||||
fmt.Printf("ucp: failed to open audit log file %s: %v\n", s.auditLogPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Log audit trail to stdout (GDPR requirement)
|
||||
fmt.Printf("AUDIT: GDPR right to erasure executed for customer profile id %d\n", id)
|
||||
|
||||
s.indexProfileAfterMutation(r.Context(), id)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity linking helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -417,5 +546,13 @@ func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerRespons
|
||||
})
|
||||
}
|
||||
|
||||
if g.EmailPreferences != nil {
|
||||
prefs := &EmailPreferencesResponse{
|
||||
OrderEmails: g.EmailPreferences.OrderEmails,
|
||||
MarketingEmails: g.EmailPreferences.MarketingEmails,
|
||||
}
|
||||
resp.EmailPreferences = prefs
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -105,6 +107,12 @@ func (a *testProfileApplier) Apply(ctx context.Context, id uint64, msgs ...proto
|
||||
g.Checkouts = append(g.Checkouts, profile.LinkedCheckout{CheckoutId: m.CheckoutId, CartId: m.CartId})
|
||||
case *messages.LinkOrder:
|
||||
g.Orders = append(g.Orders, profile.LinkedOrder{OrderReference: m.OrderReference, CartId: m.CartId, Status: m.Status})
|
||||
case *messages.AnonymizeProfile:
|
||||
g.Name = ""
|
||||
g.Email = ""
|
||||
g.Phone = ""
|
||||
g.AvatarUrl = ""
|
||||
g.Addresses = nil
|
||||
}
|
||||
}
|
||||
return &actor.MutationResult[profile.ProfileGrain]{
|
||||
@@ -138,7 +146,7 @@ func testProfileID(t *testing.T, applier *testProfileApplier) string {
|
||||
func TestGetCustomer(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
handler := CustomerHandler(applier, "")
|
||||
|
||||
// Set up some profile data
|
||||
pid, _ := profile.ParseProfileId(id)
|
||||
@@ -173,7 +181,7 @@ func TestGetCustomer(t *testing.T) {
|
||||
|
||||
func TestGetCustomerNotFound(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
handler := CustomerHandler(applier)
|
||||
handler := CustomerHandler(applier, "")
|
||||
|
||||
// "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet.
|
||||
req := httptest.NewRequest("GET", "/!invalid!", nil)
|
||||
@@ -188,7 +196,7 @@ func TestGetCustomerNotFound(t *testing.T) {
|
||||
func TestUpdateCustomer(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
handler := CustomerHandler(applier, "")
|
||||
|
||||
body := `{"name": "Bob", "email": "bob@example.com", "phone": "+46701234567", "language": "sv", "currency": "SEK"}`
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s", id), strings.NewReader(body))
|
||||
@@ -224,7 +232,7 @@ func TestUpdateCustomer(t *testing.T) {
|
||||
func TestAddAddress(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
handler := CustomerHandler(applier, "")
|
||||
|
||||
body := `{
|
||||
"label": "Home",
|
||||
@@ -266,7 +274,7 @@ func TestAddAddress(t *testing.T) {
|
||||
func TestAddAddressMissingRequired(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
handler := CustomerHandler(applier, "")
|
||||
|
||||
// Missing addressLine1
|
||||
body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}`
|
||||
@@ -283,7 +291,7 @@ func TestAddAddressMissingRequired(t *testing.T) {
|
||||
func TestUpdateAddress(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
handler := CustomerHandler(applier, "")
|
||||
|
||||
// First add an address
|
||||
pid, _ := profile.ParseProfileId(id)
|
||||
@@ -337,7 +345,7 @@ func TestUpdateAddress(t *testing.T) {
|
||||
func TestUpdateAddressInvalidID(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
handler := CustomerHandler(applier, "")
|
||||
|
||||
body := `{"label": "New Home"}`
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/abc", id), strings.NewReader(body))
|
||||
@@ -353,7 +361,7 @@ func TestUpdateAddressInvalidID(t *testing.T) {
|
||||
func TestRemoveAddress(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
handler := CustomerHandler(applier, "")
|
||||
|
||||
// First add an address
|
||||
pid, _ := profile.ParseProfileId(id)
|
||||
@@ -398,7 +406,7 @@ func TestRemoveAddressNotFound(t *testing.T) {
|
||||
applier.Get(context.Background(), uint64(pid))
|
||||
|
||||
id := pid.String()
|
||||
handler := CustomerHandler(applier)
|
||||
handler := CustomerHandler(applier, "")
|
||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
@@ -444,7 +452,7 @@ func TestRemoveAddressNotFoundMutation(t *testing.T) {
|
||||
|
||||
func TestCustomerHandlerInvalidID(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
handler := CustomerHandler(applier)
|
||||
handler := CustomerHandler(applier, "")
|
||||
|
||||
// Path "/" now has POST / registered (create customer), so GET / is 405.
|
||||
// Paths with invalid base62 characters should get 400.
|
||||
@@ -496,3 +504,72 @@ func TestCustomerHandlerThroughCombinedMount(t *testing.T) {
|
||||
t.Fatalf("expected id %q, got %q", id, resp.Id)
|
||||
}
|
||||
}
|
||||
|
||||
type testCredentialDeleter struct {
|
||||
deletedEmail string
|
||||
}
|
||||
|
||||
func (d *testCredentialDeleter) Delete(_ context.Context, email string) (bool, error) {
|
||||
d.deletedEmail = email
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestDeleteCustomer(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
deleter := &testCredentialDeleter{}
|
||||
auditPath := filepath.Join(t.TempDir(), "audit.log")
|
||||
handler := CustomerHandler(applier, auditPath, WithCredentialDeleter(deleter))
|
||||
|
||||
// Set up some profile data
|
||||
pid, _ := profile.ParseProfileId(id)
|
||||
applier.Apply(context.Background(), uint64(pid), &messages.SetProfile{
|
||||
Name: proto.String("Alice"),
|
||||
Email: proto.String("alice@example.com"),
|
||||
})
|
||||
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
|
||||
Address: &messages.Address{
|
||||
Label: "Home",
|
||||
AddressLine1: "Storgatan 1",
|
||||
City: "Stockholm",
|
||||
Zip: "111 22",
|
||||
Country: "SE",
|
||||
},
|
||||
})
|
||||
|
||||
// DELETE /customers/{id}
|
||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s", id), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected 204 No Content, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Verify credentials deleted
|
||||
if deleter.deletedEmail != "alice@example.com" {
|
||||
t.Fatalf("expected deleted email 'alice@example.com', got %q", deleter.deletedEmail)
|
||||
}
|
||||
|
||||
// Verify profile is anonymized
|
||||
g, _ := applier.Get(context.Background(), uint64(pid))
|
||||
if g.Name != "" || g.Email != "" || g.Addresses != nil {
|
||||
t.Fatalf("expected profile to be anonymized, got Name=%q, Email=%q, Addresses=%v", g.Name, g.Email, g.Addresses)
|
||||
}
|
||||
|
||||
// Verify audit log has the correct entry and no PII
|
||||
logBytes, err := os.ReadFile(auditPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read audit log: %v", err)
|
||||
}
|
||||
logContent := string(logBytes)
|
||||
if !strings.Contains(logContent, "ACTION=GDPR_ERASURE") {
|
||||
t.Fatal("expected audit log to record erasure action")
|
||||
}
|
||||
if !strings.Contains(logContent, "PROFILE_ID="+id) {
|
||||
t.Fatal("expected audit log to record profile ID")
|
||||
}
|
||||
if strings.Contains(logContent, "Alice") || strings.Contains(logContent, "alice@example.com") {
|
||||
t.Fatal("expected audit log to exclude PII")
|
||||
}
|
||||
}
|
||||
|
||||
+35
-4
@@ -1,6 +1,10 @@
|
||||
package ucp
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Order HTTP handler mounting
|
||||
@@ -86,12 +90,35 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
|
||||
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
|
||||
//
|
||||
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
|
||||
func CustomerHandler(applier ProfileApplier) http.Handler {
|
||||
s := NewCustomerServer(applier)
|
||||
// CustomerHandlerOption configures a CustomerHandler with optional dependencies.
|
||||
type CustomerHandlerOption func(s *CustomerServer)
|
||||
|
||||
// WithEmailIndex attaches a ProfileEmailIndex that the customer handler
|
||||
// updates on every mutation and uses for the GET /by-email/{email} route.
|
||||
func WithEmailIndex(ix *profile.ProfileEmailIndex) CustomerHandlerOption {
|
||||
return func(s *CustomerServer) {
|
||||
s.SetEmailIndex(ix)
|
||||
}
|
||||
}
|
||||
|
||||
// WithCredentialDeleter attaches a credential deleter for GDPR erasure.
|
||||
func WithCredentialDeleter(deleter CredentialDeleter) CustomerHandlerOption {
|
||||
return func(s *CustomerServer) {
|
||||
s.deleter = deleter
|
||||
}
|
||||
}
|
||||
|
||||
func CustomerHandler(applier ProfileApplier, auditLogPath string, opts ...CustomerHandlerOption) http.Handler {
|
||||
s := NewCustomerServer(applier, auditLogPath)
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /", s.handleCreateCustomer)
|
||||
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
|
||||
mux.HandleFunc("GET /by-email/{email}", s.handleGetCustomerByEmail)
|
||||
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
|
||||
mux.HandleFunc("DELETE /{id}", s.handleDeleteCustomer)
|
||||
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
|
||||
mux.HandleFunc("PUT /{id}/addresses/{addressId}", s.handleUpdateAddress)
|
||||
mux.HandleFunc("DELETE /{id}/addresses/{addressId}", s.handleRemoveAddress)
|
||||
@@ -107,6 +134,8 @@ type Options struct {
|
||||
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
|
||||
OrderApplier OrderApplier // optional order creation for complete endpoint
|
||||
ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking
|
||||
CredentialDeleter CredentialDeleter // optional, for deleting login credentials during GDPR erasure
|
||||
ProfileAuditLog string // file path for GDPR right-to-erasure audit log
|
||||
}
|
||||
|
||||
// Handler returns an http.Handler that serves all mounted UCP endpoints under
|
||||
@@ -148,10 +177,12 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
||||
|
||||
// Customer endpoints (optional)
|
||||
if opts.ProfileApplier != nil {
|
||||
customerSrv := NewCustomerServer(opts.ProfileApplier)
|
||||
customerSrv := NewCustomerServer(opts.ProfileApplier, opts.ProfileAuditLog, opts.CredentialDeleter)
|
||||
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
|
||||
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
|
||||
mux.HandleFunc("GET /customers/by-email/{email}", customerSrv.handleGetCustomerByEmail)
|
||||
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
|
||||
mux.HandleFunc("DELETE /customers/{id}", customerSrv.handleDeleteCustomer)
|
||||
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
|
||||
mux.HandleFunc("PUT /customers/{id}/addresses/{addressId}", customerSrv.handleUpdateAddress)
|
||||
mux.HandleFunc("DELETE /customers/{id}/addresses/{addressId}", customerSrv.handleRemoveAddress)
|
||||
|
||||
@@ -175,7 +175,7 @@ func (s *OrderServer) handleIssueRefund(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
amount := req.Amount
|
||||
if amount <= 0 {
|
||||
amount = g.CapturedAmount - g.RefundedAmount
|
||||
amount = (g.CapturedAmount - g.RefundedAmount).Int64()
|
||||
}
|
||||
if amount <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "no captured amount to refund")
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/order"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -86,10 +87,10 @@ func (a *testOrderApplier) Apply(_ context.Context, id uint64, msgs ...proto.Mes
|
||||
case *messages.IssueRefund:
|
||||
g.Refunds = append(g.Refunds, order.Refund{
|
||||
Provider: m.Provider,
|
||||
Amount: m.Amount,
|
||||
Amount: money.Cents(m.Amount),
|
||||
Reference: m.Reference,
|
||||
})
|
||||
g.RefundedAmount += m.Amount
|
||||
g.RefundedAmount += money.Cents(m.Amount)
|
||||
if g.RefundedAmount >= g.CapturedAmount {
|
||||
g.Status = order.StatusRefunded
|
||||
}
|
||||
|
||||
+56
-19
@@ -1,6 +1,7 @@
|
||||
package ucp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
@@ -72,8 +73,8 @@ func NewSigningConfigFromPEM(pemData []byte, keyID string) (*SigningConfig, erro
|
||||
|
||||
// WithSigning wraps h with an HTTP middleware that adds RFC 9421 HTTP Message
|
||||
// Signatures to every response. The Signature-Input and Signature headers are
|
||||
// computed over @status, content-type, and x-ucp-timestamp, signed with the
|
||||
// configured ECDSA P-256 key.
|
||||
// computed over @status, content-type, x-ucp-timestamp, and content-digest,
|
||||
// signed with the configured ECDSA P-256 key.
|
||||
//
|
||||
// Mount it on any existing UCP handler:
|
||||
//
|
||||
@@ -83,11 +84,9 @@ func WithSigning(h http.Handler, cfg *SigningConfig) http.Handler {
|
||||
return h // pass through without signing
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sw := &signedWriter{
|
||||
ResponseWriter: w,
|
||||
cfg: cfg,
|
||||
}
|
||||
sw := newSignedWriter(w, cfg)
|
||||
h.ServeHTTP(sw, r)
|
||||
sw.flush()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -96,40 +95,70 @@ func WithSigning(h http.Handler, cfg *SigningConfig) http.Handler {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type signedWriter struct {
|
||||
http.ResponseWriter
|
||||
responseWriter http.ResponseWriter
|
||||
header http.Header
|
||||
body bytes.Buffer
|
||||
cfg *SigningConfig
|
||||
statusCode int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func newSignedWriter(w http.ResponseWriter, cfg *SigningConfig) *signedWriter {
|
||||
return &signedWriter{
|
||||
responseWriter: w,
|
||||
header: make(http.Header),
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *signedWriter) Header() http.Header {
|
||||
return w.header
|
||||
}
|
||||
|
||||
func (w *signedWriter) WriteHeader(statusCode int) {
|
||||
if w.wroteHeader {
|
||||
return
|
||||
}
|
||||
w.wroteHeader = true
|
||||
w.statusCode = statusCode
|
||||
|
||||
created := time.Now().Unix()
|
||||
w.ResponseWriter.Header().Set("x-ucp-timestamp", strconv.FormatInt(created, 10))
|
||||
|
||||
// Add signature headers before flushing.
|
||||
w.addSignatureHeaders(created)
|
||||
|
||||
w.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (w *signedWriter) Write(p []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.ResponseWriter.Write(p)
|
||||
return w.body.Write(p)
|
||||
}
|
||||
|
||||
func (w *signedWriter) flush() {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
if w.header.Get("Content-Type") == "" && w.body.Len() > 0 {
|
||||
w.header.Set("Content-Type", http.DetectContentType(w.body.Bytes()))
|
||||
}
|
||||
|
||||
created := time.Now().Unix()
|
||||
w.header.Set("x-ucp-timestamp", strconv.FormatInt(created, 10))
|
||||
w.header.Set("Content-Digest", buildContentDigest(w.body.Bytes()))
|
||||
|
||||
// Add signature headers before flushing.
|
||||
w.addSignatureHeaders(created)
|
||||
|
||||
dst := w.responseWriter.Header()
|
||||
for k, vals := range w.header {
|
||||
dst[k] = append([]string(nil), vals...)
|
||||
}
|
||||
w.responseWriter.WriteHeader(w.statusCode)
|
||||
_, _ = w.responseWriter.Write(w.body.Bytes())
|
||||
}
|
||||
|
||||
// addSignatureHeaders computes and writes the Signature-Input and Signature
|
||||
// headers per RFC 9421 §3.2 / §3.3.
|
||||
func (w *signedWriter) addSignatureHeaders(created int64) {
|
||||
contentType := w.ResponseWriter.Header().Get("Content-Type")
|
||||
contentType := w.header.Get("Content-Type")
|
||||
sigTimestamp := strconv.FormatInt(created, 10)
|
||||
contentDigest := w.header.Get("Content-Digest")
|
||||
|
||||
// Covered components (in order). RFC 9421 §3.2: derived component names
|
||||
// (@status) are bare identifiers; HTTP header field names are sf-strings
|
||||
@@ -138,6 +167,7 @@ func (w *signedWriter) addSignatureHeaders(created int64) {
|
||||
"@status",
|
||||
`"content-type"`,
|
||||
`"x-ucp-timestamp"`,
|
||||
`"content-digest"`,
|
||||
}
|
||||
|
||||
// Build the signature base string (RFC 9421 §2.2).
|
||||
@@ -148,6 +178,8 @@ func (w *signedWriter) addSignatureHeaders(created int64) {
|
||||
base.WriteByte('\n')
|
||||
base.WriteString(fmt.Sprintf(`"x-ucp-timestamp": %s`, sigTimestamp))
|
||||
base.WriteByte('\n')
|
||||
base.WriteString(fmt.Sprintf(`"content-digest": %s`, contentDigest))
|
||||
base.WriteByte('\n')
|
||||
|
||||
// Append the signature-params pseudo-line (RFC 9421 §2.2).
|
||||
compList := strings.Join(components, " ")
|
||||
@@ -178,6 +210,11 @@ func (w *signedWriter) addSignatureHeaders(created int64) {
|
||||
// Signature: sig1=:base64url:
|
||||
sigValue := fmt.Sprintf("sig1=:%s:", sigB64)
|
||||
|
||||
w.ResponseWriter.Header().Set("Signature-Input", sigInput)
|
||||
w.ResponseWriter.Header().Set("Signature", sigValue)
|
||||
w.header.Set("Signature-Input", sigInput)
|
||||
w.header.Set("Signature", sigValue)
|
||||
}
|
||||
|
||||
func buildContentDigest(body []byte) string {
|
||||
sum := sha256.Sum256(body)
|
||||
return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ func TestWithSigning_AddsHeaders(t *testing.T) {
|
||||
sigInput := rec.Header().Get("Signature-Input")
|
||||
sig := rec.Header().Get("Signature")
|
||||
ts := rec.Header().Get("x-ucp-timestamp")
|
||||
digest := rec.Header().Get("Content-Digest")
|
||||
|
||||
if sigInput == "" {
|
||||
t.Fatal("expected Signature-Input header")
|
||||
@@ -73,9 +74,12 @@ func TestWithSigning_AddsHeaders(t *testing.T) {
|
||||
if ts == "" {
|
||||
t.Fatal("expected x-ucp-timestamp header")
|
||||
}
|
||||
if digest == "" {
|
||||
t.Fatal("expected Content-Digest header")
|
||||
}
|
||||
|
||||
// Verify the signature input format.
|
||||
if !strings.HasPrefix(sigInput, `sig1=(@status "content-type" "x-ucp-timestamp");`) {
|
||||
if !strings.HasPrefix(sigInput, `sig1=(@status "content-type" "x-ucp-timestamp" "content-digest");`) {
|
||||
t.Fatalf("unexpected Signature-Input format: %q", sigInput)
|
||||
}
|
||||
if !strings.Contains(sigInput, `keyid="k6n-ecdsa-2026"`) {
|
||||
@@ -155,6 +159,9 @@ func TestWithSigning_WithUCPCartHandler(t *testing.T) {
|
||||
if rec.Header().Get("x-ucp-timestamp") == "" {
|
||||
t.Fatal("expected x-ucp-timestamp on UCP cart response")
|
||||
}
|
||||
if rec.Header().Get("Content-Digest") == "" {
|
||||
t.Fatal("expected Content-Digest on UCP cart response")
|
||||
}
|
||||
}
|
||||
|
||||
// mustLoadTestKey loads the test PEM for signing tests.
|
||||
|
||||
+70
-23
@@ -19,11 +19,12 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/platform/money"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -56,6 +57,11 @@ type ProfileApplier interface {
|
||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
|
||||
}
|
||||
|
||||
// CredentialDeleter represents the subset of the credential store needed to delete credentials.
|
||||
type CredentialDeleter interface {
|
||||
Delete(ctx context.Context, email string) (bool, error)
|
||||
}
|
||||
|
||||
// OrderApplier is the interface the UCP checkout adapter uses to create a real
|
||||
// order from a completed checkout session. It abstracts over the transport
|
||||
// (direct grain pool, HTTP, etc.) so the adapter is portable.
|
||||
@@ -100,8 +106,8 @@ type CartItemInput struct {
|
||||
Sku string `json:"sku"`
|
||||
Quantity int `json:"quantity,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Price int64 `json:"price,omitempty"` // inc-vat in minor units (öre)
|
||||
TaxRate float64 `json:"taxRate,omitempty"` // e.g. 25 or 12.5
|
||||
Price money.Cents `json:"price,omitempty"` // inc-vat in minor units (öre / money.Cents on the wire)
|
||||
TaxRate int `json:"taxRate,omitempty"` // basis points: 2500 = 25%, 1250 = 12.5% (matches OrderLine.TaxRate / CartItem.Tax)
|
||||
Image string `json:"image,omitempty"`
|
||||
StoreId *string `json:"storeId,omitempty"`
|
||||
Children []CartItemInput `json:"children,omitempty"`
|
||||
@@ -111,15 +117,31 @@ type CartItemInput struct {
|
||||
// VoucherInput is the UCP wire format for a voucher code to apply.
|
||||
type VoucherInput struct {
|
||||
Code string `json:"code"`
|
||||
Value int64 `json:"value,omitempty"`
|
||||
Value money.Cents `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// CartResponse is the UCP cart response body.
|
||||
//
|
||||
// EvaluatedItems mirrors the per-line breakdown the canonical promotion
|
||||
// pipeline (pkg/promotions.PromotionService.EvaluateAndApply) populates
|
||||
// on the grain after every successful mutation — see
|
||||
// pkg/cart/evaluated_item.go for the EvaluatedItem type and the rounding
|
||||
// rules. The shape and field order match the live cart grain's own
|
||||
// EvaluatedItems field and the response from /promotions/evaluate-with-cart
|
||||
// (both reference cart.EvaluatedItem and cart.MapEvaluatedItems), so a
|
||||
// line verified via GET /ucp/v1/carts/{id} byte-matches the preview.
|
||||
//
|
||||
// Kept parallel to Items rather than merged into CartItem to preserve
|
||||
// the UCP wire contract — existing UCP clients keep working unchanged
|
||||
// and the per-line breakdown can grow on the EvaluatedItem type without
|
||||
// churning the standard item shape.
|
||||
type CartResponse struct {
|
||||
Id string `json:"id"`
|
||||
Items []CartItem `json:"items,omitempty"`
|
||||
EvaluatedItems []cart.EvaluatedItem `json:"evaluatedItems,omitempty"`
|
||||
Totals Totals `json:"totals"`
|
||||
Status string `json:"status"`
|
||||
AppliedPromotions []cart.AppliedPromotion `json:"appliedPromotions,omitempty"`
|
||||
}
|
||||
|
||||
// CartItem is the UCP wire format for a cart line item in responses.
|
||||
@@ -127,8 +149,8 @@ type CartItem struct {
|
||||
Sku string `json:"sku"`
|
||||
Quantity int `json:"quantity"`
|
||||
Name string `json:"name,omitempty"`
|
||||
UnitPrice int64 `json:"unitPrice"` // inc-vat minor units
|
||||
TotalPrice int64 `json:"totalPrice"` // inc-vat minor units
|
||||
UnitPrice money.Cents `json:"unitPrice"` // inc-vat minor units
|
||||
TotalPrice money.Cents `json:"totalPrice"` // inc-vat minor units
|
||||
TaxRate int `json:"taxRate"`
|
||||
Image string `json:"image,omitempty"`
|
||||
ItemId uint32 `json:"itemId,omitempty"`
|
||||
@@ -137,11 +159,11 @@ type CartItem struct {
|
||||
|
||||
// Totals is the UCP wire format for price breakdown.
|
||||
type Totals struct {
|
||||
TotalIncVat int64 `json:"totalIncVat"`
|
||||
TotalExVat int64 `json:"totalExVat"`
|
||||
TotalVat int64 `json:"totalVat"`
|
||||
TotalIncVat money.Cents `json:"totalIncVat"`
|
||||
TotalExVat money.Cents `json:"totalExVat"`
|
||||
TotalVat money.Cents `json:"totalVat"`
|
||||
Currency string `json:"currency"`
|
||||
Discount int64 `json:"discount,omitempty"`
|
||||
Discount money.Cents `json:"discount,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -215,7 +237,7 @@ type CheckoutResponse struct {
|
||||
type DeliveryResponse struct {
|
||||
Id uint32 `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Price int64 `json:"price"`
|
||||
Price money.Cents `json:"price"`
|
||||
Items []uint32 `json:"items"`
|
||||
PickupPoint *PickupPointResp `json:"pickupPoint,omitempty"`
|
||||
}
|
||||
@@ -241,7 +263,7 @@ type ContactResponse struct {
|
||||
type PaymentResponse struct {
|
||||
Id string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Amount int64 `json:"amount"`
|
||||
Amount money.Cents `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -257,15 +279,15 @@ type OrderResponse struct {
|
||||
CartId string `json:"cartId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
TotalAmount int64 `json:"totalAmount"`
|
||||
TotalTax int64 `json:"totalTax"`
|
||||
TotalAmount money.Cents `json:"totalAmount"`
|
||||
TotalTax money.Cents `json:"totalTax"`
|
||||
Lines []OrderLineResp `json:"lines,omitempty"`
|
||||
Payments []OrderPaymentResp `json:"payments,omitempty"`
|
||||
Fulfillments []OrderFulfillmentResp `json:"fulfillments,omitempty"`
|
||||
Returns []OrderReturnResp `json:"returns,omitempty"`
|
||||
Refunds []OrderRefundResp `json:"refunds,omitempty"`
|
||||
CapturedAmount int64 `json:"capturedAmount"`
|
||||
RefundedAmount int64 `json:"refundedAmount"`
|
||||
CapturedAmount money.Cents `json:"capturedAmount"`
|
||||
RefundedAmount money.Cents `json:"refundedAmount"`
|
||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
||||
CustomerName string `json:"customerName,omitempty"`
|
||||
PlacedAt string `json:"placedAt,omitempty"`
|
||||
@@ -278,19 +300,19 @@ type OrderLineResp struct {
|
||||
Sku string `json:"sku"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice int64 `json:"unitPrice"`
|
||||
UnitPrice money.Cents `json:"unitPrice"`
|
||||
TaxRate int `json:"taxRate"`
|
||||
TotalAmount int64 `json:"totalAmount"`
|
||||
TotalTax int64 `json:"totalTax"`
|
||||
TotalAmount money.Cents `json:"totalAmount"`
|
||||
TotalTax money.Cents `json:"totalTax"`
|
||||
Fulfilled int `json:"fulfilled,omitempty"`
|
||||
}
|
||||
|
||||
// OrderPaymentResp represents a payment record on an order.
|
||||
type OrderPaymentResp struct {
|
||||
Provider string `json:"provider"`
|
||||
Authorized int64 `json:"authorized"`
|
||||
Captured int64 `json:"captured"`
|
||||
Refunded int64 `json:"refunded"`
|
||||
Authorized money.Cents `json:"authorized"`
|
||||
Captured money.Cents `json:"captured"`
|
||||
Refunded money.Cents `json:"refunded"`
|
||||
AuthRef string `json:"authRef,omitempty"`
|
||||
CaptureRef string `json:"captureRef,omitempty"`
|
||||
}
|
||||
@@ -314,7 +336,7 @@ type OrderReturnResp struct {
|
||||
// OrderRefundResp represents a refund record.
|
||||
type OrderRefundResp struct {
|
||||
Provider string `json:"provider"`
|
||||
Amount int64 `json:"amount"`
|
||||
Amount money.Cents `json:"amount"`
|
||||
Reference string `json:"reference,omitempty"`
|
||||
}
|
||||
|
||||
@@ -329,6 +351,12 @@ type OrderLineEntry struct {
|
||||
// UCP Customer types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// EmailPreferencesResponse is the UCP wire format for email preference toggles.
|
||||
type EmailPreferencesResponse struct {
|
||||
OrderEmails *bool `json:"orderEmails,omitempty"`
|
||||
MarketingEmails *bool `json:"marketingEmails,omitempty"`
|
||||
}
|
||||
|
||||
// CustomerResponse is the UCP customer profile response body.
|
||||
type CustomerResponse struct {
|
||||
Id string `json:"id"`
|
||||
@@ -339,6 +367,7 @@ type CustomerResponse struct {
|
||||
Currency string `json:"currency,omitempty"`
|
||||
AvatarUrl string `json:"avatarUrl,omitempty"`
|
||||
Addresses []AddressResponse `json:"addresses,omitempty"`
|
||||
EmailPreferences *EmailPreferencesResponse `json:"emailPreferences,omitempty"`
|
||||
}
|
||||
|
||||
// AddressResponse is the UCP wire format for an address.
|
||||
@@ -357,6 +386,12 @@ type AddressResponse struct {
|
||||
IsDefaultBilling bool `json:"isDefaultBilling"`
|
||||
}
|
||||
|
||||
// EmailPreferencesInput is the body for updating a customer's email preferences.
|
||||
type EmailPreferencesInput struct {
|
||||
OrderEmails *bool `json:"orderEmails,omitempty"`
|
||||
MarketingEmails *bool `json:"marketingEmails,omitempty"`
|
||||
}
|
||||
|
||||
// CustomerUpdateRequest is the body for PUT /customers/{id}.
|
||||
type CustomerUpdateRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
@@ -365,6 +400,7 @@ type CustomerUpdateRequest struct {
|
||||
Language *string `json:"language,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
AvatarUrl *string `json:"avatarUrl,omitempty"`
|
||||
EmailPreferences *EmailPreferencesInput `json:"emailPreferences,omitempty"`
|
||||
}
|
||||
|
||||
// AddAddressRequest is the body for POST /customers/{id}/addresses.
|
||||
@@ -404,6 +440,7 @@ type UpdateAddressRequest struct {
|
||||
// ProfileData is the UCP business profile served at /.well-known/ucp.
|
||||
type ProfileData struct {
|
||||
UCP Profile `json:"ucp"`
|
||||
SigningKeys []SigningKey `json:"signing_keys,omitempty"`
|
||||
}
|
||||
|
||||
// Profile is the core UCP profile object.
|
||||
@@ -457,6 +494,16 @@ type PaymentHandlerDecl struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// SigningKey describes a published verification key for signed UCP responses.
|
||||
type SigningKey struct {
|
||||
Kid string `json:"kid"`
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv,omitempty"`
|
||||
X string `json:"x,omitempty"`
|
||||
Y string `json:"y,omitempty"`
|
||||
Alg string `json:"alg,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+16
-81
@@ -1,38 +1,24 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.k6n.net/mats/platform/uid"
|
||||
)
|
||||
|
||||
type GrainId uint64
|
||||
// GrainId is a 64-bit grain identifier with a compact base62 string form.
|
||||
type GrainId uid.ID
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
// String returns the canonical base62 encoding.
|
||||
func (id GrainId) String() string { return uid.ID(id).String() }
|
||||
|
||||
// Reverse lookup (0xFF marks invalid)
|
||||
var base62Rev [256]byte
|
||||
|
||||
func init() {
|
||||
for i := range base62Rev {
|
||||
base62Rev[i] = 0xFF
|
||||
}
|
||||
for i := 0; i < len(base62Alphabet); i++ {
|
||||
base62Rev[base62Alphabet[i]] = byte(i)
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the canonical base62 encoding of the 64-bit id.
|
||||
func (id GrainId) String() string {
|
||||
return encodeBase62(uint64(id))
|
||||
}
|
||||
|
||||
// MarshalJSON encodes the cart id as a JSON string.
|
||||
// MarshalJSON encodes the id as a JSON string.
|
||||
func (id GrainId) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(id.String())
|
||||
return json.Marshal(uid.ID(id).String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
||||
// UnmarshalJSON decodes from a base62 JSON string.
|
||||
func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
@@ -40,7 +26,7 @@ func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
parsed, ok := ParseGrainId(s)
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid cart id: %q", s)
|
||||
return fmt.Errorf("invalid grain id: %q", s)
|
||||
}
|
||||
*id = parsed
|
||||
return nil
|
||||
@@ -48,23 +34,11 @@ func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// NewGrainId generates a new cryptographically random non-zero 64-bit id.
|
||||
func NewGrainId() (GrainId, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
id, err := uid.New()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("NewGrainId: %w", err)
|
||||
}
|
||||
u := (uint64(b[0]) << 56) |
|
||||
(uint64(b[1]) << 48) |
|
||||
(uint64(b[2]) << 40) |
|
||||
(uint64(b[3]) << 32) |
|
||||
(uint64(b[4]) << 24) |
|
||||
(uint64(b[5]) << 16) |
|
||||
(uint64(b[6]) << 8) |
|
||||
uint64(b[7])
|
||||
if u == 0 {
|
||||
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
|
||||
return NewGrainId()
|
||||
}
|
||||
return GrainId(u), nil
|
||||
return GrainId(id), nil
|
||||
}
|
||||
|
||||
// MustNewGrainId panics if generation fails.
|
||||
@@ -77,55 +51,16 @@ func MustNewGrainId() GrainId {
|
||||
}
|
||||
|
||||
// ParseGrainId parses a base62 string into a GrainId.
|
||||
// Returns (0,false) for invalid input.
|
||||
func ParseGrainId(s string) (GrainId, bool) {
|
||||
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
||||
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
||||
// extensions; here we stay strict.
|
||||
if len(s) == 0 || len(s) > 11 {
|
||||
return 0, false
|
||||
}
|
||||
u, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return GrainId(u), true
|
||||
id, ok := uid.Parse(s)
|
||||
return GrainId(id), ok
|
||||
}
|
||||
|
||||
// MustParseGrainId panics on invalid base62 input.
|
||||
func MustParseGrainId(s string) GrainId {
|
||||
id, ok := ParseGrainId(s)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("invalid cart id: %q", s))
|
||||
panic(fmt.Sprintf("invalid grain id: %q", s))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// encodeBase62 converts a uint64 to base62 (shortest form).
|
||||
func encodeBase62(u uint64) string {
|
||||
if u == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [11]byte
|
||||
i := len(buf)
|
||||
for u > 0 {
|
||||
i--
|
||||
buf[i] = base62Alphabet[u%62]
|
||||
u /= 62
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// decodeBase62 converts base62 text to uint64.
|
||||
func decodeBase62(s string) (uint64, bool) {
|
||||
var v uint64
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
d := base62Rev[c]
|
||||
if d == 0xFF {
|
||||
return 0, false
|
||||
}
|
||||
v = v*62 + uint64(d)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
+363
-36
@@ -7,22 +7,38 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const defaultWriterIdleTTL = 5 * time.Minute
|
||||
|
||||
type QueueEvent struct {
|
||||
TimeStamp time.Time
|
||||
Message proto.Message
|
||||
}
|
||||
|
||||
// diskLogWriter holds one append-only log file kept open between writes.
|
||||
type diskLogWriter struct {
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
lastUsed time.Time
|
||||
purged bool // set by purgeIdleWriters before closing; getWriter re-checks after acquiring mu
|
||||
}
|
||||
|
||||
type DiskStorage[V any] struct {
|
||||
*StateStorage
|
||||
path string
|
||||
done chan struct{}
|
||||
queue *sync.Map // map[uint64][]QueueEvent
|
||||
dirOnce sync.Once
|
||||
dirErr error
|
||||
writersMu sync.Mutex
|
||||
writers map[uint64]*diskLogWriter
|
||||
writerIdleTTL time.Duration
|
||||
}
|
||||
|
||||
type LogStorage[V any] interface {
|
||||
@@ -32,10 +48,219 @@ type LogStorage[V any] interface {
|
||||
}
|
||||
|
||||
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
|
||||
return &DiskStorage[V]{
|
||||
s := &DiskStorage[V]{
|
||||
StateStorage: NewState(registry),
|
||||
path: path,
|
||||
done: make(chan struct{}),
|
||||
writers: make(map[uint64]*diskLogWriter),
|
||||
writerIdleTTL: defaultWriterIdleTTL,
|
||||
}
|
||||
go s.purgeLoop()
|
||||
go s.filesExistingLoop()
|
||||
return s
|
||||
}
|
||||
|
||||
// filesExistingLoop refreshes the EventLogFilesExisting gauge every
|
||||
// 30 seconds. The gauge represents the count of .events.log files on
|
||||
// disk, which diverges from the open-writer count whenever the idle
|
||||
// purge closes a writer but the file is still kept (the .log file is
|
||||
// not deleted by the idle purge). Periodic re-listing is cheap (one
|
||||
// os.ReadDir per tick) and is the only way the gauge can pick up files
|
||||
// created or removed out of band (e.g. GDPR purge, or the init-time
|
||||
// rebuild of a grain that already has a log).
|
||||
func (s *DiskStorage[V]) filesExistingLoop() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.refreshFilesExisting()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshFilesExisting counts .events.log files in the storage dir
|
||||
// and writes the value to the EventLogFilesExisting gauge. Safe to
|
||||
// call concurrently — os.ReadDir is atomic for our purposes (the dir
|
||||
// is owned by this pod, mutations go through the writersMu lock).
|
||||
// No-op when the dir does not exist yet (cold start) or when metrics
|
||||
// are disabled (s.StateStorage.metrics == nil).
|
||||
func (s *DiskStorage[V]) refreshFilesExisting() {
|
||||
if s.StateStorage.metrics == nil {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(s.path)
|
||||
if err != nil {
|
||||
// Dir may not exist yet on a cold start, or briefly during a
|
||||
// retention-driven directory recreation. Leave the gauge
|
||||
// untouched in that case — the next tick will catch up.
|
||||
return
|
||||
}
|
||||
count := 0
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".events.log") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
s.StateStorage.metrics.EventLogFilesExisting.Set(float64(count))
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) ensureDir() error {
|
||||
s.dirOnce.Do(func() {
|
||||
s.dirErr = os.MkdirAll(s.path, 0o755)
|
||||
})
|
||||
return s.dirErr
|
||||
}
|
||||
|
||||
// getWriter returns an open append handle for id. The caller must unlock w.mu
|
||||
// when finished writing.
|
||||
|
||||
|
||||
// SetMetrics attaches a Metrics instance to the embedded StateStorage
|
||||
// so every append / replay / unknown-type path has access to the
|
||||
// same event-log counters. The metrics struct is shared by reference;
|
||||
// do not mutate its fields from outside. Pass nil to disable
|
||||
// instrumentation (used by tests that do not care about Prometheus).
|
||||
func (s *DiskStorage[V]) SetMetrics(m *Metrics) {
|
||||
s.StateStorage.metrics = m
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) recordAppend(n int) {
|
||||
m := s.StateStorage.metrics
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.EventLogAppends.Inc()
|
||||
if n > 0 {
|
||||
m.EventLogBytesWritten.Add(float64(n))
|
||||
}
|
||||
m.EventLogLastAppendUnix.Set(float64(time.Now().Unix()))
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) writeEvents(id uint64, events []QueueEvent) error {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
w, err := s.getWriter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.mu.Unlock()
|
||||
|
||||
for _, evt := range events {
|
||||
n, err := s.Append(w.file, evt.Message, evt.TimeStamp)
|
||||
if err != nil {
|
||||
// A partial write still counts the bytes that DID land on
|
||||
// disk, so the throughput gauge is honest about a mid-write
|
||||
// failure.
|
||||
s.recordAppend(n)
|
||||
return err
|
||||
}
|
||||
s.recordAppend(n)
|
||||
}
|
||||
w.lastUsed = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) writeMutations(id uint64, msgs ...proto.Message) error {
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
w, err := s.getWriter(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for _, m := range msgs {
|
||||
n, err := s.Append(w.file, m, now)
|
||||
if err != nil {
|
||||
s.recordAppend(n)
|
||||
return err
|
||||
}
|
||||
s.recordAppend(n)
|
||||
}
|
||||
w.lastUsed = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) purgeLoop() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.purgeIdleWriters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) purgeIdleWriters() {
|
||||
cutoff := time.Now().Add(-s.writerIdleTTL)
|
||||
|
||||
s.writersMu.Lock()
|
||||
defer s.writersMu.Unlock()
|
||||
|
||||
for id, w := range s.writers {
|
||||
w.mu.Lock()
|
||||
if w.lastUsed.Before(cutoff) {
|
||||
w.purged = true
|
||||
delete(s.writers, id)
|
||||
if err := w.file.Close(); err != nil {
|
||||
log.Printf("failed to close idle event log %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) getWriter(id uint64) (*diskLogWriter, error) {
|
||||
if err := s.ensureDir(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for {
|
||||
s.writersMu.Lock()
|
||||
w, ok := s.writers[id]
|
||||
if !ok {
|
||||
fh, err := os.OpenFile(s.logPath(id), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
s.writersMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
w = &diskLogWriter{file: fh, lastUsed: time.Now()}
|
||||
s.writers[id] = w
|
||||
}
|
||||
s.writersMu.Unlock()
|
||||
|
||||
w.mu.Lock()
|
||||
if !w.purged {
|
||||
w.lastUsed = time.Now()
|
||||
return w, nil
|
||||
}
|
||||
// Writer was purged by purgeIdleWriters between our map lookup and acquiring w.mu.
|
||||
// Release it and retry — the map no longer contains this entry, so the next
|
||||
// iteration will create a fresh writer.
|
||||
w.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) closeWriters() {
|
||||
s.writersMu.Lock()
|
||||
defer s.writersMu.Unlock()
|
||||
for id, w := range s.writers {
|
||||
w.mu.Lock()
|
||||
w.purged = true
|
||||
if err := w.file.Close(); err != nil {
|
||||
log.Printf("failed to close event log %d: %v", id, err)
|
||||
}
|
||||
delete(s.writers, id)
|
||||
w.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,26 +280,23 @@ func (s *DiskStorage[V]) SaveLoop(duration time.Duration) {
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) save() {
|
||||
if s.queue == nil {
|
||||
return
|
||||
}
|
||||
carts := 0
|
||||
lines := 0
|
||||
s.queue.Range(func(key, value any) bool {
|
||||
id := key.(uint64)
|
||||
path := s.logPath(id)
|
||||
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Printf("failed to open event log file: %v", err)
|
||||
qe, ok := value.([]QueueEvent)
|
||||
if !ok {
|
||||
s.queue.Delete(id)
|
||||
return true
|
||||
}
|
||||
defer fh.Close()
|
||||
|
||||
if qe, ok := value.([]QueueEvent); ok {
|
||||
for _, msg := range qe {
|
||||
if err := s.Append(fh, msg.Message, msg.TimeStamp); err != nil {
|
||||
log.Printf("failed to append event to log file: %v", err)
|
||||
}
|
||||
lines++
|
||||
}
|
||||
if err := s.writeEvents(id, qe); err != nil {
|
||||
log.Printf("failed to append events for grain %d: %v", id, err)
|
||||
return true
|
||||
}
|
||||
lines += len(qe)
|
||||
carts++
|
||||
s.queue.Delete(id)
|
||||
return true
|
||||
@@ -88,6 +310,43 @@ func (s *DiskStorage[V]) logPath(id uint64) string {
|
||||
return filepath.Join(s.path, fmt.Sprintf("%d.events.log", id))
|
||||
}
|
||||
|
||||
// loadReplayMetrics times the replay, records duration, and tracks
|
||||
// handler errors. It is shared by LoadEvents and LoadEventsFunc. The
|
||||
// returned `onMessage` closure bumps EventLogMutationErrors on any
|
||||
// non-nil error from registry.Apply, and the deferred function bumps
|
||||
// EventLogReplayTotal / Failures / Duration on the overall return.
|
||||
func (s *DiskStorage[V]) loadReplayMetrics() (onMessage func(err error), done func(err error)) {
|
||||
m := s.StateStorage.metrics
|
||||
if m == nil {
|
||||
noop := func(error) {}
|
||||
return noop, noop
|
||||
}
|
||||
start := time.Now()
|
||||
onMessage = func(err error) {
|
||||
if err != nil {
|
||||
m.EventLogMutationErrors.Inc()
|
||||
}
|
||||
}
|
||||
done = func(err error) {
|
||||
if err != nil {
|
||||
m.EventLogReplayFailures.Inc()
|
||||
return
|
||||
}
|
||||
m.EventLogReplayTotal.Inc()
|
||||
m.EventLogReplayDuration.Observe(time.Since(start).Seconds())
|
||||
}
|
||||
return onMessage, done
|
||||
}
|
||||
|
||||
// recordReplayFailure bumps EventLogReplayFailures when the open
|
||||
// step fails before the Load loop runs (and therefore before the
|
||||
// loadReplayMetrics closure is in scope). Nil-safe.
|
||||
func (s *DiskStorage[V]) recordReplayFailure() {
|
||||
if m := s.StateStorage.metrics; m != nil {
|
||||
m.EventLogReplayFailures.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Grain[V], condition func(msg proto.Message, index int, timeStamp time.Time) bool) error {
|
||||
path := s.logPath(id)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
@@ -97,16 +356,25 @@ func (s *DiskStorage[V]) LoadEventsFunc(ctx context.Context, id uint64, grain Gr
|
||||
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
s.recordReplayFailure()
|
||||
return fmt.Errorf("open replay file: %w", err)
|
||||
}
|
||||
defer fh.Close()
|
||||
trackApply, done := s.loadReplayMetrics()
|
||||
index := 0
|
||||
return s.Load(fh, func(msg proto.Message, when time.Time) {
|
||||
loadErr := s.Load(fh, func(msg proto.Message, when time.Time) {
|
||||
if condition(msg, index, when) {
|
||||
s.registry.Apply(ctx, grain, msg)
|
||||
results, _ := s.registry.Apply(ctx, grain, msg)
|
||||
for _, r := range results {
|
||||
if r.Error != nil {
|
||||
trackApply(r.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
index++
|
||||
})
|
||||
done(loadErr)
|
||||
return loadErr
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[V]) error {
|
||||
@@ -118,18 +386,28 @@ func (s *DiskStorage[V]) LoadEvents(ctx context.Context, id uint64, grain Grain[
|
||||
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
s.recordReplayFailure()
|
||||
return fmt.Errorf("open replay file: %w", err)
|
||||
}
|
||||
defer fh.Close()
|
||||
return s.Load(fh, func(msg proto.Message, _ time.Time) {
|
||||
s.registry.Apply(ctx, grain, msg)
|
||||
trackApply, done := s.loadReplayMetrics()
|
||||
loadErr := s.Load(fh, func(msg proto.Message, _ time.Time) {
|
||||
results, _ := s.registry.Apply(ctx, grain, msg)
|
||||
for _, r := range results {
|
||||
if r.Error != nil {
|
||||
trackApply(r.Error)
|
||||
}
|
||||
}
|
||||
})
|
||||
done(loadErr)
|
||||
return loadErr
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) Close() {
|
||||
if s.queue != nil {
|
||||
s.save()
|
||||
}
|
||||
s.closeWriters()
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
@@ -145,25 +423,74 @@ func (s *DiskStorage[V]) AppendMutations(id uint64, msg ...proto.Message) error
|
||||
}
|
||||
s.queue.Store(id, queue)
|
||||
return nil
|
||||
} else {
|
||||
path := s.logPath(id)
|
||||
// Ensure the parent directory exists (e.g. first write to a new PVC mount).
|
||||
// MkdirAll is a no-op when the directory already exists, so it's safe to
|
||||
// call on every AppendMutations without extra stat overhead.
|
||||
if err := os.MkdirAll(s.path, 0755); err != nil {
|
||||
log.Printf("failed to create event log directory %s: %v", s.path, err)
|
||||
return err
|
||||
}
|
||||
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Printf("failed to open event log file: %v", err)
|
||||
return err
|
||||
}
|
||||
defer fh.Close()
|
||||
for _, m := range msg {
|
||||
err = s.Append(fh, m, time.Now())
|
||||
}
|
||||
return err
|
||||
return s.writeMutations(id, msg...)
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) openWriterCount() int {
|
||||
s.writersMu.Lock()
|
||||
defer s.writersMu.Unlock()
|
||||
return len(s.writers)
|
||||
}
|
||||
|
||||
func (s *DiskStorage[V]) PurgeOlderThan(ctx context.Context, retention time.Duration, isGrainActive func(id uint64) bool) (int, error) {
|
||||
if err := s.ensureDir(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(s.path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read storage dir: %w", err)
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-retention)
|
||||
purgedCount := 0
|
||||
|
||||
for _, entry := range files {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, ".events.log") {
|
||||
continue
|
||||
}
|
||||
|
||||
var id uint64
|
||||
_, err := fmt.Sscanf(name, "%d.events.log", &id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
log.Printf("failed to get file info for %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if info.ModTime().Before(cutoff) {
|
||||
// Check if grain is active in memory
|
||||
if isGrainActive != nil && isGrainActive(id) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we have an active writer open
|
||||
s.writersMu.Lock()
|
||||
_, hasWriter := s.writers[id]
|
||||
s.writersMu.Unlock()
|
||||
if hasWriter {
|
||||
continue
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
fullPath := filepath.Join(s.path, name)
|
||||
if err := os.Remove(fullPath); err != nil {
|
||||
log.Printf("failed to delete expired cart log %s: %v", fullPath, err)
|
||||
} else {
|
||||
purgedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return purgedCount, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type diskTestGrain struct {
|
||||
testState
|
||||
id uint64
|
||||
accessed time.Time
|
||||
}
|
||||
|
||||
func (g *diskTestGrain) GetId() uint64 { return g.id }
|
||||
func (g *diskTestGrain) GetLastAccess() time.Time { return g.accessed }
|
||||
func (g *diskTestGrain) GetLastChange() time.Time { return g.accessed }
|
||||
func (g *diskTestGrain) GetCurrentState() (*testState, error) {
|
||||
return &g.testState, nil
|
||||
}
|
||||
|
||||
func diskTestRegistry(t *testing.T) MutationRegistry {
|
||||
t.Helper()
|
||||
reg := NewMutationRegistry()
|
||||
reg.RegisterMutations(NewMutation(
|
||||
func(_ *diskTestGrain, _ *cart_messages.AddItem) error { return nil },
|
||||
))
|
||||
return reg
|
||||
}
|
||||
|
||||
func TestDiskStorageAppendAndReplay(t *testing.T) {
|
||||
reg := diskTestRegistry(t)
|
||||
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||
t.Cleanup(storage.Close)
|
||||
|
||||
const id = uint64(99)
|
||||
events := []proto.Message{
|
||||
&cart_messages.AddItem{ItemId: 1, Quantity: 2},
|
||||
&cart_messages.AddItem{ItemId: 2, Quantity: 1},
|
||||
}
|
||||
for _, evt := range events {
|
||||
if err := storage.AppendMutations(id, evt); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||
if err := storage.LoadEvents(context.Background(), id, grain); err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskStorageReusesOpenWriter(t *testing.T) {
|
||||
reg := diskTestRegistry(t)
|
||||
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||
t.Cleanup(storage.Close)
|
||||
|
||||
const id = uint64(7)
|
||||
msg := &cart_messages.AddItem{ItemId: 1, Quantity: 1}
|
||||
if err := storage.AppendMutations(id, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := storage.openWriterCount(); got != 1 {
|
||||
t.Fatalf("open writers after first append = %d, want 1", got)
|
||||
}
|
||||
if err := storage.AppendMutations(id, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := storage.openWriterCount(); got != 1 {
|
||||
t.Fatalf("open writers after second append = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskStorageIdleWriterClosed(t *testing.T) {
|
||||
reg := diskTestRegistry(t)
|
||||
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||
storage.writerIdleTTL = 20 * time.Millisecond
|
||||
t.Cleanup(storage.Close)
|
||||
|
||||
if err := storage.AppendMutations(1, &cart_messages.AddItem{ItemId: 1, Quantity: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storage.openWriterCount() != 1 {
|
||||
t.Fatal("expected one open writer")
|
||||
}
|
||||
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
storage.purgeIdleWriters()
|
||||
if storage.openWriterCount() != 0 {
|
||||
t.Fatalf("expected idle writer to be closed, got %d open", storage.openWriterCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskStorageSaveLoopUsesWriterCache(t *testing.T) {
|
||||
reg := diskTestRegistry(t)
|
||||
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||
t.Cleanup(storage.Close)
|
||||
|
||||
go storage.SaveLoop(20 * time.Millisecond)
|
||||
|
||||
const id = uint64(55)
|
||||
if err := storage.AppendMutations(id, &cart_messages.AddItem{ItemId: 3, Quantity: 4}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deadline := time.After(500 * time.Millisecond)
|
||||
for {
|
||||
if storage.openWriterCount() >= 1 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for SaveLoop flush")
|
||||
default:
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||
if err := storage.LoadEvents(context.Background(), id, grain); err != nil {
|
||||
t.Fatalf("replay after SaveLoop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskStorageConcurrentAppendSameID(t *testing.T) {
|
||||
reg := diskTestRegistry(t)
|
||||
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||
t.Cleanup(storage.Close)
|
||||
|
||||
const id = uint64(88)
|
||||
const workers = 16
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for i := range workers {
|
||||
go func(itemID uint32) {
|
||||
defer wg.Done()
|
||||
_ = storage.AppendMutations(id, &cart_messages.AddItem{ItemId: itemID, Quantity: 1})
|
||||
}(uint32(i + 1))
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||
if err := storage.LoadEvents(context.Background(), id, grain); err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskStorageCloseFlushesSaveLoop(t *testing.T) {
|
||||
reg := diskTestRegistry(t)
|
||||
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||
|
||||
// Start the buffered SaveLoop but give it a long interval so it does NOT
|
||||
// flush during the test — only Close() should trigger the flush. A brief
|
||||
// sleep lets the goroutine schedule and set s.queue before we append.
|
||||
go storage.SaveLoop(10 * time.Minute)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
const id = uint64(101)
|
||||
if err := storage.AppendMutations(id, &cart_messages.AddItem{ItemId: 7, Quantity: 3}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Verify event is queued but NOT yet on disk (no writer should be open
|
||||
// because the buffered path only opens files inside SaveLoop → save()).
|
||||
if got := storage.openWriterCount(); got != 0 {
|
||||
t.Fatalf("expected 0 open writers before flush, got %d", got)
|
||||
}
|
||||
|
||||
// Close flushes the queue then closes writers.
|
||||
storage.Close()
|
||||
|
||||
// After Close(), the event should be persisted. Open a fresh storage and
|
||||
// replay to verify.
|
||||
fresh := NewDiskStorage[testState](storage.path, reg)
|
||||
t.Cleanup(fresh.Close)
|
||||
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||
if err := fresh.LoadEvents(context.Background(), id, grain); err != nil {
|
||||
t.Fatalf("replay after Close flush: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskStorageMultipleGrainsIndependent(t *testing.T) {
|
||||
reg := diskTestRegistry(t)
|
||||
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||
t.Cleanup(storage.Close)
|
||||
|
||||
ids := []uint64{10, 20, 30}
|
||||
for i, id := range ids {
|
||||
evt := &cart_messages.AddItem{ItemId: uint32(i + 1), Quantity: int32(i + 1)}
|
||||
if err := storage.AppendMutations(id, evt); err != nil {
|
||||
t.Fatalf("append id=%d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each grain has exactly one writer open (shared per id).
|
||||
if got := storage.openWriterCount(); got != len(ids) {
|
||||
t.Fatalf("open writers = %d, want %d", got, len(ids))
|
||||
}
|
||||
|
||||
// Replay each grain and verify the correct event was stored.
|
||||
for _, id := range ids {
|
||||
ctx := context.Background()
|
||||
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||
if err := storage.LoadEvents(ctx, id, grain); err != nil {
|
||||
t.Fatalf("replay id=%d: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskStorageLoadEventsNoFile(t *testing.T) {
|
||||
reg := diskTestRegistry(t)
|
||||
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||
t.Cleanup(storage.Close)
|
||||
|
||||
// No AppendMutations — no file should exist.
|
||||
grain := &diskTestGrain{id: 999, accessed: time.Now()}
|
||||
if err := storage.LoadEvents(context.Background(), 999, grain); err != nil {
|
||||
t.Fatalf("LoadEvents on non-existent file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskStorageWriteAfterIdlePurge(t *testing.T) {
|
||||
reg := diskTestRegistry(t)
|
||||
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||
t.Cleanup(storage.Close)
|
||||
|
||||
// Set a very short idle TTL so the writer is evicted quickly.
|
||||
storage.writerIdleTTL = 10 * time.Millisecond
|
||||
|
||||
const id = uint64(202)
|
||||
msg := &cart_messages.AddItem{ItemId: 1, Quantity: 1}
|
||||
|
||||
// First write opens a writer.
|
||||
if err := storage.AppendMutations(id, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := storage.openWriterCount(); got != 1 {
|
||||
t.Fatalf("open writers after first write = %d, want 1", got)
|
||||
}
|
||||
|
||||
// Wait for idle TTL to expire and purge.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
storage.purgeIdleWriters()
|
||||
|
||||
if got := storage.openWriterCount(); got != 0 {
|
||||
t.Fatalf("open writers after purge = %d, want 0", got)
|
||||
}
|
||||
|
||||
// Second write must create a new writer through the retry loop in getWriter.
|
||||
if err := storage.AppendMutations(id, msg); err != nil {
|
||||
t.Fatalf("write after purge: %v", err)
|
||||
}
|
||||
if got := storage.openWriterCount(); got != 1 {
|
||||
t.Fatalf("open writers after write-before-close = %d, want 1", got)
|
||||
}
|
||||
|
||||
// Replay must see both events.
|
||||
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||
if err := storage.LoadEvents(context.Background(), id, grain); err != nil {
|
||||
t.Fatalf("replay after purge: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskStorageCloseWithoutSaveLoop(t *testing.T) {
|
||||
reg := diskTestRegistry(t)
|
||||
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||
|
||||
// Verify Close() on a storage that never started SaveLoop works cleanly.
|
||||
const id = uint64(303)
|
||||
if err := storage.AppendMutations(id, &cart_messages.AddItem{ItemId: 1, Quantity: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Close() — queue is nil so it skips save(), goes straight to closeWriters().
|
||||
storage.Close()
|
||||
|
||||
// A fresh storage should be able to replay the persisted event.
|
||||
fresh := NewDiskStorage[testState](storage.path, reg)
|
||||
t.Cleanup(fresh.Close)
|
||||
grain := &diskTestGrain{id: id, accessed: time.Now()}
|
||||
if err := fresh.LoadEvents(context.Background(), id, grain); err != nil {
|
||||
t.Fatalf("replay after close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiskStorageConcurrentWritesDifferentIDs verifies that concurrent writes to
|
||||
// different grains don't interfere with each other, and all events are persisted.
|
||||
func TestDiskStorageConcurrentWritesDifferentIDs(t *testing.T) {
|
||||
reg := diskTestRegistry(t)
|
||||
storage := NewDiskStorage[testState](t.TempDir(), reg)
|
||||
t.Cleanup(storage.Close)
|
||||
|
||||
const grainCount = 32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(grainCount)
|
||||
for id := range uint64(grainCount) {
|
||||
go func(grainID uint64) {
|
||||
defer wg.Done()
|
||||
for i := range 5 {
|
||||
_ = storage.AppendMutations(grainID, &cart_messages.AddItem{ItemId: uint32(i + 1), Quantity: 1})
|
||||
}
|
||||
}(id + 1)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Replay each grain and verify.
|
||||
for id := range uint64(grainCount) {
|
||||
grainID := id + 1
|
||||
ctx := context.Background()
|
||||
grain := &diskTestGrain{id: grainID, accessed: time.Now()}
|
||||
if err := storage.LoadEvents(ctx, grainID, grain); err != nil {
|
||||
t.Fatalf("replay grain %d: %v", grainID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskStoragePurge(t *testing.T) {
|
||||
reg := diskTestRegistry(t)
|
||||
dir := t.TempDir()
|
||||
storage := NewDiskStorage[testState](dir, reg)
|
||||
t.Cleanup(storage.Close)
|
||||
|
||||
ctx := context.Background()
|
||||
msg := &cart_messages.AddItem{ItemId: 1, Quantity: 1}
|
||||
|
||||
// Write mutations to create event logs for 1, 2, 3, 4
|
||||
ids := []uint64{1, 2, 3, 4}
|
||||
for _, id := range ids {
|
||||
if err := storage.AppendMutations(id, msg); err != nil {
|
||||
t.Fatalf("append %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Force save to disk
|
||||
storage.save()
|
||||
|
||||
// 1. Grain 1: Old modification time, inactive, no writer -> should be purged
|
||||
path1 := storage.logPath(1)
|
||||
oldTime := time.Now().Add(-10 * 24 * time.Hour)
|
||||
if err := os.Chtimes(path1, oldTime, oldTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// We must close the writer for grain 1, 2, 4 to make them eligible for purging (no open writer)
|
||||
storage.writersMu.Lock()
|
||||
for _, id := range []uint64{1, 2, 4} {
|
||||
if w, ok := storage.writers[id]; ok {
|
||||
w.purged = true
|
||||
w.file.Close()
|
||||
delete(storage.writers, id)
|
||||
}
|
||||
}
|
||||
storage.writersMu.Unlock()
|
||||
|
||||
// 2. Grain 2: Old mod time, but marked active -> should NOT be purged
|
||||
path2 := storage.logPath(2)
|
||||
if err := os.Chtimes(path2, oldTime, oldTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 3. Grain 3: Old mod time, but has open writer -> should NOT be purged
|
||||
path3 := storage.logPath(3)
|
||||
if err := os.Chtimes(path3, oldTime, oldTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 4. Grain 4: Recent mod time -> should NOT be purged
|
||||
path4 := storage.logPath(4)
|
||||
|
||||
activeGrains := map[uint64]bool{2: true}
|
||||
isGrainActive := func(id uint64) bool {
|
||||
return activeGrains[id]
|
||||
}
|
||||
|
||||
purged, err := storage.PurgeOlderThan(ctx, 5*24*time.Hour, isGrainActive)
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeOlderThan failed: %v", err)
|
||||
}
|
||||
|
||||
if purged != 1 {
|
||||
t.Errorf("Expected exactly 1 purged file, got %d", purged)
|
||||
}
|
||||
|
||||
// Verify file status
|
||||
if _, err := os.Stat(path1); !os.IsNotExist(err) {
|
||||
t.Error("Expected grain 1 log to be deleted")
|
||||
}
|
||||
if _, err := os.Stat(path2); err != nil {
|
||||
t.Error("Expected grain 2 log to exist (active)")
|
||||
}
|
||||
if _, err := os.Stat(path3); err != nil {
|
||||
t.Error("Expected grain 3 log to exist (active writer)")
|
||||
}
|
||||
if _, err := os.Stat(path4); err != nil {
|
||||
t.Error("Expected grain 4 log to exist (recent)")
|
||||
}
|
||||
}
|
||||
|
||||
+24
-4
@@ -19,8 +19,13 @@ type GrainPool[V any] interface {
|
||||
OwnerHost(id uint64) (Host[V], bool)
|
||||
Hostname() string
|
||||
TakeOwnership(id uint64)
|
||||
HandleOwnershipChange(host string, ids []uint64) error
|
||||
HandleRemoteExpiry(host string, ids []uint64) error
|
||||
// HandleOwnershipChange processes a remote first-touch ownership
|
||||
// claim. lastChanges is the parallel []int64 of UnixNano stamps from
|
||||
// the announcer; -1 entries fall back to the pre-arbitration
|
||||
// "always accept remote" behaviour so mixed-version rollouts
|
||||
// remain safe.
|
||||
HandleOwnershipChange(host string, ids []uint64, lastChanges []int64) error
|
||||
HandleRemoteExpiry(host string, ids []uint64, lastChanges []int64) error
|
||||
Negotiate(otherHosts []string)
|
||||
GetLocalIds() []uint64
|
||||
IsHealthy() bool
|
||||
@@ -32,7 +37,13 @@ type GrainPool[V any] interface {
|
||||
|
||||
// Host abstracts a remote node capable of proxying cart requests.
|
||||
type Host[V any] interface {
|
||||
AnnounceExpiry(ids []uint64)
|
||||
// AnnounceExpiry broadcasts per-id eviction decisions. lastChanges is
|
||||
// a parallel []int64 of UnixNano stamps taken at the moment the
|
||||
// grain was dropped from the local cache; it is informational on
|
||||
// this path (the receiver just removes the id from its remoteOwners
|
||||
// map) but kept in the wire signature for symmetry with
|
||||
// AnnounceOwnership.
|
||||
AnnounceExpiry(ids []uint64, lastChanges []int64)
|
||||
Negotiate(otherHosts []string) ([]string, error)
|
||||
Name() string
|
||||
Proxy(id uint64, w http.ResponseWriter, r *http.Request, customBody io.Reader) (bool, error)
|
||||
@@ -42,5 +53,14 @@ type Host[V any] interface {
|
||||
Close() error
|
||||
Ping() bool
|
||||
IsHealthy() bool
|
||||
AnnounceOwnership(ownerHost string, ids []uint64)
|
||||
// AnnounceOwnership broadcasts a first-touch ownership claim.
|
||||
// lastChanges is a parallel []int64 of UnixNano stamps of the
|
||||
// announcing pod's local grain's GetLastChange() at the moment of
|
||||
// broadcast; receivers use it as the first-spawn-wins oracle for
|
||||
// concurrent cold-cache first-touch (smaller stamp = older spawn =
|
||||
// owns the grain; on equal stamps the lexicographically smaller
|
||||
// hostname wins). A stamp of -1 means "no local grain to back this
|
||||
// id" — receivers treat that as legacy/no-arbitration and accept
|
||||
// the remote claim as before.
|
||||
AnnounceOwnership(ownerHost string, ids []uint64, lastChanges []int64)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/control"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
@@ -31,7 +30,6 @@ const name = "grpc_server"
|
||||
var (
|
||||
tracer = otel.Tracer(name)
|
||||
meter = otel.Meter(name)
|
||||
logger = otelslog.NewLogger(name)
|
||||
pingCalls metric.Int64Counter
|
||||
negotiateCalls metric.Int64Counter
|
||||
getLocalActorIdsCalls metric.Int64Counter
|
||||
@@ -88,10 +86,9 @@ func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.
|
||||
attribute.String("host", req.Host),
|
||||
attribute.Int("id_count", len(req.Ids)),
|
||||
)
|
||||
logger.InfoContext(ctx, "announce ownership", "host", req.Host, "id_count", len(req.Ids))
|
||||
announceOwnershipCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||
|
||||
err := s.pool.HandleOwnershipChange(req.Host, req.Ids)
|
||||
err := s.pool.HandleOwnershipChange(req.Host, req.Ids, req.LastChanges)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return &messages.OwnerChangeAck{
|
||||
@@ -139,10 +136,9 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
|
||||
attribute.String("host", req.Host),
|
||||
attribute.Int("id_count", len(req.Ids)),
|
||||
)
|
||||
logger.InfoContext(ctx, "announce expiry", "host", req.Host, "id_count", len(req.Ids))
|
||||
announceExpiryCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", req.Host)))
|
||||
|
||||
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids)
|
||||
err := s.pool.HandleRemoteExpiry(req.Host, req.Ids, req.LastChanges)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
@@ -215,7 +211,6 @@ func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.Negotiat
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.Int("known_hosts_count", len(req.KnownHosts)),
|
||||
)
|
||||
logger.InfoContext(ctx, "negotiate", "known_hosts_count", len(req.KnownHosts))
|
||||
negotiateCalls.Add(ctx, 1)
|
||||
|
||||
s.pool.Negotiate(req.KnownHosts)
|
||||
@@ -231,7 +226,6 @@ func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, req *messages.E
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.Int("id_count", len(ids)),
|
||||
)
|
||||
logger.InfoContext(ctx, "get local actor ids", "id_count", len(ids))
|
||||
getLocalActorIdsCalls.Add(ctx, 1)
|
||||
|
||||
return &messages.ActorIdsReply{Ids: ids}, nil
|
||||
@@ -246,7 +240,6 @@ func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNot
|
||||
attribute.String("component", "controlplane"),
|
||||
attribute.String("host", host),
|
||||
)
|
||||
logger.InfoContext(ctx, "closing notice", "host", host)
|
||||
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
||||
|
||||
if host != "" {
|
||||
@@ -291,9 +284,14 @@ func NewControlServer[V any](config ServerConfig, pool GrainPool[V]) (*grpc.Serv
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
log.Printf("gRPC server listening as %s on %s", pool.Hostname(), config.Addr)
|
||||
// Serve runs in a goroutine because it blocks until the listener is closed.
|
||||
// On Serve error we log it instead of crashing the whole process from deep
|
||||
// inside this package — the caller (composition root) should rely on
|
||||
// process supervision (Kubernetes) to restart, or check gRPC health via the
|
||||
// pool after this returns.
|
||||
go func() {
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
log.Fatalf("failed to serve gRPC: %v", err)
|
||||
log.Printf("gRPC server %s serve error: %v", config.Addr, err)
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ func (m *mockGrainPool) TakeOwnership(id uint64) {}
|
||||
|
||||
func (m *mockGrainPool) Hostname() string { return "test-host" }
|
||||
|
||||
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64) error { return nil }
|
||||
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64) error { return nil }
|
||||
func (m *mockGrainPool) HandleOwnershipChange(host string, ids []uint64, _ []int64) error { return nil }
|
||||
func (m *mockGrainPool) HandleRemoteExpiry(host string, ids []uint64, _ []int64) error { return nil }
|
||||
func (m *mockGrainPool) Negotiate(hosts []string) {}
|
||||
func (m *mockGrainPool) GetLocalIds() []uint64 { return []uint64{} }
|
||||
func (m *mockGrainPool) RemoveHost(host string) {}
|
||||
|
||||
+55
-14
@@ -1,47 +1,88 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||
"git.k6n.net/mats/platform/event"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
)
|
||||
|
||||
// MutationExchange is the shared topic exchange carrying grain mutation streams.
|
||||
// Routing key is "mutation.<grain>" (e.g. mutation.cart), so a consumer binds
|
||||
// "mutation.#" for every grain type or "mutation.cart" for one. This is a
|
||||
// debug/observability feed (live cart/checkout/profile activity), NOT a
|
||||
// domain-event source — domain events use platform/event's vocabulary.
|
||||
const MutationExchange = "mutations"
|
||||
|
||||
// MutationType is the event.Type for a given grain's mutation stream, e.g.
|
||||
// MutationType("cart") == "mutation.cart".
|
||||
func MutationType(grain string) event.Type { return event.Type("mutation." + grain) }
|
||||
|
||||
type LogListener interface {
|
||||
AppendMutations(id uint64, msg ...ApplyResult)
|
||||
}
|
||||
|
||||
// MutationSummary is the default feed transform: it reports the applied mutation
|
||||
// type names. The grain id and name travel in the Event's Subject/Source, so the
|
||||
// payload stays small and uniform across grains.
|
||||
func MutationSummary(_ uint64, results []ApplyResult) (any, error) {
|
||||
types := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
types = append(types, r.Type)
|
||||
}
|
||||
return map[string]any{"mutations": types}, nil
|
||||
}
|
||||
|
||||
// AmqpListener streams a grain pool's applied mutations to the MutationExchange
|
||||
// as platform/event.Event envelopes. grain names the aggregate ("cart",
|
||||
// "checkout", "profile", "order") and forms the routing key. transform shapes the
|
||||
// per-mutation payload (e.g. {cartId, mutations, ...}).
|
||||
type AmqpListener struct {
|
||||
conn *amqp.Connection
|
||||
transformer func(id uint64, msg []ApplyResult) (any, error)
|
||||
grain string
|
||||
typ event.Type
|
||||
transform func(id uint64, msg []ApplyResult) (any, error)
|
||||
}
|
||||
|
||||
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
||||
return &AmqpListener{
|
||||
conn: conn,
|
||||
transformer: transformer,
|
||||
}
|
||||
func NewAmqpListener(conn *amqp.Connection, grain string, transform func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
||||
return &AmqpListener{conn: conn, grain: grain, typ: MutationType(grain), transform: transform}
|
||||
}
|
||||
|
||||
// DefineTopics declares the mutations topic exchange so publishes succeed even
|
||||
// before any consumer binds. Call once at startup.
|
||||
func (l *AmqpListener) DefineTopics() {
|
||||
ch, err := l.conn.Channel()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to open a channel: %v", err)
|
||||
log.Printf("mutation feed (%s): open channel: %v", l.grain, err)
|
||||
return
|
||||
}
|
||||
defer ch.Close()
|
||||
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
||||
log.Fatalf("Failed to declare topic mutation: %v", err)
|
||||
if err := ch.ExchangeDeclare(MutationExchange, "topic", true, false, false, false, nil); err != nil {
|
||||
log.Printf("mutation feed (%s): declare exchange: %v", l.grain, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
||||
data, err := l.transformer(id, msg)
|
||||
payload, err := l.transform(id, msg)
|
||||
if err != nil {
|
||||
log.Printf("Failed to transform mutation event: %v", err)
|
||||
log.Printf("mutation feed (%s): transform: %v", l.grain, err)
|
||||
return
|
||||
}
|
||||
err = messaging.SendChange(l.conn, "cart", "mutation", data)
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send mutation event: %v", err)
|
||||
log.Printf("mutation feed (%s): marshal: %v", l.grain, err)
|
||||
return
|
||||
}
|
||||
ev := event.Event{
|
||||
Type: l.typ,
|
||||
Source: l.grain,
|
||||
Subject: strconv.FormatUint(id, 10),
|
||||
Payload: body,
|
||||
}
|
||||
if err := rabbit.PublishEvent(l.conn, MutationExchange, ev); err != nil {
|
||||
log.Printf("mutation feed (%s): publish: %v", l.grain, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
// Metrics bundles every prometheus metric the actor package emits. It is
|
||||
// constructed per service (cart / checkout / profile / order) with a
|
||||
// service-specific prefix so the metric names do not collide when more
|
||||
// than one actor-pool service is scraped into the same Prometheus.
|
||||
//
|
||||
// All touch sites are nil-safe: the pool / storage / registry code wraps
|
||||
// each counter / histogram call in a nil-check on the *Metrics pointer,
|
||||
// so the existing tests (which construct a pool or storage without
|
||||
// metrics) keep working without a forced dependency on this struct.
|
||||
//
|
||||
// Metric names match the panels in grafana_dashboard_cart.json exactly.
|
||||
// The dashboard had `connected_remotes` (no prefix) as a typo; the
|
||||
// source-of-truth metric exported here is `<prefix>_connected_remotes`
|
||||
// and the dashboard JSON is updated in the same change to match.
|
||||
type Metrics struct {
|
||||
// Pool — set by SimpleGrainPool.
|
||||
ActiveGrains prometheus.Gauge
|
||||
GrainsInPool prometheus.Gauge
|
||||
PoolUsage prometheus.Gauge
|
||||
ConnectedRemotes prometheus.Gauge
|
||||
GrainSpawnedTotal prometheus.Counter
|
||||
GrainLookupsTotal prometheus.Counter
|
||||
MutationsTotal prometheus.Counter
|
||||
MutationFailuresTotal prometheus.Counter
|
||||
MutationLatency prometheus.Histogram
|
||||
RemoteNegotiationTotal prometheus.Counter
|
||||
|
||||
// Event log — set by DiskStorage and StateStorage.
|
||||
EventLogAppends prometheus.Counter
|
||||
EventLogBytesWritten prometheus.Counter
|
||||
EventLogFilesExisting prometheus.Gauge
|
||||
EventLogLastAppendUnix prometheus.Gauge
|
||||
EventLogReplayTotal prometheus.Counter
|
||||
EventLogReplayFailures prometheus.Counter
|
||||
EventLogReplayDuration prometheus.Histogram
|
||||
EventLogUnknownTypes prometheus.Counter
|
||||
EventLogMutationErrors prometheus.Counter
|
||||
}
|
||||
|
||||
// NewMetrics registers every actor metric in the default Prometheus
|
||||
// registry (the one promhttp.Handler() exposes on /metrics) with the
|
||||
// service-specific prefix. The prefix is the same string the binary uses
|
||||
// as its service name (e.g. "cart", "checkout", "profile", "order"),
|
||||
// which keeps the metric names aligned with the per-service labels in
|
||||
// grafana_dashboard_cart.json.
|
||||
func NewMetrics(prefix string) *Metrics {
|
||||
return &Metrics{
|
||||
ActiveGrains: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: prefix + "_active_grains",
|
||||
Help: "Number of grains currently held in the local pool.",
|
||||
}),
|
||||
GrainsInPool: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: prefix + "_grains_in_pool",
|
||||
Help: "Alias of active_grains: the dashboard shows both as a sanity check.",
|
||||
}),
|
||||
PoolUsage: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: prefix + "_grain_pool_usage",
|
||||
Help: "Local pool fill ratio: len(grains) / poolSize (range 0..1).",
|
||||
}),
|
||||
ConnectedRemotes: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: prefix + "_connected_remotes",
|
||||
Help: "Number of remote actor-pool hosts currently connected via gRPC.",
|
||||
}),
|
||||
GrainSpawnedTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: prefix + "_grain_spawned_total",
|
||||
Help: "Total number of grains spawned (loaded from disk or freshly created).",
|
||||
}),
|
||||
GrainLookupsTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: prefix + "_grain_lookups_total",
|
||||
Help: "Total number of pool.Get calls (state reads).",
|
||||
}),
|
||||
MutationsTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: prefix + "_mutations_total",
|
||||
Help: "Total number of mutations applied across all grains (one per proto message, not per call).",
|
||||
}),
|
||||
MutationFailuresTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: prefix + "_mutation_failures_total",
|
||||
Help: "Total number of pool.Apply calls that returned an error.",
|
||||
}),
|
||||
// Latency buckets cover the typical pool.Apply (handler + registry
|
||||
// apply + storage enqueue): sub-ms in the hot path, hundreds of ms
|
||||
// under load. Tweak if a hot mutation type moves outside this range.
|
||||
MutationLatency: promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: prefix + "_mutation_latency_seconds",
|
||||
Help: "Wall-clock duration of a pool.Apply call (lock + spawn + handler + storage enqueue).",
|
||||
Buckets: prometheus.ExponentialBuckets(0.0005, 2, 14), // 0.5ms ... ~8s
|
||||
}),
|
||||
RemoteNegotiationTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: prefix + "_remote_negotiation_total",
|
||||
Help: "Total number of cluster-negotiation rounds initiated by this pod.",
|
||||
}),
|
||||
|
||||
EventLogAppends: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: prefix + "_event_log_appends_total",
|
||||
Help: "Total number of event-log line appends (one per persisted mutation message).",
|
||||
}),
|
||||
EventLogBytesWritten: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: prefix + "_event_log_bytes_written_total",
|
||||
Help: "Total bytes written to per-grain .events.log files.",
|
||||
}),
|
||||
EventLogFilesExisting: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: prefix + "_event_log_files_existing",
|
||||
Help: "Number of per-grain .events.log files currently on disk in the storage directory.",
|
||||
}),
|
||||
EventLogLastAppendUnix: promauto.NewGauge(prometheus.GaugeOpts{
|
||||
Name: prefix + "_event_log_last_append_unix",
|
||||
Help: "Unix timestamp of the last successful event-log append.",
|
||||
}),
|
||||
EventLogReplayTotal: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: prefix + "_event_log_replay_total",
|
||||
Help: "Total number of successful event-log replays (one per grain loaded from disk).",
|
||||
}),
|
||||
EventLogReplayFailures: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: prefix + "_event_log_replay_failures_total",
|
||||
Help: "Total number of failed event-log replays (open error, JSON parse error, or handler error).",
|
||||
}),
|
||||
EventLogReplayDuration: promauto.NewHistogram(prometheus.HistogramOpts{
|
||||
Name: prefix + "_event_log_replay_duration_seconds",
|
||||
Help: "Wall-clock time to replay a single grain's event log from disk.",
|
||||
Buckets: prometheus.ExponentialBuckets(0.001, 2, 14), // 1ms ... ~16s
|
||||
}),
|
||||
EventLogUnknownTypes: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: prefix + "_event_log_unknown_types_total",
|
||||
Help: "Total number of event-log entries with a type the registry does not recognise.",
|
||||
}),
|
||||
EventLogMutationErrors: promauto.NewCounter(prometheus.CounterOpts{
|
||||
Name: prefix + "_event_log_mutation_errors_total",
|
||||
Help: "Total number of mutation-handler errors observed during event-log replay.",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// MutationStarted returns a closure to be deferred at the top of
|
||||
// pool.Apply so the latency observation / counters fire exactly once
|
||||
// per call regardless of which return path is taken. mutationCount
|
||||
// is the number of proto messages in the Apply call (a single call
|
||||
// can batch several messages — SetCartItems, for example, sends
|
||||
// ClearCart + N AddItem together); the counter is incremented by
|
||||
// that count so the dashboard's `rate(cart_mutations_total[1m])`
|
||||
// reports "messages per second" rather than "calls per second",
|
||||
// matching the original `grainMutations.Add(len(data.Mutations))`
|
||||
// semantics. The returned closure is nil-safe — pass a nil *Metrics
|
||||
// in and the call is a no-op.
|
||||
func (m *Metrics) MutationStarted(mutationCount int) func(err error) {
|
||||
if m == nil {
|
||||
return func(error) {}
|
||||
}
|
||||
start := time.Now()
|
||||
return func(err error) {
|
||||
if mutationCount > 0 {
|
||||
m.MutationsTotal.Add(float64(mutationCount))
|
||||
}
|
||||
if err != nil {
|
||||
m.MutationFailuresTotal.Inc()
|
||||
}
|
||||
m.MutationLatency.Observe(time.Since(start).Seconds())
|
||||
}
|
||||
}
|
||||
|
||||
// SetPoolStats updates the three pool-level gauges (grains_in_pool,
|
||||
// active_grains, pool_usage) in one call. Safe to invoke under the
|
||||
// pool's localMu — it does not take any lock itself, only prometheus
|
||||
// atomic counters.
|
||||
func (m *Metrics) SetPoolStats(grains, poolSize int) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.GrainsInPool.Set(float64(grains))
|
||||
m.ActiveGrains.Set(float64(grains))
|
||||
if poolSize > 0 {
|
||||
m.PoolUsage.Set(float64(grains) / float64(poolSize))
|
||||
} else {
|
||||
m.PoolUsage.Set(0)
|
||||
}
|
||||
}
|
||||
|
||||
// IncSpawn bumps the grain-spawned counter. Nil-safe.
|
||||
func (m *Metrics) IncSpawn() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.GrainSpawnedTotal.Inc()
|
||||
}
|
||||
|
||||
// IncLookup bumps the grain-lookups counter. Nil-safe.
|
||||
func (m *Metrics) IncLookup() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.GrainLookupsTotal.Inc()
|
||||
}
|
||||
|
||||
// IncNegotiation bumps the remote-negotiation counter. Nil-safe.
|
||||
func (m *Metrics) IncNegotiation() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.RemoteNegotiationTotal.Inc()
|
||||
}
|
||||
|
||||
// SetConnectedRemotes updates the connected-remotes gauge. Nil-safe.
|
||||
func (m *Metrics) SetConnectedRemotes(n int) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.ConnectedRemotes.Set(float64(n))
|
||||
}
|
||||
@@ -99,6 +99,21 @@ type RegisteredMutation[V any, T proto.Message] struct {
|
||||
msgType reflect.Type
|
||||
}
|
||||
|
||||
// NewMutation registers a typed mutation handler for state V and message T.
|
||||
//
|
||||
// T MUST be a pointer to a generated proto message (e.g. *cart.AddLineRequest).
|
||||
// Passing a non-pointer (e.g. cart.AddLineRequest) is a developer error caught
|
||||
// at registration time — the type system on its own cannot tell a proto
|
||||
// message from a struct, so we surface the violation here. The convention is
|
||||
// the same as the broader Go MustX pattern (e.g. regexp.MustCompile): failures
|
||||
// here mean the caller is misusing the API at startup, so we panic rather than
|
||||
// silently storing an unusable handler.
|
||||
//
|
||||
// This is INTENTIONALLY not converted to a returned error: there is no
|
||||
// composition-root decision to make — the program is wrong and must not boot.
|
||||
// The composer-side registration code (see e.g. cart.NewCartMultationRegistry)
|
||||
// runs in package init / function init contexts where returning an error is
|
||||
// not possible.
|
||||
func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredMutation[V, T] {
|
||||
// Derive the name and message type from a concrete instance produced by create().
|
||||
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
|
||||
@@ -109,8 +124,7 @@ func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredM
|
||||
if rt != nil && rt.Kind() == reflect.Pointer {
|
||||
return reflect.New(rt.Elem()).Interface().(proto.Message)
|
||||
}
|
||||
log.Fatalf("expected to create proto message got %+v", rt)
|
||||
return nil
|
||||
panic(fmt.Sprintf("NewMutation: T must be a pointer to a proto message, got %v", rt))
|
||||
}
|
||||
instance := create()
|
||||
rt := reflect.TypeOf(instance)
|
||||
|
||||
+162
-19
@@ -28,6 +28,12 @@ type SimpleGrainPool[V any] struct {
|
||||
// grain processes a single message at a time (the actor guarantee).
|
||||
grainLocks *keyedMutex
|
||||
|
||||
// metrics is the prometheus instrumentation wired in by NewMetrics
|
||||
// at composition time. Nil is fine — every touch site nil-checks
|
||||
// first, so the existing tests (which construct a pool without
|
||||
// metrics) keep working.
|
||||
metrics *Metrics
|
||||
|
||||
// Cluster coordination --------------------------------------------------
|
||||
hostname string
|
||||
remoteMu sync.RWMutex
|
||||
@@ -39,6 +45,16 @@ type SimpleGrainPool[V any] struct {
|
||||
purgeTicker *time.Ticker
|
||||
}
|
||||
|
||||
// noLocalStamp is the sentinel value used in the parallel []int64
|
||||
// lastChanges slice to mark an id for which the broadcaster has no
|
||||
// local grain (e.g. TakeOwnership called before anyone has loaded the
|
||||
// grain, or a custom Host implementation that doesn't carry a stamp).
|
||||
// Receivers treat this sentinel as "no arbitration possible" and fall
|
||||
// back to the pre-arbitration "always accept remote" verdict so the
|
||||
// behaviour is identical to running an older binary that didn't carry
|
||||
// stamps at all.
|
||||
const noLocalStamp int64 = -1
|
||||
|
||||
type GrainPoolConfig[V any] struct {
|
||||
Hostname string
|
||||
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
||||
@@ -51,6 +67,13 @@ type GrainPoolConfig[V any] struct {
|
||||
PoolSize int
|
||||
MutationRegistry MutationRegistry
|
||||
Storage LogStorage[V]
|
||||
// Metrics is the prometheus instrumentation to register pool-level
|
||||
// counters, gauges, and histograms with. Nil-safe: when unset, all
|
||||
// touch sites are no-ops. Use actor.NewMetrics("cart") (or the
|
||||
// matching service prefix) to construct the per-service metrics
|
||||
// struct, and pair it with DiskStorage.SetMetrics so the same
|
||||
// Metrics instance covers both pool and event-log metrics.
|
||||
Metrics *Metrics
|
||||
}
|
||||
|
||||
func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V], error) {
|
||||
@@ -64,6 +87,7 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
|
||||
ttl: config.TTL,
|
||||
poolSize: config.PoolSize,
|
||||
hostname: config.Hostname,
|
||||
metrics: config.Metrics,
|
||||
remoteOwners: make(map[uint64]Host[V]),
|
||||
remoteHosts: make(map[string]Host[V]),
|
||||
grainLocks: newKeyedMutex(),
|
||||
@@ -76,6 +100,11 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
|
||||
}
|
||||
}()
|
||||
|
||||
// Publish the initial pool stats so the dashboard has a number to
|
||||
// draw on first scrape, rather than waiting for the first grain
|
||||
// spawn / eviction to bump the gauges.
|
||||
p.metrics.SetPoolStats(0, p.poolSize)
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -95,22 +124,31 @@ func (p *SimpleGrainPool[V]) RemoveListener(listener LogListener) {
|
||||
func (p *SimpleGrainPool[V]) purge() {
|
||||
purgeLimit := time.Now().Add(-p.ttl)
|
||||
purgedIds := make([]uint64, 0, len(p.grains))
|
||||
purgedStamps := make([]int64, 0, len(p.grains))
|
||||
p.localMu.Lock()
|
||||
evicted := 0
|
||||
for id, grain := range p.grains {
|
||||
if grain.GetLastAccess().Before(purgeLimit) {
|
||||
purgedIds = append(purgedIds, id)
|
||||
if p.destroy != nil {
|
||||
if err := p.destroy(grain); err != nil {
|
||||
log.Printf("failed to destroy grain %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the lastChange stamp BEFORE delete so peers
|
||||
// receive an honest per-id eviction record on the wire.
|
||||
purgedIds = append(purgedIds, id)
|
||||
purgedStamps = append(purgedStamps, grain.GetLastChange().UnixNano())
|
||||
delete(p.grains, id)
|
||||
evicted++
|
||||
}
|
||||
}
|
||||
remaining := len(p.grains)
|
||||
p.localMu.Unlock()
|
||||
if evicted > 0 {
|
||||
p.metrics.SetPoolStats(remaining, p.poolSize)
|
||||
}
|
||||
p.forAllHosts(func(remote Host[V]) {
|
||||
remote.AnnounceExpiry(purgedIds)
|
||||
remote.AnnounceExpiry(purgedIds, purgedStamps)
|
||||
})
|
||||
|
||||
}
|
||||
@@ -136,7 +174,14 @@ func (p *SimpleGrainPool[V]) GetLocalIds() []uint64 {
|
||||
return ids
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error {
|
||||
func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64, _ []int64) error {
|
||||
// lastChanges is informational on this path; expiry is unilateral
|
||||
// from the announcer's perspective — we just drop the id from any
|
||||
// remoteOwners mapping so future cross-pod forwards stop hitting a
|
||||
// dead peer. The parallel stamp slice is kept in the signature for
|
||||
// wire symmetry with AnnounceOwnership and so a future hardening
|
||||
// pass can use stamps to detect ghost-evictions (e.g. peer evicted
|
||||
// with a stamp newer than our last seen Apply result).
|
||||
p.remoteMu.Lock()
|
||||
defer p.remoteMu.Unlock()
|
||||
for _, id := range ids {
|
||||
@@ -145,7 +190,7 @@ func (p *SimpleGrainPool[V]) HandleRemoteExpiry(host string, ids []uint64) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) error {
|
||||
func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64, lastChanges []int64) error {
|
||||
p.remoteMu.RLock()
|
||||
remoteHost, exists := p.remoteHosts[host]
|
||||
p.remoteMu.RUnlock()
|
||||
@@ -156,14 +201,75 @@ func (p *SimpleGrainPool[V]) HandleOwnershipChange(host string, ids []uint64) er
|
||||
}
|
||||
remoteHost = createdHost
|
||||
}
|
||||
// Lock order: remoteMu before localMu. Unlock in reverse order so
|
||||
// the localMu gauge read below cannot deadlock against a concurrent
|
||||
// p.localMu holder.
|
||||
p.remoteMu.Lock()
|
||||
defer p.remoteMu.Unlock()
|
||||
p.localMu.Lock()
|
||||
defer p.localMu.Unlock()
|
||||
for _, id := range ids {
|
||||
log.Printf("Handling ownership change for cart %d to host %s", id, host)
|
||||
var accepted, kept, legacy int
|
||||
for i, id := range ids {
|
||||
// remoteStamp is the announcer's lastChange at broadcast time,
|
||||
// or noLocalStamp (-1) if the announcer didn't have a local
|
||||
// grain (legacy / TakeOwnership path). The
|
||||
// 0..len(lastChanges)-1 slice is indexed parallel to ids; out
|
||||
// of range falls back to noLocalStamp (= "no arbitration").
|
||||
var remoteStamp int64 = noLocalStamp
|
||||
if i < len(lastChanges) {
|
||||
remoteStamp = lastChanges[i]
|
||||
}
|
||||
var localStamp int64 = noLocalStamp
|
||||
var hasLocal bool
|
||||
if g, ok := p.grains[id]; ok {
|
||||
localStamp = g.GetLastChange().UnixNano()
|
||||
hasLocal = true
|
||||
}
|
||||
|
||||
// First-spawn-wins arbitration. Five distinct cases, ordered:
|
||||
// 1) remoteStamp == noLocalStamp → announcer didn't carry a
|
||||
// real stamp (legacy or TakeOwnership). Keep current
|
||||
// "always accept remote" behaviour so mixed-version
|
||||
// rollouts and unbroadcast-advertised TakeOwnership don't
|
||||
// regress.
|
||||
// 2) localStamp == noLocalStamp → we have no grain at all.
|
||||
// Accept remote so the next read forwards to the right
|
||||
// owner instead of us spawning a parallel projection.
|
||||
// 3) stamps differ → first-spawn-wins. The pod that spawned
|
||||
// the grain earlier (smaller stamp) owns it; its broadcast
|
||||
// is authoritative. The newer pod's projection is
|
||||
// redundant — we drop our local copy and defer to remote
|
||||
// so future OwnerHost lookups route correctly.
|
||||
// 4) stamps equal → deterministic tie-break: lexicographically
|
||||
// smaller hostname wins (both pods compute the same
|
||||
// verdict, so the cold-cache first-touch cannot flip-flop).
|
||||
var keepLocal bool
|
||||
switch {
|
||||
case remoteStamp == noLocalStamp:
|
||||
keepLocal = false
|
||||
if hasLocal {
|
||||
legacy++
|
||||
}
|
||||
case localStamp == noLocalStamp:
|
||||
keepLocal = false
|
||||
case localStamp != remoteStamp:
|
||||
keepLocal = localStamp < remoteStamp
|
||||
default:
|
||||
keepLocal = p.hostname < host
|
||||
}
|
||||
if keepLocal {
|
||||
kept++
|
||||
continue
|
||||
}
|
||||
delete(p.grains, id)
|
||||
p.remoteOwners[id] = remoteHost
|
||||
accepted++
|
||||
}
|
||||
remaining := len(p.grains)
|
||||
p.localMu.Unlock()
|
||||
p.remoteMu.Unlock()
|
||||
p.metrics.SetPoolStats(remaining, p.poolSize)
|
||||
if kept > 0 || accepted > 0 || legacy > 0 {
|
||||
log.Printf("HandleOwnershipChange from %s: accepted_remote=%d kept_local=%d legacy_no_stamp=%d",
|
||||
host, accepted, kept, legacy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -209,8 +315,9 @@ func (p *SimpleGrainPool[V]) AddRemote(host string) (Host[V], error) {
|
||||
return existing, nil
|
||||
}
|
||||
p.remoteHosts[host] = remote
|
||||
count := len(p.remoteHosts)
|
||||
p.remoteMu.Unlock()
|
||||
// connectedRemotes.Set(float64(p.RemoteCount()))
|
||||
p.metrics.SetConnectedRemotes(count)
|
||||
|
||||
log.Printf("Connected to remote host %s", host)
|
||||
go p.pingLoop(remote)
|
||||
@@ -250,6 +357,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
||||
delete(p.remoteOwners, id)
|
||||
}
|
||||
}
|
||||
remoteCount := len(p.remoteHosts)
|
||||
log.Printf("Removing host %s, grains: %d", host, count)
|
||||
p.remoteMu.Unlock()
|
||||
|
||||
@@ -257,7 +365,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
||||
if exists {
|
||||
remote.Close()
|
||||
}
|
||||
// connectedRemotes.Set(float64(p.RemoteCount()))
|
||||
p.metrics.SetConnectedRemotes(remoteCount)
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) RemoteCount() int {
|
||||
@@ -332,7 +440,7 @@ func (p *SimpleGrainPool[V]) Negotiate(otherHosts []string) {
|
||||
}
|
||||
|
||||
func (p *SimpleGrainPool[V]) SendNegotiation() {
|
||||
//negotiationCount.Inc()
|
||||
p.metrics.IncNegotiation()
|
||||
|
||||
p.remoteMu.RLock()
|
||||
hosts := make([]string, 0, len(p.remoteHosts)+1)
|
||||
@@ -366,10 +474,9 @@ func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host[V])) {
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
for _, host := range rh {
|
||||
|
||||
wg.Go(func() { fn(host) })
|
||||
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for name, host := range rh {
|
||||
if !host.IsHealthy() {
|
||||
@@ -387,8 +494,27 @@ func (p *SimpleGrainPool[V]) broadcastOwnership(ids []uint64) {
|
||||
return
|
||||
}
|
||||
|
||||
// Capture each id's grain.lastChange UnixNano stamp under the local
|
||||
// read-lock so peers receive an honest oracle for the
|
||||
// first-spawn-wins arbitration. For ids that don't have a local
|
||||
// grain (e.g. TakeOwnership called before anyone has loaded the
|
||||
// grain, or a caller pushing ids into the pool from outside the
|
||||
// spawn path), substitute the noLocalStamp sentinel — receivers see
|
||||
// -1 and fall back to "always accept remote", preserving the
|
||||
// pre-arbitration semantics for that case.
|
||||
p.localMu.RLock()
|
||||
stamps := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if g, ok := p.grains[id]; ok {
|
||||
stamps = append(stamps, g.GetLastChange().UnixNano())
|
||||
} else {
|
||||
stamps = append(stamps, noLocalStamp)
|
||||
}
|
||||
}
|
||||
p.localMu.RUnlock()
|
||||
|
||||
p.forAllHosts(func(rh Host[V]) {
|
||||
rh.AnnounceOwnership(p.hostname, ids)
|
||||
rh.AnnounceOwnership(p.hostname, ids, stamps)
|
||||
})
|
||||
log.Printf("%s taking ownership of %d ids", p.hostname, len(ids))
|
||||
// go p.statsUpdate()
|
||||
@@ -406,10 +532,13 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.metrics.IncSpawn()
|
||||
go p.broadcastOwnership([]uint64{id})
|
||||
p.localMu.Lock()
|
||||
p.grains[id] = grain
|
||||
remaining := len(p.grains)
|
||||
p.localMu.Unlock()
|
||||
p.metrics.SetPoolStats(remaining, p.poolSize)
|
||||
|
||||
return grain, nil
|
||||
}
|
||||
@@ -418,7 +547,20 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
|
||||
// var ErrNotOwner = fmt.Errorf("not owner")
|
||||
|
||||
// Apply applies a mutation to a grain.
|
||||
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*MutationResult[V], error) {
|
||||
func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (result *MutationResult[V], err error) {
|
||||
// The metric closure fires exactly once on every return path: the
|
||||
// latency / counters cover the full Apply (lock acquire + spawn +
|
||||
// handler + storage enqueue), and the failure counter only bumps
|
||||
// when the call returned a non-nil error. `len(mutation)` is the
|
||||
// number of proto messages in this Apply call — a single call can
|
||||
// batch several messages (SetCartItems sends ClearCart + N AddItem
|
||||
// together), and the counter is incremented by that count so the
|
||||
// dashboard's `rate(cart_mutations_total[1m])` reports messages
|
||||
// per second rather than calls per second, matching the original
|
||||
// `grainMutations.Add(len(data.Mutations))` semantics.
|
||||
done := p.metrics.MutationStarted(len(mutation))
|
||||
defer func() { done(err) }()
|
||||
|
||||
// Serialize all access to this grain: spawn, mutation handlers and the
|
||||
// final state read happen atomically with respect to other callers of the
|
||||
// same id. Different ids never contend.
|
||||
@@ -444,19 +586,20 @@ func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...p
|
||||
for _, listener := range p.listeners {
|
||||
go listener.AppendMutations(id, mutations...)
|
||||
}
|
||||
result, err := grain.GetCurrentState()
|
||||
state, err := grain.GetCurrentState()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &MutationResult[V]{
|
||||
Result: *result,
|
||||
Result: *state,
|
||||
Mutations: mutations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get returns the current state of a grain.
|
||||
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (*V, error) {
|
||||
func (p *SimpleGrainPool[V]) Get(ctx context.Context, id uint64) (result *V, err error) {
|
||||
p.metrics.IncLookup()
|
||||
unlock := p.grainLocks.lock(id)
|
||||
defer unlock()
|
||||
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type testState struct {
|
||||
Value int `json:"value"`
|
||||
}
|
||||
|
||||
type testGrain struct {
|
||||
id uint64
|
||||
accessed time.Time
|
||||
}
|
||||
|
||||
func (g *testGrain) GetId() uint64 { return g.id }
|
||||
func (g *testGrain) GetLastAccess() time.Time { return g.accessed }
|
||||
func (g *testGrain) GetLastChange() time.Time { return g.accessed }
|
||||
func (g *testGrain) GetCurrentState() (*testState, error) {
|
||||
return &testState{Value: int(g.id)}, nil
|
||||
}
|
||||
|
||||
type mockHost struct {
|
||||
name string
|
||||
healthy bool
|
||||
closed atomic.Bool
|
||||
closeCalls atomic.Int32
|
||||
actorIDs []uint64
|
||||
negotiateFn func([]string) ([]string, error)
|
||||
mu sync.Mutex
|
||||
ownership []uint64
|
||||
expiry []uint64
|
||||
}
|
||||
|
||||
func newMockHost(name string) *mockHost {
|
||||
return &mockHost{name: name, healthy: true}
|
||||
}
|
||||
|
||||
func (m *mockHost) AnnounceExpiry(ids []uint64, _ []int64) {
|
||||
m.mu.Lock()
|
||||
m.expiry = append(m.expiry, ids...)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *mockHost) Negotiate(otherHosts []string) ([]string, error) {
|
||||
if m.negotiateFn != nil {
|
||||
return m.negotiateFn(otherHosts)
|
||||
}
|
||||
return otherHosts, nil
|
||||
}
|
||||
|
||||
func (m *mockHost) Name() string { return m.name }
|
||||
|
||||
func (m *mockHost) Proxy(_ uint64, _ http.ResponseWriter, _ *http.Request, _ io.Reader) (bool, error) {
|
||||
return false, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (m *mockHost) Apply(_ context.Context, id uint64, _ ...proto.Message) (*MutationResult[testState], error) {
|
||||
return &MutationResult[testState]{Result: testState{Value: int(id)}}, nil
|
||||
}
|
||||
|
||||
func (m *mockHost) Get(_ context.Context, id uint64) (*testState, error) {
|
||||
return &testState{Value: int(id)}, nil
|
||||
}
|
||||
|
||||
func (m *mockHost) GetActorIds() []uint64 { return m.actorIDs }
|
||||
|
||||
func (m *mockHost) Close() error {
|
||||
m.closeCalls.Add(1)
|
||||
m.closed.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHost) Ping() bool { return m.healthy }
|
||||
|
||||
func (m *mockHost) IsHealthy() bool { return m.healthy }
|
||||
|
||||
func (m *mockHost) AnnounceOwnership(_ string, ids []uint64, _ []int64) {
|
||||
m.mu.Lock()
|
||||
m.ownership = append(m.ownership, ids...)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func newTestPool(t *testing.T, spawnHost func(string) (Host[testState], error)) *SimpleGrainPool[testState] {
|
||||
t.Helper()
|
||||
reg := NewMutationRegistry()
|
||||
pool, err := NewSimpleGrainPool(GrainPoolConfig[testState]{
|
||||
Hostname: "local",
|
||||
Spawn: func(_ context.Context, id uint64) (Grain[testState], error) {
|
||||
return &testGrain{id: id, accessed: time.Now()}, nil
|
||||
},
|
||||
SpawnHost: spawnHost,
|
||||
TTL: time.Hour,
|
||||
PoolSize: 100,
|
||||
MutationRegistry: reg,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return pool
|
||||
}
|
||||
|
||||
func TestSimpleGrainPoolGetAndApply(t *testing.T) {
|
||||
pool := newTestPool(t, func(string) (Host[testState], error) {
|
||||
return nil, fmt.Errorf("no remotes")
|
||||
})
|
||||
|
||||
got, err := pool.Get(context.Background(), 42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Value != 42 {
|
||||
t.Fatalf("Get value = %d, want 42", got.Value)
|
||||
}
|
||||
|
||||
ids := pool.GetLocalIds()
|
||||
if len(ids) != 1 || ids[0] != 42 {
|
||||
t.Fatalf("GetLocalIds = %v, want [42]", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleGrainPoolAddRemoteDedup(t *testing.T) {
|
||||
var spawnCalls atomic.Int32
|
||||
host := newMockHost("peer-a")
|
||||
|
||||
pool := newTestPool(t, func(name string) (Host[testState], error) {
|
||||
spawnCalls.Add(1)
|
||||
if name != host.name {
|
||||
t.Fatalf("spawn host %q, want %q", name, host.name)
|
||||
}
|
||||
return host, nil
|
||||
})
|
||||
|
||||
const workers = 8
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers)
|
||||
for range workers {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := pool.AddRemote(host.name); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if pool.RemoteCount() != 1 {
|
||||
t.Fatalf("RemoteCount = %d, want 1", pool.RemoteCount())
|
||||
}
|
||||
if spawnCalls.Load() != 1 {
|
||||
t.Fatalf("spawn calls = %d, want 1", spawnCalls.Load())
|
||||
}
|
||||
if host.closeCalls.Load() != 0 {
|
||||
t.Fatalf("duplicate remote closed = %d, want 0", host.closeCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleGrainPoolAddRemoteRaceClosesDuplicate(t *testing.T) {
|
||||
start := make(chan struct{})
|
||||
var spawnCalls atomic.Int32
|
||||
var spawned []*mockHost
|
||||
|
||||
pool := newTestPool(t, func(name string) (Host[testState], error) {
|
||||
spawnCalls.Add(1)
|
||||
h := newMockHost(name)
|
||||
spawned = append(spawned, h)
|
||||
if spawnCalls.Load() == 1 {
|
||||
<-start
|
||||
}
|
||||
return h, nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
if _, err := pool.AddRemote("peer-b"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
close(start)
|
||||
if _, err := pool.AddRemote("peer-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if pool.RemoteCount() != 1 {
|
||||
t.Fatalf("RemoteCount = %d, want 1", pool.RemoteCount())
|
||||
}
|
||||
if spawnCalls.Load() != 2 {
|
||||
t.Fatalf("spawn calls = %d, want 2", spawnCalls.Load())
|
||||
}
|
||||
|
||||
closed := 0
|
||||
deadline := time.After(2 * time.Second)
|
||||
for closed < 1 {
|
||||
closed = 0
|
||||
for _, h := range spawned {
|
||||
if h.closeCalls.Load() > 0 {
|
||||
closed++
|
||||
}
|
||||
}
|
||||
if closed >= 1 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for duplicate remote host Close")
|
||||
default:
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleGrainPoolRemoveHost(t *testing.T) {
|
||||
host := newMockHost("peer-c")
|
||||
pool := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
|
||||
|
||||
if _, err := pool.AddRemote(host.name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool.remoteMu.Lock()
|
||||
pool.remoteOwners[99] = host
|
||||
pool.remoteMu.Unlock()
|
||||
|
||||
pool.RemoveHost(host.name)
|
||||
|
||||
if pool.RemoteCount() != 0 {
|
||||
t.Fatalf("RemoteCount = %d, want 0", pool.RemoteCount())
|
||||
}
|
||||
if owner, ok := pool.OwnerHost(99); ok || owner != nil {
|
||||
t.Fatalf("OwnerHost still mapped: %#v ok=%v", owner, ok)
|
||||
}
|
||||
if host.closeCalls.Load() != 1 {
|
||||
t.Fatalf("Close calls = %d, want 1", host.closeCalls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleGrainPoolHandleOwnershipChange(t *testing.T) {
|
||||
host := newMockHost("peer-d")
|
||||
pool := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
|
||||
|
||||
// First-spawn-wins arbitration: a remote broadcast wins only when its
|
||||
// lastChange stamp is strictly earlier than the local grain's stamp
|
||||
// (or the local grain is absent). Capture the local spawn time once
|
||||
// and pass a stamp 1ns earlier to the remote so the test continues to
|
||||
// assert "external owner takes over given an earlier remote spawn"
|
||||
// under the new arbitration rule.
|
||||
spawnTime := time.Now()
|
||||
pool.localMu.Lock()
|
||||
pool.grains[7] = &testGrain{id: 7, accessed: spawnTime}
|
||||
pool.localMu.Unlock()
|
||||
|
||||
remoteStamp := spawnTime.UnixNano() - 1
|
||||
if err := pool.HandleOwnershipChange(host.name, []uint64{7}, []int64{remoteStamp}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pool.localMu.RLock()
|
||||
_, local := pool.grains[7]
|
||||
pool.localMu.RUnlock()
|
||||
if local {
|
||||
t.Fatal("local grain should be removed after ownership change")
|
||||
}
|
||||
|
||||
owner, ok := pool.OwnerHost(7)
|
||||
if !ok || owner.Name() != host.name {
|
||||
t.Fatalf("OwnerHost = (%v, %v), want (%q, true)", owner, ok, host.name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSimpleGrainPoolHandleOwnershipChangeArbitration verifies the
|
||||
// first-spawn-wins arbitration rules for HandleOwnershipChange:
|
||||
//
|
||||
// - Remote with earlier lastChange than local → accept remote.
|
||||
// - Remote with later lastChange than local → keep local.
|
||||
// - Equal lastChange → lower hostname wins.
|
||||
// - Remote stamp == -1 (legacy path) → accept remote (pre-arbitration).
|
||||
// - Local grain absent → accept remote.
|
||||
func TestSimpleGrainPoolHandleOwnershipChangeArbitration(t *testing.T) {
|
||||
host := newMockHost("peer-arbitrate")
|
||||
// Each table case constructs a fresh pool inside t.Run — we don't
|
||||
// share state across cases because per-case hostname differs
|
||||
// (hostname is the tie-break oracle on equal lastChange stamps).
|
||||
|
||||
now := time.Now().UnixNano()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
name_local string // pool hostname for ordering check
|
||||
id uint64
|
||||
localStamp int64 // UnixNano of the local grain; -1 = no local grain
|
||||
remoteStamp int64 // UnixNano stamped on the broadcast
|
||||
expectLocal bool // true → local grain still resident
|
||||
expectRemote bool // true → remoteOwners[id] = host
|
||||
}{
|
||||
{
|
||||
name: "remote older stamp wins",
|
||||
name_local: "zzz", // pool above "peer-arbitrate" lex
|
||||
id: 10,
|
||||
localStamp: now + 100,
|
||||
remoteStamp: now,
|
||||
expectLocal: false,
|
||||
expectRemote: true,
|
||||
},
|
||||
{
|
||||
name: "local earlier stamp keeps",
|
||||
name_local: "aaa", // pool below "peer-arbitrate" lex but stamp decides
|
||||
id: 11,
|
||||
localStamp: now,
|
||||
remoteStamp: now + 100,
|
||||
expectLocal: true,
|
||||
expectRemote: false,
|
||||
},
|
||||
{
|
||||
name: "equal stamp + lower local hostname keeps",
|
||||
name_local: "a-peer", // below host
|
||||
id: 12,
|
||||
localStamp: now,
|
||||
remoteStamp: now,
|
||||
expectLocal: true,
|
||||
expectRemote: false,
|
||||
},
|
||||
{
|
||||
name: "equal stamp + higher local hostname defers",
|
||||
name_local: "zzz-peer", // above host
|
||||
id: 13,
|
||||
localStamp: now,
|
||||
remoteStamp: now,
|
||||
expectLocal: false,
|
||||
expectRemote: true,
|
||||
},
|
||||
{
|
||||
name: "legacy (no remote stamp) accepts remote",
|
||||
name_local: "zzz", // we'd otherwise keep local
|
||||
id: 14,
|
||||
localStamp: now + 100,
|
||||
remoteStamp: -1,
|
||||
expectLocal: false,
|
||||
expectRemote: true,
|
||||
},
|
||||
{
|
||||
name: "no local grain accepts remote",
|
||||
name_local: "aaa",
|
||||
id: 15,
|
||||
localStamp: -1,
|
||||
remoteStamp: now,
|
||||
expectLocal: false,
|
||||
expectRemote: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Fresh pool per case so hostname differences don't leak.
|
||||
p := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
|
||||
p.hostname = tc.name_local
|
||||
|
||||
p.localMu.Lock()
|
||||
if tc.localStamp != -1 {
|
||||
p.grains[tc.id] = &testGrain{id: tc.id, accessed: time.Unix(0, tc.localStamp)}
|
||||
}
|
||||
p.localMu.Unlock()
|
||||
|
||||
if err := p.HandleOwnershipChange(host.name, []uint64{tc.id}, []int64{tc.remoteStamp}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p.localMu.RLock()
|
||||
_, hasLocal := p.grains[tc.id]
|
||||
p.localMu.RUnlock()
|
||||
if hasLocal != tc.expectLocal {
|
||||
t.Fatalf("local grain presence = %v, want %v", hasLocal, tc.expectLocal)
|
||||
}
|
||||
|
||||
owner, ok := p.OwnerHost(tc.id)
|
||||
if ok != tc.expectRemote {
|
||||
t.Fatalf("OwnerHost ok = %v, want %v", ok, tc.expectRemote)
|
||||
}
|
||||
if tc.expectRemote && (owner == nil || owner.Name() != host.name) {
|
||||
t.Fatalf("Owner = %v, want host %q", owner, host.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleGrainPoolPurgeEvictsStaleGrains(t *testing.T) {
|
||||
pool := newTestPool(t, func(string) (Host[testState], error) {
|
||||
return nil, fmt.Errorf("no remotes")
|
||||
})
|
||||
pool.ttl = time.Minute
|
||||
|
||||
stale := &testGrain{id: 1, accessed: time.Now().Add(-2 * time.Minute)}
|
||||
fresh := &testGrain{id: 2, accessed: time.Now()}
|
||||
|
||||
pool.localMu.Lock()
|
||||
pool.grains[1] = stale
|
||||
pool.grains[2] = fresh
|
||||
pool.localMu.Unlock()
|
||||
|
||||
pool.purge()
|
||||
|
||||
pool.localMu.RLock()
|
||||
defer pool.localMu.RUnlock()
|
||||
if _, ok := pool.grains[1]; ok {
|
||||
t.Fatal("stale grain should be purged")
|
||||
}
|
||||
if _, ok := pool.grains[2]; !ok {
|
||||
t.Fatal("fresh grain should remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleGrainPoolBroadcastOwnership(t *testing.T) {
|
||||
host := newMockHost("peer-e")
|
||||
pool := newTestPool(t, func(string) (Host[testState], error) { return host, nil })
|
||||
if _, err := pool.AddRemote(host.name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pool.broadcastOwnership([]uint64{11, 12})
|
||||
|
||||
host.mu.Lock()
|
||||
defer host.mu.Unlock()
|
||||
if len(host.ownership) != 2 {
|
||||
t.Fatalf("ownership announces = %v, want 2 ids", host.ownership)
|
||||
}
|
||||
}
|
||||
+29
-7
@@ -12,6 +12,12 @@ import (
|
||||
|
||||
type StateStorage struct {
|
||||
registry MutationRegistry
|
||||
// metrics is set by the owning DiskStorage via SetMetrics. Nil is fine —
|
||||
// every method that touches it nil-checks first. The pointer is
|
||||
// intentionally unexported: callers set it through DiskStorage.SetMetrics
|
||||
// so the embedded StateStorage always tracks the parent disk storage's
|
||||
// metrics, even if the embedded value is replaced.
|
||||
metrics *Metrics
|
||||
}
|
||||
|
||||
type StorageEvent struct {
|
||||
@@ -50,10 +56,20 @@ func (s *StateStorage) Load(r io.Reader, onMessage func(msg proto.Message, timeS
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) error {
|
||||
// Append serialises a mutation as a StorageEvent and writes it (plus a
|
||||
// trailing newline) to io. The first return value is the number of
|
||||
// bytes written, which the caller (DiskStorage) adds to the
|
||||
// event_log_bytes_written_total counter; the second is the underlying
|
||||
// write error, if any. ErrUnknownType is returned (with 0 bytes) when
|
||||
// the mutation's type is not registered, and is also counted in
|
||||
// event_log_unknown_types_total.
|
||||
func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp time.Time) (int, error) {
|
||||
typeName, ok := s.registry.GetTypeName(mutation)
|
||||
if !ok {
|
||||
return ErrUnknownType
|
||||
if s.metrics != nil {
|
||||
s.metrics.EventLogUnknownTypes.Inc()
|
||||
}
|
||||
return 0, ErrUnknownType
|
||||
}
|
||||
event := &StorageEvent{
|
||||
Type: typeName,
|
||||
@@ -62,13 +78,16 @@ func (s *StateStorage) Append(io io.Writer, mutation proto.Message, timeStamp ti
|
||||
}
|
||||
jsonBytes, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
if _, err := io.Write(jsonBytes); err != nil {
|
||||
return err
|
||||
n, err := io.Write(jsonBytes)
|
||||
total := n
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
io.Write([]byte("\n"))
|
||||
return nil
|
||||
n, err = io.Write([]byte("\n"))
|
||||
total += n
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
|
||||
@@ -83,6 +102,9 @@ func (s *StateStorage) Read(r *bufio.Scanner) (*StorageEvent, error) {
|
||||
typeName := event.Type
|
||||
mutation, ok := s.registry.Create(typeName)
|
||||
if !ok {
|
||||
if s.metrics != nil {
|
||||
s.metrics.EventLogUnknownTypes.Inc()
|
||||
}
|
||||
return nil, ErrUnknownType
|
||||
}
|
||||
if err := json.Unmarshal(event.Mutation, mutation); err != nil {
|
||||
|
||||
+105
-86
@@ -12,20 +12,19 @@ package backofficeadmin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
actor "git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||
"git.k6n.net/mats/platform/rabbit"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// CartFileInfo is the metadata returned by the cart listing endpoint.
|
||||
@@ -52,9 +51,8 @@ type Config struct {
|
||||
CheckoutDataDir string
|
||||
// ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled).
|
||||
ProfileDataDir string
|
||||
// RedisAddress / RedisPassword reach the inventory store.
|
||||
RedisAddress string
|
||||
RedisPassword string
|
||||
// PromotionsFile holds the path to promotions.json (optional).
|
||||
PromotionsFile string
|
||||
}
|
||||
|
||||
// App is the commerce admin control plane. Construct with New, register its
|
||||
@@ -62,15 +60,16 @@ type Config struct {
|
||||
type App struct {
|
||||
fs *FileServer
|
||||
hub *Hub
|
||||
rdb *redis.Client
|
||||
inv *inventory.RedisInventoryService
|
||||
cs *CustomerServer
|
||||
OnVouchersChange func(codes []string)
|
||||
}
|
||||
|
||||
// New constructs the commerce admin: it opens the Redis inventory service, the
|
||||
// cart/checkout disk event-log stores, the file server, and the WebSocket hub.
|
||||
// It does not register routes (RegisterRoutes), start background work (Start),
|
||||
// or open a listener.
|
||||
// New constructs the commerce admin: it opens the cart/checkout disk event-log
|
||||
// stores, the file server, and the WebSocket hub. It does not register routes
|
||||
// (RegisterRoutes), start background work (Start), or open a listener.
|
||||
//
|
||||
// Inventory writes no longer live here — they go through the standalone inventory
|
||||
// service's HTTP API (/api/inventory/batch), reverse-proxied by the host.
|
||||
func New(cfg Config) (*App, error) {
|
||||
if cfg.DataDir == "" {
|
||||
cfg.DataDir = "data"
|
||||
@@ -79,18 +78,74 @@ func New(cfg Config) (*App, error) {
|
||||
cfg.CheckoutDataDir = "checkout-data"
|
||||
}
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.RedisAddress,
|
||||
Password: cfg.RedisPassword,
|
||||
DB: 0,
|
||||
})
|
||||
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(cfg.DataDir, 0755)
|
||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||
|
||||
promotionsPath := cfg.PromotionsFile
|
||||
if promotionsPath == "" {
|
||||
promotionsPath = os.Getenv("PROMOTIONS_FILE")
|
||||
}
|
||||
if promotionsPath == "" {
|
||||
promotionsPath = "data/promotions.json"
|
||||
}
|
||||
if promotionStore, err := promotions.NewStore(promotionsPath); err == nil {
|
||||
promotionService := promotions.NewPromotionService(nil)
|
||||
reg.RegisterProcessor(
|
||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||
for _, v := range g.Vouchers {
|
||||
if v != nil {
|
||||
v.BypassedByPromotions = false
|
||||
}
|
||||
}
|
||||
|
||||
g.UpdateTotals()
|
||||
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||
|
||||
hasBypassed := false
|
||||
for _, res := range results {
|
||||
if res.Applicable {
|
||||
promotions.WalkConditions(res.Rule.Conditions, func(c promotions.Condition) bool {
|
||||
if bc, ok := c.(promotions.BaseCondition); ok && bc.Type == promotions.CondCouponCode {
|
||||
var codes []string
|
||||
if s, ok := bc.Value.AsString(); ok {
|
||||
codes = append(codes, strings.ToLower(s))
|
||||
} else if arr, ok := bc.Value.AsStringSlice(); ok {
|
||||
for _, s := range arr {
|
||||
codes = append(codes, strings.ToLower(s))
|
||||
}
|
||||
}
|
||||
for _, code := range codes {
|
||||
for _, v := range g.Vouchers {
|
||||
if v != nil && strings.ToLower(v.Code) == code {
|
||||
if !v.BypassedByPromotions {
|
||||
v.BypassedByPromotions = true
|
||||
hasBypassed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if hasBypassed {
|
||||
g.UpdateTotals()
|
||||
promotionCtx = promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
||||
results, _ = promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
||||
}
|
||||
|
||||
promotionService.ApplyResults(g, results, promotionCtx)
|
||||
g.Version++
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
log.Printf("backofficeadmin: unable to load promotions store from %s: %v", promotionsPath, err)
|
||||
}
|
||||
|
||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](cfg.DataDir, reg)
|
||||
|
||||
_ = os.MkdirAll(cfg.CheckoutDataDir, 0755)
|
||||
@@ -107,10 +162,8 @@ func New(cfg Config) (*App, error) {
|
||||
}
|
||||
|
||||
return &App{
|
||||
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, diskStorage, diskStorageCheckout),
|
||||
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, promotionsPath, diskStorage, diskStorageCheckout),
|
||||
hub: NewHub(),
|
||||
rdb: rdb,
|
||||
inv: inventoryService,
|
||||
cs: customerSrv,
|
||||
}, nil
|
||||
}
|
||||
@@ -119,11 +172,11 @@ func New(cfg Config) (*App, error) {
|
||||
// apply auth or CORS — the composing host (or standalone main) wraps the mux
|
||||
// with those.
|
||||
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
a.fs.OnVouchersChange = a.OnVouchersChange
|
||||
mux.HandleFunc("GET /carts", a.fs.CartsHandler)
|
||||
mux.HandleFunc("GET /cart/{id}", a.fs.CartHandler)
|
||||
mux.HandleFunc("GET /checkouts", a.fs.CheckoutsHandler)
|
||||
mux.HandleFunc("GET /checkout/{id}", a.fs.CheckoutHandler)
|
||||
mux.HandleFunc("PUT /inventory/{locationId}/{sku}", a.updateInventory)
|
||||
mux.HandleFunc("/promotions", a.fs.PromotionsHandler)
|
||||
mux.HandleFunc("/vouchers", a.fs.VoucherHandler)
|
||||
mux.HandleFunc("/promotion/{id}", a.fs.PromotionPartHandler)
|
||||
@@ -134,87 +187,53 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) updateInventory(w http.ResponseWriter, r *http.Request) {
|
||||
locationID := inventory.LocationID(r.PathValue("locationId"))
|
||||
sku := inventory.SKU(r.PathValue("sku"))
|
||||
pipe := a.rdb.Pipeline()
|
||||
var payload struct {
|
||||
Quantity int64 `json:"quantity"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
a.inv.UpdateInventory(r.Context(), pipe, sku, locationID, payload.Quantity)
|
||||
if _, err := pipe.Exec(r.Context()); err != nil {
|
||||
http.Error(w, "failed to update inventory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := a.inv.SendInventoryChanged(r.Context(), sku, locationID); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
|
||||
// Start runs the WebSocket hub loop and, when conn is non-nil, the RabbitMQ
|
||||
// mutation consumer that broadcasts cart mutations to connected clients. The
|
||||
// background goroutines stop when ctx is cancelled.
|
||||
func (a *App) Start(ctx context.Context, conn *amqp.Connection) error {
|
||||
// consumer re-subscribes automatically on reconnect (conn is a managed
|
||||
// reconnecting connection). Background goroutines stop when ctx is cancelled.
|
||||
func (a *App) Start(ctx context.Context, conn *rabbit.Conn) error {
|
||||
go a.hub.Run()
|
||||
if conn != nil {
|
||||
if err := startMutationConsumer(ctx, conn, a.hub); err != nil {
|
||||
return err
|
||||
}
|
||||
startMutationConsumer(ctx, conn, a.hub)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown releases the App's resources (the Redis client). The background
|
||||
// goroutines are stopped by cancelling the ctx passed to Start.
|
||||
// Shutdown releases the App's resources. The background goroutines are stopped
|
||||
// by cancelling the ctx passed to Start.
|
||||
func (a *App) Shutdown(_ context.Context) error {
|
||||
return a.rdb.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// startMutationConsumer subscribes to the cart "mutation" topic and forwards
|
||||
// each message to the hub for broadcast over WebSocket. Best-effort: a full hub
|
||||
// queue drops the message rather than blocking.
|
||||
func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub) error {
|
||||
// each message to the hub for broadcast over WebSocket. It (re)subscribes on the
|
||||
// initial connect and on every reconnect — via conn.NotifyOnReconnect — so a
|
||||
// broker blip doesn't silently kill the feed. Best-effort: a full hub queue
|
||||
// drops the message rather than blocking.
|
||||
func startMutationConsumer(ctx context.Context, conn *rabbit.Conn, hub *Hub) {
|
||||
conn.NotifyOnReconnect(func() {
|
||||
ch, err := conn.Channel()
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
msgs, err := messaging.DeclareBindAndConsume(ch, "cart", "mutation")
|
||||
if err != nil {
|
||||
_ = ch.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer ch.Close()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case m, ok := <-msgs:
|
||||
if !ok {
|
||||
log.Printf("mutation consumer: channel closed")
|
||||
log.Printf("mutation consumer: open channel: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("mutation event: %s", string(m.Body))
|
||||
// Subscribe to every grain's mutation stream (mutation.#) on the shared
|
||||
// "mutations" exchange. Bodies are platform/event.Event envelopes
|
||||
// (Source=grain, Subject=id, Payload=mutation summary) — forwarded raw to
|
||||
// WebSocket clients, which is exactly what a debug feed wants.
|
||||
if err := rabbit.ListenToPattern(ch, actor.MutationExchange, "mutation.#", func(d amqp.Delivery) error {
|
||||
if hub != nil {
|
||||
select {
|
||||
case hub.broadcast <- m.Body:
|
||||
case hub.broadcast <- d.Body:
|
||||
default:
|
||||
// hub queue full: drop to avoid blocking
|
||||
}
|
||||
}
|
||||
if err := m.Ack(false); err != nil {
|
||||
log.Printf("error acknowledging message: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Printf("mutation consumer: subscribe: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,14 +26,17 @@ type FileServer struct {
|
||||
// Define fields here
|
||||
dataDir string
|
||||
checkoutDataDir string
|
||||
promotionsFile string
|
||||
storage actor.LogStorage[cart.CartGrain]
|
||||
checkoutStorage actor.LogStorage[checkout.CheckoutGrain]
|
||||
OnVouchersChange func(codes []string)
|
||||
}
|
||||
|
||||
func NewFileServer(dataDir string, checkoutDataDir string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
|
||||
func NewFileServer(dataDir string, checkoutDataDir string, promotionsFile string, storage actor.LogStorage[cart.CartGrain], checkoutStorage actor.LogStorage[checkout.CheckoutGrain]) *FileServer {
|
||||
return &FileServer{
|
||||
dataDir: dataDir,
|
||||
checkoutDataDir: checkoutDataDir,
|
||||
promotionsFile: promotionsFile,
|
||||
storage: storage,
|
||||
checkoutStorage: checkoutStorage,
|
||||
}
|
||||
@@ -215,7 +218,10 @@ func (fs *FileServer) CheckoutsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fileName := filepath.Join(fs.dataDir, "promotions.json")
|
||||
fileName := fs.promotionsFile
|
||||
if fileName == "" {
|
||||
fileName = filepath.Join(fs.dataDir, "promotions.json")
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
file, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
@@ -271,15 +277,37 @@ func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
|
||||
file, err := os.Create(fileName)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
io.Copy(file, r.Body)
|
||||
var parsed struct {
|
||||
State struct {
|
||||
Vouchers []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"vouchers"`
|
||||
} `json:"state"`
|
||||
}
|
||||
var codes []string
|
||||
if err := json.Unmarshal(body, &parsed); err == nil {
|
||||
for _, v := range parsed.State.Vouchers {
|
||||
if v.Code != "" {
|
||||
codes = append(codes, v.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
|
||||
if err := os.WriteFile(fileName, body, 0644); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if fs.OnVouchersChange != nil {
|
||||
fs.OnVouchersChange(codes)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# `pkg/cart` — mutation inventory
|
||||
|
||||
Every mutation file under `pkg/cart/mutation_*.go` plus the slice of cart
|
||||
state it touches. The grouping is **subjective** ("Subscription lifecycle",
|
||||
"Discount surface", etc.) — used as a starting point for collapsing the
|
||||
file-per-mutation pattern flagged in `agents.md`. Move two related
|
||||
mutations into one file first; if the test surface stays smaller than the
|
||||
two-file version, the grouping is real.
|
||||
|
||||
| File | Concern | State it READS | State it WRITES |
|
||||
| --------------------------------------------------- | -------------------- | ----------------------------- | ---------------------------------------- |
|
||||
| `mutation_items.go` (`mutation_add_item.go` and `mutation_remove_item.go` merged; tests stay in `mutation_add_item_test.go` + `mutation_remove_item_test.go`) | Line item add/remove | `state.Items` | `state.Items`, `state.UpdatedAt` |
|
||||
| `mutation_change_quantity.go` | Line item quantity | `state.Items[idx]` | `state.Items[idx]`, `state.UpdatedAt` |
|
||||
| `mutation_clear_cart.go` | Wholesale clear | — | `state.Items`, `state.Vouchers`, `state.UpdatedAt` |
|
||||
| `mutation_set_user_id.go` | Ownership | — | `state.UserID`, `state.UpdatedAt` |
|
||||
| `mutation_set_cart_type.go` (+ test) | Cart type | — | `state.Type`, `state.UpdatedAt` |
|
||||
| `mutation_add_voucher.go` | Discount surface | `state.Vouchers` | `state.Vouchers`, `state.LineTotals` (re-eval), `state.UpdatedAt` |
|
||||
| `mutation_set_custom_fields.go` (+ tests) | Custom K/V | `state.CustomFields` | `state.CustomFields`, `state.UpdatedAt` |
|
||||
| `mutation_set_recovery_contact.go` (+ tests) | Recovery | — | `state.Recovery`, `state.UpdatedAt` |
|
||||
| `mutation_subscription_added.go` | Subscription | `state.Items` | `state.Subscriptions`, `state.UpdatedAt` |
|
||||
| `mutation_upsert_subscriptiondetails.go` | Subscription details | `state.Subscriptions` | `state.Subscriptions`, `state.UpdatedAt` |
|
||||
| `mutation_markings.go` (`mutation_line_item_marking.go` and `mutation_remove_line_item_marking.go` merged) | Markings apply/remove | `state.Items[idx].Markings` | `state.Items[idx].Markings`, `state.UpdatedAt` |
|
||||
|
||||
## Delete-test quick check
|
||||
|
||||
For any proposed grouping, ask: does deleting the merged file concentrate
|
||||
complexity, or just move lines? For markings — yes, the shared logic
|
||||
(cascade to pricing) makes the merge worthwhile, and the merge is now done
|
||||
(see below). For `voucher` vs `custom_fields` — no, those read different
|
||||
state and have different error contracts; keep separate.
|
||||
|
||||
### What is already merged
|
||||
|
||||
`pkg/cart/mutation_items.go` (added 2025-07) — AddItem and RemoveItem
|
||||
both touch `state.Items` and share the `ErrPaymentInProgress`,
|
||||
`decodeExtra`, and `getOrgPrice` helpers. Merge passed the delete-test
|
||||
checks: the receiver `*CartMutationContext`, same grain (`*CartGrain`),
|
||||
same mutation-registry dispatch. Both `mutation_*_test.go` files remain
|
||||
because they assert specific method behaviour, not generic package
|
||||
behaviour. Verified: `go test -count=1 ./pkg/cart/...` clean (voucher test
|
||||
which references `ErrPaymentInProgress` still green).
|
||||
|
||||
#### Table corrections when merging
|
||||
|
||||
When the two source rows collapsed into one, two corrections landed in
|
||||
the merge:
|
||||
|
||||
- AddItem's old READS column listed `state.Vouchers (for stacking)`.
|
||||
That clause was **incorrect** — voucher stacking is `AddVoucher`'s
|
||||
concern, not AddItem's. The merge drops the clause because reading
|
||||
`mutation_add_item.go` confirms AddItem only reads `state.Items`.
|
||||
This is a documentation correction, not a behaviour change.
|
||||
- Both `State it READS / WRITES` columns originally had `(both)`
|
||||
annotations to remind that two distinct mutations were sharing the
|
||||
row. After collapse the row already says `Line item add/remove`, so
|
||||
the `(both)` markers are noise — dropped.
|
||||
|
||||
If a future merge lands, follow the same pattern: read the actual source
|
||||
to verify the row's claimed state surface, and prune label-noise before
|
||||
compressing two rows into one.
|
||||
|
||||
`pkg/cart/mutation_markings.go` (added 2025-07) — LineItemMarking and
|
||||
RemoveLineItemMarking both touch `state.Items[idx].Markings` and share
|
||||
the same item-lookup loop (find by ID, mutate `Marking` field). Merge
|
||||
passed the delete-test: the shared write surface and identical error
|
||||
contract ("item with ID %d not found") make the grouping real. No test
|
||||
files existed for either mutation, so the test surface is unchanged.
|
||||
Verified: `go build ./pkg/cart/...` and `go test -count=1 ./pkg/cart/...`
|
||||
clean.
|
||||
|
||||
### NOT to merge next
|
||||
|
||||
`mutation_change_quantity.go` looks like an obvious candidate for the next
|
||||
merge (same receiver, same grain, same `state.Items` write surface). It
|
||||
is **not** — quantity has a partial-line-drop arithmetic contract that
|
||||
add/remove do not:
|
||||
|
||||
- `AddItem` always merges or appends; never sets quantity down.
|
||||
- `RemoveItem` always removes whole lines; never decrements quantity.
|
||||
- `ChangeQuantity` may drop a line entirely if quantity reaches 0.
|
||||
|
||||
If you merge `ChangeQuantity` first, doing the same delete-test check
|
||||
yields: deleting the merged file leaves the partial-line arithmetic
|
||||
logic stranded in `Apply`, which IS a real concentration (good). But
|
||||
the inverse: if `AddItem` later grows a "decrement" edge case, the
|
||||
merged file will balloon and a re-split becomes expensive. Either
|
||||
keep `ChangeQuantity` separate, OR merge all three with the merge
|
||||
scoped to a "line item lifecycle" package and the partial-line
|
||||
arithmetic under a clearly-named helper.
|
||||
|
||||
## How this maps onto a refactor
|
||||
|
||||
1. Pick a row with shared logic (markings, subscription details).
|
||||
2. Move related mutations + their tests into one file under
|
||||
`pkg/cart/<concern>.go`.
|
||||
3. Re-run `go test ./pkg/cart/...` — same assertions, smaller surface.
|
||||
4. Update this table to reflect the new file layout.
|
||||
+55
-2
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
)
|
||||
|
||||
@@ -54,6 +55,8 @@ type CartItem struct {
|
||||
OrderReference string `json:"orderReference,omitempty"`
|
||||
IsSubscribed bool `json:"isSubscribed,omitempty"`
|
||||
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
|
||||
InventoryTracked bool `json:"inventoryTracked,omitempty"`
|
||||
DropShip bool `json:"dropShip,omitempty"`
|
||||
|
||||
// Extra holds arbitrary dynamic product data. Its keys are flattened onto
|
||||
// the item object in JSON (see cart_item_json.go), so they are returned
|
||||
@@ -83,6 +86,25 @@ type Notice struct {
|
||||
Code *string `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// PushToken is the Go-level mirror of cart_messages.PushToken: a generic,
|
||||
// provider-agnostic push-delivery target. Stored on the grain so the
|
||||
// abandoned-cart recovery scanner can hand it to a notifier without
|
||||
// re-decoding proto messages; the wire shape lives in proto/cart.proto.
|
||||
//
|
||||
// SECURITY: the raw Token persists verbatim to the per-pod event log
|
||||
// (CartGrain JSON-marshals PushTokens on every mutation). Anyone with read
|
||||
// access to the cart service's data dir — PVC snapshots, debug dumps, log
|
||||
// archival — can extract device handles. A future hardening pass should
|
||||
// either hash-on-write (hash.compareAtLaunch) for stateless matching or
|
||||
// encrypt-at-rest with a key the notifier owns. v0 keeps the seam: a real
|
||||
// notifier should treat the stored token as opaque, fetch any canonical
|
||||
// canonicalised handle from the input side (frontend/web SDK), and use
|
||||
// what's here only for routing.
|
||||
type PushToken struct {
|
||||
Platform string `json:"platform"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type CartPaymentStatus string
|
||||
|
||||
const (
|
||||
@@ -117,6 +139,7 @@ type AppliedPromotion struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Discount *Price `json:"discount,omitempty"`
|
||||
ItemIds []uint32 `json:"itemIds,omitempty"`
|
||||
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
Progress map[string]interface{} `json:"progress,omitempty"`
|
||||
@@ -128,15 +151,28 @@ type CartGrain struct {
|
||||
lastVoucherId uint32
|
||||
lastAccess time.Time
|
||||
lastChange time.Time // unix seconds of last successful mutation (replay sets from event ts)
|
||||
userId string
|
||||
UserId string `json:"userId,omitempty"`
|
||||
Currency string `json:"currency"`
|
||||
Language string `json:"language"`
|
||||
Version uint `json:"version"`
|
||||
InventoryReserved bool `json:"inventoryReserved"`
|
||||
Type cart_messages.CartType `json:"type,omitempty"`
|
||||
Id CartId `json:"id"`
|
||||
Items []*CartItem `json:"items"`
|
||||
TotalPrice *Price `json:"totalPrice"`
|
||||
TotalDiscount *Price `json:"totalDiscount"`
|
||||
// EvaluatedItems is the per-line promotion breakdown — see
|
||||
// EvaluatedItem for shape and semantics. Populated by the canonical
|
||||
// promotion pipeline (pkg/promotions.PromotionService.EvaluateAndApply)
|
||||
// after every successful mutation, alongside TotalDiscount and
|
||||
// AppliedPromotions, so any consumer of the grain (UCP cart response,
|
||||
// legacy /cart HTTP handler, cart-mcp, AMQP mutation feed) sees the
|
||||
// per-line discount/effective totals without re-running the engine at
|
||||
// response time. Mirrors the same shape /promotions/evaluate-with-cart
|
||||
// has returned since the breakdown feature was introduced; deliberately
|
||||
// persisted via the event-log JSON block so we don't have to re-derive
|
||||
// it on every read path (same precedent as TotalPrice/TotalDiscount).
|
||||
EvaluatedItems []EvaluatedItem `json:"evaluatedItems,omitempty"`
|
||||
Processing bool `json:"processing"`
|
||||
//PaymentInProgress uint16 `json:"paymentInProgress"`
|
||||
OrderReference string `json:"orderReference,omitempty"`
|
||||
@@ -146,6 +182,14 @@ type CartGrain struct {
|
||||
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
||||
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
||||
|
||||
// Email is the cart's recovery contact address. Set explicitly via the
|
||||
// SetRecoveryContact mutation (independent of the linked profile's email),
|
||||
// so abandoned-cart recovery can fire before login. Empty string = no email.
|
||||
Email string `json:"email,omitempty"`
|
||||
// PushTokens holds every push delivery target the shopper attached to this
|
||||
// cart. Empty list = no push. PUT-style replaced by SetRecoveryContact.
|
||||
PushTokens []PushToken `json:"pushTokens,omitempty"`
|
||||
|
||||
//CheckoutOrderId string `json:"checkoutOrderId,omitempty"`
|
||||
CheckoutStatus *CartPaymentStatus `json:"checkoutStatus,omitempty"`
|
||||
//CheckoutCountry string `json:"checkoutCountry,omitempty"`
|
||||
@@ -158,6 +202,7 @@ type Voucher struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Id uint32 `json:"id"`
|
||||
Value int64 `json:"value"`
|
||||
BypassedByPromotions bool `json:"-"`
|
||||
}
|
||||
|
||||
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
||||
@@ -294,11 +339,19 @@ func (c *CartGrain) UpdateTotals() {
|
||||
|
||||
}
|
||||
|
||||
if c.Type == cart_messages.CartType_WISHLIST || c.Type == cart_messages.CartType_OFFER {
|
||||
return
|
||||
}
|
||||
|
||||
for _, voucher := range c.Vouchers {
|
||||
_, ok := voucher.AppliesTo(c)
|
||||
voucher.Applied = false
|
||||
if ok {
|
||||
value := NewPriceFromIncVat(voucher.Value, 25)
|
||||
if voucher.BypassedByPromotions {
|
||||
voucher.Applied = true
|
||||
continue
|
||||
}
|
||||
value := NewPriceFromIncVat(voucher.Value, 2500) // 25% in basis points
|
||||
if c.TotalPrice.IncVat <= value.IncVat {
|
||||
// don't apply discounts to more than the total price
|
||||
continue
|
||||
|
||||
@@ -2,17 +2,19 @@ package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"git.k6n.net/mats/platform/inventory"
|
||||
)
|
||||
|
||||
type CartMutationContext struct {
|
||||
reservationService inventory.CartReservationService
|
||||
reservationService inventory.ReservationPolicy
|
||||
}
|
||||
|
||||
func NewCartMutationContext(reservationService inventory.CartReservationService) *CartMutationContext {
|
||||
func NewCartMutationContext(reservationService inventory.ReservationPolicy) *CartMutationContext {
|
||||
return &CartMutationContext{
|
||||
reservationService: reservationService,
|
||||
}
|
||||
@@ -30,7 +32,7 @@ func (c *CartMutationContext) ReserveItem(ctx context.Context, cartId CartId, sk
|
||||
}
|
||||
ttl := time.Minute * 15
|
||||
endTime := time.Now().Add(ttl)
|
||||
err := c.reservationService.ReserveForCart(ctx, inventory.CartReserveRequest{
|
||||
err := c.reservationService.Reserve(ctx, inventory.CartReserveRequest{
|
||||
CartID: inventory.CartID(cartId.String()),
|
||||
InventoryReference: &inventory.InventoryReference{
|
||||
SKU: inventory.SKU(sku),
|
||||
@@ -51,7 +53,13 @@ func (c *CartMutationContext) UseReservations(item *CartItem) bool {
|
||||
if item.ReservationEndTime != nil {
|
||||
return true
|
||||
}
|
||||
return item.Cgm == "55010"
|
||||
if item.DropShip {
|
||||
return false
|
||||
}
|
||||
if item.InventoryTracked {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sku string, locationId *string) error {
|
||||
@@ -62,7 +70,101 @@ func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sk
|
||||
if locationId != nil {
|
||||
l = inventory.LocationID(*locationId)
|
||||
}
|
||||
return c.reservationService.ReleaseForCart(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
|
||||
return c.reservationService.Release(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
|
||||
}
|
||||
|
||||
// equalOptional is the canonical nullable-equality primitive used by the
|
||||
// cart mutations when deciding whether two identifier fields (parent/child
|
||||
// links, store ids, anything else nullable) refer to the same thing.
|
||||
//
|
||||
// Returns true when both pointers are nil, when both alias the same
|
||||
// address, or when both are non-nil and dereference to equal values. The
|
||||
// same-address short-circuit is a no-op for correctness but lets a caller
|
||||
// who passes the same variable get back true without traversing the
|
||||
// generic's comparison path.
|
||||
//
|
||||
// One tested primitive covers every *T the cart currently compares —
|
||||
// `*uint32` for ParentId, `*string` for StoreId — and any future
|
||||
// comparable pointer type. Hand-rolled "both nil OR both non-nil and
|
||||
// equal" expressions were duplicated at AddItem's merge site and were a
|
||||
// footgun whenever a new field was added (which axis of the predicate
|
||||
// was the relevant one?). Keeping a single helper makes future merges
|
||||
// safer to copy.
|
||||
func equalOptional[T comparable](a, b *T) bool {
|
||||
if a == b { // both nil OR same address
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// CascadeChildQuantities scales every direct child's quantity
|
||||
// proportionally when a parent's quantity is bumped (ChangeQuantity or
|
||||
// the AddItem-merge path).
|
||||
//
|
||||
// - Ratio: newChildQty = oldChildQty × newParentQty / oldParentQty.
|
||||
// - Floor: scale-down via integer division can yield 0 (e.g. parent
|
||||
// 2 → 1, child qty 1 → (1×1)/2 = 0). We clamp to 1 so a scale-down
|
||||
// never silently deletes an accessory. The mismatch is acceptable —
|
||||
// shipping one extra child is better than dropping one.
|
||||
// - Reservations: each child's reservation is released then re-acquired
|
||||
// at the new quantity if UseReservations is true for that child AND a
|
||||
// prior reservation existed. A failing reservation is logged, not
|
||||
// raised, so a single bad child doesn't abort the whole cascade
|
||||
// (matches the existing removal pattern in mutation_items.go).
|
||||
// - Recursion: descends into grandchildren using the child's own
|
||||
// before/after quantities as the next ratio's input, so deeply
|
||||
// nested accessory trees (parent → child → grandchild) stay in
|
||||
// ratio with the top-level change.
|
||||
//
|
||||
// Caller is responsible for locking any grain mutex and for re-running
|
||||
// UpdateTotals after the cascade has settled.
|
||||
func (c *CartMutationContext) CascadeChildQuantities(
|
||||
ctx context.Context,
|
||||
g *CartGrain,
|
||||
parentLineId uint32,
|
||||
oldParentQty uint16,
|
||||
newParentQty uint16,
|
||||
) {
|
||||
if oldParentQty == 0 || oldParentQty == newParentQty {
|
||||
return
|
||||
}
|
||||
|
||||
for _, it := range g.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
if it.ParentId == nil || *it.ParentId != parentLineId {
|
||||
continue
|
||||
}
|
||||
oldChildQty := it.Quantity
|
||||
ratio := uint32(newParentQty) * uint32(oldChildQty) / uint32(oldParentQty)
|
||||
newChildQty := uint16(ratio)
|
||||
if newChildQty == 0 {
|
||||
newChildQty = 1
|
||||
}
|
||||
if newChildQty == oldChildQty {
|
||||
continue
|
||||
}
|
||||
|
||||
if g.Type == cart_messages.CartType_REGULAR && c.UseReservations(it) && it.ReservationEndTime != nil {
|
||||
if err := c.ReleaseItem(ctx, g.Id, it.Sku, it.StoreId); err != nil {
|
||||
log.Printf("CascadeChildQuantities: failed to release %s: %v", it.Sku, err)
|
||||
}
|
||||
endTime, err := c.ReserveItem(ctx, g.Id, it.Sku, it.StoreId, newChildQty)
|
||||
if err != nil {
|
||||
log.Printf("CascadeChildQuantities: failed to reserve %s at qty %d: %v", it.Sku, newChildQty, err)
|
||||
} else if endTime != nil {
|
||||
it.ReservationEndTime = endTime
|
||||
}
|
||||
}
|
||||
|
||||
it.Quantity = newChildQty
|
||||
// Descend so grandchildren track this child's ratio.
|
||||
c.CascadeChildQuantities(ctx, g, it.Id, oldChildQty, newChildQty)
|
||||
}
|
||||
}
|
||||
|
||||
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
|
||||
@@ -81,6 +183,8 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist
|
||||
actor.NewMutation(RemoveLineItemMarking),
|
||||
actor.NewMutation(SetLineItemCustomFields),
|
||||
actor.NewMutation(SubscriptionAdded),
|
||||
actor.NewMutation(context.SetCartType),
|
||||
actor.NewMutation(SetRecoveryContact),
|
||||
// actor.NewMutation(SubscriptionRemoved),
|
||||
)
|
||||
return reg
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package cart
|
||||
|
||||
import "testing"
|
||||
|
||||
// equalOptional is the nullable-equality primitive shared by the AddItem
|
||||
// merge site and any future field-comparison. Two call sites use it today
|
||||
// with two distinct types — *uint32 (ParentId) and *string (StoreId) — so
|
||||
// the test pins both via generic instantiation.
|
||||
func TestEqualOptional(t *testing.T) {
|
||||
t.Run("both nil is equal", func(t *testing.T) {
|
||||
if !equalOptional[string](nil, nil) {
|
||||
t.Error("both nil should be equal")
|
||||
}
|
||||
if !equalOptional[uint32](nil, nil) {
|
||||
t.Error("both nil should be equal (uint32)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same address alias is equal", func(t *testing.T) {
|
||||
s := "x"
|
||||
if !equalOptional(&s, &s) {
|
||||
t.Error("same-address *string should be equal")
|
||||
}
|
||||
v := uint32(5)
|
||||
if !equalOptional(&v, &v) {
|
||||
t.Error("same-address *uint32 should be equal")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("distinct addresses with equal values are equal", func(t *testing.T) {
|
||||
a, b := "x", "x"
|
||||
if !equalOptional(&a, &b) {
|
||||
t.Error("equal strings at distinct addresses should be equal")
|
||||
}
|
||||
u, w := uint32(5), uint32(5)
|
||||
if !equalOptional(&u, &w) {
|
||||
t.Error("equal uint32 at distinct addresses should be equal")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("one nil, one set is not equal", func(t *testing.T) {
|
||||
a := "x"
|
||||
if equalOptional(&a, nil) {
|
||||
t.Error("non-nil vs nil should differ (string)")
|
||||
}
|
||||
if equalOptional[string](nil, &a) {
|
||||
t.Error("nil vs non-nil should differ (string)")
|
||||
}
|
||||
u := uint32(5)
|
||||
if equalOptional(&u, nil) {
|
||||
t.Error("non-nil vs nil should differ (uint32)")
|
||||
}
|
||||
if equalOptional[uint32](nil, &u) {
|
||||
t.Error("nil vs non-nil should differ (uint32)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("differing values are not equal", func(t *testing.T) {
|
||||
a, b := "x", "y"
|
||||
if equalOptional(&a, &b) {
|
||||
t.Error("differing strings should differ")
|
||||
}
|
||||
u, w := uint32(5), uint32(6)
|
||||
if equalOptional(&u, &w) {
|
||||
t.Error("differing uint32 should differ")
|
||||
}
|
||||
})
|
||||
}
|
||||
+12
-78
@@ -1,11 +1,10 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/platform/uid"
|
||||
)
|
||||
|
||||
// cart_id.go
|
||||
@@ -36,30 +35,16 @@ import (
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CartId actor.GrainId
|
||||
// CartId is a 64-bit cart identifier with a compact base62 string form,
|
||||
// backed by the shared platform/uid package.
|
||||
type CartId uid.ID
|
||||
|
||||
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
// Reverse lookup (0xFF marks invalid)
|
||||
var base62Rev [256]byte
|
||||
|
||||
func init() {
|
||||
for i := range base62Rev {
|
||||
base62Rev[i] = 0xFF
|
||||
}
|
||||
for i := 0; i < len(base62Alphabet); i++ {
|
||||
base62Rev[base62Alphabet[i]] = byte(i)
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the canonical base62 encoding of the 64-bit id.
|
||||
func (id CartId) String() string {
|
||||
return encodeBase62(uint64(id))
|
||||
}
|
||||
// String returns the canonical base62 encoding.
|
||||
func (id CartId) String() string { return uid.ID(id).String() }
|
||||
|
||||
// MarshalJSON encodes the cart id as a JSON string.
|
||||
func (id CartId) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(id.String())
|
||||
return json.Marshal(uid.ID(id).String())
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
||||
@@ -78,23 +63,11 @@ func (id *CartId) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// NewCartId generates a new cryptographically random non-zero 64-bit id.
|
||||
func NewCartId() (CartId, error) {
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
id, err := uid.New()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("NewCartId: %w", err)
|
||||
}
|
||||
u := (uint64(b[0]) << 56) |
|
||||
(uint64(b[1]) << 48) |
|
||||
(uint64(b[2]) << 40) |
|
||||
(uint64(b[3]) << 32) |
|
||||
(uint64(b[4]) << 24) |
|
||||
(uint64(b[5]) << 16) |
|
||||
(uint64(b[6]) << 8) |
|
||||
uint64(b[7])
|
||||
if u == 0 {
|
||||
// Extremely unlikely; regenerate once to avoid "0" identifier if desired.
|
||||
return NewCartId()
|
||||
}
|
||||
return CartId(u), nil
|
||||
return CartId(id), nil
|
||||
}
|
||||
|
||||
// MustNewCartId panics if generation fails.
|
||||
@@ -107,19 +80,9 @@ func MustNewCartId() CartId {
|
||||
}
|
||||
|
||||
// ParseCartId parses a base62 string into a CartId.
|
||||
// Returns (0,false) for invalid input.
|
||||
func ParseCartId(s string) (CartId, bool) {
|
||||
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
||||
// Provide a slightly looser upper bound (<=16) only if you anticipate future
|
||||
// extensions; here we stay strict.
|
||||
if len(s) == 0 || len(s) > 11 {
|
||||
return 0, false
|
||||
}
|
||||
u, ok := decodeBase62(s)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return CartId(u), true
|
||||
id, ok := uid.Parse(s)
|
||||
return CartId(id), ok
|
||||
}
|
||||
|
||||
// MustParseCartId panics on invalid base62 input.
|
||||
@@ -130,32 +93,3 @@ func MustParseCartId(s string) CartId {
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// encodeBase62 converts a uint64 to base62 (shortest form).
|
||||
func encodeBase62(u uint64) string {
|
||||
if u == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [11]byte
|
||||
i := len(buf)
|
||||
for u > 0 {
|
||||
i--
|
||||
buf[i] = base62Alphabet[u%62]
|
||||
u /= 62
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// decodeBase62 converts base62 text to uint64.
|
||||
func decodeBase62(s string) (uint64, bool) {
|
||||
var v uint64
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
d := base62Rev[c]
|
||||
if d == 0xFF {
|
||||
return 0, false
|
||||
}
|
||||
v = v*62 + uint64(d)
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
+14
-15
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.k6n.net/mats/platform/uid"
|
||||
)
|
||||
|
||||
// TestNewCartIdUniqueness generates many ids and checks for collisions.
|
||||
@@ -96,26 +98,25 @@ func TestJSONMarshalUnmarshalCartId(t *testing.T) {
|
||||
|
||||
// TestBase62LengthBound checks worst-case length (near max uint64).
|
||||
func TestBase62LengthBound(t *testing.T) {
|
||||
// Largest uint64
|
||||
const maxU64 = ^uint64(0)
|
||||
s := encodeBase62(maxU64)
|
||||
s := uid.ID(maxU64).String()
|
||||
if len(s) > 11 {
|
||||
t.Fatalf("max uint64 encoded length > 11: %d (%s)", len(s), s)
|
||||
}
|
||||
dec, ok := decodeBase62(s)
|
||||
if !ok || dec != maxU64 {
|
||||
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, dec, maxU64)
|
||||
dec, ok := uid.Parse(s)
|
||||
if !ok || uint64(dec) != maxU64 {
|
||||
t.Fatalf("decode failed for max uint64: ok=%v dec=%d want=%d", ok, uint64(dec), maxU64)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroEncoding ensures zero value encodes to "0" and parses back.
|
||||
func TestZeroEncoding(t *testing.T) {
|
||||
if s := encodeBase62(0); s != "0" {
|
||||
t.Fatalf("encodeBase62(0) expected '0', got %q", s)
|
||||
if s := uid.ID(0).String(); s != "0" {
|
||||
t.Fatalf("uid.ID(0).String() expected '0', got %q", s)
|
||||
}
|
||||
v, ok := decodeBase62("0")
|
||||
v, ok := uid.Parse("0")
|
||||
if !ok || v != 0 {
|
||||
t.Fatalf("decodeBase62('0') failed: ok=%v v=%d", ok, v)
|
||||
t.Fatalf("uid.Parse('0') failed: ok=%v v=%d", ok, v)
|
||||
}
|
||||
if _, ok := ParseCartId("0"); !ok {
|
||||
t.Fatalf("ParseCartId(\"0\") should succeed")
|
||||
@@ -145,16 +146,14 @@ func BenchmarkNewCartId(b *testing.B) {
|
||||
|
||||
// BenchmarkEncodeBase62 measures encoding performance.
|
||||
func BenchmarkEncodeBase62(b *testing.B) {
|
||||
// Precompute sample values
|
||||
samples := make([]uint64, 1024)
|
||||
for i := range samples {
|
||||
// Spread bits without crypto randomness overhead
|
||||
samples[i] = (uint64(i) << 53) ^ (uint64(i) * 0x9E3779B185EBCA87)
|
||||
}
|
||||
b.ResetTimer()
|
||||
var sink string
|
||||
for i := 0; i < b.N; i++ {
|
||||
sink = encodeBase62(samples[i%len(samples)])
|
||||
sink = uid.ID(samples[i%len(samples)]).String()
|
||||
}
|
||||
_ = sink
|
||||
}
|
||||
@@ -163,16 +162,16 @@ func BenchmarkEncodeBase62(b *testing.B) {
|
||||
func BenchmarkDecodeBase62(b *testing.B) {
|
||||
encoded := make([]string, 1024)
|
||||
for i := range encoded {
|
||||
encoded[i] = encodeBase62((uint64(i) << 32) | uint64(i))
|
||||
encoded[i] = uid.ID((uint64(i) << 32) | uint64(i)).String()
|
||||
}
|
||||
b.ResetTimer()
|
||||
var sum uint64
|
||||
for i := 0; i < b.N; i++ {
|
||||
v, ok := decodeBase62(encoded[i%len(encoded)])
|
||||
v, ok := uid.Parse(encoded[i%len(encoded)])
|
||||
if !ok {
|
||||
b.Fatalf("decode failure for %s", encoded[i%len(encoded)])
|
||||
}
|
||||
sum ^= v
|
||||
sum ^= uint64(v)
|
||||
}
|
||||
_ = sum
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package cart
|
||||
|
||||
// EvaluatedItem is one line in the per-line promotion breakdown exposed
|
||||
// alongside a cart. It mirrors the line that was evaluated (sku,
|
||||
// quantity, unit list price, optional OrgPrice for the strikethrough) and
|
||||
// adds the per-line discount the engine applied (orgPrice-based +
|
||||
// promotion-based, additive) plus the resulting effective per-unit and
|
||||
// per-line totals.
|
||||
//
|
||||
// Lives in pkg/cart rather than pkg/promotions so CartGrain can carry the
|
||||
// breakdown as a derived field (populated by the canonical promotion
|
||||
// pipeline that already manages TotalDiscount/AppliedPromotions), and so
|
||||
// every consumer of the grain — UCP cart response, legacy /cart HTTP
|
||||
// handler, cart-mcp, AMQP mutation feed — naturally sees the same shape
|
||||
// without ad-hoc wrappers at each call site. JSON tags are the canonical
|
||||
// EvaluatedItem shape that /promotions/evaluate-with-cart has returned
|
||||
// since the breakdown feature was introduced.
|
||||
//
|
||||
// Distributing the total cart discount down to the line level lets the
|
||||
// storefront render "Item X: 100 kr → 80 kr" without re-doing the math
|
||||
// on the client and lets verifiers confirm which promotion hit which
|
||||
// line (per-line DiscountIncVat is the engine's authoritative answer).
|
||||
type EvaluatedItem struct {
|
||||
Sku string `json:"sku"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Quantity uint16 `json:"qty"`
|
||||
PriceIncVat int64 `json:"priceIncVat"` // per-unit list price
|
||||
OrgPriceIncVat int64 `json:"orgPriceIncVat,omitempty"` // per-unit pre-discount list price (for strikethrough)
|
||||
DiscountIncVat int64 `json:"discountIncVat"` // per-line TOTAL discount (orgPrice + promotion), incVat in öre
|
||||
EffectivePriceIncVat int64 `json:"effectivePriceIncVat"` // per-unit, after discount, incVat in öre (rounded)
|
||||
EffectiveTotalIncVat int64 `json:"effectiveTotalIncVat"` // per-line, after discount, incVat in öre (exact)
|
||||
}
|
||||
|
||||
// MapEvaluatedItems walks the grain's items and produces the per-line
|
||||
// EvaluatedItem list. The grain's item.Discount carries the TOTAL per-line
|
||||
// discount (orgPrice + promotion — additive, set by UpdateTotals and the
|
||||
// effects' per-line distribution). The effective totals are derived as
|
||||
// (price * qty - discount), clamped to 0 (a promotion that over-discounted
|
||||
// a line shows as free, not negative). The effective per-unit price is the
|
||||
// per-line total divided by quantity, rounded to the nearest öre so the
|
||||
// per-unit display matches the per-line total when multiplied back.
|
||||
//
|
||||
// Returns nil for a nil grain (callers that always pass a non-nil grain
|
||||
// — the typical case — get a non-nil empty slice for an empty cart, which
|
||||
// is the desired behavior for JSON omitempty at the consumer).
|
||||
//
|
||||
// Exported so the canonical promotion pipeline (pkg/promotions.EvaluateAndApply)
|
||||
// and the manual /promotions/evaluate-with-cart preview handler can produce
|
||||
// the same per-line shape.
|
||||
func MapEvaluatedItems(g *CartGrain) []EvaluatedItem {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]EvaluatedItem, 0, len(g.Items))
|
||||
for _, it := range g.Items {
|
||||
if it == nil {
|
||||
continue
|
||||
}
|
||||
var name string
|
||||
if it.Meta != nil {
|
||||
name = it.Meta.Name
|
||||
}
|
||||
var orgPrice int64
|
||||
if it.OrgPrice != nil {
|
||||
orgPrice = it.OrgPrice.IncVat.Int64()
|
||||
}
|
||||
var discount int64
|
||||
if it.Discount != nil {
|
||||
discount = it.Discount.IncVat.Int64()
|
||||
}
|
||||
priceRow := it.Price.IncVat.Int64() * int64(it.Quantity)
|
||||
effTotal := priceRow - discount
|
||||
if effTotal < 0 {
|
||||
effTotal = 0
|
||||
}
|
||||
var effUnit int64
|
||||
if it.Quantity > 0 {
|
||||
// Nearest-öre rounding so per-unit * qty matches the
|
||||
// per-line total (within 1 öre either way). Half-up:
|
||||
// (effTotal + qty/2) / qty.
|
||||
effUnit = (effTotal + int64(it.Quantity)/2) / int64(it.Quantity)
|
||||
}
|
||||
out = append(out, EvaluatedItem{
|
||||
Sku: it.Sku,
|
||||
Name: name,
|
||||
Quantity: it.Quantity,
|
||||
PriceIncVat: it.Price.IncVat.Int64(),
|
||||
OrgPriceIncVat: orgPrice,
|
||||
DiscountIncVat: discount,
|
||||
EffectivePriceIncVat: effUnit,
|
||||
EffectiveTotalIncVat: effTotal,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package mcp
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// JSON-RPC 2.0 envelope types (the wire format MCP rides on). Notifications are
|
||||
// requests with no id and must not receive a response.
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
func (r *rpcRequest) isNotification() bool { return len(r.ID) == 0 }
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
codeParseError = -32700
|
||||
codeInvalidRequest = -32600
|
||||
codeMethodNotFound = -32601
|
||||
codeInvalidParams = -32602
|
||||
codeInternalError = -32603
|
||||
)
|
||||
|
||||
func result(id json.RawMessage, v any) *rpcResponse {
|
||||
return &rpcResponse{JSONRPC: "2.0", ID: id, Result: v}
|
||||
}
|
||||
|
||||
func errorResponse(id json.RawMessage, code int, msg string) *rpcResponse {
|
||||
return &rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}}
|
||||
}
|
||||
+9
-119
@@ -3,27 +3,25 @@
|
||||
// (list items, change quantities, remove items, clear carts, apply vouchers,
|
||||
// manage users, etc.).
|
||||
//
|
||||
// Transport is streamable HTTP (JSON-RPC 2.0 over POST). It is mounted by
|
||||
// cmd/cart under /mcp on the same HTTP server as the cart API. Tools map
|
||||
// 1:1 to cart grain read/apply operations; no restart is needed because every
|
||||
// tool call goes through the shared grain pool.
|
||||
// Transport (JSON-RPC 2.0 over streamable HTTP), dispatch, and the schema/result
|
||||
// helpers live in platform/mcp; this package only defines the cart-specific
|
||||
// tools and wires them to the grain pool. Mounted by cmd/cart under /mcp.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
pmcp "git.k6n.net/mats/platform/mcp"
|
||||
"net/http"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
serverName = "cart"
|
||||
serverVersion = "0.1.0"
|
||||
protocolVersion = "2025-06-18"
|
||||
)
|
||||
|
||||
// Applier is the minimal grain-pool interface the cart MCP needs: read a
|
||||
@@ -37,123 +35,15 @@ type Applier interface {
|
||||
// Server is the MCP edge over the cart grain pool.
|
||||
type Server struct {
|
||||
applier Applier
|
||||
tools []tool
|
||||
mcp *pmcp.Server
|
||||
}
|
||||
|
||||
// New builds an MCP server exposing the cart grain as tools.
|
||||
func New(applier Applier) *Server {
|
||||
s := &Server{applier: applier}
|
||||
s.tools = s.buildTools()
|
||||
s.mcp = pmcp.NewServer(pmcp.Info{Name: serverName, Version: serverVersion}, s.buildTools()...)
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the streamable-HTTP handler to mount (e.g. at /mcp).
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return http.HandlerFunc(s.serveHTTP)
|
||||
}
|
||||
|
||||
func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
|
||||
if err != nil {
|
||||
writeRPC(w, errorResponse(nil, codeParseError, "read error"))
|
||||
return
|
||||
}
|
||||
if isBatch(body) {
|
||||
var reqs []rpcRequest
|
||||
if err := json.Unmarshal(body, &reqs); err != nil {
|
||||
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
|
||||
return
|
||||
}
|
||||
var out []*rpcResponse
|
||||
for i := range reqs {
|
||||
if resp := s.dispatch(&reqs[i]); resp != nil {
|
||||
out = append(out, resp)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
writeRPC(w, out)
|
||||
return
|
||||
}
|
||||
var req rpcRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
writeRPC(w, errorResponse(nil, codeParseError, "invalid JSON"))
|
||||
return
|
||||
}
|
||||
resp := s.dispatch(&req)
|
||||
if resp == nil {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
writeRPC(w, resp)
|
||||
}
|
||||
|
||||
func (s *Server) dispatch(req *rpcRequest) *rpcResponse {
|
||||
if req.JSONRPC != "2.0" {
|
||||
if req.isNotification() {
|
||||
return nil
|
||||
}
|
||||
return errorResponse(req.ID, codeInvalidRequest, "jsonrpc must be \"2.0\"")
|
||||
}
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
return result(req.ID, s.initialize(req.Params))
|
||||
case "ping":
|
||||
return result(req.ID, struct{}{})
|
||||
case "tools/list":
|
||||
return result(req.ID, map[string]any{"tools": s.tools})
|
||||
case "tools/call":
|
||||
return s.callTool(req)
|
||||
case "notifications/initialized", "notifications/cancelled":
|
||||
return nil
|
||||
default:
|
||||
if req.isNotification() {
|
||||
return nil
|
||||
}
|
||||
return errorResponse(req.ID, codeMethodNotFound, "unknown method: "+req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) initialize(params json.RawMessage) map[string]any {
|
||||
pv := protocolVersion
|
||||
if len(params) > 0 {
|
||||
var p struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
}
|
||||
if json.Unmarshal(params, &p) == nil && p.ProtocolVersion != "" {
|
||||
pv = p.ProtocolVersion
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"protocolVersion": pv,
|
||||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||
"serverInfo": map[string]any{"name": serverName, "version": serverVersion},
|
||||
}
|
||||
}
|
||||
|
||||
func isBatch(body []byte) bool {
|
||||
for _, b := range body {
|
||||
switch b {
|
||||
case ' ', '\t', '\r', '\n':
|
||||
continue
|
||||
case '[':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeRPC(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
func (s *Server) Handler() http.Handler { return s.mcp.Handler() }
|
||||
|
||||
+68
-200
@@ -4,107 +4,33 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
pmcp "git.k6n.net/mats/platform/mcp"
|
||||
)
|
||||
|
||||
// tool is one MCP tool: a name, a description, a JSON-Schema for its arguments,
|
||||
// and the handler that runs it.
|
||||
type tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
invoke func(args json.RawMessage) (any, error) `json:"-"`
|
||||
}
|
||||
|
||||
func (s *Server) callTool(req *rpcRequest) *rpcResponse {
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, &p); err != nil {
|
||||
return errorResponse(req.ID, codeInvalidParams, "invalid params")
|
||||
}
|
||||
|
||||
// Extract UCP meta from arguments (UCP-Agent profile advertisement).
|
||||
// Per the UCP spec, meta is a sibling key inside arguments:
|
||||
// "arguments": { "meta": { "ucp-agent": { "profile": "..." } }, ... }
|
||||
args := extractUCPAgentMeta(&p.Arguments)
|
||||
|
||||
var t *tool
|
||||
for i := range s.tools {
|
||||
if s.tools[i].Name == p.Name {
|
||||
t = &s.tools[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if t == nil {
|
||||
return errorResponse(req.ID, codeInvalidParams, "unknown tool: "+p.Name)
|
||||
}
|
||||
out, err := t.invoke(args)
|
||||
if err != nil {
|
||||
return result(req.ID, toolError(err))
|
||||
}
|
||||
return result(req.ID, toolText(out))
|
||||
}
|
||||
|
||||
// extractUCPAgentMeta checks if the JSON arguments contain a "meta" key with
|
||||
// a nested "ucp-agent.profile" field. If found, it logs the profile (for
|
||||
// observability) and strips the "meta" key before returning the cleaned
|
||||
// arguments to the tool handler, so the meta object does not leak into the
|
||||
// tool's argument namespace.
|
||||
func extractUCPAgentMeta(arguments *json.RawMessage) json.RawMessage {
|
||||
if arguments == nil || len(*arguments) == 0 {
|
||||
return *arguments
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(*arguments, &obj); err != nil {
|
||||
return *arguments
|
||||
}
|
||||
metaRaw, hasMeta := obj["meta"]
|
||||
if !hasMeta {
|
||||
return *arguments
|
||||
}
|
||||
// Try to extract the profile for observability.
|
||||
var meta struct {
|
||||
UCPAgent *struct {
|
||||
Profile string `json:"profile"`
|
||||
} `json:"ucp-agent"`
|
||||
}
|
||||
if err := json.Unmarshal(metaRaw, &meta); err == nil && meta.UCPAgent != nil && meta.UCPAgent.Profile != "" {
|
||||
log.Printf("ucp-agent: profile=%s", meta.UCPAgent.Profile)
|
||||
}
|
||||
// Strip meta from arguments before passing to the tool handler.
|
||||
delete(obj, "meta")
|
||||
cleaned, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return *arguments
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func (s *Server) buildTools() []tool {
|
||||
return []tool{
|
||||
// buildTools returns the cart's MCP tools (transport/dispatch live in platform/mcp).
|
||||
func (s *Server) buildTools() []pmcp.Tool {
|
||||
return []pmcp.Tool{
|
||||
{
|
||||
Name: "get_cart",
|
||||
Description: "Get the full state of a cart by its base62 cart id: items, totals, vouchers, promotions, user info, currency, language, checkout status, subscription details, and all applied/pending promotions with progress nudges.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
}, []string{"cartId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
g, err := s.applier.Get(context.Background(), uint64(id))
|
||||
g, err := s.applier.Get(ctx, uint64(id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get cart: %w", err)
|
||||
}
|
||||
@@ -114,21 +40,21 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "get_cart_items",
|
||||
Description: "List all items in a cart with their SKU, name, quantity, unit price, total price, stock, tax rate, markings, custom fields, and optional child/parent relationships.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
}, []string{"cartId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
g, err := s.applier.Get(context.Background(), uint64(id))
|
||||
g, err := s.applier.Get(ctx, uint64(id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get cart: %w", err)
|
||||
}
|
||||
@@ -138,21 +64,21 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "get_cart_totals",
|
||||
Description: "Get price breakdown for a cart: total incVat, total exVat, VAT breakdown by rate, total discount (vouchers + promotions), and per-item totals.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
}, []string{"cartId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
g, err := s.applier.Get(context.Background(), uint64(id))
|
||||
g, err := s.applier.Get(ctx, uint64(id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get cart: %w", err)
|
||||
}
|
||||
@@ -168,21 +94,21 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "get_cart_promotions",
|
||||
Description: "Get all applied and pending promotions on a cart, including discount amounts and progress nudges (e.g. 'spend 1200 SEK more for free shipping').",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
}, []string{"cartId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
g, err := s.applier.Get(context.Background(), uint64(id))
|
||||
g, err := s.applier.Get(ctx, uint64(id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get cart: %w", err)
|
||||
}
|
||||
@@ -195,21 +121,21 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "get_cart_vouchers",
|
||||
Description: "List all vouchers applied to a cart with their codes, values, rules, and whether they are currently active.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
}, []string{"cartId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
g, err := s.applier.Get(context.Background(), uint64(id))
|
||||
g, err := s.applier.Get(ctx, uint64(id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get cart: %w", err)
|
||||
}
|
||||
@@ -219,25 +145,25 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "update_item_quantity",
|
||||
Description: "Change the quantity of a line item in a cart. Use quantity=0 to remove the item. Returns the updated cart state.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
"itemId": integer("the line item id (numeric, e.g. 1, 2, 3)"),
|
||||
"quantity": integer("the new quantity (0 removes the item)"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
"itemId": pmcp.Integer("the line item id (numeric, e.g. 1, 2, 3)"),
|
||||
"quantity": pmcp.Integer("the new quantity (0 removes the item)"),
|
||||
}, []string{"cartId", "itemId", "quantity"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
ItemID int `json:"itemId"`
|
||||
Quantity int32 `json:"quantity"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.ChangeQuantity{
|
||||
res, err := s.applier.Apply(ctx, uint64(id), &messages.ChangeQuantity{
|
||||
Id: uint32(a.ItemID),
|
||||
Quantity: a.Quantity,
|
||||
})
|
||||
@@ -256,23 +182,23 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "remove_cart_item",
|
||||
Description: "Remove a line item (and its children) from a cart by item id. Returns the updated cart state.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
"itemId": integer("the line item id to remove (numeric, e.g. 1, 2, 3)"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
"itemId": pmcp.Integer("the line item id to remove (numeric, e.g. 1, 2, 3)"),
|
||||
}, []string{"cartId", "itemId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
ItemID int `json:"itemId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RemoveItem{Id: uint32(a.ItemID)})
|
||||
res, err := s.applier.Apply(ctx, uint64(id), &messages.RemoveItem{Id: uint32(a.ItemID)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remove item: %w", err)
|
||||
}
|
||||
@@ -287,21 +213,21 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "clear_cart",
|
||||
Description: "Remove all items and vouchers from a cart, resetting it to an empty state. The cart id, user id, and currency are preserved. Returns the updated (empty) cart.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
}, []string{"cartId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.ClearCartRequest{})
|
||||
res, err := s.applier.Apply(ctx, uint64(id), &messages.ClearCartRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clear cart: %w", err)
|
||||
}
|
||||
@@ -316,14 +242,14 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "apply_voucher",
|
||||
Description: "Apply a voucher code to a cart. The voucher value is in öre (e.g. 10000 = 100 kr). Returns the updated cart with the voucher applied.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
"code": str("the voucher code"),
|
||||
"value": integer("the voucher value in öre (e.g. 10000 = 100 kr)"),
|
||||
"description": str("optional description of the voucher"),
|
||||
"rules": stringArray("optional list of rule expressions for when the voucher applies"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
"code": pmcp.String("the voucher code"),
|
||||
"value": pmcp.Integer("the voucher value in öre (e.g. 10000 = 100 kr)"),
|
||||
"description": pmcp.String("optional description of the voucher"),
|
||||
"rules": pmcp.StringArray("optional list of rule expressions for when the voucher applies"),
|
||||
}, []string{"cartId", "code", "value"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
Code string `json:"code"`
|
||||
@@ -331,14 +257,14 @@ func (s *Server) buildTools() []tool {
|
||||
Description string `json:"description"`
|
||||
Rules []string `json:"rules"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.AddVoucher{
|
||||
res, err := s.applier.Apply(ctx, uint64(id), &messages.AddVoucher{
|
||||
Code: a.Code,
|
||||
Value: a.Value,
|
||||
Description: a.Description,
|
||||
@@ -358,23 +284,23 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "remove_voucher",
|
||||
Description: "Remove a voucher from a cart by its voucher id. Returns the updated cart.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
"voucherId": integer("the voucher id to remove (numeric)"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
"voucherId": pmcp.Integer("the voucher id to remove (numeric)"),
|
||||
}, []string{"cartId", "voucherId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
VoucherID int `json:"voucherId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.RemoveVoucher{Id: uint32(a.VoucherID)})
|
||||
res, err := s.applier.Apply(ctx, uint64(id), &messages.RemoveVoucher{Id: uint32(a.VoucherID)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remove voucher: %w", err)
|
||||
}
|
||||
@@ -389,23 +315,23 @@ func (s *Server) buildTools() []tool {
|
||||
{
|
||||
Name: "set_cart_user",
|
||||
Description: "Set or update the user ID on a cart, linking it to a customer account. Returns the updated cart.",
|
||||
InputSchema: object(props{
|
||||
"cartId": str("the base62 cart id"),
|
||||
"userId": str("the user/customer id"),
|
||||
InputSchema: pmcp.Object(pmcp.Props{
|
||||
"cartId": pmcp.String("the base62 cart id"),
|
||||
"userId": pmcp.String("the user/customer id"),
|
||||
}, []string{"cartId", "userId"}),
|
||||
invoke: func(args json.RawMessage) (any, error) {
|
||||
Invoke: func(ctx context.Context, args json.RawMessage) (any, error) {
|
||||
var a struct {
|
||||
CartID string `json:"cartId"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if err := decode(args, &a); err != nil {
|
||||
if err := pmcp.Decode(args, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := cart.ParseCartId(a.CartID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid cart id %q", a.CartID)
|
||||
}
|
||||
res, err := s.applier.Apply(context.Background(), uint64(id), &messages.SetUserId{UserId: a.UserID})
|
||||
res, err := s.applier.Apply(ctx, uint64(id), &messages.SetUserId{UserId: a.UserID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("set user: %w", err)
|
||||
}
|
||||
@@ -421,61 +347,3 @@ func (s *Server) buildTools() []tool {
|
||||
}
|
||||
|
||||
// ---- result + schema helpers -----------------------------------------------
|
||||
|
||||
func toolText(v any) map[string]any {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return toolError(err)
|
||||
}
|
||||
return map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": string(b)}},
|
||||
}
|
||||
}
|
||||
|
||||
func toolError(err error) map[string]any {
|
||||
return map[string]any{
|
||||
"isError": true,
|
||||
"content": []map[string]any{{"type": "text", "text": err.Error()}},
|
||||
}
|
||||
}
|
||||
|
||||
// decode unmarshals tool arguments, tolerating empty/absent arguments.
|
||||
func decode(args json.RawMessage, v any) error {
|
||||
if len(args) == 0 || string(args) == "null" {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(args, v); err != nil {
|
||||
return fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type props map[string]json.RawMessage
|
||||
|
||||
func object(p props, required []string) json.RawMessage {
|
||||
m := map[string]any{
|
||||
"type": "object",
|
||||
"properties": p,
|
||||
}
|
||||
if len(required) > 0 {
|
||||
m["required"] = required
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return b
|
||||
}
|
||||
|
||||
func str(desc string) json.RawMessage { return scalar("string", desc) }
|
||||
func integer(d string) json.RawMessage { return scalar("integer", d) }
|
||||
func obj(desc string) json.RawMessage { return scalar("object", desc) }
|
||||
|
||||
func scalar(typ, desc string) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]any{"type": typ, "description": desc})
|
||||
return b
|
||||
}
|
||||
|
||||
func stringArray(desc string) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"type": "array", "description": desc, "items": map[string]string{"type": "string"},
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
package cart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// decodeExtra unpacks the dynamic product data carried as raw JSON. Invalid
|
||||
// payloads are logged and dropped rather than failing the mutation.
|
||||
func decodeExtra(b []byte) map[string]json.RawMessage {
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
log.Printf("AddItem: invalid extra_json: %v", err)
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// mutation_add_item.go
|
||||
//
|
||||
// Registers the AddItem cart mutation in the generic mutation registry.
|
||||
// This replaces the legacy switch-based logic previously found in CartGrain.Apply.
|
||||
//
|
||||
// Behavior:
|
||||
// - Validates quantity > 0
|
||||
// - If an item with the same item id (ItemId) exists -> increases quantity
|
||||
// - Else creates a new CartItem with computed tax amounts
|
||||
// - Totals recalculated automatically via WithTotals()
|
||||
//
|
||||
// Item identity is the catalog item id (ItemId), not the SKU: the product
|
||||
// service is looked up by id and the returned SKU is reference-only.
|
||||
//
|
||||
// NOTE: Any future field additions in messages.AddItem that affect pricing / tax
|
||||
// must keep this handler in sync.
|
||||
var ErrPaymentInProgress = errors.New("payment in progress")
|
||||
|
||||
func (c *CartMutationContext) AddItem(g *CartGrain, m *cart_messages.AddItem) error {
|
||||
ctx := context.Background()
|
||||
if m == nil {
|
||||
return fmt.Errorf("AddItem: nil payload")
|
||||
}
|
||||
if m.Quantity < 1 {
|
||||
return fmt.Errorf("AddItem: invalid quantity %d", m.Quantity)
|
||||
}
|
||||
|
||||
// Merge with any existing item having the same item id and matching StoreId
|
||||
// (including both nil). Identity is the id; SKU is reference-only.
|
||||
for _, existing := range g.Items {
|
||||
if existing.ItemId != m.ItemId {
|
||||
continue
|
||||
}
|
||||
sameStore := (existing.StoreId == nil && m.StoreId == nil) ||
|
||||
(existing.StoreId != nil && m.StoreId != nil && *existing.StoreId == *m.StoreId)
|
||||
if !sameStore {
|
||||
continue
|
||||
}
|
||||
if c.UseReservations(existing) {
|
||||
if err := c.ReleaseItem(ctx, g.Id, existing.Sku, existing.StoreId); err != nil {
|
||||
log.Printf("failed to release item %d: %v", existing.Id, err)
|
||||
}
|
||||
endTime, err := c.ReserveItem(ctx, g.Id, existing.Sku, existing.StoreId, existing.Quantity+uint16(m.Quantity))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing.ReservationEndTime = endTime
|
||||
}
|
||||
existing.Quantity += uint16(m.Quantity)
|
||||
existing.Stock = uint16(m.Stock)
|
||||
// If existing had nil store but new has one, adopt it.
|
||||
if existing.StoreId == nil && m.StoreId != nil {
|
||||
existing.StoreId = m.StoreId
|
||||
}
|
||||
// Refresh dynamic product data with the latest payload.
|
||||
if extra := decodeExtra(m.ExtraJson); extra != nil {
|
||||
existing.Extra = extra
|
||||
}
|
||||
// Replace custom fields when provided on the re-add.
|
||||
if len(m.CustomFields) > 0 {
|
||||
existing.CustomFields = m.CustomFields
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.lastItemId++
|
||||
taxRate := float32(25.0)
|
||||
if m.Tax > 0 {
|
||||
taxRate = float32(int(m.Tax) / 100)
|
||||
}
|
||||
|
||||
pricePerItem := NewPriceFromIncVat(m.Price, taxRate)
|
||||
|
||||
needsReservation := true
|
||||
if m.ReservationEndTime != nil {
|
||||
needsReservation = m.ReservationEndTime.AsTime().Before(time.Now())
|
||||
}
|
||||
|
||||
cartItem := &CartItem{
|
||||
Id: g.lastItemId,
|
||||
ItemId: uint32(m.ItemId),
|
||||
Quantity: uint16(m.Quantity),
|
||||
Sku: m.Sku,
|
||||
Tax: int(taxRate * 100),
|
||||
Meta: &ItemMeta{
|
||||
Name: m.Name,
|
||||
Image: m.Image,
|
||||
Brand: m.Brand,
|
||||
Category: m.Category,
|
||||
Category2: m.Category2,
|
||||
Category3: m.Category3,
|
||||
Category4: m.Category4,
|
||||
Category5: m.Category5,
|
||||
Outlet: m.Outlet,
|
||||
SellerName: m.SellerName,
|
||||
},
|
||||
SellerId: m.SellerId,
|
||||
Cgm: m.Cgm,
|
||||
SaleStatus: m.SaleStatus,
|
||||
ParentId: m.ParentId,
|
||||
|
||||
Price: *pricePerItem,
|
||||
TotalPrice: *MultiplyPrice(*pricePerItem, int64(m.Quantity)),
|
||||
|
||||
Stock: uint16(m.Stock),
|
||||
Disclaimer: m.Disclaimer,
|
||||
|
||||
OrgPrice: getOrgPrice(m.OrgPrice, taxRate),
|
||||
ArticleType: m.ArticleType,
|
||||
|
||||
StoreId: m.StoreId,
|
||||
|
||||
Extra: decodeExtra(m.ExtraJson),
|
||||
CustomFields: m.CustomFields,
|
||||
}
|
||||
|
||||
if needsReservation && c.UseReservations(cartItem) {
|
||||
endTime, err := c.ReserveItem(ctx, g.Id, m.Sku, m.StoreId, uint16(m.Quantity))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if endTime != nil {
|
||||
m.ReservationEndTime = timestamppb.New(*endTime)
|
||||
t := m.ReservationEndTime.AsTime()
|
||||
cartItem.ReservationEndTime = &t
|
||||
}
|
||||
}
|
||||
|
||||
g.Items = append(g.Items, cartItem)
|
||||
g.UpdateTotals()
|
||||
return nil
|
||||
}
|
||||
|
||||
func getOrgPrice(orgPrice int64, taxRate float32) *Price {
|
||||
if orgPrice <= 0 {
|
||||
return nil
|
||||
}
|
||||
return NewPriceFromIncVat(orgPrice, taxRate)
|
||||
}
|
||||
@@ -38,3 +38,155 @@ func TestAddItem_MergesByItemIdNotSku(t *testing.T) {
|
||||
t.Fatalf("items = %d, want 2", len(g.Items))
|
||||
}
|
||||
}
|
||||
|
||||
// Re-adding the same parent (same ItemId + StoreId, no ParentId on the
|
||||
// re-add itself) merges the quantity, and the proportional cascade resizes
|
||||
// every direct child to match the new parent qty so a "+1 drill" leaves
|
||||
// the bundle internally consistent.
|
||||
func TestAddItem_MergeCascadesToChildren(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||
t.Fatalf("add parent: %v", err)
|
||||
}
|
||||
parentLine := g.Items[0].Id
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 101, Sku: "C1", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
|
||||
t.Fatalf("add child: %v", err)
|
||||
}
|
||||
|
||||
// Re-add the same parent — same ItemId, no ParentId on the re-add itself.
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||
t.Fatalf("re-add parent: %v", err)
|
||||
}
|
||||
|
||||
// Parent qty 1 -> 2; child qty 1 -> 2 via cascade.
|
||||
for _, it := range g.Items {
|
||||
if it.Quantity != 2 {
|
||||
t.Errorf("line %s qty = %d, want 2 (merge cascade)", it.Sku, it.Quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Item identity for merge purposes includes ParentId: a standalone root
|
||||
// line (ParentId == nil) and a child line (ParentId == &someParentLine) that
|
||||
// happen to share an ItemId are different roles and MUST stay as distinct
|
||||
// lines. Merging would silently turn an accessory into a +N on the root
|
||||
// (or vice versa), corrupting the parent-child link.
|
||||
func TestAddItem_DoesNotMergeWhenParentIdDiffers(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
// Standalone root (no ParentId).
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||
t.Fatalf("add standalone: %v", err)
|
||||
}
|
||||
parentLine := g.Items[0].Id
|
||||
|
||||
// Same ItemId+StoreId but with ParentId set — must create a NEW line,
|
||||
// not bump the standalone's qty.
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000, ParentId: &parentLine}); err != nil {
|
||||
t.Fatalf("add child-form: %v", err)
|
||||
}
|
||||
|
||||
if len(g.Items) != 2 {
|
||||
t.Fatalf("items = %d, want 2 (different ParentId => distinct lines)", len(g.Items))
|
||||
}
|
||||
// Standalone untouched at qty 1; child-form is its own line at qty 1.
|
||||
for _, it := range g.Items {
|
||||
if it.Quantity != 1 {
|
||||
t.Errorf("line %d (parentId=%v) qty = %d, want 1 (no merge)", it.Id, it.ParentId, it.Quantity)
|
||||
}
|
||||
// One of them has nil ParentId and the other has &parentLine — guard
|
||||
// against an accidental merge ever flipping both to non-nil.
|
||||
}
|
||||
var seenNil, seenSet bool
|
||||
for _, it := range g.Items {
|
||||
if it.ParentId == nil {
|
||||
seenNil = true
|
||||
} else if *it.ParentId == parentLine {
|
||||
seenSet = true
|
||||
}
|
||||
}
|
||||
if !seenNil || !seenSet {
|
||||
t.Errorf("expected one line with nil ParentId and one with &parentLine; got nil=%v set=%v", seenNil, seenSet)
|
||||
}
|
||||
}
|
||||
|
||||
// Positive control: when BOTH sides have ParentId set to the SAME parent
|
||||
// line, the merge proceeds (this guards the new sameParent guard against
|
||||
// accidentally rejecting a legitimate "re-add same child of same parent"
|
||||
// case — e.g. an idempotent retry from a flaky request).
|
||||
func TestAddItem_MergesWhenBothParentIdMatch(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P", Quantity: 1, Price: 1000}); err != nil {
|
||||
t.Fatalf("add parent: %v", err)
|
||||
}
|
||||
parentLine := g.Items[0].Id
|
||||
|
||||
// First child: ItemId 200 under parent.
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 200, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
|
||||
t.Fatalf("add child: %v", err)
|
||||
}
|
||||
// Re-add the SAME child (same ItemId+StoreId+ParentId) — must merge into
|
||||
// qty 2, not create a second accessory line.
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 200, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentLine}); err != nil {
|
||||
t.Fatalf("re-add child: %v", err)
|
||||
}
|
||||
|
||||
if len(g.Items) != 2 {
|
||||
t.Fatalf("items = %d, want 2 (parent + merged child)", len(g.Items))
|
||||
}
|
||||
for _, it := range g.Items {
|
||||
switch it.Id {
|
||||
case parentLine:
|
||||
if it.Quantity != 1 {
|
||||
t.Errorf("parent qty = %d, want 1 (untouched)", it.Quantity)
|
||||
}
|
||||
default:
|
||||
if it.Quantity != 2 {
|
||||
t.Errorf("child qty = %d, want 2 (merged on same-PARENT re-add)", it.Quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same ItemId+StoreId+both with ParentId set to *different* parent lines
|
||||
// must also stay distinct — children belong to their own parent.
|
||||
func TestAddItem_DoesNotMergeAcrossDifferentParentLines(t *testing.T) {
|
||||
reg := NewCartMultationRegistry(NewCartMutationContext(nil))
|
||||
g := NewCartGrain(1, time.Now())
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 100, Sku: "P1", Quantity: 1, Price: 500}); err != nil {
|
||||
t.Fatalf("add parent A: %v", err)
|
||||
}
|
||||
parentA := g.Items[0].Id
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 200, Sku: "P2", Quantity: 1, Price: 500}); err != nil {
|
||||
t.Fatalf("add parent B: %v", err)
|
||||
}
|
||||
parentB := g.Items[1].Id
|
||||
|
||||
// Two children of different parents sharing an ItemId — distinct lines.
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 999, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentA}); err != nil {
|
||||
t.Fatalf("add child of A: %v", err)
|
||||
}
|
||||
if _, err := reg.Apply(ctx, g, &cart_messages.AddItem{ItemId: 999, Sku: "C", Quantity: 1, Price: 100, ParentId: &parentB}); err != nil {
|
||||
t.Fatalf("add child of B: %v", err)
|
||||
}
|
||||
|
||||
// 2 parent lines + 2 child lines = 4 total; no merges across.
|
||||
if len(g.Items) != 4 {
|
||||
t.Errorf("items = %d, want 4 (parents A,B + their distinct children)", len(g.Items))
|
||||
}
|
||||
for _, it := range g.Items {
|
||||
if it.Quantity != 1 {
|
||||
t.Errorf("line %d qty = %d, want 1 (no accidental merge)", it.Id, it.Quantity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user