Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e36af2524 |
@@ -10,7 +10,7 @@ jobs:
|
|||||||
runs-on: amd64
|
runs-on: amd64
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- name: Build amd64 image.
|
- name: Build amd64 image
|
||||||
run: |
|
run: |
|
||||||
docker build \
|
docker build \
|
||||||
--progress=plain \
|
--progress=plain \
|
||||||
|
|||||||
+6
-29
@@ -22,10 +22,7 @@
|
|||||||
############################
|
############################
|
||||||
# Build Stage
|
# Build Stage
|
||||||
############################
|
############################
|
||||||
# Run the Go toolchain on the NATIVE build platform and cross-compile to the
|
FROM golang:1.25-alpine AS build
|
||||||
# target via GOOS/GOARCH below — avoids emulating an amd64 toolchain on arm64
|
|
||||||
# hosts (QEMU segfaults during module download). No-op when build==target.
|
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
@@ -41,18 +38,12 @@ ARG TARGETOS
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
|
ENV CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH}
|
||||||
|
|
||||||
# Sibling module sources, supplied as BuildKit named contexts (see
|
# Dependency caching
|
||||||
# deploy/docker-compose.yml additional_contexts + deploy/k8s/build-push.sh
|
COPY go.mod go.sum ./
|
||||||
# --build-context). They are resolved locally via the go.work written below,
|
RUN go mod download
|
||||||
# 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 full source (relay on .dockerignore to prune)
|
||||||
COPY . ./go-cart-actor
|
COPY . .
|
||||||
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:
|
# (Optional) If you do NOT check in generated protobuf code, uncomment generation:
|
||||||
# RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
|
# RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \
|
||||||
@@ -86,18 +77,6 @@ RUN go build -trimpath -ldflags="-s -w \
|
|||||||
-X main.BuildDate=${BUILD_DATE}" \
|
-X main.BuildDate=${BUILD_DATE}" \
|
||||||
-o /out/go-checkout-actor ./cmd/checkout
|
-o /out/go-checkout-actor ./cmd/checkout
|
||||||
|
|
||||||
RUN go build -trimpath -ldflags="-s -w \
|
|
||||||
-X main.Version=${VERSION} \
|
|
||||||
-X main.GitCommit=${GIT_COMMIT} \
|
|
||||||
-X main.BuildDate=${BUILD_DATE}" \
|
|
||||||
-o /out/go-order-actor ./cmd/order
|
|
||||||
|
|
||||||
RUN go build -trimpath -ldflags="-s -w \
|
|
||||||
-X main.Version=${VERSION} \
|
|
||||||
-X main.GitCommit=${GIT_COMMIT} \
|
|
||||||
-X main.BuildDate=${BUILD_DATE}" \
|
|
||||||
-o /out/go-profile-actor ./cmd/profile
|
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# Runtime Stage
|
# Runtime Stage
|
||||||
############################
|
############################
|
||||||
@@ -109,8 +88,6 @@ COPY --from=build /out/go-cart-actor /go-cart-actor
|
|||||||
COPY --from=build /out/go-checkout-actor /go-checkout-actor
|
COPY --from=build /out/go-checkout-actor /go-checkout-actor
|
||||||
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
|
COPY --from=build /out/go-cart-backoffice /go-cart-backoffice
|
||||||
COPY --from=build /out/go-cart-inventory /go-cart-inventory
|
COPY --from=build /out/go-cart-inventory /go-cart-inventory
|
||||||
COPY --from=build /out/go-order-actor /go-order-actor
|
|
||||||
COPY --from=build /out/go-profile-actor /go-profile-actor
|
|
||||||
|
|
||||||
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
# Document (not expose forcibly) typical ports: 8080 (HTTP), 1337 (gRPC)
|
||||||
EXPOSE 8080 1337
|
EXPOSE 8080 1337
|
||||||
|
|||||||
@@ -14,17 +14,15 @@
|
|||||||
# Conventions:
|
# Conventions:
|
||||||
# - All .proto files live in $(PROTO_DIR)
|
# - All .proto files live in $(PROTO_DIR)
|
||||||
# - Generated Go code is emitted under $(PROTO_DIR) via go_package mapping
|
# - Generated Go code is emitted under $(PROTO_DIR) via go_package mapping
|
||||||
# - go_package is set to: git.k6n.net/mats/go-cart-actor/proto;messages
|
# - go_package is set to: git.k6n.net/go-cart-actor/proto;messages
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
MODULE_PATH := git.k6n.net/mats/go-cart-actor
|
MODULE_PATH := git.k6n.net/go-cart-actor
|
||||||
PROTO_DIR := proto
|
PROTO_DIR := proto
|
||||||
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto $(PROTO_DIR)/order.proto $(PROTO_DIR)/profile.proto
|
PROTOS := $(PROTO_DIR)/cart.proto $(PROTO_DIR)/control_plane.proto $(PROTO_DIR)/checkout.proto
|
||||||
CART_PROTO_DIR := $(PROTO_DIR)/cart
|
CART_PROTO_DIR := $(PROTO_DIR)/cart
|
||||||
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
|
CONTROL_PROTO_DIR := $(PROTO_DIR)/control
|
||||||
CHECKOUT_PROTO_DIR := $(PROTO_DIR)/checkout
|
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
|
# Allow override: make PROTOC=/path/to/protoc
|
||||||
PROTOC ?= protoc
|
PROTOC ?= protoc
|
||||||
@@ -85,13 +83,6 @@ protogen: check_tools
|
|||||||
--go_out=./proto/checkout --go_opt=paths=source_relative \
|
--go_out=./proto/checkout --go_opt=paths=source_relative \
|
||||||
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
|
--go-grpc_out=./proto/checkout --go-grpc_opt=paths=source_relative \
|
||||||
$(PROTO_DIR)/checkout.proto
|
$(PROTO_DIR)/checkout.proto
|
||||||
$(PROTOC) -I $(PROTO_DIR) \
|
|
||||||
--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)"
|
@echo "$(GREEN)Protobuf generation complete.$(RESET)"
|
||||||
|
|
||||||
clean_proto:
|
clean_proto:
|
||||||
@@ -99,8 +90,6 @@ clean_proto:
|
|||||||
@rm -f $(PROTO_DIR)/cart/*_grpc.pb.go $(PROTO_DIR)/cart/*.pb.go
|
@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)/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)/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)"
|
@echo "$(GREEN)Clean complete.$(RESET)"
|
||||||
|
|
||||||
verify_proto:
|
verify_proto:
|
||||||
@@ -112,12 +101,17 @@ verify_proto:
|
|||||||
fi
|
fi
|
||||||
@echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)"
|
@echo "$(GREEN)Proto layout OK (no root-level *.pb.go files).$(RESET)"
|
||||||
|
|
||||||
run:
|
|
||||||
REDIS_ADDRESS=10.10.3.18:6379 \
|
|
||||||
REDIS_PASSWORD=slaskredis \
|
|
||||||
CART_DEBUG_PORT=8091 \
|
|
||||||
CART_PORT=8090 \
|
|
||||||
go run ./cmd/cart/
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
tidy:
|
tidy:
|
||||||
@echo "$(YELLOW)Running go mod tidy...$(RESET)"
|
@echo "$(YELLOW)Running go mod tidy...$(RESET)"
|
||||||
|
|||||||
@@ -1,404 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
# UCP API test suite
|
|
||||||
# Tests the Universal Commerce Protocol REST endpoints (cart, checkout, order)
|
|
||||||
# via curl.
|
|
||||||
#
|
|
||||||
# Prerequisites:
|
|
||||||
# make up-infra (infra compose up — order :8092, cart :8090)
|
|
||||||
# OR full stack (docker compose --profile "*" up — nginx on :8080)
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# # Direct to Docker services (infra stack):
|
|
||||||
# bash api-tests/test_ucp.sh --infra
|
|
||||||
#
|
|
||||||
# # Via nginx (full compose stack):
|
|
||||||
# bash api-tests/test_ucp.sh --edge
|
|
||||||
#
|
|
||||||
# # 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
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
MODE="${1:---infra}"
|
|
||||||
JQ="${JQ:-$(command -v jq || true)}"
|
|
||||||
|
|
||||||
# ── Base URLs ────────────────────────────────────────────────────────────────
|
|
||||||
if [ "$MODE" = "--edge" ]; then
|
|
||||||
BASE="http://localhost:8080"
|
|
||||||
CART_URL="$BASE"
|
|
||||||
ORDER_URL="$BASE"
|
|
||||||
CHECKOUT_URL="$BASE"
|
|
||||||
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:-http://localhost:8094}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "═══════════════════════════════════════════════════════════════"
|
|
||||||
echo " UCP API Test Suite — mode: $MODE"
|
|
||||||
echo " CART: ${CART_URL:-"(unreachable)"}"
|
|
||||||
echo " ORDER: ${ORDER_URL:-"(unreachable)"}"
|
|
||||||
echo " CHECK: ${CHECKOUT_URL:-"(unreachable)"}"
|
|
||||||
echo "═══════════════════════════════════════════════════════════════"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
pass() { echo " ✅ $1"; }
|
|
||||||
fail() { echo " ❌ $1"; }
|
|
||||||
header() { echo ""; echo "─── $1 ───"; }
|
|
||||||
|
|
||||||
check_jq() {
|
|
||||||
if [ -z "$JQ" ]; then
|
|
||||||
echo " ⚠️ jq not installed — showing raw JSON instead"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
maybe_jq() {
|
|
||||||
if [ -n "$JQ" ]; then
|
|
||||||
echo "$1" | jq "$2" 2>/dev/null || echo "$1" | head -c 200
|
|
||||||
else
|
|
||||||
echo "$1" | head -c 200
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_status() {
|
|
||||||
local label="$1" expected="$2" actual="$3" body="$4"
|
|
||||||
if [ "$actual" -eq "$expected" ]; then
|
|
||||||
pass "$label (HTTP $actual)"
|
|
||||||
else
|
|
||||||
fail "$label — expected HTTP $expected, got $actual"
|
|
||||||
echo " body: $(echo "$body" | head -c 300)"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# SECTION 1: Order service tests
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
if [ -n "${ORDER_URL:-}" ]; then
|
|
||||||
header "1. Order Service → $ORDER_URL"
|
|
||||||
|
|
||||||
# ── Health ──────────────────────────────────────────────────────────────────
|
|
||||||
echo "--- 1.1 Health check ---"
|
|
||||||
resp=$(curl -sf "$ORDER_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp"
|
|
||||||
|
|
||||||
# ── Create order via checkout ───────────────────────────────────────────────
|
|
||||||
echo "--- 1.2 Create order via POST /checkout ---"
|
|
||||||
create_resp=$(curl -s -w '\n%{http_code}' -X POST "$ORDER_URL/checkout" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"orderReference": "ucp-test-1",
|
|
||||||
"currency": "SEK",
|
|
||||||
"lines": [
|
|
||||||
{
|
|
||||||
"reference": "l1",
|
|
||||||
"sku": "TEST-SKU-1",
|
|
||||||
"name": "Test Product",
|
|
||||||
"quantity": 2,
|
|
||||||
"unitPrice": 19900,
|
|
||||||
"taxRate": 2500
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}')
|
|
||||||
create_code=$(echo "$create_resp" | tail -1)
|
|
||||||
create_body=$(echo "$create_resp" | sed '$d')
|
|
||||||
assert_status "Create order" 201 "$create_code" "$create_body"
|
|
||||||
ORDER_ID=$(echo "$create_body" | sed -n 's/.*"orderId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
|
||||||
if [ -n "$ORDER_ID" ]; then
|
|
||||||
pass "Order ID: $ORDER_ID"
|
|
||||||
else
|
|
||||||
ORDER_ID="1"
|
|
||||||
fail "Could not extract order ID, using '$ORDER_ID'"
|
|
||||||
echo " response: $(echo "$create_body" | head -c 400)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Read order via UCP ──────────────────────────────────────────────────────
|
|
||||||
echo "--- 1.3 GET /ucp/v1/orders/{id} ---"
|
|
||||||
get_resp=$(curl -s -w '\n%{http_code}' "$ORDER_URL/ucp/v1/orders/$ORDER_ID")
|
|
||||||
get_code=$(echo "$get_resp" | tail -1)
|
|
||||||
get_body=$(echo "$get_resp" | sed '$d')
|
|
||||||
assert_status "GET UCP order" 200 "$get_code" "$get_body"
|
|
||||||
echo " order: $(maybe_jq "$get_body" '.data.orderId + " status=" + .data.status')"
|
|
||||||
|
|
||||||
# ── Cancel order ────────────────────────────────────────────────────────────
|
|
||||||
echo "--- 1.4 POST /ucp/v1/orders/{id}/cancel ---"
|
|
||||||
cancel_resp=$(curl -s -w '\n%{http_code}' -X POST "$ORDER_URL/ucp/v1/orders/$ORDER_ID/cancel" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"reason": "test cancellation"}')
|
|
||||||
cancel_code=$(echo "$cancel_resp" | tail -1)
|
|
||||||
cancel_body=$(echo "$cancel_resp" | sed '$d')
|
|
||||||
# May be 200 (cancelled) or 422 (already cancelled by flow) — both ok
|
|
||||||
if [ "$cancel_code" = "200" ] || [ "$cancel_code" = "422" ]; then
|
|
||||||
pass "Cancel order (HTTP $cancel_code)"
|
|
||||||
echo " response: $(echo "$cancel_body" | head -c 200)"
|
|
||||||
if [ "$cancel_code" = "200" ]; then
|
|
||||||
ORDER_CANCELLED=true
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
fail "Cancel order — expected 200 or 422, got $cancel_code"
|
|
||||||
echo " body: $(echo "$cancel_body" | head -c 300)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 404 for non-existent order ──────────────────────────────────────────────
|
|
||||||
echo "--- 1.5 GET non-existent order (404) ---"
|
|
||||||
notfound_resp=$(curl -s -w '\n%{http_code}' "$ORDER_URL/ucp/v1/orders/nonexistent")
|
|
||||||
notfound_code=$(echo "$notfound_resp" | tail -1)
|
|
||||||
notfound_body=$(echo "$notfound_resp" | sed '$d')
|
|
||||||
assert_status "GET non-existent" 404 "$notfound_code" "$notfound_body"
|
|
||||||
|
|
||||||
# ── Signature headers (if enabled) ──────────────────────────────────────────
|
|
||||||
echo "--- 1.6 Check signature headers ---"
|
|
||||||
sig_resp=$(curl -s -i "$ORDER_URL/ucp/v1/orders/$ORDER_ID" 2>/dev/null | head -30)
|
|
||||||
if echo "$sig_resp" | grep -qi "signature-input"; then
|
|
||||||
pass "Signature-Input header present"
|
|
||||||
echo " $(echo "$sig_resp" | grep -i "signature-input" | head -1)"
|
|
||||||
else
|
|
||||||
echo " ℹ️ No Signature-Input header (signing key not mounted — expected in infra stack)"
|
|
||||||
fi
|
|
||||||
if echo "$sig_resp" | grep -qi "^signature:"; then
|
|
||||||
pass "Signature header present"
|
|
||||||
else
|
|
||||||
echo " ℹ️ No Signature header"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# SECTION 2: Cart service tests
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
if [ -n "${CART_URL:-}" ]; then
|
|
||||||
header "2. Cart Service → $CART_URL"
|
|
||||||
|
|
||||||
# ── Health ──────────────────────────────────────────────────────────────────
|
|
||||||
echo "--- 2.1 Health check ---"
|
|
||||||
resp=$(curl -sf "$CART_URL/healthz" 2>&1) && pass "/healthz" || fail "/healthz: $resp"
|
|
||||||
|
|
||||||
# ── Create cart via UCP ─────────────────────────────────────────────────────
|
|
||||||
echo "--- 2.2 POST /ucp/v1/carts/ (create cart) ---"
|
|
||||||
# Note: must use trailing slash — Go's ServeMux with StripPrefix only
|
|
||||||
# registers the subtree pattern (/ucp/v1/carts/) to avoid a redirect bug.
|
|
||||||
cart_resp=$(curl -s -w '\n%{http_code}' -X POST "$CART_URL/ucp/v1/carts/" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"currency": "SEK",
|
|
||||||
"country": "se",
|
|
||||||
"locale": "sv-SE"
|
|
||||||
}')
|
|
||||||
cart_code=$(echo "$cart_resp" | tail -1)
|
|
||||||
cart_body=$(echo "$cart_resp" | sed '$d')
|
|
||||||
assert_status "Create cart" 201 "$cart_code" "$cart_body"
|
|
||||||
CART_ID=$(echo "$cart_body" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
|
||||||
if [ -n "$CART_ID" ]; then
|
|
||||||
pass "Cart ID: $CART_ID"
|
|
||||||
else
|
|
||||||
CART_ID="1"
|
|
||||||
fail "Could not extract cart ID, using '$CART_ID'"
|
|
||||||
echo " response: $(echo "$cart_body" | head -c 400)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── 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,
|
|
||||||
"price": 19900,
|
|
||||||
"taxRate": 25
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}')
|
|
||||||
add_code=$(echo "$add_resp" | tail -1)
|
|
||||||
add_body=$(echo "$add_resp" | sed '$d')
|
|
||||||
assert_status "Update cart items" 200 "$add_code" "$add_body"
|
|
||||||
|
|
||||||
# ── Get cart ────────────────────────────────────────────────────────────────
|
|
||||||
echo "--- 2.4 GET /ucp/v1/carts/{id} ---"
|
|
||||||
get_cart_resp=$(curl -s -w '\n%{http_code}' "$CART_URL/ucp/v1/carts/$CART_ID")
|
|
||||||
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 " totalIncVat: $(echo "$get_cart_body" | sed -n 's/.*"totalIncVat"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# SECTION 3: Checkout service tests
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
if [ -n "${CHECKOUT_URL:-}" ] && [ -n "${CART_ID:-}" ]; then
|
|
||||||
header "3. Checkout Service → $CHECKOUT_URL"
|
|
||||||
|
|
||||||
# ── 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"'",
|
|
||||||
"currency": "SEK",
|
|
||||||
"country": "se"
|
|
||||||
}')
|
|
||||||
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: 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 "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 "--- 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
|
|
||||||
if echo "$line" | grep -qiE "(signature-input|signature:|x-ucp-timestamp|content-type)"; then
|
|
||||||
echo " $line"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo " To manually verify:"
|
|
||||||
echo " 1. Extract the Signature-Input and Signature headers"
|
|
||||||
echo " 2. Reconstruct the signature base string per RFC 9421 §2.2"
|
|
||||||
echo " 3. Verify using the public key (x/y in docs/ucp-profile.json)"
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
echo "═══════════════════════════════════════════════════════════════"
|
|
||||||
echo " Done."
|
|
||||||
echo ""
|
|
||||||
echo " Note: devProxies for /ucp/v1/* are now in islands.config.json."
|
|
||||||
echo " Run 'make dev' for same-origin proxied access."
|
|
||||||
echo "═══════════════════════════════════════════════════════════════"
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package backofficeadmin
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -219,15 +219,8 @@ func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
file, err := os.Open(fileName)
|
file, err := os.Open(fileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
|
return
|
||||||
_ = os.WriteFile(fileName, []byte("{}"), 0644)
|
|
||||||
file, err = os.Open(fileName)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
@@ -235,7 +228,6 @@ func (fs *FileServer) PromotionsHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
|
|
||||||
file, err := os.Create(fileName)
|
file, err := os.Create(fileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -255,15 +247,8 @@ func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
file, err := os.Open(fileName)
|
file, err := os.Open(fileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
|
return
|
||||||
_ = os.WriteFile(fileName, []byte("{}"), 0644)
|
|
||||||
file, err = os.Open(fileName)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
@@ -271,7 +256,6 @@ func (fs *FileServer) VoucherHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
_ = os.MkdirAll(filepath.Dir(fileName), 0755)
|
|
||||||
file, err := os.Create(fileName)
|
file, err := os.Create(fileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package backofficeadmin
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestAppendFileInfoRandomProjectFile picks a random existing .go source file in the
|
// TestAppendFileInfoRandomProjectFile picks a random existing .go source file in the
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package backofficeadmin
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
+147
-21
@@ -2,31 +2,156 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/backofficeadmin"
|
actor "git.k6n.net/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/platform/config"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/platform/rabbit"
|
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||||
|
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||||
|
"github.com/matst80/slask-finder/pkg/messaging"
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
type CartFileInfo struct {
|
||||||
addr := config.EnvString("ADDR", ":8080")
|
ID string `json:"id"`
|
||||||
amqpURL := os.Getenv("AMQP_URL")
|
CartId cart.CartId `json:"cartId"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Modified time.Time `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
app, err := backofficeadmin.New(backofficeadmin.Config{
|
type CheckoutFileInfo struct {
|
||||||
DataDir: config.EnvString("CART_DIR", "data"),
|
ID string `json:"id"`
|
||||||
CheckoutDataDir: config.EnvString("CHECKOUT_DIR", "checkout-data"),
|
CheckoutId checkout.CheckoutId `json:"checkoutId"`
|
||||||
})
|
Size int64 `json:"size"`
|
||||||
|
Modified time.Time `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOrDefault(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func startMutationConsumer(ctx context.Context, conn *amqp.Connection, hub *Hub) error {
|
||||||
|
ch, err := conn.Channel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error creating backoffice: %v", err)
|
_ = 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.Fatalf("connection closed")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Log and broadcast to all websocket clients
|
||||||
|
log.Printf("mutation event: %s", string(m.Body))
|
||||||
|
|
||||||
|
if hub != nil {
|
||||||
|
select {
|
||||||
|
case hub.broadcast <- m.Body:
|
||||||
|
default:
|
||||||
|
// if hub queue is full, drop to avoid blocking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.Ack(false); err != nil {
|
||||||
|
log.Printf("error acknowledging message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
||||||
|
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dataDir := envOrDefault("DATA_DIR", "data")
|
||||||
|
addr := envOrDefault("ADDR", ":8080")
|
||||||
|
amqpURL := os.Getenv("AMQP_URL")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.MkdirAll(dataDir, 0755)
|
||||||
|
|
||||||
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
||||||
|
diskStorage := actor.NewDiskStorage[cart.CartGrain](dataDir, reg)
|
||||||
|
|
||||||
|
checkoutDataDir := envOrDefault("CHECKOUT_DATA_DIR", "checkout-data")
|
||||||
|
_ = os.MkdirAll(checkoutDataDir, 0755)
|
||||||
|
|
||||||
|
regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
|
||||||
|
diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDataDir, regCheckout)
|
||||||
|
|
||||||
|
fs := NewFileServer(dataDir, checkoutDataDir, diskStorage, diskStorageCheckout)
|
||||||
|
|
||||||
|
hub := NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
app.RegisterRoutes(mux)
|
mux.HandleFunc("GET /carts", fs.CartsHandler)
|
||||||
|
mux.HandleFunc("GET /cart/{id}", fs.CartHandler)
|
||||||
|
mux.HandleFunc("GET /checkouts", fs.CheckoutsHandler)
|
||||||
|
mux.HandleFunc("GET /checkout/{id}", fs.CheckoutHandler)
|
||||||
|
mux.HandleFunc("PUT /inventory/{locationId}/{sku}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
inventoryLocationId := inventory.LocationID(r.PathValue("locationId"))
|
||||||
|
inventorySku := inventory.SKU(r.PathValue("sku"))
|
||||||
|
pipe := rdb.Pipeline()
|
||||||
|
var payload struct {
|
||||||
|
Quantity int64 `json:"quantity"`
|
||||||
|
}
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid payload", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inventoryService.UpdateInventory(r.Context(), pipe, inventorySku, inventoryLocationId, payload.Quantity)
|
||||||
|
|
||||||
|
_, err = pipe.Exec(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to update inventory", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = inventoryService.SendInventoryChanged(r.Context(), inventorySku, inventoryLocationId)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/promotions", fs.PromotionsHandler)
|
||||||
|
mux.HandleFunc("/vouchers", fs.VoucherHandler)
|
||||||
|
mux.HandleFunc("/promotion/{id}", fs.PromotionPartHandler)
|
||||||
|
|
||||||
|
mux.HandleFunc("/ws", hub.ServeWS)
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
@@ -40,7 +165,7 @@ func main() {
|
|||||||
_, _ = w.Write([]byte("ok"))
|
_, _ = w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Global CORS middleware allowing all origins and handling preflight.
|
// Global CORS middleware allowing all origins and handling preflight
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
@@ -64,21 +189,22 @@ func main() {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
var conn *rabbit.Conn
|
|
||||||
if amqpURL != "" {
|
if amqpURL != "" {
|
||||||
conn, err = rabbit.Dial(amqpURL, "cart-backoffice")
|
conn, err := amqp.Dial(amqpURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||||
}
|
}
|
||||||
}
|
if err := startMutationConsumer(ctx, conn, hub); err != nil {
|
||||||
if err := app.Start(ctx, conn); err != nil {
|
log.Printf("AMQP listener disabled: %v", err)
|
||||||
log.Printf("AMQP listener disabled: %v", err)
|
} else {
|
||||||
} else if conn != nil {
|
log.Printf("AMQP listener connected")
|
||||||
log.Printf("AMQP listener connected")
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("backoffice HTTP listening on %s", addr)
|
log.Printf("backoffice HTTP listening on %s (dataDir=%s)", addr, dataDir)
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Fatalf("http server error: %v", err)
|
log.Fatalf("http server error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// server stopped
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
"git.k6n.net/go-cart-actor/pkg/discovery"
|
||||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
@@ -24,9 +24,7 @@ func GetDiscovery() discovery.Discovery {
|
|||||||
log.Fatalf("Error creating client: %v\n", err)
|
log.Fatalf("Error creating client: %v\n", err)
|
||||||
}
|
}
|
||||||
timeout := int64(30)
|
timeout := int64(30)
|
||||||
// Scope discovery to this pod's namespace so it only needs a namespaced Role
|
return discovery.NewK8sDiscovery(client, v1.ListOptions{
|
||||||
// (pods: get/list/watch), not a cluster-wide ClusterRole.
|
|
||||||
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
|
|
||||||
LabelSelector: "actor-pool=cart",
|
LabelSelector: "actor-pool=cart",
|
||||||
TimeoutSeconds: &timeout,
|
TimeoutSeconds: &timeout,
|
||||||
})
|
})
|
||||||
|
|||||||
+89
-341
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@@ -13,23 +12,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/go-cart-actor/pkg/promotions"
|
||||||
cartmcp "git.k6n.net/mats/go-cart-actor/pkg/cart/mcp"
|
"git.k6n.net/go-cart-actor/pkg/proxy"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"git.k6n.net/go-cart-actor/pkg/voucher"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||||
"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/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"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
)
|
)
|
||||||
@@ -56,35 +47,6 @@ var amqpUrl = os.Getenv("AMQP_URL")
|
|||||||
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
var redisAddress = os.Getenv("REDIS_ADDRESS")
|
||||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||||
|
|
||||||
// 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 {
|
|
||||||
if strings.Contains(v, ":") {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return ":" + v
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadUCPCartSigner 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 loadUCPCartSigner() *ucp.SigningConfig {
|
|
||||||
path := os.Getenv("UCP_SIGNING_KEY_PATH")
|
|
||||||
if path == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pemData, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ucp signing: cannot read key %s: %v", path, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ucp signing: invalid key at %s: %v", path, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCountryFromHost(host string) string {
|
func getCountryFromHost(host string) string {
|
||||||
if strings.Contains(strings.ToLower(host), "-no") {
|
if strings.Contains(strings.ToLower(host), "-no") {
|
||||||
return "no"
|
return "no"
|
||||||
@@ -92,7 +54,7 @@ func getCountryFromHost(host string) string {
|
|||||||
if strings.Contains(strings.ToLower(host), "-se") {
|
if strings.Contains(strings.ToLower(host), "-se") {
|
||||||
return "se"
|
return "se"
|
||||||
}
|
}
|
||||||
return "se"
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
type MutationContext struct {
|
type MutationContext struct {
|
||||||
@@ -104,189 +66,70 @@ type CartChangeEvent struct {
|
|||||||
Mutations []actor.ApplyResult `json:"mutations"`
|
Mutations []actor.ApplyResult `json:"mutations"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// catalogProjectionCache is the cart's per-pod, cold-start-ready catalog
|
func matchesSkuAndLocation(update inventory.InventoryResult, item cart.CartItem) bool {
|
||||||
// projection cache, backed by platform/catalog.ProjectionStore. It consumes the
|
if string(update.SKU) == item.Sku {
|
||||||
// catalog.projection_published wire — a full snapshot (begin/chunk/end, framed by
|
if update.LocationID == "se" && item.StoreId == nil {
|
||||||
// epoch) plus incremental deltas — into its OWN local .bin and serves catalog
|
return true
|
||||||
// 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
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// projectionDeliveryHandler is the AMQP delivery handler for
|
|
||||||
// `catalog.projection_published` on the `catalog` topic exchange. Decodes the
|
|
||||||
// []ProjectionUpdate batch and merges it into cache. Returns nil on a batch
|
|
||||||
// that doesn't apply locally so the AMQP delivery is acked; returns an error
|
|
||||||
// on a decode failure so the broker knows to redeliver / quarantine.
|
|
||||||
//
|
|
||||||
// Recovery: any panic in a single event is caught so the goroutine keeps
|
|
||||||
// draining the queue; the bus stays live across malformed events.
|
|
||||||
func projectionDeliveryHandler(cache *catalogProjectionCache) func(amqp.Delivery) error {
|
|
||||||
return func(d amqp.Delivery) (err error) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
err = fmt.Errorf("projection handler panic recovered: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
var ev event.Event
|
|
||||||
if err := json.Unmarshal(d.Body, &ev); err != nil {
|
|
||||||
return fmt.Errorf("decode event envelope: %w", err)
|
|
||||||
}
|
}
|
||||||
// Route the frame (snapshot begin/chunk/end or delta) into the store;
|
if item.StoreId == nil {
|
||||||
// epoch ordering, snapshot assembly + atomic swap, and tombstones all live
|
return false
|
||||||
// in platform/catalog.ProjectionStore.
|
}
|
||||||
if err := cache.HandleFrame(ev.Meta, ev.Payload); err != nil {
|
if *item.StoreId == string(update.LocationID) {
|
||||||
return fmt.Errorf("apply projection frame: %w", err)
|
return true
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
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 := config.EnvString("CART_PORT", "8080")
|
|
||||||
if i := strings.LastIndex(cartPort, ":"); i >= 0 {
|
|
||||||
cartPort = cartPort[i+1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
controlPlaneConfig := actor.DefaultServerConfig()
|
controlPlaneConfig := actor.DefaultServerConfig()
|
||||||
|
|
||||||
promotionStore, err := promotions.NewStore("data/promotions.json")
|
promotionData, err := promotions.LoadStateFile("data/promotions.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error loading promotions: %v\n", err)
|
log.Printf("Error loading promotions: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("loaded %d promotions", len(promotionStore.List()))
|
log.Printf("loaded %d promotions", len(promotionData.State.Promotions))
|
||||||
|
|
||||||
promotionService := promotions.NewPromotionService(nil)
|
inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
||||||
|
|
||||||
//inventoryPubSub := actor.NewPubSub[inventory.InventoryChange]()
|
|
||||||
|
|
||||||
|
// promotionService := promotions.NewPromotionService(nil)
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: redisAddress,
|
Addr: redisAddress,
|
||||||
Password: redisPassword,
|
Password: redisPassword,
|
||||||
DB: 0,
|
DB: 0,
|
||||||
})
|
})
|
||||||
|
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error creating inventory service: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
inventoryReservationService, err := inventory.NewRedisCartReservationService(rdb)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
log.Fatalf("Error creating inventory reservation service: %v\n", err)
|
||||||
}
|
}
|
||||||
reservationPolicy := inventory.NewReservationPolicyAdapter(inventoryReservationService)
|
|
||||||
|
|
||||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(reservationPolicy))
|
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(inventoryReservationService))
|
||||||
reg.RegisterProcessor(
|
reg.RegisterProcessor(
|
||||||
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
actor.NewMutationProcessor(func(ctx context.Context, g *cart.CartGrain) error {
|
||||||
_, span := tracer.Start(ctx, "Totals and promotions")
|
_, span := tracer.Start(ctx, "Totals and promotions")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
// Clear bypass flags first
|
|
||||||
for _, v := range g.Vouchers {
|
|
||||||
if v != nil {
|
|
||||||
v.BypassedByPromotions = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g.UpdateTotals()
|
g.UpdateTotals()
|
||||||
promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()))
|
|
||||||
results, _ := promotionService.EvaluateAll(promotionStore.Snapshot(), promotionCtx)
|
|
||||||
|
|
||||||
// Check if any qualified promotion rule has a coupon_code condition matching our vouchers
|
|
||||||
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 {
|
|
||||||
// Re-evaluate with bypassed vouchers
|
|
||||||
g.UpdateTotals()
|
|
||||||
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)
|
|
||||||
g.Version++
|
g.Version++
|
||||||
|
// promotionCtx := promotions.NewContextFromCart(g, promotions.WithNow(time.Now()), promotions.WithCustomerSegment("vip"))
|
||||||
|
// _, actions := promotionService.EvaluateAll(promotionData.State.Promotions, promotionCtx)
|
||||||
|
// for _, action := range actions {
|
||||||
|
|
||||||
|
// log.Printf("apply: %+v", action)
|
||||||
|
|
||||||
|
// g.UpdateTotals()
|
||||||
|
// }
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](config.EnvString("CART_DIR", "data"), reg)
|
diskStorage := actor.NewDiskStorage[cart.CartGrain]("data", reg)
|
||||||
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
poolConfig := actor.GrainPoolConfig[cart.CartGrain]{
|
||||||
MutationRegistry: reg,
|
MutationRegistry: reg,
|
||||||
Storage: diskStorage,
|
Storage: diskStorage,
|
||||||
@@ -297,46 +140,46 @@ func main() {
|
|||||||
ret := cart.NewCartGrain(id, time.Now())
|
ret := cart.NewCartGrain(id, time.Now())
|
||||||
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
// Set baseline lastChange at spawn; replay may update it to last event timestamp.
|
||||||
|
|
||||||
// inventoryPubSub.Subscribe(ret.HandleInventoryChange)
|
inventoryPubSub.Subscribe(ret.HandleInventoryChange)
|
||||||
err := diskStorage.LoadEvents(ctx, id, ret)
|
err := diskStorage.LoadEvents(ctx, id, ret)
|
||||||
// if err == nil && inventoryService != nil {
|
if err == nil && inventoryService != nil {
|
||||||
// refs := make([]*inventory.InventoryReference, 0)
|
refs := make([]*inventory.InventoryReference, 0)
|
||||||
// for _, item := range ret.Items {
|
for _, item := range ret.Items {
|
||||||
// refs = append(refs, &inventory.InventoryReference{
|
refs = append(refs, &inventory.InventoryReference{
|
||||||
// SKU: inventory.SKU(item.Sku),
|
SKU: inventory.SKU(item.Sku),
|
||||||
// LocationID: getLocationId(item),
|
LocationID: getLocationId(item),
|
||||||
// })
|
})
|
||||||
// }
|
}
|
||||||
// _, span := tracer.Start(ctx, "update inventory")
|
_, span := tracer.Start(ctx, "update inventory")
|
||||||
// defer span.End()
|
defer span.End()
|
||||||
// res, err := inventoryService.GetInventoryBatch(ctx, refs...)
|
res, err := inventoryService.GetInventoryBatch(ctx, refs...)
|
||||||
// if err != nil {
|
if err != nil {
|
||||||
// log.Printf("unable to update inventory %v", err)
|
log.Printf("unable to update inventory %v", err)
|
||||||
// } else {
|
} else {
|
||||||
// for _, update := range res {
|
for _, update := range res {
|
||||||
// for _, item := range ret.Items {
|
for _, item := range ret.Items {
|
||||||
// if matchesSkuAndLocation(update, *item) && update.Quantity != uint32(item.Stock) {
|
if matchesSkuAndLocation(update, *item) && update.Quantity != uint32(item.Stock) {
|
||||||
// // maybe apply an update to give visibility to the cart
|
// maybe apply an update to give visibility to the cart
|
||||||
// item.Stock = uint16(update.Quantity)
|
item.Stock = uint16(update.Quantity)
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
return ret, err
|
return ret, err
|
||||||
},
|
},
|
||||||
Destroy: func(grain actor.Grain[cart.CartGrain]) error {
|
Destroy: func(grain actor.Grain[cart.CartGrain]) error {
|
||||||
// cart, err := grain.GetCurrentState()
|
cart, err := grain.GetCurrentState()
|
||||||
// if err != nil {
|
if err != nil {
|
||||||
// return err
|
return err
|
||||||
// }
|
}
|
||||||
//inventoryPubSub.Unsubscribe(cart.HandleInventoryChange)
|
inventoryPubSub.Unsubscribe(cart.HandleInventoryChange)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
|
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
|
||||||
return proxy.NewRemoteHost[cart.CartGrain](host, cartPort)
|
return proxy.NewRemoteHost[cart.CartGrain](host)
|
||||||
},
|
},
|
||||||
TTL: 5 * time.Minute,
|
TTL: 5 * time.Minute,
|
||||||
PoolSize: 2 * 65535,
|
PoolSize: 2 * 65535,
|
||||||
@@ -348,56 +191,7 @@ func main() {
|
|||||||
log.Fatalf("Error creating cart pool: %v\n", err)
|
log.Fatalf("Error creating cart pool: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cartMCP := cartmcp.New(pool)
|
syncedServer := NewPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), inventoryService, inventoryReservationService)
|
||||||
|
|
||||||
// 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 := rabbit.Dial(amqpUrl, "cart"); derr != nil {
|
|
||||||
log.Printf("cart: AMQP connect failed, mutation feed disabled: %v", derr)
|
|
||||||
} else {
|
|
||||||
listener := actor.NewAmqpListener(conn.Connection(), "cart", actor.MutationSummary)
|
|
||||||
listener.DefineTopics()
|
|
||||||
pool.AddListener(listener)
|
|
||||||
log.Printf("cart: mutation feed enabled (mutation.cart)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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), projectionDeliveryHandler(projectionCache)); 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{
|
app := &App{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
@@ -413,49 +207,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer grpcSrv.GracefulStop()
|
defer grpcSrv.GracefulStop()
|
||||||
|
|
||||||
go diskStorage.SaveLoop(20 * time.Second)
|
// go diskStorage.SaveLoop(10 * time.Second)
|
||||||
UseDiscovery(pool)
|
UseDiscovery(pool)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
otelShutdown, err := setupOTelSDK(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
log.Fatalf("Unable to start otel %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
syncedServer.Serve(mux)
|
syncedServer.Serve(mux)
|
||||||
|
|
||||||
// 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 /promotions-mcp.
|
|
||||||
mux.Handle("/cart-mcp", cartMCP.Handler())
|
|
||||||
mux.Handle("/cart-mcp/", cartMCP.Handler())
|
|
||||||
|
|
||||||
// UCP REST adapter — Universal Commerce Protocol
|
|
||||||
cartUCP := ucp.CartHandler(pool)
|
|
||||||
if signer := loadUCPCartSigner(); signer != nil {
|
|
||||||
cartUCP = ucp.WithSigning(cartUCP, signer)
|
|
||||||
log.Print("ucp signing enabled")
|
|
||||||
}
|
|
||||||
// StripPrefix is required because the sub-mux (CartHandler) registers
|
|
||||||
// routes like "GET /{id}" which expect a relative path; Go's ServeMux
|
|
||||||
// passes the full request path (e.g. /ucp/v1/carts/123) without stripping
|
|
||||||
// the prefix, so the sub-mux would never match.
|
|
||||||
//
|
|
||||||
// Only the subtree pattern (/ucp/v1/carts/) is registered, NOT the exact
|
|
||||||
// pattern (/ucp/v1/carts), because registering both causes Go's ServeMux
|
|
||||||
// to redirect the exact match to the subtree root, and StripPrefix
|
|
||||||
// interferes with the redirect target (producing a 307 to "/" instead
|
|
||||||
// of "/ucp/v1/carts/"). Consumers should use the trailing-slash variant.
|
|
||||||
mux.Handle("/ucp/v1/carts/", http.StripPrefix("/ucp/v1/carts", cartUCP))
|
|
||||||
|
|
||||||
// Stateless promotion evaluation: POST a (possibly partial) eval context and
|
|
||||||
// get back the resulting totals plus the applied/pending effect breakdown,
|
|
||||||
// without creating or mutating a real cart.
|
|
||||||
mux.HandleFunc("POST /promotions/evaluate", newPromotionEvaluateHandler(promotionStore, promotionService))
|
|
||||||
|
|
||||||
// only for local
|
// only for local
|
||||||
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /add/remote/{host}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
pool.AddRemote(r.PathValue("host"))
|
pool.AddRemote(r.PathValue("host"))
|
||||||
@@ -467,23 +230,6 @@ func main() {
|
|||||||
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
debugMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||||
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
debugMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||||
debugMux.Handle("/metrics", promhttp.Handler())
|
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) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
// Grain pool health: simple capacity check (mirrors previous GrainHandler.IsHealthy)
|
||||||
grainCount, capacity := app.pool.LocalUsage()
|
grainCount, capacity := app.pool.LocalUsage()
|
||||||
@@ -516,16 +262,10 @@ func main() {
|
|||||||
|
|
||||||
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
|
mux.HandleFunc("/openapi.json", ServeEmbeddedOpenAPI)
|
||||||
|
|
||||||
httpAddr := normalizeListenAddr(cartPort)
|
|
||||||
debugAddr := normalizeListenAddr(config.EnvString("CART_DEBUG_PORT", "8081"))
|
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: httpAddr,
|
Addr: ":8080",
|
||||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
// Close idle keep-alive connections so a load test with many short-lived
|
|
||||||
// clients doesn't pin file handles open indefinitely.
|
|
||||||
IdleTimeout: 60 * time.Second,
|
|
||||||
WriteTimeout: 20 * time.Second,
|
WriteTimeout: 20 * time.Second,
|
||||||
Handler: otelhttp.NewHandler(mux, "/"),
|
Handler: otelhttp.NewHandler(mux, "/"),
|
||||||
}
|
}
|
||||||
@@ -544,15 +284,23 @@ func main() {
|
|||||||
srvErr <- srv.ListenAndServe()
|
srvErr <- srv.ListenAndServe()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Inventory change consumption used to live here over the bare Redis
|
listener := inventory.NewInventoryChangeListener(rdb, context.Background(), func(changes []inventory.InventoryChange) {
|
||||||
// `inventory_changed` channel; it is now owned by the cart-inventory
|
for _, change := range changes {
|
||||||
// service, which translates quantity changes into inventory.level_changed
|
log.Printf("inventory change: %v", change)
|
||||||
// bus crossings. The cart reads exact stock synchronously when it needs it.
|
inventoryPubSub.Publish(change)
|
||||||
// See docs/inventory-shape.md.
|
}
|
||||||
|
})
|
||||||
|
|
||||||
log.Printf("Server started at %s (debug %s)", httpAddr, debugAddr)
|
go func() {
|
||||||
|
err := listener.Start()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Unable to start inventory listener: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
go http.ListenAndServe(debugAddr, debugMux)
|
log.Print("Server started at port 8080")
|
||||||
|
|
||||||
|
go http.ListenAndServe(":8081", debugMux)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case err = <-srvErr:
|
case err = <-srvErr:
|
||||||
|
|||||||
+4
-81
@@ -358,40 +358,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/cart/item/{itemId}/custom-fields": {
|
|
||||||
"put": {
|
|
||||||
"summary": "Set/merge custom input fields on a line item",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "itemId",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": { "type": "integer", "format": "int64", "minimum": 0 },
|
|
||||||
"description": "Internal cart line item identifier."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"required": true,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": { "$ref": "#/components/schemas/SetCustomFieldsRequest" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Custom fields updated",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": { "$ref": "#/components/schemas/CartGrain" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": { "description": "Invalid body or id" },
|
|
||||||
"500": { "description": "Server error" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/cart/item/{itemId}/marking": {
|
"/cart/item/{itemId}/marking": {
|
||||||
"put": {
|
"put": {
|
||||||
"summary": "Set marking for line item",
|
"summary": "Set marking for line item",
|
||||||
@@ -989,14 +955,10 @@
|
|||||||
},
|
},
|
||||||
"CartItem": {
|
"CartItem": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Cart line item. Beyond the typed properties below, arbitrary dynamic product data is flattened onto the object as additional top-level keys (see additionalProperties).",
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "integer" },
|
"id": { "type": "integer" },
|
||||||
"itemId": { "type": "integer" },
|
"itemId": { "type": "integer" },
|
||||||
"parentId": {
|
"parentId": { "type": "integer" },
|
||||||
"type": "integer",
|
|
||||||
"description": "Line-item id (the `id` field) of the parent item, set when this line is a child/sub-article"
|
|
||||||
},
|
|
||||||
"sku": { "type": "string" },
|
"sku": { "type": "string" },
|
||||||
"price": { "$ref": "#/components/schemas/Price" },
|
"price": { "$ref": "#/components/schemas/Price" },
|
||||||
"totalPrice": { "$ref": "#/components/schemas/Price" },
|
"totalPrice": { "$ref": "#/components/schemas/Price" },
|
||||||
@@ -1013,19 +975,11 @@
|
|||||||
"meta": { "$ref": "#/components/schemas/ItemMeta" },
|
"meta": { "$ref": "#/components/schemas/ItemMeta" },
|
||||||
"saleStatus": { "type": "string" },
|
"saleStatus": { "type": "string" },
|
||||||
"marking": { "$ref": "#/components/schemas/Marking" },
|
"marking": { "$ref": "#/components/schemas/Marking" },
|
||||||
"customFields": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": { "type": "string" },
|
|
||||||
"description": "User-supplied custom input fields for this line"
|
|
||||||
},
|
|
||||||
"subscriptionDetailsId": { "type": "string" },
|
"subscriptionDetailsId": { "type": "string" },
|
||||||
"orderReference": { "type": "string" },
|
"orderReference": { "type": "string" },
|
||||||
"isSubscribed": { "type": "boolean" }
|
"isSubscribed": { "type": "boolean" }
|
||||||
},
|
},
|
||||||
"required": ["id", "sku", "price", "qty"],
|
"required": ["id", "sku", "price", "qty"]
|
||||||
"additionalProperties": {
|
|
||||||
"description": "Dynamic product data carried through verbatim (e.g. glas, hangning, materialkular). Value can be any JSON type."
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"CartDelivery": {
|
"CartDelivery": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1067,17 +1021,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Two-letter country code (inferred if omitted)"
|
"description": "Two-letter country code (inferred if omitted)"
|
||||||
},
|
},
|
||||||
"storeId": { "type": "string", "nullable": true },
|
"storeId": { "type": "string", "nullable": true }
|
||||||
"children": {
|
|
||||||
"type": "array",
|
|
||||||
"description": "Sub-articles (accessories, services, insurance, ...) added as separate cart lines whose parentId points to this item's line, and priced relative to this parent item.",
|
|
||||||
"items": { "$ref": "#/components/schemas/Item" }
|
|
||||||
},
|
|
||||||
"customFields": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": { "type": "string" },
|
|
||||||
"description": "Optional user-supplied input fields for this line"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"required": ["sku"]
|
"required": ["sku"]
|
||||||
},
|
},
|
||||||
@@ -1098,17 +1042,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"sku": { "type": "string" },
|
"sku": { "type": "string" },
|
||||||
"quantity": { "type": "integer", "minimum": 1 },
|
"quantity": { "type": "integer", "minimum": 1 },
|
||||||
"storeId": { "type": "string", "nullable": true },
|
"storeId": { "type": "string", "nullable": true }
|
||||||
"children": {
|
|
||||||
"type": "array",
|
|
||||||
"description": "Sub-articles (accessories, services, insurance, ...). Each is added as a separate cart line whose parentId points to this item's line, and is priced relative to this parent item.",
|
|
||||||
"items": { "$ref": "#/components/schemas/Item" }
|
|
||||||
},
|
|
||||||
"customFields": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": { "type": "string" },
|
|
||||||
"description": "Optional user-supplied input fields for this line"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"required": ["sku", "quantity"]
|
"required": ["sku", "quantity"]
|
||||||
},
|
},
|
||||||
@@ -1187,17 +1121,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["type", "text"]
|
"required": ["type", "text"]
|
||||||
},
|
},
|
||||||
"SetCustomFieldsRequest": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"customFields": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": { "type": "string" },
|
|
||||||
"description": "Fields to upsert on the line; keys not present are left untouched"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["customFields"]
|
|
||||||
},
|
|
||||||
"Notice": {
|
"Notice": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package telemetry
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -6,24 +6,25 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.opentelemetry.io/otel"
|
"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/otlpmetric/otlpmetricgrpc"
|
||||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||||
"go.opentelemetry.io/otel/log/global"
|
"go.opentelemetry.io/otel/log/global"
|
||||||
"go.opentelemetry.io/otel/propagation"
|
"go.opentelemetry.io/otel/propagation"
|
||||||
|
"go.opentelemetry.io/otel/sdk/log"
|
||||||
"go.opentelemetry.io/otel/sdk/metric"
|
"go.opentelemetry.io/otel/sdk/metric"
|
||||||
"go.opentelemetry.io/otel/sdk/trace"
|
"go.opentelemetry.io/otel/sdk/trace"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetupOTelSDK bootstraps the OpenTelemetry pipeline (propagator + traces +
|
// setupOTelSDK bootstraps the OpenTelemetry pipeline.
|
||||||
// metrics, and an opt-in logger — see NewLoggerProvider). It is shared by every
|
// If it does not return an error, make sure to call shutdown for proper cleanup.
|
||||||
// service main package; the service name comes from OTEL_RESOURCE_ATTRIBUTES, so
|
func setupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
||||||
// the same setup works everywhere. If it returns no error, call the returned
|
|
||||||
// shutdown for proper cleanup.
|
|
||||||
func SetupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
|
||||||
var shutdownFuncs []func(context.Context) error
|
var shutdownFuncs []func(context.Context) error
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// shutdown calls the registered cleanup functions, joining their errors.
|
// 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 {
|
shutdown := func(ctx context.Context) error {
|
||||||
var err error
|
var err error
|
||||||
for _, fn := range shutdownFuncs {
|
for _, fn := range shutdownFuncs {
|
||||||
@@ -33,13 +34,16 @@ func SetupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleErr runs shutdown and surfaces all errors.
|
// handleErr calls shutdown for cleanup and makes sure that all errors are returned.
|
||||||
handleErr := func(inErr error) {
|
handleErr := func(inErr error) {
|
||||||
err = errors.Join(inErr, shutdown(ctx))
|
err = errors.Join(inErr, shutdown(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
otel.SetTextMapPropagator(newPropagator())
|
// Set up propagator.
|
||||||
|
prop := newPropagator()
|
||||||
|
otel.SetTextMapPropagator(prop)
|
||||||
|
|
||||||
|
// Set up trace provider.
|
||||||
tracerProvider, err := newTracerProvider()
|
tracerProvider, err := newTracerProvider()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleErr(err)
|
handleErr(err)
|
||||||
@@ -48,6 +52,7 @@ func SetupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
|||||||
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
|
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
|
||||||
otel.SetTracerProvider(tracerProvider)
|
otel.SetTracerProvider(tracerProvider)
|
||||||
|
|
||||||
|
// Set up meter provider.
|
||||||
meterProvider, err := newMeterProvider()
|
meterProvider, err := newMeterProvider()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleErr(err)
|
handleErr(err)
|
||||||
@@ -56,17 +61,14 @@ func SetupOTelSDK(ctx context.Context) (func(context.Context) error, error) {
|
|||||||
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
|
||||||
otel.SetMeterProvider(meterProvider)
|
otel.SetMeterProvider(meterProvider)
|
||||||
|
|
||||||
// Logger provider is opt-in via OTEL_LOGS_ENABLED; nil means logs are off.
|
// Set up logger provider.
|
||||||
// Traces + metrics above are always on.
|
loggerProvider, err := newLoggerProvider()
|
||||||
loggerProvider, err := NewLoggerProvider(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleErr(err)
|
handleErr(err)
|
||||||
return shutdown, err
|
return shutdown, err
|
||||||
}
|
}
|
||||||
if loggerProvider != nil {
|
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
||||||
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
|
global.SetLoggerProvider(loggerProvider)
|
||||||
global.SetLoggerProvider(loggerProvider)
|
|
||||||
}
|
|
||||||
|
|
||||||
return shutdown, err
|
return shutdown, err
|
||||||
}
|
}
|
||||||
@@ -83,9 +85,13 @@ func newTracerProvider() (*trace.TracerProvider, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return trace.NewTracerProvider(
|
|
||||||
trace.WithBatcher(traceExporter, trace.WithBatchTimeout(time.Second)),
|
tracerProvider := trace.NewTracerProvider(
|
||||||
), nil
|
trace.WithBatcher(traceExporter,
|
||||||
|
// Default is 5s. Set to 1s for demonstrative purposes.
|
||||||
|
trace.WithBatchTimeout(time.Second)),
|
||||||
|
)
|
||||||
|
return tracerProvider, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMeterProvider() (*metric.MeterProvider, error) {
|
func newMeterProvider() (*metric.MeterProvider, error) {
|
||||||
@@ -93,5 +99,19 @@ func newMeterProvider() (*metric.MeterProvider, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exporter))), nil
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
+45
-278
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,13 +10,15 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
"git.k6n.net/go-cart-actor/pkg/voucher"
|
||||||
|
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
"google.golang.org/protobuf/types/known/anypb"
|
"google.golang.org/protobuf/types/known/anypb"
|
||||||
@@ -41,20 +42,17 @@ var (
|
|||||||
|
|
||||||
type PoolServer struct {
|
type PoolServer struct {
|
||||||
actor.GrainPool[cart.CartGrain]
|
actor.GrainPool[cart.CartGrain]
|
||||||
pod_name string
|
pod_name string
|
||||||
// idx is the bus-fed catalog projection cache, consulted at HTTP-fetch
|
inventoryService inventory.InventoryService
|
||||||
// sites (AddSkuToCartHandler, buildItemGroups) to overlay authoritative
|
reservationService inventory.CartReservationService
|
||||||
// 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, idx *catalogProjectionCache) *PoolServer {
|
func NewPoolServer(pool actor.GrainPool[cart.CartGrain], pod_name string, inventoryService inventory.InventoryService, inventoryReservationService inventory.CartReservationService) *PoolServer {
|
||||||
srv := &PoolServer{
|
srv := &PoolServer{
|
||||||
GrainPool: pool,
|
GrainPool: pool,
|
||||||
pod_name: pod_name,
|
pod_name: pod_name,
|
||||||
idx: idx,
|
inventoryService: inventoryService,
|
||||||
|
reservationService: inventoryReservationService,
|
||||||
}
|
}
|
||||||
|
|
||||||
return srv
|
return srv
|
||||||
@@ -75,41 +73,9 @@ 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 {
|
func (s *PoolServer) AddSkuToCartHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||||
sku := r.PathValue("sku")
|
sku := r.PathValue("sku")
|
||||||
country := getCountryFromHost(r.Host)
|
msg, err := GetItemAddMessage(r.Context(), sku, 1, getCountryFromHost(r.Host), nil)
|
||||||
if s.idx != nil && s.idx.IsDeleted(sku) {
|
if err != nil {
|
||||||
// Bus-deleted SKU — skip the HTTP fetch and reject the add. Mirrors the
|
return err
|
||||||
// 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 {
|
|
||||||
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)
|
data, err := s.ApplyLocal(r.Context(), id, msg)
|
||||||
@@ -168,12 +134,6 @@ type Item struct {
|
|||||||
Sku string `json:"sku"`
|
Sku string `json:"sku"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
StoreId *string `json:"storeId,omitempty"`
|
StoreId *string `json:"storeId,omitempty"`
|
||||||
// Children are sub-articles (accessories, services, insurance, ...) priced
|
|
||||||
// relative to this item. They are added as separate cart lines whose
|
|
||||||
// ParentId points to this item's line-item id.
|
|
||||||
Children []Item `json:"children,omitempty"`
|
|
||||||
// CustomFields are optional user-supplied input fields for this line.
|
|
||||||
CustomFields map[string]string `json:"customFields,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetCartItems struct {
|
type SetCartItems struct {
|
||||||
@@ -181,167 +141,25 @@ type SetCartItems struct {
|
|||||||
Items []Item `json:"items"`
|
Items []Item `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// itemGroup is a top-level item together with its child sub-articles. The child
|
func getMultipleAddMessages(ctx context.Context, items []Item, country string) []proto.Message {
|
||||||
// AddItem messages are built with their price already derived from the parent
|
|
||||||
// product; their ParentId is filled in after the parent line is applied.
|
|
||||||
type itemGroup struct {
|
|
||||||
parent *messages.AddItem
|
|
||||||
children []*messages.AddItem
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildItemGroups fetches every product and builds the AddItem messages. Groups
|
|
||||||
// are built concurrently; within a group the parent is fetched first so it can
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// 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{}
|
wg := sync.WaitGroup{}
|
||||||
for i, itm := range items {
|
mu := sync.Mutex{}
|
||||||
wg.Go(func() {
|
msgs := make([]proto.Message, 0, len(items))
|
||||||
if idx != nil && idx.IsDeleted(itm.Sku) {
|
for _, itm := range items {
|
||||||
log.Printf("error adding item %s: bus-deleted (skipping)", itm.Sku)
|
wg.Go(
|
||||||
return
|
func() {
|
||||||
}
|
msg, err := GetItemAddMessage(ctx, itm.Sku, itm.Quantity, country, itm.StoreId)
|
||||||
|
|
||||||
// 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 {
|
if err != nil {
|
||||||
log.Printf("error adding item %s: %v", itm.Sku, err)
|
log.Printf("error adding item %s: %v", itm.Sku, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if idx != nil {
|
mu.Lock()
|
||||||
if p, ok := idx.Get(itm.Sku); ok {
|
msgs = append(msgs, msg)
|
||||||
ApplyProjectionOverlay(parentMsg, p)
|
mu.Unlock()
|
||||||
}
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
parentMsg.CustomFields = itm.CustomFields
|
|
||||||
groups[i].parent = parentMsg
|
|
||||||
|
|
||||||
if len(itm.Children) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
children := make([]*messages.AddItem, len(itm.Children))
|
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
cwg.Wait()
|
|
||||||
for _, c := range children {
|
|
||||||
if c != nil {
|
|
||||||
groups[i].children = append(groups[i].children, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
return groups
|
return msgs
|
||||||
}
|
|
||||||
|
|
||||||
// applyItemGroups applies each group in dependency order: the parent first, then
|
|
||||||
// its children with ParentId set to the parent's resolved line-item id. Returns
|
|
||||||
// the last mutation result for rendering.
|
|
||||||
func (s *PoolServer) applyItemGroups(ctx context.Context, id cart.CartId, groups []itemGroup) (*actor.MutationResult[cart.CartGrain], error) {
|
|
||||||
var last *actor.MutationResult[cart.CartGrain]
|
|
||||||
for _, g := range groups {
|
|
||||||
if g.parent == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
res, err := s.ApplyLocal(ctx, id, g.parent)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
last = res
|
|
||||||
|
|
||||||
if len(g.children) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parentLineId, ok := parentLineId(&res.Result, g.parent)
|
|
||||||
if !ok {
|
|
||||||
log.Printf("could not resolve parent line for item %d (sku %s); skipping %d children",
|
|
||||||
g.parent.ItemId, g.parent.Sku, len(g.children))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
childMsgs := make([]proto.Message, len(g.children))
|
|
||||||
for i, c := range g.children {
|
|
||||||
c.ParentId = &parentLineId
|
|
||||||
childMsgs[i] = c
|
|
||||||
}
|
|
||||||
res, err = s.ApplyLocal(ctx, id, childMsgs...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
last = res
|
|
||||||
}
|
|
||||||
return last, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parentLineId finds the line-item id of the just-applied parent: the resident
|
|
||||||
// item with the parent's catalog ItemId, matching store, and no parent of its
|
|
||||||
// own. AddItem merges by sku+store, so at most one such line exists.
|
|
||||||
func parentLineId(grain *cart.CartGrain, parent *messages.AddItem) (uint32, bool) {
|
|
||||||
for _, it := range grain.Items {
|
|
||||||
if it == nil || it.ParentId != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if it.ItemId != parent.ItemId {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sameStore := (it.StoreId == nil && parent.StoreId == nil) ||
|
|
||||||
(it.StoreId != nil && parent.StoreId != nil && *it.StoreId == *parent.StoreId)
|
|
||||||
if sameStore {
|
|
||||||
return it.Id, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||||
@@ -351,22 +169,14 @@ func (s *PoolServer) SetCartItemsHandler(w http.ResponseWriter, r *http.Request,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := s.ApplyLocal(r.Context(), id, &messages.ClearCartRequest{}); err != nil {
|
msgs := make([]proto.Message, 0, len(setCartItems.Items)+1)
|
||||||
return err
|
msgs = append(msgs, &messages.ClearCartRequest{})
|
||||||
}
|
msgs = append(msgs, getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)...)
|
||||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx)
|
|
||||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if reply == nil {
|
|
||||||
// Cart was cleared but nothing added; return current (empty) state.
|
|
||||||
grain, err := s.Get(r.Context(), uint64(id))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, grain)
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
return s.WriteResult(w, reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,18 +187,12 @@ func (s *PoolServer) AddMultipleItemHandler(w http.ResponseWriter, r *http.Reque
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := buildItemGroups(r.Context(), setCartItems.Items, setCartItems.Country, s.idx)
|
msgs := getMultipleAddMessages(r.Context(), setCartItems.Items, setCartItems.Country)
|
||||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
|
||||||
|
reply, err := s.ApplyLocal(r.Context(), id, msgs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if reply == nil {
|
|
||||||
grain, err := s.Get(r.Context(), uint64(id))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, grain)
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
return s.WriteResult(w, reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,10 +201,6 @@ type AddRequest struct {
|
|||||||
Quantity int32 `json:"quantity"`
|
Quantity int32 `json:"quantity"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
StoreId *string `json:"storeId"`
|
StoreId *string `json:"storeId"`
|
||||||
// Children are sub-articles priced relative to this item (see Item.Children).
|
|
||||||
Children []Item `json:"children,omitempty"`
|
|
||||||
// CustomFields are optional user-supplied input fields for this line.
|
|
||||||
CustomFields map[string]string `json:"customFields,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request, id cart.CartId) error {
|
||||||
@@ -409,26 +209,16 @@ func (s *PoolServer) AddSkuRequestHandler(w http.ResponseWriter, r *http.Request
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
msg, err := GetItemAddMessage(r.Context(), addRequest.Sku, int(addRequest.Quantity), addRequest.Country, addRequest.StoreId)
|
||||||
item := Item{
|
|
||||||
Sku: addRequest.Sku,
|
|
||||||
Quantity: int(addRequest.Quantity),
|
|
||||||
StoreId: addRequest.StoreId,
|
|
||||||
Children: addRequest.Children,
|
|
||||||
CustomFields: addRequest.CustomFields,
|
|
||||||
}
|
|
||||||
groups := buildItemGroups(r.Context(), []Item{item}, addRequest.Country, s.idx)
|
|
||||||
reply, err := s.applyItemGroups(r.Context(), id, groups)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if reply == nil {
|
|
||||||
grain, err := s.Get(r.Context(), uint64(id))
|
reply, err := s.ApplyLocal(r.Context(), id, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
return s.WriteResult(w, grain)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.WriteResult(w, reply)
|
return s.WriteResult(w, reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,6 +279,7 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
|
|||||||
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
span.SetAttributes(attribute.String("cartid", cartId.String()))
|
||||||
hostAttr := attribute.String("other host", ownerHost.Name())
|
hostAttr := attribute.String("other host", ownerHost.Name())
|
||||||
span.SetAttributes(hostAttr)
|
span.SetAttributes(hostAttr)
|
||||||
|
logger.InfoContext(ctx, "cart proxyed", "result", ownerHost.Name())
|
||||||
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
proxyCalls.Add(ctx, 1, metric.WithAttributes(hostAttr))
|
||||||
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
|
handled, err := ownerHost.Proxy(uint64(cartId), w, r, nil)
|
||||||
|
|
||||||
@@ -508,6 +299,7 @@ func (s *PoolServer) ProxyHandler(fn func(w http.ResponseWriter, r *http.Request
|
|||||||
var (
|
var (
|
||||||
tracer = otel.Tracer(name)
|
tracer = otel.Tracer(name)
|
||||||
meter = otel.Meter(name)
|
meter = otel.Meter(name)
|
||||||
|
logger = otelslog.NewLogger(name)
|
||||||
proxyCalls metric.Int64Counter
|
proxyCalls metric.Int64Counter
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -644,29 +436,6 @@ func (s *PoolServer) RemoveLineItemMarkingHandler(w http.ResponseWriter, r *http
|
|||||||
return s.WriteResult(w, reply)
|
return s.WriteResult(w, reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetCustomFieldsRequest struct {
|
|
||||||
CustomFields map[string]string `json:"customFields"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) SetCustomFieldsHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
|
||||||
itemId, err := strconv.ParseInt(r.PathValue("itemId"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req := SetCustomFieldsRequest{}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
reply, err := s.ApplyLocal(r.Context(), cartId, &messages.SetLineItemCustomFields{
|
|
||||||
Id: uint32(itemId),
|
|
||||||
CustomFields: req.CustomFields,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.WriteResult(w, reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PoolServer) InternalApplyMutationHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
func (s *PoolServer) InternalApplyMutationHandler(w http.ResponseWriter, r *http.Request, cartId cart.CartId) error {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
@@ -744,7 +513,6 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
|
|||||||
|
|
||||||
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
handleFunc("PUT /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
||||||
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
handleFunc("DELETE /cart/item/{itemId}/marking", CookieCartIdHandler(s.ProxyHandler(s.RemoveLineItemMarkingHandler)))
|
||||||
handleFunc("PUT /cart/item/{itemId}/custom-fields", CookieCartIdHandler(s.ProxyHandler(s.SetCustomFieldsHandler)))
|
|
||||||
|
|
||||||
//mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
//mux.HandleFunc("GET /cart/checkout", CookieCartIdHandler(s.ProxyHandler(s.HandleCheckout)))
|
||||||
//mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
//mux.HandleFunc("GET /cart/confirmation/{orderId}", CookieCartIdHandler(s.ProxyHandler(s.HandleConfirmation)))
|
||||||
@@ -759,5 +527,4 @@ func (s *PoolServer) Serve(mux *http.ServeMux) {
|
|||||||
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
handleFunc("PUT /cart/byid/{id}/user", CartIdHandler(s.ProxyHandler(s.SetUserIdHandler)))
|
||||||
handleFunc("PUT /cart/byid/{id}/item/{itemId}/marking", CartIdHandler(s.ProxyHandler(s.LineItemMarkingHandler)))
|
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("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)))
|
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-184
@@ -4,78 +4,34 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"math"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/platform/config"
|
messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||||
|
|
||||||
|
"github.com/matst80/slask-finder/pkg/index"
|
||||||
)
|
)
|
||||||
|
|
||||||
// consumedKeys are product-document keys that are mapped to typed AddItem
|
// TODO make this configurable
|
||||||
// fields. They are stripped from the dynamic remainder so Extra only holds
|
|
||||||
// genuinely dynamic data.
|
|
||||||
var consumedKeys = []string{
|
|
||||||
"id", "sku", "title", "img", "price", "vat",
|
|
||||||
"discount", "inStock", "supplierId", "supplierName", "deleted",
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBaseUrl returns the product service base url. Overridable via
|
|
||||||
// PRODUCT_BASE_URL (the country argument is currently unused but kept for when
|
|
||||||
// per-market routing returns).
|
|
||||||
func getBaseUrl(country string) string {
|
func getBaseUrl(country string) string {
|
||||||
return config.EnvString("PRODUCT_BASE_URL", "http://localhost:8082")
|
// if country == "se" {
|
||||||
|
// return "http://s10n-se:8080"
|
||||||
|
// }
|
||||||
|
if country == "no" {
|
||||||
|
return "http://s10n-no.s10n:8080"
|
||||||
|
}
|
||||||
|
if country == "se" {
|
||||||
|
return "http://s10n-se.s10n:8080"
|
||||||
|
}
|
||||||
|
return "http://localhost:8082"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductItem is the flat product document served by GET /api/get/{sku}.
|
func FetchItem(ctx context.Context, sku string, country string) (*index.DataItem, error) {
|
||||||
// Only the fields the cart currently maps mechanically are decoded; the rest
|
|
||||||
// of the (dynamic) document is intentionally ignored until the cart item model
|
|
||||||
// is reworked to carry arbitrary data.
|
|
||||||
type ProductItem 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"`
|
|
||||||
Discount int `json:"discount"` // percent off the original price
|
|
||||||
InStock int32 `json:"inStock"`
|
|
||||||
SupplierId int `json:"supplierId"`
|
|
||||||
SupplierName string `json:"supplierName"`
|
|
||||||
Deleted bool `json:"deleted"`
|
|
||||||
|
|
||||||
// Extra is the rest of the product document (everything not mapped to a
|
|
||||||
// typed field above), preserved verbatim and surfaced on the cart item.
|
|
||||||
Extra map[string]json.RawMessage `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// numberField reads a numeric dynamic property from the product document
|
|
||||||
// (width, height, ...). Like JS Number(), it accepts a JSON number or a numeric
|
|
||||||
// string. Returns false when the key is absent or not numeric.
|
|
||||||
func (p *ProductItem) numberField(key string) (float64, bool) {
|
|
||||||
raw, ok := p.Extra[key]
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
var f float64
|
|
||||||
if err := json.Unmarshal(raw, &f); err == nil {
|
|
||||||
return f, true
|
|
||||||
}
|
|
||||||
var s string
|
|
||||||
if err := json.Unmarshal(raw, &s); err == nil {
|
|
||||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
|
||||||
return f, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func FetchItem(ctx context.Context, sku string, country string) (*ProductItem, error) {
|
|
||||||
baseUrl := getBaseUrl(country)
|
baseUrl := getBaseUrl(country)
|
||||||
|
req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/by-sku/%s", baseUrl, sku), nil)
|
||||||
innerCtx, span := tracer.Start(ctx, fmt.Sprintf("fetching data for %s", sku))
|
innerCtx, span := tracer.Start(ctx, fmt.Sprintf("fetching data for %s", sku))
|
||||||
defer span.End()
|
defer span.End()
|
||||||
req, err := http.NewRequestWithContext(innerCtx, http.MethodGet, fmt.Sprintf("%s/api/get/%s", baseUrl, sku), nil)
|
req = req.WithContext(innerCtx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -84,140 +40,111 @@ func FetchItem(ctx context.Context, sku string, country string) (*ProductItem, e
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
if res.StatusCode != http.StatusOK {
|
var item index.DataItem
|
||||||
return nil, fmt.Errorf("product service returned %d for sku %s", res.StatusCode, sku)
|
err = json.NewDecoder(res.Body).Decode(&item)
|
||||||
}
|
return &item, err
|
||||||
body, err := io.ReadAll(res.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var item ProductItem
|
|
||||||
if err := json.Unmarshal(body, &item); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture everything not mapped to a typed field as dynamic data.
|
|
||||||
all := map[string]json.RawMessage{}
|
|
||||||
if err := json.Unmarshal(body, &all); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, k := range consumedKeys {
|
|
||||||
delete(all, k)
|
|
||||||
}
|
|
||||||
if len(all) > 0 {
|
|
||||||
item.Extra = all
|
|
||||||
}
|
|
||||||
|
|
||||||
return &item, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetItemAddMessage(ctx context.Context, sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
|
func GetItemAddMessage(ctx context.Context, sku string, qty int, country string, storeId *string) (*messages.AddItem, error) {
|
||||||
msg, _, err := BuildItemMessage(ctx, sku, qty, country, storeId, nil)
|
|
||||||
return msg, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildItemMessage fetches a product and builds its AddItem mutation, returning
|
|
||||||
// the fetched product too so it can serve as the parent for child items. When
|
|
||||||
// parent is non-nil the line is priced as a child of that parent.
|
|
||||||
func BuildItemMessage(ctx context.Context, sku string, qty int, country string, storeId *string, parent *ProductItem) (*messages.AddItem, *ProductItem, error) {
|
|
||||||
item, err := FetchItem(ctx, sku, country)
|
item, err := FetchItem(ctx, sku, country)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
msg, err := ToItemAddMessage(item, parent, storeId, qty, country)
|
return ToItemAddMessage(item, storeId, qty, country)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ToItemAddMessage(item *index.DataItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
||||||
|
orgPrice, _ := getInt(item.GetNumberFieldValue(5)) // getInt(item.Fields[5])
|
||||||
|
|
||||||
|
price, err := getInt(item.GetNumberFieldValue(4)) //Fields[4]
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return msg, item, nil
|
stk := item.GetStock()
|
||||||
}
|
stock := cart.StockStatus(0)
|
||||||
|
|
||||||
// Glass is billed at a minimum surface even when the real pane is smaller, so a
|
|
||||||
// small window doesn't get a near-zero per-m² price.
|
|
||||||
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,
|
|
||||||
// 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).
|
|
||||||
func areaFromItem(item *ProductItem) float64 {
|
|
||||||
if item == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
w, okW := item.numberField("width")
|
|
||||||
h, okH := item.numberField("height")
|
|
||||||
if !okW || !okH || w <= 0 || h <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
glassW := w * 100 // - (item.numberField("widthMargin") + item.numberField("borderWidth"))
|
|
||||||
glassH := h * 100 // - (item.numberField("heightMargin") + item.numberField("borderHeight"))
|
|
||||||
if glassW <= 0 || glassH <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return math.Max((glassW*glassH)/1_000_000, minGlassArea)
|
|
||||||
}
|
|
||||||
|
|
||||||
// accessoryPrice computes a child line's unit price (inc vat, minor units): the
|
|
||||||
// child's own per-m² price scaled by the parent window's billed glass area.
|
|
||||||
// When the parent has no usable dimensions the area is 0, so the price is 0.
|
|
||||||
func accessoryPrice(parent *ProductItem, child *ProductItem) int64 {
|
|
||||||
area := areaFromItem(parent)
|
|
||||||
return int64(child.Price*100*area + 0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
// orgPriceFromDiscount reconstructs the pre-discount price from a percentage.
|
|
||||||
// Returns 0 when there is no usable discount, in which case OrgPrice is omitted.
|
|
||||||
func orgPriceFromDiscount(price int64, discountPercent int) int64 {
|
|
||||||
if discountPercent <= 0 || discountPercent >= 100 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return int64(float64(price)/(1-float64(discountPercent)/100) + 0.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ToItemAddMessage(item *ProductItem, parent *ProductItem, storeId *string, qty int, country string) (*messages.AddItem, error) {
|
|
||||||
// Central stock only: store-specific stock is no longer in the payload, so a
|
|
||||||
// storeId request currently resolves to zero stock.
|
|
||||||
var stock int32
|
|
||||||
if storeId == nil {
|
if storeId == nil {
|
||||||
stock = item.InStock
|
centralStock, ok := stk[country]
|
||||||
}
|
if ok {
|
||||||
|
if !item.Buyable {
|
||||||
|
return nil, fmt.Errorf("item not available")
|
||||||
|
}
|
||||||
|
|
||||||
// Top-level items price from their own product; children are priced from the
|
if centralStock == 0 && item.SaleStatus == "TBD" {
|
||||||
// parent product via the accessory-price hook.
|
return nil, fmt.Errorf("no items available")
|
||||||
price := int64(item.Price)
|
}
|
||||||
if parent != nil {
|
stock = cart.StockStatus(centralStock)
|
||||||
price = accessoryPrice(parent, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := &messages.AddItem{
|
|
||||||
ItemId: uint32(item.Id),
|
|
||||||
Quantity: int32(qty),
|
|
||||||
Price: price,
|
|
||||||
OrgPrice: orgPriceFromDiscount(price, item.Discount),
|
|
||||||
Sku: item.Sku,
|
|
||||||
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,
|
|
||||||
Country: country,
|
|
||||||
StoreId: storeId,
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(item.Extra) > 0 {
|
|
||||||
extra, err := json.Marshal(item.Extra)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("marshal extra product data: %w", err)
|
|
||||||
}
|
}
|
||||||
msg.ExtraJson = extra
|
|
||||||
|
} else {
|
||||||
|
if !item.BuyableInStore {
|
||||||
|
return nil, fmt.Errorf("item not available in store")
|
||||||
|
}
|
||||||
|
storeStock, ok := stk[*storeId]
|
||||||
|
if ok {
|
||||||
|
stock = cart.StockStatus(storeStock)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg, nil
|
articleType, _ := item.GetStringFieldValue(1) //.Fields[1].(string)
|
||||||
|
outletGrade, ok := item.GetStringFieldValue(20) //.Fields[20].(string)
|
||||||
|
var outlet *string
|
||||||
|
if ok {
|
||||||
|
outlet = &outletGrade
|
||||||
|
}
|
||||||
|
sellerId, _ := item.GetStringFieldValue(24) // .Fields[24].(string)
|
||||||
|
sellerName, _ := item.GetStringFieldValue(9) // .Fields[9].(string)
|
||||||
|
|
||||||
|
brand, _ := item.GetStringFieldValue(2) //.Fields[2].(string)
|
||||||
|
category, _ := item.GetStringFieldValue(10) //.Fields[10].(string)
|
||||||
|
category2, _ := item.GetStringFieldValue(11) //.Fields[11].(string)
|
||||||
|
category3, _ := item.GetStringFieldValue(12) //.Fields[12].(string)
|
||||||
|
category4, _ := item.GetStringFieldValue(13) //Fields[13].(string)
|
||||||
|
category5, _ := item.GetStringFieldValue(14) //.Fields[14].(string)
|
||||||
|
|
||||||
|
cgm, _ := item.GetStringFieldValue(35) // Customer Group Membership
|
||||||
|
|
||||||
|
return &messages.AddItem{
|
||||||
|
ItemId: uint32(item.Id),
|
||||||
|
Quantity: int32(qty),
|
||||||
|
Price: int64(price),
|
||||||
|
OrgPrice: int64(orgPrice),
|
||||||
|
Sku: item.GetSku(),
|
||||||
|
Name: item.Title,
|
||||||
|
Image: item.Img,
|
||||||
|
Stock: int32(stock),
|
||||||
|
Brand: brand,
|
||||||
|
Category: category,
|
||||||
|
Category2: category2,
|
||||||
|
Category3: category3,
|
||||||
|
Category4: category4,
|
||||||
|
Category5: category5,
|
||||||
|
Tax: getTax(articleType),
|
||||||
|
SellerId: sellerId,
|
||||||
|
SellerName: sellerName,
|
||||||
|
ArticleType: articleType,
|
||||||
|
Disclaimer: item.Disclaimer,
|
||||||
|
Country: country,
|
||||||
|
Outlet: outlet,
|
||||||
|
StoreId: storeId,
|
||||||
|
SaleStatus: item.SaleStatus,
|
||||||
|
Cgm: cgm,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTax(articleType string) int32 {
|
||||||
|
switch articleType {
|
||||||
|
case "ZDIE":
|
||||||
|
return 600
|
||||||
|
default:
|
||||||
|
return 2500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInt(data float64, ok bool) (int, error) {
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("invalid type")
|
||||||
|
}
|
||||||
|
return int(data), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,390 +0,0 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
// Integration tests for the product fetcher. These hit a live product service
|
|
||||||
// and are excluded from the default build/test run. Run them with:
|
|
||||||
//
|
|
||||||
// go test -tags=integration ./cmd/cart/
|
|
||||||
//
|
|
||||||
// Point at a different service with PRODUCT_BASE_URL (default http://localhost:8082).
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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/proxy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newTestPoolServer builds a real, disk-backed PoolServer for exercising the
|
|
||||||
// HTTP handlers end-to-end (no clustering peers).
|
|
||||||
func newTestPoolServer(t *testing.T) *PoolServer {
|
|
||||||
t.Helper()
|
|
||||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
|
||||||
reg.RegisterProcessor(actor.NewMutationProcessor(func(_ context.Context, g *cart.CartGrain) error {
|
|
||||||
g.UpdateTotals()
|
|
||||||
g.Version++
|
|
||||||
return nil
|
|
||||||
}))
|
|
||||||
dir := t.TempDir()
|
|
||||||
disk := actor.NewDiskStorage[cart.CartGrain](dir, reg)
|
|
||||||
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[cart.CartGrain]{
|
|
||||||
MutationRegistry: reg,
|
|
||||||
Storage: disk,
|
|
||||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[cart.CartGrain], error) {
|
|
||||||
g := cart.NewCartGrain(id, time.Now())
|
|
||||||
err := disk.LoadEvents(ctx, id, g)
|
|
||||||
return g, err
|
|
||||||
},
|
|
||||||
Destroy: func(actor.Grain[cart.CartGrain]) error { return nil },
|
|
||||||
SpawnHost: func(host string) (actor.Host[cart.CartGrain], error) {
|
|
||||||
return proxy.NewRemoteHost[cart.CartGrain](host, "8080")
|
|
||||||
},
|
|
||||||
TTL: 5 * time.Minute,
|
|
||||||
PoolSize: 1000,
|
|
||||||
Hostname: "",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new pool: %v", err)
|
|
||||||
}
|
|
||||||
return NewPoolServer(pool, "test", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// exampleId is the catalog item the fetcher rework was validated against.
|
|
||||||
const exampleId = "8163"
|
|
||||||
|
|
||||||
// requireService skips the test when the product service is not reachable so
|
|
||||||
// the suite degrades gracefully in environments without it.
|
|
||||||
func requireService(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
base := getBaseUrl("se")
|
|
||||||
req, err := http.NewRequest(http.MethodGet, base+"/api/get/"+exampleId, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build probe request: %v", err)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
res, err := http.DefaultClient.Do(req.WithContext(ctx))
|
|
||||||
if err != nil {
|
|
||||||
var netErr net.Error
|
|
||||||
if errors.As(err, &netErr) || errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
t.Skipf("product service %s not reachable: %v", base, err)
|
|
||||||
}
|
|
||||||
t.Fatalf("probe product service: %v", err)
|
|
||||||
}
|
|
||||||
res.Body.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetchItem_Example8163(t *testing.T) {
|
|
||||||
requireService(t)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
item, err := FetchItem(ctx, exampleId, "se")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("FetchItem(%s): %v", exampleId, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if item.Id != 8163 {
|
|
||||||
t.Errorf("Id = %d, want 8163", item.Id)
|
|
||||||
}
|
|
||||||
if item.Sku == "" {
|
|
||||||
t.Error("Sku is empty")
|
|
||||||
}
|
|
||||||
if item.Price <= 0 {
|
|
||||||
t.Errorf("Price = %v, want > 0", item.Price)
|
|
||||||
}
|
|
||||||
t.Logf("fetched %d sku=%s price=%v vat=%d discount=%d inStock=%d supplier=%q",
|
|
||||||
item.Id, item.Sku, item.Price, item.Vat, item.Discount, item.InStock, item.SupplierName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestToItemAddMessage_Example8163(t *testing.T) {
|
|
||||||
requireService(t)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
item, err := FetchItem(ctx, exampleId, "se")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("FetchItem(%s): %v", exampleId, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Central stock (no storeId, no parent).
|
|
||||||
msg, err := ToItemAddMessage(item, nil, nil, 2, "se")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ToItemAddMessage: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg.ItemId != uint32(item.Id) {
|
|
||||||
t.Errorf("ItemId = %d, want %d", msg.ItemId, item.Id)
|
|
||||||
}
|
|
||||||
if msg.Quantity != 2 {
|
|
||||||
t.Errorf("Quantity = %d, want 2", msg.Quantity)
|
|
||||||
}
|
|
||||||
if want := int64(item.Price * 100); msg.Price != want {
|
|
||||||
t.Errorf("Price = %d, want %d (price*100)", msg.Price, want)
|
|
||||||
}
|
|
||||||
if msg.Sku != item.Sku {
|
|
||||||
t.Errorf("Sku = %q, want %q", msg.Sku, item.Sku)
|
|
||||||
}
|
|
||||||
if msg.Name == "" {
|
|
||||||
t.Error("Name is empty")
|
|
||||||
}
|
|
||||||
if want := int32(item.Vat * 100); msg.Tax != want {
|
|
||||||
t.Errorf("Tax = %d, want vat*100 = %d", msg.Tax, want)
|
|
||||||
}
|
|
||||||
if msg.Stock != item.InStock {
|
|
||||||
t.Errorf("Stock = %d, want central inStock %d", msg.Stock, item.InStock)
|
|
||||||
}
|
|
||||||
if msg.SellerName != item.SupplierName {
|
|
||||||
t.Errorf("SellerName = %q, want %q", msg.SellerName, item.SupplierName)
|
|
||||||
}
|
|
||||||
if want := strconv.Itoa(item.SupplierId); msg.SellerId != want {
|
|
||||||
t.Errorf("SellerId = %q, want %q", msg.SellerId, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrgPrice is reconstructed from the discount percent.
|
|
||||||
if item.Discount > 0 && item.Discount < 100 {
|
|
||||||
if msg.OrgPrice <= msg.Price {
|
|
||||||
t.Errorf("OrgPrice = %d, want > Price %d (discount %d%%)", msg.OrgPrice, msg.Price, item.Discount)
|
|
||||||
}
|
|
||||||
} else if msg.OrgPrice != 0 {
|
|
||||||
t.Errorf("OrgPrice = %d, want 0 (no usable discount)", msg.OrgPrice)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store stock currently resolves to zero (store-level stock not in payload).
|
|
||||||
storeId := "1234"
|
|
||||||
storeMsg, err := ToItemAddMessage(item, nil, &storeId, 1, "se")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ToItemAddMessage(store): %v", err)
|
|
||||||
}
|
|
||||||
if storeMsg.Stock != 0 {
|
|
||||||
t.Errorf("store Stock = %d, want 0", storeMsg.Stock)
|
|
||||||
}
|
|
||||||
if storeMsg.StoreId == nil || *storeMsg.StoreId != storeId {
|
|
||||||
t.Errorf("StoreId = %v, want %q", storeMsg.StoreId, storeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(msg.ExtraJson) == 0 {
|
|
||||||
t.Error("expected ExtraJson to carry dynamic product data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDynamicData_RoundTripsToCartItem exercises the full path: fetch the
|
|
||||||
// product, build the AddItem mutation, apply it through the cart mutation
|
|
||||||
// registry, then render the cart exactly as the API does — asserting that a
|
|
||||||
// dynamic product key ("glas" for item 8163) is flattened onto the item.
|
|
||||||
func TestDynamicData_RoundTripsToCartItem(t *testing.T) {
|
|
||||||
requireService(t)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
msg, err := GetItemAddMessage(ctx, exampleId, 1, "se", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetItemAddMessage: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
|
||||||
grain := cart.NewCartGrain(1, time.Now())
|
|
||||||
if _, err := reg.Apply(ctx, grain, msg); err != nil {
|
|
||||||
t.Fatalf("apply AddItem: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marshal the grain the way the HTTP handlers do (WriteResult -> json).
|
|
||||||
b, err := json.Marshal(grain)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal grain: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var rendered struct {
|
|
||||||
Items []map[string]json.RawMessage `json:"items"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(b, &rendered); err != nil {
|
|
||||||
t.Fatalf("unmarshal grain: %v", err)
|
|
||||||
}
|
|
||||||
if len(rendered.Items) != 1 {
|
|
||||||
t.Fatalf("items = %d, want 1", len(rendered.Items))
|
|
||||||
}
|
|
||||||
|
|
||||||
item := rendered.Items[0]
|
|
||||||
// Typed field present.
|
|
||||||
if _, ok := item["sku"]; !ok {
|
|
||||||
t.Error("rendered item missing typed key \"sku\"")
|
|
||||||
}
|
|
||||||
// Dynamic key flattened onto the item and returned over the API.
|
|
||||||
glas, ok := item["glas"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("rendered item missing dynamic key \"glas\"; got keys %v", keysOf(item))
|
|
||||||
}
|
|
||||||
if string(glas) != `"V"` {
|
|
||||||
t.Errorf("glas = %s, want \"V\"", glas)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSetCartItems_ParentWithChildren reproduces the reported scenario through
|
|
||||||
// the real SetCartItemsHandler: a parent SKU with three distinct children.
|
|
||||||
func TestSetCartItems_ParentWithChildren(t *testing.T) {
|
|
||||||
requireService(t)
|
|
||||||
|
|
||||||
s := newTestPoolServer(t)
|
|
||||||
body := `{"sku":"146620","quantity":1,"children":[{"sku":"170852","quantity":1},{"sku":"123075","quantity":1},{"sku":"123076","quantity":1}]}`
|
|
||||||
// The handler decodes SetCartItems{country, items:[Item]} — the reported
|
|
||||||
// payload is a single Item, so wrap it in the items array.
|
|
||||||
payload := `{"country":"se","items":[` + body + `]}`
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/cart/set", bytes.NewBufferString(payload))
|
|
||||||
if err := s.SetCartItemsHandler(rec, req, cart.CartId(42)); err != nil {
|
|
||||||
t.Fatalf("SetCartItemsHandler: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
type lineT struct {
|
|
||||||
Id uint32 `json:"id"`
|
|
||||||
ItemId uint32 `json:"itemId"`
|
|
||||||
ParentId *uint32 `json:"parentId"`
|
|
||||||
Sku string `json:"sku"`
|
|
||||||
}
|
|
||||||
// Handler returns a MutationResult {result:{items}, mutations}. Fall back to
|
|
||||||
// top-level items for the empty-cart path.
|
|
||||||
var resp struct {
|
|
||||||
Result struct {
|
|
||||||
Items []lineT `json:"items"`
|
|
||||||
} `json:"result"`
|
|
||||||
Items []lineT `json:"items"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode response: %v\nbody: %s", err, rec.Body.String())
|
|
||||||
}
|
|
||||||
grain := struct{ Items []lineT }{Items: resp.Result.Items}
|
|
||||||
if len(grain.Items) == 0 {
|
|
||||||
grain.Items = resp.Items
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("result has %d items:", len(grain.Items))
|
|
||||||
for _, it := range grain.Items {
|
|
||||||
t.Logf(" line %d itemId=%d sku=%s parentId=%v", it.Id, it.ItemId, it.Sku, it.ParentId)
|
|
||||||
}
|
|
||||||
if len(grain.Items) != 4 {
|
|
||||||
t.Fatalf("items = %d, want 4 (parent + 3 children)", len(grain.Items))
|
|
||||||
}
|
|
||||||
children := 0
|
|
||||||
for _, it := range grain.Items {
|
|
||||||
if it.ParentId != nil {
|
|
||||||
children++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if children != 3 {
|
|
||||||
t.Errorf("children = %d, want 3", children)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAddSkuRequest_SingleItemWithChildren posts the bare single-item body (no
|
|
||||||
// items wrapper) to the single-add handler and asserts children are added.
|
|
||||||
func TestAddSkuRequest_SingleItemWithChildren(t *testing.T) {
|
|
||||||
requireService(t)
|
|
||||||
|
|
||||||
s := newTestPoolServer(t)
|
|
||||||
payload := `{"sku":"146620","quantity":1,"country":"se","children":[{"sku":"170852","quantity":1},{"sku":"123075","quantity":1},{"sku":"123076","quantity":1}]}`
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/cart", bytes.NewBufferString(payload))
|
|
||||||
if err := s.AddSkuRequestHandler(rec, req, cart.CartId(7)); err != nil {
|
|
||||||
t.Fatalf("AddSkuRequestHandler: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp struct {
|
|
||||||
Result struct {
|
|
||||||
Items []struct {
|
|
||||||
ParentId *uint32 `json:"parentId"`
|
|
||||||
} `json:"items"`
|
|
||||||
} `json:"result"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v\nbody: %s", err, rec.Body.String())
|
|
||||||
}
|
|
||||||
if len(resp.Result.Items) != 4 {
|
|
||||||
t.Fatalf("items = %d, want 4 (parent + 3 children)", len(resp.Result.Items))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func keysOf(m map[string]json.RawMessage) []string {
|
|
||||||
ks := make([]string, 0, len(m))
|
|
||||||
for k := range m {
|
|
||||||
ks = append(ks, k)
|
|
||||||
}
|
|
||||||
return ks
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestChildren_ParentLinkage exercises the nested-children build + ordered apply:
|
|
||||||
// buildItemGroups fetches the parent product and prices the child against it,
|
|
||||||
// then parentLineId resolves the parent's line id and the child line links to it.
|
|
||||||
//
|
|
||||||
// Structural test: it reuses 8163 as both parent and child (child pinned to a
|
|
||||||
// store so it does not merge with the parent line). Real children would be
|
|
||||||
// distinct service/insurance/accessory SKUs.
|
|
||||||
func TestChildren_ParentLinkage(t *testing.T) {
|
|
||||||
requireService(t)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
childStore := "1"
|
|
||||||
items := []Item{{
|
|
||||||
Sku: exampleId,
|
|
||||||
Quantity: 1,
|
|
||||||
Children: []Item{{Sku: exampleId, Quantity: 1, StoreId: &childStore}},
|
|
||||||
}}
|
|
||||||
|
|
||||||
groups := buildItemGroups(ctx, items, "se", nil)
|
|
||||||
if len(groups) != 1 {
|
|
||||||
t.Fatalf("groups = %d, want 1", len(groups))
|
|
||||||
}
|
|
||||||
if groups[0].parent == nil {
|
|
||||||
t.Fatal("parent message not built")
|
|
||||||
}
|
|
||||||
if len(groups[0].children) != 1 {
|
|
||||||
t.Fatalf("children = %d, want 1", len(groups[0].children))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply through the real mutation registry to verify the linkage the handler
|
|
||||||
// performs (applyItemGroups does the same with pool.ApplyLocal).
|
|
||||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
|
||||||
grain := cart.NewCartGrain(1, time.Now())
|
|
||||||
|
|
||||||
if _, err := reg.Apply(ctx, grain, groups[0].parent); err != nil {
|
|
||||||
t.Fatalf("apply parent: %v", err)
|
|
||||||
}
|
|
||||||
pid, ok := parentLineId(grain, groups[0].parent)
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("parentLineId did not resolve the parent line")
|
|
||||||
}
|
|
||||||
|
|
||||||
child := groups[0].children[0]
|
|
||||||
child.ParentId = &pid
|
|
||||||
if _, err := reg.Apply(ctx, grain, child); err != nil {
|
|
||||||
t.Fatalf("apply child: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(grain.Items) != 2 {
|
|
||||||
t.Fatalf("cart items = %d, want 2 (parent + child)", len(grain.Items))
|
|
||||||
}
|
|
||||||
|
|
||||||
var parentLine, childLine *cart.CartItem
|
|
||||||
for _, it := range grain.Items {
|
|
||||||
if it.ParentId == nil {
|
|
||||||
parentLine = it
|
|
||||||
} else {
|
|
||||||
childLine = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if parentLine == nil || childLine == nil {
|
|
||||||
t.Fatalf("expected one parent and one child line, got %+v", grain.Items)
|
|
||||||
}
|
|
||||||
if *childLine.ParentId != parentLine.Id {
|
|
||||||
t.Errorf("child.ParentId = %d, want parent line id %d", *childLine.ParentId, parentLine.Id)
|
|
||||||
}
|
|
||||||
t.Logf("parent line %d, child line %d -> parent %d", parentLine.Id, childLine.Id, *childLine.ParentId)
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func makeProduct(price float64, extra map[string]any) *ProductItem {
|
|
||||||
m := map[string]json.RawMessage{}
|
|
||||||
for k, v := range extra {
|
|
||||||
b, _ := json.Marshal(v)
|
|
||||||
m[k] = b
|
|
||||||
}
|
|
||||||
return &ProductItem{Price: price, Extra: m}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAreaFromItem(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
extra map[string]any
|
|
||||||
want float64
|
|
||||||
}{
|
|
||||||
{"floors small window to min", map[string]any{"width": 5, "height": 6}, 0.4}, // 500*600/1e6 = 0.30 -> floor 0.4
|
|
||||||
{"normal window", map[string]any{"width": 10, "height": 10}, 1.0}, // 1000*1000/1e6 = 1.0
|
|
||||||
{"numeric strings", map[string]any{"width": "10", "height": "10"}, 1.0}, // JS Number() parity
|
|
||||||
{"missing height", map[string]any{"width": 10}, 0},
|
|
||||||
{"zero width", map[string]any{"width": 0, "height": 10}, 0},
|
|
||||||
{"non-numeric", map[string]any{"width": "abc", "height": 10}, 0},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got := areaFromItem(makeProduct(0, tc.extra))
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("areaFromItem = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if got := areaFromItem(nil); got != 0 {
|
|
||||||
t.Errorf("areaFromItem(nil) = %v, want 0", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAccessoryPrice(t *testing.T) {
|
|
||||||
// area 1.0 m^2, child 100.00 -> 100.00 * 100 * 1.0 = 10000 minor units.
|
|
||||||
parent := makeProduct(0, map[string]any{"width": 10, "height": 10})
|
|
||||||
if got := accessoryPrice(parent, makeProduct(100, nil)); got != 10000 {
|
|
||||||
t.Errorf("accessoryPrice = %d, want 10000", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// area floored to 0.4, child 9952.78 -> round(9952.78 * 100 * 0.4) = 398111.
|
|
||||||
small := makeProduct(0, map[string]any{"width": 5, "height": 6})
|
|
||||||
if got := accessoryPrice(small, makeProduct(9952.78, nil)); got != 398111 {
|
|
||||||
t.Errorf("accessoryPrice = %d, want 398111", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parent without dimensions -> area 0 -> price 0.
|
|
||||||
if got := accessoryPrice(makeProduct(0, nil), makeProduct(500, nil)); got != 0 {
|
|
||||||
t.Errorf("accessoryPrice (no dims) = %d, want 0", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,601 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newPromotionEvaluateHandler serves POST /promotions/evaluate: it takes a
|
|
||||||
// (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).
|
|
||||||
func newPromotionEvaluateHandler(store *promotions.Store, svc *promotions.PromotionService) 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
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -5,8 +5,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getCurrency(country string) string {
|
func getCurrency(country string) string {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,8 +9,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
messages "git.k6n.net/go-cart-actor/proto/checkout"
|
||||||
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
|
"github.com/adyen/adyen-go-api-library/v21/src/hmacvalidator"
|
||||||
@@ -125,14 +124,6 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
//}
|
//}
|
||||||
|
|
||||||
// CAPTURE is the moment the Adyen payment is fully settled — the
|
|
||||||
// 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.createAdyenOrder(r.Context(), *checkoutId, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
case "AUTHORISATION":
|
case "AUTHORISATION":
|
||||||
|
|
||||||
isSuccess := item.Success == "true"
|
isSuccess := item.Success == "true"
|
||||||
@@ -185,11 +176,11 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error capturing payment: %v", err)
|
log.Printf("Error capturing payment: %v", err)
|
||||||
} else {
|
} else {
|
||||||
// Capture requested. The order is NOT created here — we
|
log.Printf("Payment captured successfully: %+v", res)
|
||||||
// only have a PSP reference, not an order. Adyen sends a
|
s.ApplyAnywhere(r.Context(), *checkoutId, &messages.OrderCreated{
|
||||||
// CAPTURE notification once the capture settles, and the
|
OrderId: res.PaymentPspReference,
|
||||||
// CAPTURE branch above creates the event-sourced order.
|
Status: item.EventCode,
|
||||||
log.Printf("Payment capture requested successfully: %+v", res)
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -218,36 +209,6 @@ func (s *CheckoutPoolServer) AdyenHookHandler(w http.ResponseWriter, r *http.Req
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// createAdyenOrder turns a settled (captured) Adyen payment into an
|
|
||||||
// event-sourced order, mirroring KlarnaPushHandler. The Adyen webhook carries
|
|
||||||
// the charged amount + currency but not the shopper's country/locale, so those
|
|
||||||
// are derived from the currency. Failures are logged and left for the next
|
|
||||||
// CAPTURE notification to retry — the from-checkout endpoint is idempotent on
|
|
||||||
// "checkout-{checkoutId}".
|
|
||||||
func (s *CheckoutPoolServer) createAdyenOrder(ctx context.Context, checkoutId cart.CartId, item webhook.NotificationRequestItem) {
|
|
||||||
grain, err := s.GetAnywhere(ctx, checkoutId)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("from-checkout: could not load checkout grain %s: %v", checkoutId.String(), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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{
|
|
||||||
Provider: "adyen",
|
|
||||||
Reference: item.PspReference,
|
|
||||||
Amount: item.Amount.Value,
|
|
||||||
Currency: item.Amount.Currency,
|
|
||||||
Country: country,
|
|
||||||
Locale: getLocale(country),
|
|
||||||
}); oerr != nil {
|
|
||||||
log.Printf("from-checkout failed for checkout %s; will retry on next CAPTURE: %v", checkoutId.String(), oerr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *CheckoutPoolServer) AdyenReturnHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
log.Println("Redirect received")
|
log.Println("Redirect received")
|
||||||
|
|
||||||
|
|||||||
@@ -6,27 +6,20 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CartClient struct {
|
type CartClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
baseUrl string
|
baseUrl string
|
||||||
agentProfile string // UCP-Agent profile URI
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCartClient(baseUrl string) *CartClient {
|
func NewCartClient(baseUrl string) *CartClient {
|
||||||
profile := os.Getenv("UCP_AGENT_PROFILE")
|
|
||||||
if profile == "" {
|
|
||||||
profile = "https://checkout.k6n.net/.well-known/ucp"
|
|
||||||
}
|
|
||||||
return &CartClient{
|
return &CartClient{
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
agentProfile: profile,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +52,6 @@ func (s *CartClient) getCartGrain(ctx context.Context, cartId cart.CartId) (*car
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("UCP-Agent", `profile="`+s.agentProfile+`"`)
|
|
||||||
resp, err := s.httpClient.Do(req)
|
resp, err := s.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestGetCartGrain_RealService tests against the actual service at https://cart.k6n.net/
|
// TestGetCartGrain_RealService tests against the actual service at https://cart.k6n.net/
|
||||||
|
|||||||
@@ -4,51 +4,25 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||||
"git.k6n.net/mats/platform/tax"
|
|
||||||
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
adyenCheckout "github.com/adyen/adyen-go-api-library/v21/src/checkout"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultCallbackBaseURL is where Klarna's server-to-server callbacks
|
|
||||||
// (push/notification/validate) are sent when CHECKOUT_CALLBACK_BASE_URL is unset.
|
|
||||||
// These must be a publicly reachable https origin for the checkout service.
|
|
||||||
const defaultCallbackBaseURL = "https://cart.k6n.net"
|
|
||||||
|
|
||||||
var (
|
|
||||||
// checkoutPublicURL overrides the request-derived site base for the
|
|
||||||
// shopper-facing Klarna URLs (terms/checkout/confirmation). Set this to a
|
|
||||||
// public https origin (e.g. a tunnel or shop-test.tornberg.me) for local
|
|
||||||
// KCO testing, since Klarna rejects non-https merchant_urls. Empty = derive
|
|
||||||
// from the request (X-Forwarded-Host + https).
|
|
||||||
checkoutPublicURL = strings.TrimRight(os.Getenv("CHECKOUT_PUBLIC_URL"), "/")
|
|
||||||
// checkoutCallbackBaseURL is the base for Klarna's server-to-server
|
|
||||||
// callbacks. Defaults to defaultCallbackBaseURL.
|
|
||||||
checkoutCallbackBaseURL = func() string {
|
|
||||||
if v := strings.TrimRight(os.Getenv("CHECKOUT_CALLBACK_BASE_URL"), "/"); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return defaultCallbackBaseURL
|
|
||||||
}()
|
|
||||||
)
|
|
||||||
|
|
||||||
// CheckoutMeta carries the external / URL metadata required to build a
|
// CheckoutMeta carries the external / URL metadata required to build a
|
||||||
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
|
// Klarna CheckoutOrder from a CartGrain snapshot. It deliberately excludes
|
||||||
// any Klarna-specific response fields (HTML snippet, client token, etc.).
|
// any Klarna-specific response fields (HTML snippet, client token, etc.).
|
||||||
type CheckoutMeta struct {
|
type CheckoutMeta struct {
|
||||||
SiteUrl string
|
SiteUrl string
|
||||||
// CallbackBaseUrl is the base for Klarna server-to-server callbacks
|
// Terms string
|
||||||
// (push/notification/validate); may differ from SiteUrl in prod where the
|
// Checkout string
|
||||||
// checkout service and storefront are on different hosts.
|
// Confirmation string
|
||||||
CallbackBaseUrl string
|
ClientIp string
|
||||||
ClientIp string
|
Country string
|
||||||
Country string
|
Currency string // optional override (defaults to "SEK" if empty)
|
||||||
Currency string // optional override (defaults to "SEK" if empty)
|
Locale string // optional override (defaults to "sv-se" if empty)
|
||||||
Locale string // optional override (defaults to "sv-se" if empty)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildCheckoutOrderPayload converts the current cart grain + meta information
|
// BuildCheckoutOrderPayload converts the current cart grain + meta information
|
||||||
@@ -65,9 +39,7 @@ type CheckoutMeta struct {
|
|||||||
//
|
//
|
||||||
// If you later need to support different tax rates per line, you can extend
|
// If you later need to support different tax rates per line, you can extend
|
||||||
// CartItem / Delivery to expose that data and propagate it here.
|
// CartItem / Delivery to expose that data and propagate it here.
|
||||||
// tp is an optional TaxProvider; when non-nil, its DefaultTaxRate is used for
|
func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta) ([]byte, *CheckoutOrder, error) {
|
||||||
// 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 {
|
if grain == nil {
|
||||||
return nil, nil, fmt.Errorf("nil grain")
|
return nil, nil, fmt.Errorf("nil grain")
|
||||||
}
|
}
|
||||||
@@ -109,32 +81,12 @@ 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 := cart.NewPrice()
|
||||||
total.Add(*grain.CartState.TotalPrice)
|
total.Add(*grain.CartState.TotalPrice)
|
||||||
|
|
||||||
// Delivery lines — use the configured default tax rate for the purchase country.
|
// Delivery lines
|
||||||
defaultKlarnaRate := defaultKlarnaTaxRate(tp, country)
|
|
||||||
for _, d := range grain.Deliveries {
|
for _, d := range grain.Deliveries {
|
||||||
if d == nil {
|
if d == nil || d.Price.IncVat <= 0 {
|
||||||
continue
|
|
||||||
}
|
|
||||||
unitPrice := d.Price.IncVat
|
|
||||||
taxAmount := d.Price.TotalVat()
|
|
||||||
if hasFreeShipping {
|
|
||||||
unitPrice = 0
|
|
||||||
taxAmount = 0
|
|
||||||
}
|
|
||||||
if unitPrice <= 0 && !hasFreeShipping {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
//total.Add(d.Price)
|
//total.Add(d.Price)
|
||||||
@@ -143,11 +95,11 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
Reference: d.Provider,
|
Reference: d.Provider,
|
||||||
Name: "Delivery",
|
Name: "Delivery",
|
||||||
Quantity: 1,
|
Quantity: 1,
|
||||||
UnitPrice: int(unitPrice),
|
UnitPrice: int(d.Price.IncVat),
|
||||||
TaxRate: defaultKlarnaRate,
|
TaxRate: 2500,
|
||||||
QuantityUnit: "st",
|
QuantityUnit: "st",
|
||||||
TotalAmount: int(unitPrice),
|
TotalAmount: int(d.Price.IncVat),
|
||||||
TotalTaxAmount: int(taxAmount),
|
TotalTaxAmount: int(d.Price.TotalVat()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,11 +113,11 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
MerchantReference1: grain.Id.String(),
|
MerchantReference1: grain.Id.String(),
|
||||||
MerchantURLS: &CheckoutMerchantURLS{
|
MerchantURLS: &CheckoutMerchantURLS{
|
||||||
Terms: fmt.Sprintf("%s/terms", meta.SiteUrl),
|
Terms: fmt.Sprintf("%s/terms", meta.SiteUrl),
|
||||||
Checkout: fmt.Sprintf("%s/kassa?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
|
Checkout: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
|
||||||
Confirmation: fmt.Sprintf("%s/kassa?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
|
Confirmation: fmt.Sprintf("%s/checkout?order_id={checkout.order.id}&provider=klarna", meta.SiteUrl),
|
||||||
Notification: fmt.Sprintf("%s/payment/klarna/notification", meta.CallbackBaseUrl),
|
Notification: "https://cart.k6n.net/payment/klarna/notification",
|
||||||
Validation: fmt.Sprintf("%s/payment/klarna/validate", meta.CallbackBaseUrl),
|
Validation: "https://cart.k6n.net/payment/klarna/validate",
|
||||||
Push: fmt.Sprintf("%s/payment/klarna/push?order_id={checkout.order.id}", meta.CallbackBaseUrl),
|
Push: "https://cart.k6n.net/payment/klarna/push?order_id={checkout.order.id}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,45 +129,19 @@ func BuildCheckoutOrderPayload(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
return payload, order, nil
|
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 {
|
func GetCheckoutMetaFromRequest(r *http.Request) *CheckoutMeta {
|
||||||
host := getOriginalHost(r)
|
host := getOriginalHost(r)
|
||||||
country := getCountryFromHost(host)
|
country := getCountryFromHost(host)
|
||||||
siteUrl := fmt.Sprintf("%s://%s", getScheme(r), host)
|
|
||||||
if checkoutPublicURL != "" {
|
|
||||||
siteUrl = checkoutPublicURL
|
|
||||||
}
|
|
||||||
return &CheckoutMeta{
|
return &CheckoutMeta{
|
||||||
ClientIp: getClientIp(r),
|
ClientIp: getClientIp(r),
|
||||||
SiteUrl: siteUrl,
|
SiteUrl: fmt.Sprintf("https://%s", host),
|
||||||
CallbackBaseUrl: checkoutCallbackBaseURL,
|
Country: country,
|
||||||
Country: country,
|
Currency: getCurrency(country),
|
||||||
Currency: getCurrency(country),
|
Locale: getLocale(country),
|
||||||
Locale: getLocale(country),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta, tp tax.Provider) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
|
func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta) (*adyenCheckout.CreateCheckoutSessionRequest, error) {
|
||||||
if grain == nil {
|
if grain == nil {
|
||||||
return nil, fmt.Errorf("nil grain")
|
return nil, fmt.Errorf("nil grain")
|
||||||
}
|
}
|
||||||
@@ -241,54 +167,34 @@ func BuildAdyenCheckoutSession(grain *checkout.CheckoutGrain, meta *CheckoutMeta
|
|||||||
}
|
}
|
||||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||||
Quantity: common.PtrInt64(int64(it.Quantity)),
|
Quantity: common.PtrInt64(int64(it.Quantity)),
|
||||||
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat.Int64()),
|
AmountIncludingTax: common.PtrInt64(it.TotalPrice.IncVat),
|
||||||
Description: common.PtrString(it.Meta.Name),
|
Description: common.PtrString(it.Meta.Name),
|
||||||
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat().Int64()),
|
AmountExcludingTax: common.PtrInt64(it.TotalPrice.ValueExVat()),
|
||||||
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat().Int64()),
|
TaxAmount: common.PtrInt64(it.TotalPrice.TotalVat()),
|
||||||
TaxPercentage: common.PtrInt64(int64(it.Tax)),
|
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 := cart.NewPrice()
|
||||||
total.Add(*grain.CartState.TotalPrice)
|
total.Add(*grain.CartState.TotalPrice)
|
||||||
|
|
||||||
// Delivery lines — use the configured default tax rate for the purchase country.
|
// Delivery lines
|
||||||
defaultAdyenRate := defaultAdyenTaxRate(tp, country)
|
|
||||||
for _, d := range grain.Deliveries {
|
for _, d := range grain.Deliveries {
|
||||||
if d == nil {
|
if d == nil || d.Price.IncVat <= 0 {
|
||||||
continue
|
|
||||||
}
|
|
||||||
amountIncTax := d.Price.IncVat.Int64()
|
|
||||||
amountExTax := d.Price.ValueExVat().Int64()
|
|
||||||
if hasFreeShipping {
|
|
||||||
amountIncTax = 0
|
|
||||||
amountExTax = 0
|
|
||||||
}
|
|
||||||
if amountIncTax <= 0 && !hasFreeShipping {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lineItems = append(lineItems, adyenCheckout.LineItem{
|
lineItems = append(lineItems, adyenCheckout.LineItem{
|
||||||
Quantity: common.PtrInt64(1),
|
Quantity: common.PtrInt64(1),
|
||||||
AmountIncludingTax: common.PtrInt64(amountIncTax),
|
AmountIncludingTax: common.PtrInt64(d.Price.IncVat),
|
||||||
Description: common.PtrString("Delivery"),
|
Description: common.PtrString("Delivery"),
|
||||||
AmountExcludingTax: common.PtrInt64(amountExTax),
|
AmountExcludingTax: common.PtrInt64(d.Price.ValueExVat()),
|
||||||
TaxPercentage: common.PtrInt64(defaultAdyenRate),
|
TaxPercentage: common.PtrInt64(25),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return &adyenCheckout.CreateCheckoutSessionRequest{
|
return &adyenCheckout.CreateCheckoutSessionRequest{
|
||||||
Reference: grain.Id.String(),
|
Reference: grain.Id.String(),
|
||||||
Amount: adyenCheckout.Amount{
|
Amount: adyenCheckout.Amount{
|
||||||
Value: total.IncVat.Int64(),
|
Value: total.IncVat,
|
||||||
Currency: currency,
|
Currency: currency,
|
||||||
},
|
},
|
||||||
CountryCode: common.PtrString(country),
|
CountryCode: common.PtrString(country),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/discovery"
|
"git.k6n.net/go-cart-actor/pkg/discovery"
|
||||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
@@ -24,9 +24,7 @@ func GetDiscovery() discovery.Discovery {
|
|||||||
log.Fatalf("Error creating client: %v\n", err)
|
log.Fatalf("Error creating client: %v\n", err)
|
||||||
}
|
}
|
||||||
timeout := int64(30)
|
timeout := int64(30)
|
||||||
// Scope discovery to this pod's namespace so it only needs a namespaced Role
|
return discovery.NewK8sDiscovery(client, v1.ListOptions{
|
||||||
// (pods: get/list/watch), not a cluster-wide ClusterRole.
|
|
||||||
return discovery.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
|
|
||||||
LabelSelector: "actor-pool=checkout",
|
LabelSelector: "actor-pool=checkout",
|
||||||
TimeoutSeconds: &timeout,
|
TimeoutSeconds: &timeout,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
messages "git.k6n.net/go-cart-actor/proto/checkout"
|
||||||
"git.k6n.net/mats/platform/inventory"
|
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -181,12 +181,20 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inventory is NOT committed here. Checkout announces a completed sale; the
|
if s.inventoryService != nil {
|
||||||
// order saga emits order.created and the inventory service reacts (commit +
|
inventoryRequests := getInventoryRequests(grain.CartState.Items)
|
||||||
// release the cart hold + emit a level crossing), idempotently. This keeps a
|
err = s.inventoryService.ReserveInventory(r.Context(), inventoryRequests...)
|
||||||
// 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
|
if err != nil {
|
||||||
// docs/inventory-shape.md.
|
logger.WarnContext(r.Context(), "placeorder inventory reservation failed")
|
||||||
|
w.WriteHeader(http.StatusNotAcceptable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.Apply(r.Context(), uint64(grain.Id), &messages.InventoryReserved{
|
||||||
|
Id: grain.Id.String(),
|
||||||
|
Status: "success",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{
|
s.ApplyAnywhere(r.Context(), grain.Id, &messages.PaymentCompleted{
|
||||||
PaymentId: orderId,
|
PaymentId: orderId,
|
||||||
@@ -197,14 +205,19 @@ func (s *CheckoutPoolServer) KlarnaPushHandler(w http.ResponseWriter, r *http.Re
|
|||||||
CompletedAt: timestamppb.Now(),
|
CompletedAt: timestamppb.Now(),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Create the event-sourced order grain (HTTP with AMQP fallback) ────
|
// err = confirmOrder(r.Context(), order, orderHandler)
|
||||||
if _, orderErr := createOrderFromCheckout(r.Context(), s, grain, order, "klarna"); orderErr != nil {
|
// if err != nil {
|
||||||
// Non-fatal: the checkout grain is updated, the order can be
|
// log.Printf("Error confirming order: %v\n", err)
|
||||||
// created on retry (idempotency key guards against duplicates).
|
// w.WriteHeader(http.StatusInternalServerError)
|
||||||
logger.WarnContext(r.Context(), "from-checkout failed; will retry on next push",
|
// return
|
||||||
"err", orderErr, "checkoutId", grain.Id.String())
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
|
// err = triggerOrderCompleted(r.Context(), a.server, order)
|
||||||
|
// if err != nil {
|
||||||
|
// log.Printf("Error processing cart message: %v\n", err)
|
||||||
|
// w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
|
err = s.klarnaClient.AcknowledgeOrder(r.Context(), orderId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error acknowledging order: %v\n", err)
|
log.Printf("Error acknowledging order: %v\n", err)
|
||||||
@@ -227,36 +240,28 @@ var tpl = `<!DOCTYPE html>
|
|||||||
</html>
|
</html>
|
||||||
`
|
`
|
||||||
|
|
||||||
// createOrderFromCheckout builds and sends the from-checkout request to the
|
|
||||||
// order service, creating an event-sourced order grain from the settled checkout.
|
|
||||||
// The idempotency key is "checkout-{checkoutId}" so that retries (Klarna may
|
|
||||||
// push the same order multiple times) are safe.
|
|
||||||
func createOrderFromCheckout(ctx context.Context, s *CheckoutPoolServer, grain *checkout.CheckoutGrain, klarnaOrder *CheckoutOrder, provider string) (*CreateOrderResult, error) {
|
|
||||||
return createOrderFromSettledCheckout(ctx, s, grain, settledPayment{
|
|
||||||
Provider: provider,
|
|
||||||
Reference: klarnaOrder.ID,
|
|
||||||
Amount: int64(klarnaOrder.OrderAmount),
|
|
||||||
Currency: klarnaOrder.PurchaseCurrency,
|
|
||||||
Country: klarnaOrder.PurchaseCountry,
|
|
||||||
Locale: klarnaOrder.Locale,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
func getLocationId(item *cart.CartItem) inventory.LocationID {
|
||||||
if item.StoreId == nil || *item.StoreId == "" {
|
if item.StoreId == nil || *item.StoreId == "" {
|
||||||
return inventory.LocationID("se")
|
return "se"
|
||||||
}
|
}
|
||||||
return inventory.LocationID(*item.StoreId)
|
return inventory.LocationID(*item.StoreId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldTrackInventory(item *cart.CartItem) bool {
|
func getInventoryRequests(items []*cart.CartItem) []inventory.ReserveRequest {
|
||||||
if item.DropShip {
|
var requests []inventory.ReserveRequest
|
||||||
return false
|
for _, item := range items {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
requests = append(requests, inventory.ReserveRequest{
|
||||||
|
InventoryReference: &inventory.InventoryReference{
|
||||||
|
SKU: inventory.SKU(item.Sku),
|
||||||
|
LocationID: getLocationId(item),
|
||||||
|
},
|
||||||
|
Quantity: uint32(item.Quantity),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if item.InventoryTracked {
|
return requests
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, error) {
|
func (a *CheckoutPoolServer) getGrainFromKlarnaOrder(ctx context.Context, order *CheckoutOrder) (*checkout.CheckoutGrain, error) {
|
||||||
|
|||||||
+11
-111
@@ -10,17 +10,12 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/go-cart-actor/pkg/proxy"
|
||||||
"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/adyen"
|
||||||
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
"github.com/adyen/adyen-go-api-library/v21/src/common"
|
||||||
|
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
@@ -52,40 +47,6 @@ var redisAddress = os.Getenv("REDIS_ADDRESS")
|
|||||||
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
var redisPassword = os.Getenv("REDIS_PASSWORD")
|
||||||
var cartInternalUrl = os.Getenv("CART_INTERNAL_URL") // e.g., http://cart-service:8081
|
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 {
|
|
||||||
path := os.Getenv("UCP_SIGNING_KEY_PATH")
|
|
||||||
if path == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pemData, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ucp signing: cannot read key %s: %v", path, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ucp signing: invalid key at %s: %v", path, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
controlPlaneConfig := actor.DefaultServerConfig()
|
controlPlaneConfig := actor.DefaultServerConfig()
|
||||||
@@ -102,19 +63,13 @@ func main() {
|
|||||||
Password: redisPassword,
|
Password: redisPassword,
|
||||||
DB: 0,
|
DB: 0,
|
||||||
})
|
})
|
||||||
|
inventoryService, err := inventory.NewRedisInventoryService(rdb)
|
||||||
reservationService, err := redisinv.NewRedisCartReservationService(rdb)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error creating reservation service: %v\n", err)
|
log.Fatalf("Error creating inventory service: %v\n", err)
|
||||||
}
|
}
|
||||||
reservationPolicy := redisinv.NewReservationPolicyAdapter(reservationService)
|
|
||||||
|
|
||||||
checkoutDir := os.Getenv("CHECKOUT_DIR")
|
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain]("data", reg)
|
||||||
if checkoutDir == "" {
|
|
||||||
checkoutDir = "data"
|
|
||||||
}
|
|
||||||
diskStorage := actor.NewDiskStorage[checkout.CheckoutGrain](checkoutDir, reg)
|
|
||||||
var syncedServer *CheckoutPoolServer
|
|
||||||
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
|
poolConfig := actor.GrainPoolConfig[checkout.CheckoutGrain]{
|
||||||
MutationRegistry: reg,
|
MutationRegistry: reg,
|
||||||
Storage: diskStorage,
|
Storage: diskStorage,
|
||||||
@@ -132,11 +87,6 @@ func main() {
|
|||||||
return ret, nil
|
return ret, nil
|
||||||
},
|
},
|
||||||
Destroy: func(grain actor.Grain[checkout.CheckoutGrain]) error {
|
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
|
return nil
|
||||||
},
|
},
|
||||||
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
|
SpawnHost: func(host string) (actor.Host[checkout.CheckoutGrain], error) {
|
||||||
@@ -161,38 +111,8 @@ func main() {
|
|||||||
|
|
||||||
cartClient := NewCartClient(cartInternalUrl)
|
cartClient := NewCartClient(cartInternalUrl)
|
||||||
|
|
||||||
var orderClient *OrderClient
|
syncedServer := NewCheckoutPoolServer(pool, fmt.Sprintf("%s, %s", name, podIp), klarnaClient, cartClient, adyenClient)
|
||||||
if orderServiceURL := os.Getenv("ORDER_SERVICE_URL"); orderServiceURL != "" {
|
syncedServer.inventoryService = inventoryService
|
||||||
orderClient = NewOrderClient(orderServiceURL)
|
|
||||||
log.Printf("order client enabled: %s", orderServiceURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
var orderHandler *AmqpOrderHandler
|
|
||||||
conn, err := rabbit.Dial(amqpUrl, "cart-checkout")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
|
||||||
}
|
|
||||||
orderHandler = NewAmqpOrderHandler(conn.Connection())
|
|
||||||
if err := orderHandler.DefineQueue(); err != nil {
|
|
||||||
log.Fatalf("failed to declare order queue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
mux := http.NewServeMux()
|
||||||
debugMux := http.NewServeMux()
|
debugMux := http.NewServeMux()
|
||||||
@@ -212,33 +132,13 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
otelShutdown, err := telemetry.SetupOTelSDK(ctx)
|
otelShutdown, err := setupOTelSDK(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Unable to start otel %v", err)
|
log.Fatalf("Unable to start otel %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
syncedServer.Serve(mux)
|
syncedServer.Serve(mux)
|
||||||
|
|
||||||
// UCP REST adapter — Universal Commerce Protocol checkout sessions
|
|
||||||
// When an order service URL is configured, the complete endpoint creates
|
|
||||||
// real orders via the order service's from-checkout API.
|
|
||||||
var ucpOrderSvc ucp.OrderApplier
|
|
||||||
if orderServiceURL := os.Getenv("ORDER_SERVICE_URL"); orderServiceURL != "" {
|
|
||||||
ucpOrderSvc = ucp.NewOrderHTTPClient(orderServiceURL)
|
|
||||||
log.Printf("ucp order applier enabled: %s", orderServiceURL)
|
|
||||||
}
|
|
||||||
checkoutUCP := ucp.CheckoutHandler(pool, ucpOrderSvc)
|
|
||||||
if signer := loadUCPCheckoutSigner(); signer != nil {
|
|
||||||
checkoutUCP = ucp.WithSigning(checkoutUCP, signer)
|
|
||||||
log.Print("ucp signing enabled")
|
|
||||||
}
|
|
||||||
// StripPrefix is required because the sub-mux (CheckoutHandler) registers
|
|
||||||
// routes like "GET /{id}" which expect a relative path; Go's ServeMux
|
|
||||||
// passes the full request path without stripping the prefix.
|
|
||||||
// Only the subtree pattern is registered (see comment in cmd/cart/main.go
|
|
||||||
// for why the exact-match pattern is omitted).
|
|
||||||
mux.Handle("/ucp/v1/checkout-sessions/", http.StripPrefix("/ucp/v1/checkout-sessions", checkoutUCP))
|
|
||||||
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
grainCount, capacity := pool.LocalUsage()
|
grainCount, capacity := pool.LocalUsage()
|
||||||
if grainCount >= capacity {
|
if grainCount >= capacity {
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// OrderClient is a minimal HTTP client for the order service. It is the
|
|
||||||
// checkout service's counterpart to the CartClient — the same pattern, but
|
|
||||||
// calling into order-service endpoints instead of cart-service ones.
|
|
||||||
//
|
|
||||||
// Currently only CreateOrder is implemented (the from-checkout handoff).
|
|
||||||
// Add GetOrder/ListOrders methods as the backoffice order UI evolves.
|
|
||||||
type OrderClient struct {
|
|
||||||
httpClient *http.Client
|
|
||||||
baseURL string // e.g. "http://order-service:8092"
|
|
||||||
agentProfile string // UCP-Agent profile URI
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOrderClient returns a client that talks to the order service at baseURL.
|
|
||||||
func NewOrderClient(baseURL string) *OrderClient {
|
|
||||||
profile := os.Getenv("UCP_AGENT_PROFILE")
|
|
||||||
if profile == "" {
|
|
||||||
profile = "https://cart.k6n.net/.well-known/ucp"
|
|
||||||
}
|
|
||||||
return &OrderClient{
|
|
||||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
baseURL: baseURL,
|
|
||||||
agentProfile: profile,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateOrderReq is the JSON payload POSTed to the order service's
|
|
||||||
// /api/orders/from-checkout endpoint. Every field is required except where
|
|
||||||
// noted. See cmd/order/handlers_checkout.go for the full definition.
|
|
||||||
type CreateOrderReq struct {
|
|
||||||
CheckoutId string `json:"checkoutId"`
|
|
||||||
IdempotencyKey string `json:"idempotencyKey"`
|
|
||||||
|
|
||||||
CartId string `json:"cartId"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
Locale string `json:"locale,omitempty"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
|
||||||
CustomerName string `json:"customerName,omitempty"`
|
|
||||||
Lines []OrderLine `json:"lines"`
|
|
||||||
|
|
||||||
Payment struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Amount int64 `json:"amount"`
|
|
||||||
} `json:"payment"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderLine is one orderable item in the CreateOrderReq, matching the
|
|
||||||
// lineReq type in the order service.
|
|
||||||
type OrderLine 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"`
|
|
||||||
DropShip bool `json:"drop_ship,omitempty"`
|
|
||||||
Location string `json:"location,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateOrderResult is the success response from POST /api/orders/from-checkout.
|
|
||||||
type CreateOrderResult struct {
|
|
||||||
OrderId string `json:"orderId"`
|
|
||||||
Flow json.RawMessage `json:"flow,omitempty"`
|
|
||||||
Order json.RawMessage `json:"order,omitempty"`
|
|
||||||
// Existing is true when the response is a 409 Conflict — the idempotency
|
|
||||||
// key was already used and this is the previously-created order.
|
|
||||||
Existing bool `json:"existing,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateOrder sends a settled checkout's data to the order service and returns
|
|
||||||
// the created (or previously-created, via idempotency) order. An idempotencyKey
|
|
||||||
// should be generated per checkout (e.g. "checkout-{checkoutId}") to guard
|
|
||||||
// against retries. flowName overrides the default flow; use "" for the default.
|
|
||||||
func (c *OrderClient) CreateOrder(ctx context.Context, req CreateOrderReq, flowName string) (*CreateOrderResult, error) {
|
|
||||||
url := c.baseURL + "/api/orders/from-checkout"
|
|
||||||
if flowName != "" {
|
|
||||||
url += "?flow=" + flowName
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("order client: marshal: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("order client: new request: %w", err)
|
|
||||||
}
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
httpReq.Header.Set("UCP-Agent", `profile="`+c.agentProfile+`"`)
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("order client: do: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Both 201 (created) and 409 (idempotent hit) are valid responses.
|
|
||||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
|
|
||||||
return nil, fmt.Errorf("order client: %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result CreateOrderResult
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return nil, fmt.Errorf("order client: decode: %w", err)
|
|
||||||
}
|
|
||||||
result.Existing = resp.StatusCode == http.StatusConflict
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// settledPayment carries the provider-agnostic facts about an already-settled
|
|
||||||
// payment. Each payment integration (Klarna push, Adyen CAPTURE notification,
|
|
||||||
// …) fills this in from its own callback and hands it to
|
|
||||||
// createOrderFromSettledCheckout, so the from-checkout request is identical
|
|
||||||
// regardless of which provider settled the payment.
|
|
||||||
type settledPayment struct {
|
|
||||||
Provider string // "klarna" | "adyen"
|
|
||||||
Reference string // processor / PSP reference
|
|
||||||
Amount int64 // minor units, as charged
|
|
||||||
Currency string
|
|
||||||
Country string
|
|
||||||
Locale string
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildOrderLinesFromGrain converts the checkout grain's cart items and delivery
|
|
||||||
// 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.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 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
unitPrice := d.Price.IncVat.Int64()
|
|
||||||
if hasFreeShipping {
|
|
||||||
unitPrice = 0
|
|
||||||
}
|
|
||||||
if unitPrice <= 0 && !hasFreeShipping {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, OrderLine{
|
|
||||||
Reference: d.Provider,
|
|
||||||
Sku: d.Provider,
|
|
||||||
Name: "Delivery",
|
|
||||||
Quantity: 1,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// createOrderFromSettledCheckout sends the from-checkout request for a settled
|
|
||||||
// checkout (any provider) and, on success, records the returned order id on the
|
|
||||||
// checkout grain via the OrderCreated mutation so the backoffice can link
|
|
||||||
// checkout → order.
|
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
var customerEmail, customerName string
|
|
||||||
if grain.ContactDetails != nil {
|
|
||||||
if grain.ContactDetails.Email != nil {
|
|
||||||
customerEmail = *grain.ContactDetails.Email
|
|
||||||
}
|
|
||||||
if grain.ContactDetails.Name != nil {
|
|
||||||
customerName = *grain.ContactDetails.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
req := CreateOrderReq{
|
|
||||||
CheckoutId: grain.Id.String(),
|
|
||||||
IdempotencyKey: "checkout-" + grain.Id.String(),
|
|
||||||
CartId: grain.CartId.String(),
|
|
||||||
Currency: pay.Currency,
|
|
||||||
Locale: pay.Locale,
|
|
||||||
Country: pay.Country,
|
|
||||||
CustomerEmail: customerEmail,
|
|
||||||
CustomerName: customerName,
|
|
||||||
Lines: buildOrderLinesFromGrain(grain),
|
|
||||||
}
|
|
||||||
req.Payment.Provider = pay.Provider
|
|
||||||
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 {
|
|
||||||
_ = s.ApplyAnywhere(ctx, grain.Id, &messages.OrderCreated{
|
|
||||||
OrderId: result.OrderId,
|
|
||||||
Status: "completed",
|
|
||||||
CreatedAt: timestamppb.Now(),
|
|
||||||
})
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
+32
-58
@@ -5,19 +5,20 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||||
"git.k6n.net/mats/platform/tax"
|
messages "git.k6n.net/go-cart-actor/proto/checkout"
|
||||||
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"
|
adyen "github.com/adyen/adyen-go-api-library/v21/src/adyen"
|
||||||
|
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||||
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
@@ -50,21 +51,16 @@ type CheckoutPoolServer struct {
|
|||||||
klarnaClient *KlarnaClient
|
klarnaClient *KlarnaClient
|
||||||
adyenClient *adyen.APIClient
|
adyenClient *adyen.APIClient
|
||||||
cartClient *CartClient
|
cartClient *CartClient
|
||||||
orderClient *OrderClient
|
inventoryService *inventory.RedisInventoryService
|
||||||
orderHandler *AmqpOrderHandler
|
|
||||||
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 {
|
func NewCheckoutPoolServer(pool actor.GrainPool[checkout.CheckoutGrain], pod_name string, klarnaClient *KlarnaClient, cartClient *CartClient, adyenClient *adyen.APIClient) *CheckoutPoolServer {
|
||||||
srv := &CheckoutPoolServer{
|
srv := &CheckoutPoolServer{
|
||||||
GrainPool: pool,
|
GrainPool: pool,
|
||||||
pod_name: pod_name,
|
pod_name: pod_name,
|
||||||
klarnaClient: klarnaClient,
|
klarnaClient: klarnaClient,
|
||||||
cartClient: cartClient,
|
cartClient: cartClient,
|
||||||
adyenClient: adyenClient,
|
adyenClient: adyenClient,
|
||||||
orderClient: orderClient,
|
|
||||||
orderHandler: orderHandler,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return srv
|
return srv
|
||||||
@@ -199,23 +195,6 @@ func (s *CheckoutPoolServer) ContactDetailsUpdatedHandler(w http.ResponseWriter,
|
|||||||
return s.WriteResult(w, result)
|
return s.WriteResult(w, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) CancelPaymentHandler(w http.ResponseWriter, r *http.Request, checkoutId checkout.CheckoutId) error {
|
|
||||||
paymentId := r.PathValue("id")
|
|
||||||
result, err := s.ApplyLocal(r.Context(), checkoutId, &messages.CancelPayment{
|
|
||||||
PaymentId: paymentId,
|
|
||||||
CancelledAt: timestamppb.New(time.Now()),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
cartIdStr := r.PathValue("cartid")
|
cartIdStr := r.PathValue("cartid")
|
||||||
if cartIdStr == "" {
|
if cartIdStr == "" {
|
||||||
@@ -245,13 +224,15 @@ func (s *CheckoutPoolServer) StartCheckoutHandler(w http.ResponseWriter, r *http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The checkout grain is 1:1 with the cart it checks out (CheckoutId ==
|
// Create checkout with same ID as cart
|
||||||
// CartId). Keying it by the cart id — not a lingering checkoutid cookie —
|
var checkoutId checkout.CheckoutId = cart.MustNewCartId()
|
||||||
// means a fresh cart always gets a fresh checkout, so the Klarna order
|
cookie, err := r.Cookie(checkoutCookieName)
|
||||||
// reflects the cart shown at /kassa. Reusing the cookie made a new cart's
|
if err == nil {
|
||||||
// checkout latch onto a previous cart's frozen snapshot (InitializeCheckout
|
parsed, ok := cart.ParseCartId(cookie.Value)
|
||||||
// silently refuses to re-snapshot a grain that already has a CartState).
|
if ok {
|
||||||
checkoutId := checkout.CheckoutId(cartId)
|
checkoutId = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize checkout with cart state wrapped in Any
|
// Initialize checkout with cart state wrapped in Any
|
||||||
cartStateAny := &messages.InitializeCheckout{
|
cartStateAny := &messages.InitializeCheckout{
|
||||||
@@ -301,7 +282,7 @@ func (s *CheckoutPoolServer) CreateOrUpdateCheckout(r *http.Request, grain *chec
|
|||||||
|
|
||||||
meta := GetCheckoutMetaFromRequest(r)
|
meta := GetCheckoutMetaFromRequest(r)
|
||||||
|
|
||||||
payload, _, err := BuildCheckoutOrderPayload(grain, meta, s.taxProvider)
|
payload, _, err := BuildCheckoutOrderPayload(grain, meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -437,7 +418,7 @@ func (s *CheckoutPoolServer) StartPaymentHandler(w http.ResponseWriter, r *http.
|
|||||||
switch payload.Provider {
|
switch payload.Provider {
|
||||||
case "adyen":
|
case "adyen":
|
||||||
meta := GetCheckoutMetaFromRequest(r)
|
meta := GetCheckoutMetaFromRequest(r)
|
||||||
sessionData, err := BuildAdyenCheckoutSession(grain, meta, s.taxProvider)
|
sessionData, err := BuildAdyenCheckoutSession(grain, meta)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("unable to build adyen session", "error", err)
|
logger.Error("unable to build adyen session", "error", err)
|
||||||
return err
|
return err
|
||||||
@@ -533,6 +514,14 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
|||||||
handleFunc("/payment/klarna/push", s.KlarnaPushHandler)
|
handleFunc("/payment/klarna/push", s.KlarnaPushHandler)
|
||||||
handleFunc("/payment/klarna/notification", s.KlarnaNotificationHandler)
|
handleFunc("/payment/klarna/notification", s.KlarnaNotificationHandler)
|
||||||
|
|
||||||
|
conn, err := amqp.Dial(amqpUrl)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to connect to RabbitMQ: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderHandler := NewAmqpOrderHandler(conn)
|
||||||
|
orderHandler.DefineQueue()
|
||||||
|
|
||||||
handleFunc("POST /api/checkout/start/{cartid}", s.StartCheckoutHandler)
|
handleFunc("POST /api/checkout/start/{cartid}", s.StartCheckoutHandler)
|
||||||
handleFunc("GET /api/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.GetCheckoutHandler)))
|
handleFunc("GET /api/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.GetCheckoutHandler)))
|
||||||
handleFunc("POST /api/checkout/delivery", CookieCheckoutIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
handleFunc("POST /api/checkout/delivery", CookieCheckoutIdHandler(s.ProxyHandler(s.SetDeliveryHandler)))
|
||||||
@@ -541,11 +530,10 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
|||||||
handleFunc("POST /api/checkout/contact-details", CookieCheckoutIdHandler(s.ProxyHandler(s.ContactDetailsUpdatedHandler)))
|
handleFunc("POST /api/checkout/contact-details", CookieCheckoutIdHandler(s.ProxyHandler(s.ContactDetailsUpdatedHandler)))
|
||||||
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
|
handleFunc("POST /payment", CookieCheckoutIdHandler(s.ProxyHandler(s.StartPaymentHandler)))
|
||||||
handleFunc("POST /payment/{id}/session", CookieCheckoutIdHandler(s.ProxyHandler(s.GetPaymentSessionHandler)))
|
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/initialize", CookieCheckoutIdHandler(s.ProxyHandler(s.InitializeCheckoutHandler)))
|
// handleFunc("POST /api/checkout/inventory-reserved", CookieCheckoutIdHandler(s.ProxyHandler(s.InventoryReservedHandler)))
|
||||||
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/order-created", CookieCheckoutIdHandler(s.ProxyHandler(s.OrderCreatedHandler)))
|
// handleFunc("POST /api/checkout/confirmation-viewed", CookieCheckoutIdHandler(s.ProxyHandler(s.ConfirmationViewedHandler)))
|
||||||
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/session", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaSessionHandler)))
|
||||||
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
|
//handleFunc("GET /payment/klarna/checkout", CookieCheckoutIdHandler(s.ProxyHandler(s.KlarnaHtmlCheckoutHandler)))
|
||||||
@@ -553,17 +541,3 @@ func (s *CheckoutPoolServer) Serve(mux *http.ServeMux) {
|
|||||||
handleFunc("GET /payment/klarna/confirmation/{order_id}", s.KlarnaConfirmationHandler)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+7
-43
@@ -2,15 +2,13 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/go-cart-actor/pkg/checkout"
|
||||||
"git.k6n.net/mats/platform/inventory"
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/metric"
|
"go.opentelemetry.io/otel/metric"
|
||||||
)
|
)
|
||||||
@@ -23,19 +21,6 @@ func getOriginalHost(r *http.Request) string {
|
|||||||
return r.Host
|
return r.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
// getScheme resolves the public scheme for building absolute URLs (e.g. Klarna
|
|
||||||
// merchant_urls). Honors X-Forwarded-Proto set by the edge/dev proxy; defaults
|
|
||||||
// to https so production (TLS terminated at nginx/ingress, no header) is correct.
|
|
||||||
func getScheme(r *http.Request) string {
|
|
||||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
|
||||||
return proto
|
|
||||||
}
|
|
||||||
if r.TLS != nil {
|
|
||||||
return "https"
|
|
||||||
}
|
|
||||||
return "https"
|
|
||||||
}
|
|
||||||
|
|
||||||
func getClientIp(r *http.Request) string {
|
func getClientIp(r *http.Request) string {
|
||||||
ip := r.Header.Get("X-Forwarded-For")
|
ip := r.Header.Get("X-Forwarded-For")
|
||||||
if ip == "" {
|
if ip == "" {
|
||||||
@@ -58,16 +43,6 @@ func getLocale(country string) string {
|
|||||||
return "sv-se"
|
return "sv-se"
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCountryFromCurrency is the reverse of getCurrency, used by server-to-server
|
|
||||||
// callbacks (e.g. the Adyen webhook) that carry the settled currency but not the
|
|
||||||
// shopper's host/country.
|
|
||||||
func getCountryFromCurrency(currency string) string {
|
|
||||||
if strings.EqualFold(currency, "NOK") {
|
|
||||||
return "no"
|
|
||||||
}
|
|
||||||
return "se"
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCountryFromHost(host string) string {
|
func getCountryFromHost(host string) string {
|
||||||
if strings.Contains(strings.ToLower(host), "-no") {
|
if strings.Contains(strings.ToLower(host), "-no") {
|
||||||
return "no"
|
return "no"
|
||||||
@@ -79,23 +54,12 @@ func getCountryFromHost(host string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
|
func (a *CheckoutPoolServer) reserveInventory(ctx context.Context, grain *checkout.CheckoutGrain) error {
|
||||||
if a.reservationPolicy == nil {
|
if a.inventoryService != nil {
|
||||||
return nil
|
inventoryRequests := getInventoryRequests(grain.CartState.Items)
|
||||||
}
|
_, err := a.inventoryService.ReservationCheck(ctx, inventoryRequests...)
|
||||||
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("inventory check failed for SKU %s: %w", item.Sku, err)
|
logger.WarnContext(ctx, "placeorder inventory check failed")
|
||||||
}
|
return 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
|
return nil
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
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})
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+25
-135
@@ -6,29 +6,20 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||||
"git.k6n.net/mats/platform/event"
|
"github.com/matst80/slask-finder/pkg/index"
|
||||||
orderevent "git.k6n.net/mats/platform/order"
|
"github.com/matst80/slask-finder/pkg/messaging"
|
||||||
"git.k6n.net/mats/slask-finder/pkg/index"
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"github.com/redis/go-redis/v9/maintnotifications"
|
"github.com/redis/go-redis/v9/maintnotifications"
|
||||||
|
|
||||||
"git.k6n.net/mats/platform/rabbit"
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
inventoryService *inventory.RedisInventoryService
|
inventoryService *inventory.RedisInventoryService
|
||||||
reservationService *inventory.RedisCartReservationService
|
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) {
|
func (srv *Server) livezHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -75,12 +66,6 @@ var country = "se"
|
|||||||
var redisAddress = "10.10.3.18:6379"
|
var redisAddress = "10.10.3.18:6379"
|
||||||
var redisPassword = "slaskredis"
|
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() {
|
func init() {
|
||||||
// Override redis config from environment variables if set
|
// Override redis config from environment variables if set
|
||||||
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
|
if addr, ok := os.LookupEnv("REDIS_ADDRESS"); ok {
|
||||||
@@ -92,19 +77,6 @@ func init() {
|
|||||||
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
|
if ctry, ok := os.LookupEnv("COUNTRY"); ok {
|
||||||
country = ctry
|
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() {
|
func main() {
|
||||||
@@ -129,140 +101,58 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
server := &Server{inventoryService: s, reservationService: r, rdb: rdb}
|
server := &Server{inventoryService: s, reservationService: r}
|
||||||
|
|
||||||
// Set up HTTP routes
|
// Set up HTTP routes
|
||||||
http.HandleFunc("/livez", server.livezHandler)
|
http.HandleFunc("/livez", server.livezHandler)
|
||||||
http.HandleFunc("/readyz", server.readyzHandler)
|
http.HandleFunc("/readyz", server.readyzHandler)
|
||||||
http.HandleFunc("/inventory/{sku}/{locationId}", server.getInventoryHandler)
|
http.HandleFunc("/inventory/{sku}/{locationId}", server.getInventoryHandler)
|
||||||
http.HandleFunc("/reservations/{sku}/{locationId}", server.getReservationHandler)
|
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{
|
stockhandler := &StockHandler{
|
||||||
MainStockLocationID: inventory.LocationID(country),
|
MainStockLocationID: inventory.LocationID(country),
|
||||||
rdb: rdb,
|
rdb: rdb,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
svc: *s,
|
svc: *s,
|
||||||
country: country,
|
|
||||||
low: lowWatermark,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
|
amqpUrl, ok := os.LookupEnv("RABBIT_HOST")
|
||||||
if ok {
|
if ok {
|
||||||
log.Printf("Connecting to rabbitmq")
|
log.Printf("Connecting to rabbitmq")
|
||||||
conn, err := rabbit.Dial(amqpUrl, "cart-inventory")
|
conn, err := amqp.DialConfig(amqpUrl, amqp.Config{
|
||||||
|
Properties: amqp.NewConnectionProperties(),
|
||||||
|
})
|
||||||
|
//a.conn = conn
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
|
log.Fatalf("Failed to connect to RabbitMQ: %v", err)
|
||||||
}
|
}
|
||||||
// The catalog-feed handler emits inventory.level_changed crossings
|
ch, err := conn.Channel()
|
||||||
// directly on this connection as it writes stock.
|
if err != nil {
|
||||||
stockhandler.conn = conn
|
log.Fatalf("Failed to open a channel: %v", err)
|
||||||
// Share the bus connection with the batch handler so its writes emit
|
}
|
||||||
// inventory.level_changed crossings too.
|
// items listener
|
||||||
server.conn = conn
|
err = messaging.ListenToTopic(ch, country, "item_added", func(d amqp.Delivery) error {
|
||||||
// Reconnecting consumer: re-listen on reconnect.
|
var items []*index.DataItem
|
||||||
conn.NotifyOnReconnect(func() {
|
err := json.Unmarshal(d.Body, &items)
|
||||||
ch, err := conn.Channel()
|
if err == nil {
|
||||||
if err != nil {
|
log.Printf("Got upserts %d, message count %d", len(items), d.MessageCount)
|
||||||
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
|
|
||||||
}
|
|
||||||
wg := &sync.WaitGroup{}
|
wg := &sync.WaitGroup{}
|
||||||
if err := index.ForEachRawDataItemInJSONArray(ev.Payload, func(item *index.RawDataItem) error {
|
for _, item := range items {
|
||||||
stockhandler.HandleItem(item, wg)
|
stockhandler.HandleItem(item, wg)
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("inventory: apply items: %v", err)
|
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
log.Print("Batch done...")
|
log.Print("Batch done...")
|
||||||
return nil
|
} else {
|
||||||
}); err != nil {
|
log.Printf("Failed to unmarshal upsert message %v", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
return err
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to listen to item_added topic: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start HTTP server
|
// Start HTTP server
|
||||||
log.Println("Starting HTTP server on :8080")
|
log.Println("Starting HTTP server on :8080")
|
||||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
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 ""
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
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})
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,12 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||||
"git.k6n.net/mats/platform/rabbit"
|
"github.com/matst80/slask-finder/pkg/types"
|
||||||
"git.k6n.net/mats/slask-finder/pkg/types"
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,35 +17,33 @@ type StockHandler struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
svc inventory.RedisInventoryService
|
svc inventory.RedisInventoryService
|
||||||
MainStockLocationID inventory.LocationID
|
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) {
|
func (s *StockHandler) HandleItem(item types.Item, wg *sync.WaitGroup) {
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
ctx := s.ctx
|
ctx := s.ctx
|
||||||
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: 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()
|
pipe := s.rdb.Pipeline()
|
||||||
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(sku), s.MainStockLocationID, int64(centralStock))
|
centralStockString, ok := item.GetStringFieldValue(3)
|
||||||
if _, err := pipe.Exec(ctx); err != nil {
|
if !ok {
|
||||||
|
centralStockString = "0"
|
||||||
|
}
|
||||||
|
centralStockString = strings.Replace(centralStockString, "+", "", -1)
|
||||||
|
centralStockString = strings.Replace(centralStockString, "<", "", -1)
|
||||||
|
centralStockString = strings.Replace(centralStockString, ">", "", -1)
|
||||||
|
centralStock, err := strconv.ParseInt(centralStockString, 10, 64)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("unable to parse central stock for item %s: %v", item.GetSku(), err)
|
||||||
|
centralStock = 0
|
||||||
|
} else {
|
||||||
|
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(item.GetSku()), s.MainStockLocationID, int64(centralStock))
|
||||||
|
}
|
||||||
|
for id, value := range item.GetStock() {
|
||||||
|
s.svc.UpdateInventory(ctx, pipe, inventory.SKU(item.GetSku()), inventory.LocationID(id), int64(value))
|
||||||
|
}
|
||||||
|
_, err = pipe.Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
log.Printf("unable to update stock: %v", err)
|
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))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/platform/rabbit"
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
// rabbitPublisher is the broker-delivery side used by the outbox relay. It dials
|
|
||||||
// lazily and reconnects on failure, so the relay can be started even when the
|
|
||||||
// broker is down at boot: messages accumulate durably in the outbox and drain
|
|
||||||
// once the broker is reachable.
|
|
||||||
type rabbitPublisher struct {
|
|
||||||
url string
|
|
||||||
mu sync.Mutex
|
|
||||||
conn *amqp.Connection
|
|
||||||
ch *amqp.Channel
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRabbitPublisher(url string) *rabbitPublisher {
|
|
||||||
return &rabbitPublisher{url: url}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *rabbitPublisher) connect() error {
|
|
||||||
conn, err := rabbit.Connect(p.url, "cart-order-publisher")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ch, err := conn.Channel()
|
|
||||||
if err != nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
p.conn, p.ch = conn, ch
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *rabbitPublisher) reset() {
|
|
||||||
if p.ch != nil {
|
|
||||||
_ = p.ch.Close()
|
|
||||||
}
|
|
||||||
if p.conn != nil {
|
|
||||||
_ = p.conn.Close()
|
|
||||||
}
|
|
||||||
p.ch, p.conn = nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *rabbitPublisher) Publish(exchange, routingKey string, body []byte) error {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
if p.ch == nil {
|
|
||||||
if err := p.connect(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Bound the publish so a half-open/black-holed connection can't wedge the
|
|
||||||
// outbox relay indefinitely; on timeout the error path resets and the relay
|
|
||||||
// retries on its next tick.
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
err := p.ch.PublishWithContext(ctx, exchange, routingKey, false, false,
|
|
||||||
amqp.Publishing{ContentType: "application/json", Body: body})
|
|
||||||
if err != nil {
|
|
||||||
p.reset() // force a reconnect on the next attempt
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 len(req.Lines) == 0 {
|
|
||||||
return nil // nothing actionable
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, _, _, runErr, err := s.createOrderFromCheckoutInternal(ctx, &req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if runErr != nil {
|
|
||||||
return runErr
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
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 {
|
|
||||||
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 {
|
|
||||||
s.logger.Warn("order ingest: queue declare on reconnect", "err", err)
|
|
||||||
_ = ch.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msgs, err := ch.Consume(q.Name, "order-service", true, false, false, false, nil)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Warn("order ingest: consume on reconnect", "err", err)
|
|
||||||
_ = ch.Close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.logger.Info("order ingest: consuming from order-queue (reconnect)")
|
|
||||||
go func() {
|
|
||||||
defer ch.Close()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case d, ok := <-msgs:
|
|
||||||
if !ok {
|
|
||||||
s.logger.Warn("order ingest: channel closed (will reconnect)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := ingestOrderFromQueue(ctx, s, d.Body); err != nil {
|
|
||||||
s.logger.Error("order queue ingest failed", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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 testServer(t *testing.T) *server {
|
|
||||||
t.Helper()
|
|
||||||
reg := actor.NewMutationRegistry()
|
|
||||||
order.RegisterMutations(reg)
|
|
||||||
storage := actor.NewDiskStorage[order.OrderGrain](t.TempDir(), reg)
|
|
||||||
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
|
|
||||||
Hostname: "test",
|
|
||||||
Spawn: func(_ context.Context, id uint64) (actor.Grain[order.OrderGrain], error) {
|
|
||||||
return order.NewOrderGrain(id, time.Now()), nil
|
|
||||||
},
|
|
||||||
SpawnHost: func(string) (actor.Host[order.OrderGrain], error) { return nil, fmt.Errorf("none") },
|
|
||||||
Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
|
|
||||||
TTL: time.Hour,
|
|
||||||
PoolSize: 100,
|
|
||||||
|
|
||||||
MutationRegistry: reg,
|
|
||||||
Storage: nil,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Cleanup(pool.Close)
|
|
||||||
|
|
||||||
applier := &orderedApplier{pool: pool, storage: storage}
|
|
||||||
provider := order.NewPassthroughProvider("legacy", "ref", 15000)
|
|
||||||
|
|
||||||
freg := buildFlowRegistry(applier, provider, 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 TestIngestOrderFromQueue(t *testing.T) {
|
|
||||||
s := testServer(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
if g.Status != order.StatusCaptured {
|
|
||||||
t.Fatalf("status = %q, want captured", g.Status)
|
|
||||||
}
|
|
||||||
if g.CapturedAmount != 15000 {
|
|
||||||
t.Fatalf("captured = %d, want 15000", g.CapturedAmount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redelivery must be idempotent — no double capture, no new payment.
|
|
||||||
if err := ingestOrderFromQueue(ctx, s, body); err != nil {
|
|
||||||
t.Fatalf("re-ingest: %v", err)
|
|
||||||
}
|
|
||||||
g2, _ := s.applier.Get(ctx, id)
|
|
||||||
if g2.CapturedAmount != 15000 {
|
|
||||||
t.Fatalf("redelivery double-captured: %d", g2.CapturedAmount)
|
|
||||||
}
|
|
||||||
if len(g2.Payments) != 1 {
|
|
||||||
t.Fatalf("redelivery created %d payments, want 1", len(g2.Payments))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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}`),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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, 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},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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,
|
|
||||||
logger *slog.Logger,
|
|
||||||
) *flow.Registry {
|
|
||||||
freg := flow.NewRegistry()
|
|
||||||
flow.RegisterBuiltinHooks(freg)
|
|
||||||
order.RegisterFlowActions(freg, applier, provider)
|
|
||||||
order.RegisterInventoryReservationActions(freg, applier, inventoryReservations)
|
|
||||||
order.RegisterEmitHook(freg, emitPub)
|
|
||||||
order.RegisterEmailHook(freg, applier, emailSender, emailTemplates)
|
|
||||||
order.RegisterFulfillmentWebhookHook(freg, applier, nil)
|
|
||||||
order.RegisterOrderCreatedEmit(freg, applier, emitPub, "order", "order")
|
|
||||||
registerProjectFlowHooks(freg, applier, logger)
|
|
||||||
return freg
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/flow"
|
|
||||||
)
|
|
||||||
|
|
||||||
// flowStore holds the saga/flow definitions the service can run. Definitions
|
|
||||||
// are JSON files in a directory (editable by the backoffice saga editor),
|
|
||||||
// overlaid on a set of built-in seeds so a fresh deployment always has the
|
|
||||||
// defaults. Saving validates against the engine before writing.
|
|
||||||
type flowStore struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
dir string
|
|
||||||
engine *flow.Engine
|
|
||||||
defs map[string]*flow.Definition
|
|
||||||
}
|
|
||||||
|
|
||||||
var validFlowName = func(s string) bool {
|
|
||||||
if s == "" || len(s) > 64 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, r := range s {
|
|
||||||
if !(r == '-' || r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// newFlowStore seeds with the built-in definitions, then overlays any JSON files
|
|
||||||
// found in dir (on-disk wins, so edits persist and take effect).
|
|
||||||
func newFlowStore(dir string, engine *flow.Engine, seed map[string]*flow.Definition) (*flowStore, error) {
|
|
||||||
s := &flowStore{dir: dir, engine: engine, defs: map[string]*flow.Definition{}}
|
|
||||||
for name, def := range seed {
|
|
||||||
s.defs[name] = def
|
|
||||||
}
|
|
||||||
matches, _ := filepath.Glob(filepath.Join(dir, "*.json"))
|
|
||||||
for _, m := range matches {
|
|
||||||
data, err := os.ReadFile(m)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read flow %s: %w", m, err)
|
|
||||||
}
|
|
||||||
def, err := flow.Parse(data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("parse flow %s: %w", m, err)
|
|
||||||
}
|
|
||||||
if def.Name == "" {
|
|
||||||
def.Name = strings.TrimSuffix(filepath.Base(m), ".json")
|
|
||||||
}
|
|
||||||
s.defs[def.Name] = def
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *flowStore) get(name string) (*flow.Definition, bool) {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
d, ok := s.defs[name]
|
|
||||||
return d, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *flowStore) list() []*flow.Definition {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
names := make([]string, 0, len(s.defs))
|
|
||||||
for n := range s.defs {
|
|
||||||
names = append(names, n)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
out := make([]*flow.Definition, 0, len(names))
|
|
||||||
for _, n := range names {
|
|
||||||
out = append(out, s.defs[n])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// save validates the definition, persists it to <dir>/<name>.json and updates
|
|
||||||
// the in-memory map so the next checkout uses it.
|
|
||||||
func (s *flowStore) save(name string, def *flow.Definition) error {
|
|
||||||
if !validFlowName(name) {
|
|
||||||
return fmt.Errorf("invalid flow name %q", name)
|
|
||||||
}
|
|
||||||
if def.Name == "" {
|
|
||||||
def.Name = name
|
|
||||||
}
|
|
||||||
if err := s.engine.Validate(def); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
data, err := json.MarshalIndent(def, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(s.dir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(s.dir, name+".json"), append(data, '\n'), 0o644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
s.defs[name] = def
|
|
||||||
s.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
func nowMs() int64 { return time.Now().UnixMilli() }
|
|
||||||
|
|
||||||
// applyOne applies a single mutation and surfaces the handler error (the pool
|
|
||||||
// returns a top-level error only for unregistered mutations; a rejected
|
|
||||||
// transition is carried per-mutation in the result).
|
|
||||||
func applyOne(ctx context.Context, app *orderedApplier, id uint64, msg proto.Message) error {
|
|
||||||
res, err := app.Apply(ctx, id, msg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if res != nil {
|
|
||||||
for _, m := range res.Mutations {
|
|
||||||
if m.Error != nil {
|
|
||||||
return m.Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// capturedRef returns the capture reference of the first captured payment, which
|
|
||||||
// the payment provider needs to issue a refund against.
|
|
||||||
func capturedRef(g *order.OrderGrain) string {
|
|
||||||
for _, p := range g.Payments {
|
|
||||||
if p.Captured > 0 && p.CaptureRef != "" {
|
|
||||||
return p.CaptureRef
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadOrder parses the id and returns the current grain, writing 400/404 itself
|
|
||||||
// and returning ok=false when the caller should stop.
|
|
||||||
func (s *server) loadOrder(w http.ResponseWriter, r *http.Request) (order.OrderId, *order.OrderGrain, bool) {
|
|
||||||
id, ok := order.ParseOrderId(r.PathValue("id"))
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
|
|
||||||
return 0, nil, false
|
|
||||||
}
|
|
||||||
g, err := s.applier.Get(r.Context(), uint64(id))
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err)
|
|
||||||
return 0, nil, false
|
|
||||||
}
|
|
||||||
if g.Status == order.StatusNew {
|
|
||||||
writeErr(w, http.StatusNotFound, fmt.Errorf("order not found"))
|
|
||||||
return 0, nil, false
|
|
||||||
}
|
|
||||||
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, idemKey string) {
|
|
||||||
res, err := s.applier.Apply(r.Context(), uint64(id), msg)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, m := range res.Mutations {
|
|
||||||
if m.Error != nil {
|
|
||||||
writeErr(w, http.StatusConflict, m.Error)
|
|
||||||
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)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- lifecycle (b) --------------------------------------------------------
|
|
||||||
|
|
||||||
type fulfillReq struct {
|
|
||||||
Carrier string `json:"carrier,omitempty"`
|
|
||||||
TrackingNumber string `json:"trackingNumber,omitempty"`
|
|
||||||
TrackingURI string `json:"trackingUri,omitempty"`
|
|
||||||
Lines []struct {
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Quantity int32 `json:"quantity"`
|
|
||||||
} `json:"lines"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleFulfill(w http.ResponseWriter, r *http.Request) {
|
|
||||||
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: 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, 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) {
|
|
||||||
id, _, ok := s.loadOrder(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
id, _, ok := s.loadOrder(w, r)
|
|
||||||
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()}, idemKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
type returnReq struct {
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
Lines []struct {
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Quantity int32 `json:"quantity"`
|
|
||||||
} `json:"lines"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleReturn(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 returnReq
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rid, _ := order.NewOrderId()
|
|
||||||
msg := &messages.RequestReturn{Id: "r_" + rid.String(), Reason: req.Reason, 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, idemKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleRefund(w http.ResponseWriter, r *http.Request) {
|
|
||||||
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 struct {
|
|
||||||
Amount int64 `json:"amount"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
amount := req.Amount
|
|
||||||
if amount <= 0 {
|
|
||||||
amount = (g.CapturedAmount - g.RefundedAmount).Int64() // default: full remaining
|
|
||||||
}
|
|
||||||
captureRef := capturedRef(g)
|
|
||||||
ref, err := s.provider.Refund(r.Context(), captureRef, amount)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusBadGateway, fmt.Errorf("refund failed: %w", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.applyAndRespond(w, r, id, &messages.IssueRefund{
|
|
||||||
Provider: ref.Provider,
|
|
||||||
Amount: ref.Amount,
|
|
||||||
Reference: ref.Reference,
|
|
||||||
AtMs: nowMs(),
|
|
||||||
}, idemKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- saga / flow admin (a) ------------------------------------------------
|
|
||||||
|
|
||||||
func (s *server) handleCapabilities(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
writeJSON(w, http.StatusOK, s.freg.Capabilities())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleListFlows(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
writeJSON(w, http.StatusOK, s.flows.list())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleGetFlow(w http.ResponseWriter, r *http.Request) {
|
|
||||||
def, ok := s.flows.get(r.PathValue("name"))
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusNotFound, fmt.Errorf("flow not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, def)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleSaveFlow(w http.ResponseWriter, r *http.Request) {
|
|
||||||
name := r.PathValue("name")
|
|
||||||
var def flow.Definition
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.flows.save(name, &def); err != nil {
|
|
||||||
// Validation failures (unknown action/hook/predicate) are client errors.
|
|
||||||
writeErr(w, http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, &def)
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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
|
|
||||||
// from a settled (paid) checkout. All payment processing has already happened
|
|
||||||
// via Klarna/Adyen on the checkout grain; the order service records the result
|
|
||||||
// in its event-sourced grain.
|
|
||||||
type fromCheckoutReq struct {
|
|
||||||
CheckoutId string `json:"checkoutId"`
|
|
||||||
IdempotencyKey string `json:"idempotencyKey"`
|
|
||||||
Flow string `json:"flow,omitempty"`
|
|
||||||
|
|
||||||
CartId string `json:"cartId"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
Locale string `json:"locale,omitempty"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
|
||||||
CustomerName string `json:"customerName,omitempty"`
|
|
||||||
Lines []lineReq `json:"lines"`
|
|
||||||
|
|
||||||
// Payment carries the externally-settled payment result. The provider has
|
|
||||||
// already authorized and captured; the order grain records these facts.
|
|
||||||
Payment struct {
|
|
||||||
Provider string `json:"provider"` // "klarna" or "adyen"
|
|
||||||
Reference string `json:"reference"` // processor reference (PSP reference)
|
|
||||||
Amount int64 `json:"amount"` // minor units
|
|
||||||
} `json:"payment"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleFromCheckout creates an event-sourced order from a settled checkout.
|
|
||||||
// It runs the place-and-pay flow with a passthrough provider that records the
|
|
||||||
// already-completed authorization and capture, bypassing any external payment
|
|
||||||
// call. The response carries the order grain, flow result, and order ID.
|
|
||||||
//
|
|
||||||
// Idempotent: a retry with the same idempotencyKey returns the existing order
|
|
||||||
// (409 Conflict) rather than creating a duplicate.
|
|
||||||
func (s *server) handleFromCheckout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req fromCheckoutReq
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("bad body: %w", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.Lines) == 0 {
|
|
||||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("from-checkout: at least one line required"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ordID, flowRes, grain, exists, runErr, err := s.createOrderFromCheckoutInternal(r.Context(), &req)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
writeJSON(w, http.StatusConflict, map[string]any{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := order.NewOrderId()
|
|
||||||
if err != nil {
|
|
||||||
return 0, nil, nil, false, nil, fmt.Errorf("new order id: %w", err)
|
|
||||||
}
|
|
||||||
ordID := uint64(id)
|
|
||||||
|
|
||||||
po := buildFromCheckoutPlaceOrder(id, req)
|
|
||||||
provider := order.NewPassthroughProvider(req.Payment.Provider, req.Payment.Reference, req.Payment.Amount)
|
|
||||||
|
|
||||||
flowName := req.Flow
|
|
||||||
if flowName == "" {
|
|
||||||
flowName = s.defaultFlow
|
|
||||||
}
|
|
||||||
def, ok := s.flows.get(flowName)
|
|
||||||
if !ok {
|
|
||||||
return 0, nil, nil, false, nil, fmt.Errorf("unknown flow %q", flowName)
|
|
||||||
}
|
|
||||||
|
|
||||||
freg := buildFlowRegistry(s.applier, provider, s.inventoryReservations, 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(ctx, def, st)
|
|
||||||
grain, getErr := s.applier.Get(ctx, ordID)
|
|
||||||
if getErr != nil {
|
|
||||||
return 0, nil, nil, false, nil, fmt.Errorf("get order grain: %w", getErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if runErr != nil {
|
|
||||||
s.logger.Warn("from-checkout flow failed", "orderId", id.String(), "err", runErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ordID, res, grain, false, runErr, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildFromCheckoutPlaceOrder converts a from-checkout request into a PlaceOrder
|
|
||||||
// proto message, computing per-line and order totals from the lineReq entries.
|
|
||||||
func buildFromCheckoutPlaceOrder(id order.OrderId, req *fromCheckoutReq) *messages.PlaceOrder {
|
|
||||||
orderRef := "ord-" + id.String()
|
|
||||||
po := &messages.PlaceOrder{
|
|
||||||
OrderReference: orderRef,
|
|
||||||
CartId: req.CartId,
|
|
||||||
Currency: req.Currency,
|
|
||||||
Locale: req.Locale,
|
|
||||||
Country: req.Country,
|
|
||||||
CustomerEmail: req.CustomerEmail,
|
|
||||||
CustomerName: req.CustomerName,
|
|
||||||
PlacedAtMs: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
var total, totalTax int64
|
|
||||||
for _, l := range req.Lines {
|
|
||||||
lineTotal := l.UnitPrice * int64(l.Quantity)
|
|
||||||
lineTax := order.ComputeTax(money.Cents(lineTotal), int(l.TaxRate)).Int64()
|
|
||||||
total += lineTotal
|
|
||||||
totalTax += lineTax
|
|
||||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
|
||||||
Reference: l.Reference,
|
|
||||||
Sku: l.Sku,
|
|
||||||
Name: l.Name,
|
|
||||||
Quantity: l.Quantity,
|
|
||||||
UnitPrice: l.UnitPrice,
|
|
||||||
TaxRate: l.TaxRate,
|
|
||||||
TotalAmount: lineTotal,
|
|
||||||
TotalTax: lineTax,
|
|
||||||
Location: l.Location,
|
|
||||||
DropShip: l.DropShip,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
po.TotalAmount = total
|
|
||||||
po.TotalTax = totalTax
|
|
||||||
return po
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,598 +0,0 @@
|
|||||||
// Command order is a single-node HTTP entrypoint for the order grain. It exposes
|
|
||||||
// a simple checkout that creates an order by running the place-and-pay flow
|
|
||||||
// (place -> mock authorize -> capture) on the actor framework, plus read APIs
|
|
||||||
// for an order and its event timeline. No clustering, no external payment — the
|
|
||||||
// simplest path to a real, event-sourced, captured order.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/mail"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"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/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"
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
type server struct {
|
|
||||||
pool *actor.SimpleGrainPool[order.OrderGrain]
|
|
||||||
applier *orderedApplier
|
|
||||||
storage *actor.DiskStorage[order.OrderGrain]
|
|
||||||
reg actor.MutationRegistry
|
|
||||||
engine *flow.Engine
|
|
||||||
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 := 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))
|
|
||||||
|
|
||||||
reg := actor.NewMutationRegistry()
|
|
||||||
order.RegisterMutations(reg)
|
|
||||||
storage := actor.NewDiskStorage[order.OrderGrain](dataDir, reg)
|
|
||||||
|
|
||||||
pool, err := actor.NewSimpleGrainPool(actor.GrainPoolConfig[order.OrderGrain]{
|
|
||||||
Hostname: "order-1",
|
|
||||||
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.
|
|
||||||
if err := storage.LoadEvents(ctx, id, g); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return g, nil
|
|
||||||
},
|
|
||||||
SpawnHost: func(string) (actor.Host[order.OrderGrain], error) {
|
|
||||||
return nil, fmt.Errorf("order service is single-node")
|
|
||||||
},
|
|
||||||
Destroy: func(actor.Grain[order.OrderGrain]) error { return nil },
|
|
||||||
TTL: time.Hour,
|
|
||||||
PoolSize: 10000,
|
|
||||||
// Storage is intentionally nil here: the pool persists mutations in a
|
|
||||||
// fire-and-forget goroutine, so concurrent appends for one grain can land
|
|
||||||
// out of order on disk — fatal for an event log, where replay order is the
|
|
||||||
// state. We persist through orderedApplier instead (synchronous, ordered,
|
|
||||||
// and only for mutations that actually applied). Replay on spawn still uses
|
|
||||||
// `storage` directly in Spawn above.
|
|
||||||
MutationRegistry: reg,
|
|
||||||
Storage: nil,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("create pool: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
applier := &orderedApplier{pool: pool, storage: storage}
|
|
||||||
provider := selectProvider(logger)
|
|
||||||
|
|
||||||
// Flow registry: generic hooks + the order lifecycle actions + predicates,
|
|
||||||
// paying via the selected provider. The amqp_emit hook bridges saga steps to
|
|
||||||
// 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
|
|
||||||
// directly; a relay goroutine drains it to RabbitMQ (reconnecting), so an
|
|
||||||
// event survives a broker outage or a crash after the state change.
|
|
||||||
box, err := outbox.Open(filepath.Join(dataDir, "outbox.log"))
|
|
||||||
if err != nil {
|
|
||||||
// Durability-critical (same as the idempotency store): fail fast rather
|
|
||||||
// than silently publish events nowhere for the whole process lifetime.
|
|
||||||
log.Fatalf("open outbox: %v", err)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
_ = ch.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
freg := buildFlowRegistry(applier, provider, inventoryReservations, emitPub, emailSender, emailTemplates, 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})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("load flows: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Durable idempotency store for order creation — survives a restart, so a
|
|
||||||
// client retry after a crash returns the existing order instead of creating
|
|
||||||
// a duplicate.
|
|
||||||
idem, err := idempotency.Open(filepath.Join(dataDir, "idempotency.log"))
|
|
||||||
if err != nil {
|
|
||||||
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()
|
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
|
||||||
|
|
||||||
// Checkout + order reads.
|
|
||||||
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/{id}", s.handleGet)
|
|
||||||
mux.HandleFunc("GET /api/orders/{id}/events", s.handleEvents)
|
|
||||||
|
|
||||||
// Post-purchase lifecycle (the grain already supports these transitions).
|
|
||||||
mux.HandleFunc("POST /api/orders/{id}/fulfillments", s.handleFulfill)
|
|
||||||
mux.HandleFunc("POST /api/orders/{id}/complete", s.handleComplete)
|
|
||||||
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)
|
|
||||||
if signer := loadUCPOrderSigner(); signer != nil {
|
|
||||||
orderUCP = ucp.WithSigning(orderUCP, signer)
|
|
||||||
logger.Info("ucp signing enabled")
|
|
||||||
}
|
|
||||||
// StripPrefix is required because the sub-mux (OrderHandler) registers
|
|
||||||
// routes like "GET /{id}" which expect a relative path; Go's ServeMux
|
|
||||||
// passes the full request path without stripping the prefix.
|
|
||||||
// Only the subtree pattern is registered (see comment in cmd/cart/main.go
|
|
||||||
// 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 checkout orders from order-queue (Klarna/Adyen fallback).
|
|
||||||
if amqpURL != "" {
|
|
||||||
startOrderIngest(context.Background(), amqpURL, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("order service listening", "addr", addr, "data", dataDir, "flows", flowsDir)
|
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
|
||||||
log.Fatalf("serve: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// orderedApplier wraps the grain pool to persist the event log correctly: it
|
|
||||||
// appends each applied mutation synchronously and in call order (the pool's own
|
|
||||||
// storage path is async/unordered), and only persists mutations whose handler
|
|
||||||
// succeeded — a rejected transition must never enter the log. A single mutex
|
|
||||||
// serialises all appends, which is ample for a single-node checkout service.
|
|
||||||
type orderedApplier struct {
|
|
||||||
pool *actor.SimpleGrainPool[order.OrderGrain]
|
|
||||||
storage *actor.DiskStorage[order.OrderGrain]
|
|
||||||
mu sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *orderedApplier) Get(ctx context.Context, id uint64) (*order.OrderGrain, error) {
|
|
||||||
return a.pool.Get(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *orderedApplier) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) {
|
|
||||||
res, err := a.pool.Apply(ctx, id, mutation...)
|
|
||||||
if err != nil || res == nil {
|
|
||||||
return res, err
|
|
||||||
}
|
|
||||||
// Persist only the mutations that actually applied (result index aligns with
|
|
||||||
// the input order).
|
|
||||||
applied := make([]proto.Message, 0, len(mutation))
|
|
||||||
for i, m := range mutation {
|
|
||||||
if i < len(res.Mutations) && res.Mutations[i].Error != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
applied = append(applied, m)
|
|
||||||
}
|
|
||||||
if len(applied) > 0 {
|
|
||||||
a.mu.Lock()
|
|
||||||
appendErr := a.storage.AppendMutations(id, applied...)
|
|
||||||
a.mu.Unlock()
|
|
||||||
if appendErr != nil {
|
|
||||||
return res, appendErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- checkout -------------------------------------------------------------
|
|
||||||
|
|
||||||
type lineReq struct {
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Sku string `json:"sku"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Quantity int32 `json:"quantity"`
|
|
||||||
UnitPrice int64 `json:"unitPrice"` // inc-vat, minor units
|
|
||||||
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 {
|
|
||||||
OrderReference string `json:"orderReference,omitempty"`
|
|
||||||
CartId string `json:"cartId,omitempty"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
Locale string `json:"locale,omitempty"`
|
|
||||||
Country string `json:"country,omitempty"`
|
|
||||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
|
||||||
CustomerName string `json:"customerName,omitempty"`
|
|
||||||
BillingAddress json.RawMessage `json:"billingAddress,omitempty"`
|
|
||||||
ShippingAddress json.RawMessage `json:"shippingAddress,omitempty"`
|
|
||||||
Lines []lineReq `json:"lines"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) handleCheckout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req checkoutReq
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("bad body: %w", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.Lines) == 0 {
|
|
||||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("checkout requires at least one line"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := order.NewOrderId()
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.OrderReference == "" {
|
|
||||||
req.OrderReference = "ord-" + id.String()
|
|
||||||
}
|
|
||||||
if req.Currency == "" {
|
|
||||||
req.Currency = "SEK"
|
|
||||||
}
|
|
||||||
|
|
||||||
flowName := r.URL.Query().Get("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
|
|
||||||
}
|
|
||||||
|
|
||||||
po := s.buildPlaceOrder(id, &req)
|
|
||||||
|
|
||||||
st := flow.NewState(uint64(id), s.logger)
|
|
||||||
st.Vars[order.PlaceOrderVar] = po
|
|
||||||
|
|
||||||
res, runErr := s.engine.Run(r.Context(), def, st)
|
|
||||||
grain, getErr := s.pool.Get(r.Context(), uint64(id))
|
|
||||||
if getErr != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, getErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
status := http.StatusCreated
|
|
||||||
if runErr != nil {
|
|
||||||
// The flow compensated; the order exists but is not paid. Report 402 with
|
|
||||||
// the (now cancelled/failed) order and the flow trace so the caller knows.
|
|
||||||
status = http.StatusPaymentRequired
|
|
||||||
s.logger.Warn("checkout flow failed", "orderId", id.String(), "err", runErr)
|
|
||||||
}
|
|
||||||
writeJSON(w, status, map[string]any{
|
|
||||||
"orderId": id.String(),
|
|
||||||
"flow": res,
|
|
||||||
"order": grain,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildPlaceOrder converts a checkout request into the PlaceOrder event,
|
|
||||||
// computing per-line and order totals (inc-vat) from the lines.
|
|
||||||
// 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,
|
|
||||||
Currency: req.Currency,
|
|
||||||
Locale: req.Locale,
|
|
||||||
Country: req.Country,
|
|
||||||
CustomerEmail: req.CustomerEmail,
|
|
||||||
CustomerName: req.CustomerName,
|
|
||||||
BillingAddress: req.BillingAddress,
|
|
||||||
ShippingAddress: req.ShippingAddress,
|
|
||||||
PlacedAtMs: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
var total, totalTax int64
|
|
||||||
for _, l := range req.Lines {
|
|
||||||
lineTotal := l.UnitPrice * int64(l.Quantity)
|
|
||||||
lineTax := s.taxProvider.Compute(lineTotal, int(l.TaxRate))
|
|
||||||
total += lineTotal
|
|
||||||
totalTax += lineTax
|
|
||||||
po.Lines = append(po.Lines, &messages.OrderLine{
|
|
||||||
Reference: l.Reference,
|
|
||||||
Sku: l.Sku,
|
|
||||||
Name: l.Name,
|
|
||||||
Quantity: l.Quantity,
|
|
||||||
UnitPrice: l.UnitPrice,
|
|
||||||
TaxRate: l.TaxRate,
|
|
||||||
TotalAmount: lineTotal,
|
|
||||||
TotalTax: lineTax,
|
|
||||||
DropShip: l.DropShip,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
po.TotalAmount = total
|
|
||||||
po.TotalTax = totalTax
|
|
||||||
return po
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- reads ----------------------------------------------------------------
|
|
||||||
|
|
||||||
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := order.ParseOrderId(r.PathValue("id"))
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
grain, err := s.pool.Get(r.Context(), uint64(id))
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if grain.Status == order.StatusNew {
|
|
||||||
writeErr(w, http.StatusNotFound, fmt.Errorf("order not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "order": grain})
|
|
||||||
}
|
|
||||||
|
|
||||||
type eventView struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Time string `json:"time,omitempty"`
|
|
||||||
Payload json.RawMessage `json:"payload,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleEvents returns the raw event timeline by reading the grain's append-only
|
|
||||||
// log (the source of truth) without re-applying it to live state.
|
|
||||||
func (s *server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := order.ParseOrderId(r.PathValue("id"))
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusBadRequest, fmt.Errorf("invalid order id"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var events []eventView
|
|
||||||
throwaway := order.NewOrderGrain(uint64(id), time.Now())
|
|
||||||
err := s.storage.LoadEventsFunc(r.Context(), uint64(id), throwaway,
|
|
||||||
func(msg proto.Message, _ int, ts time.Time) bool {
|
|
||||||
name, _ := s.reg.GetTypeName(msg)
|
|
||||||
payload, _ := protojson.Marshal(msg)
|
|
||||||
events = append(events, eventView{Type: name, Time: ts.UTC().Format(time.RFC3339), Payload: payload})
|
|
||||||
return false // collect only; do not apply
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"orderId": id.String(), "events": events})
|
|
||||||
}
|
|
||||||
|
|
||||||
type orderSummary struct {
|
|
||||||
OrderId string `json:"orderId"`
|
|
||||||
Reference string `json:"reference,omitempty"`
|
|
||||||
Status order.Status `json:"status"`
|
|
||||||
TotalAmount int64 `json:"totalAmount"`
|
|
||||||
CapturedAmount int64 `json:"capturedAmount"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
PlacedAt string `json:"placedAt,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleList scans the data dir for order logs and summarises each (replaying it
|
|
||||||
// through the pool). Fine for a single-node simple checkout; a production list
|
|
||||||
// would use an index/projection store.
|
|
||||||
func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
|
|
||||||
matches, _ := filepath.Glob(filepath.Join(s.dataDir, "*.events.log"))
|
|
||||||
out := make([]orderSummary, 0, len(matches))
|
|
||||||
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
|
|
||||||
}
|
|
||||||
out = append(out, orderSummary{
|
|
||||||
OrderId: order.OrderId(raw).String(),
|
|
||||||
Reference: g.OrderReference,
|
|
||||||
Status: g.Status,
|
|
||||||
TotalAmount: g.TotalAmount.Int64(),
|
|
||||||
CapturedAmount: g.CapturedAmount.Int64(),
|
|
||||||
Currency: g.Currency,
|
|
||||||
PlacedAt: g.PlacedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- 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).
|
|
||||||
func selectProvider(logger *slog.Logger) order.PaymentProvider {
|
|
||||||
if os.Getenv("PAYMENT_PROVIDER") == "stripe" {
|
|
||||||
key := os.Getenv("STRIPE_SECRET_KEY")
|
|
||||||
if key == "" {
|
|
||||||
logger.Warn("PAYMENT_PROVIDER=stripe but STRIPE_SECRET_KEY is empty; falling back to mock")
|
|
||||||
return order.NewMockProvider()
|
|
||||||
}
|
|
||||||
logger.Info("using stripe payment provider")
|
|
||||||
return order.NewStripeProvider(key, os.Getenv("STRIPE_API_BASE"), os.Getenv("STRIPE_PAYMENT_METHOD"))
|
|
||||||
}
|
|
||||||
return order.NewMockProvider()
|
|
||||||
}
|
|
||||||
|
|
||||||
// selectEmailSender picks the flow-email transport. SMTP is the only sender
|
|
||||||
// implementation for now; when no SMTP host 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"
|
|
||||||
}
|
|
||||||
if kind != "smtp" {
|
|
||||||
return nil, fmt.Errorf("unsupported ORDER_EMAIL_SENDER %q", kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
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", kind, "host", config.EnvString("ORDER_EMAIL_SMTP_HOST", ""), "from", from.Address)
|
|
||||||
return sender, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
path := os.Getenv("UCP_SIGNING_KEY_PATH")
|
|
||||||
if path == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pemData, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ucp signing: cannot read key %s: %v", path, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ucp signing: invalid key at %s: %v", path, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeErr(w http.ResponseWriter, status int, err error) {
|
|
||||||
writeJSON(w, status, map[string]string{"error": err.Error()})
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetDiscovery returns the k8s pod-watcher that finds peer profile replicas, or
|
|
||||||
// nil when POD_IP is unset (single-node / local dev — clustering disabled). The
|
|
||||||
// watch is scoped to this pod's own namespace so it only needs a namespaced Role
|
|
||||||
// (pods: get/list/watch), not a cluster-wide role.
|
|
||||||
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.NewK8sDiscoveryInNamespace(client, discovery.InClusterNamespace(), v1.ListOptions{
|
|
||||||
LabelSelector: "actor-pool=profile",
|
|
||||||
TimeoutSeconds: &timeout,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UseDiscovery starts the watch loop that adds/removes peer hosts from the pool
|
|
||||||
// as profile replicas become ready or go away. A nil discovery (POD_IP unset)
|
|
||||||
// logs and returns, leaving the pool in single-node mode.
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"path/filepath"
|
|
||||||
"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).
|
|
||||||
func authSecret() []byte {
|
|
||||||
if v := os.Getenv("CUSTOMER_AUTH_SECRET"); v != "" {
|
|
||||||
return []byte(v)
|
|
||||||
}
|
|
||||||
b := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
log.Fatalf("could not generate auth secret: %v", err)
|
|
||||||
}
|
|
||||||
log.Print("warning: CUSTOMER_AUTH_SECRET unset — using an ephemeral key; customer sessions will not survive a restart")
|
|
||||||
return []byte(hex.EncodeToString(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
var podIp = os.Getenv("POD_IP")
|
|
||||||
var name = os.Getenv("POD_NAME")
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
path := os.Getenv("UCP_SIGNING_KEY_PATH")
|
|
||||||
if path == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pemData, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ucp signing: cannot read key %s: %v", path, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cfg, err := ucp.NewSigningConfigFromPEM(pemData, "k6n-ecdsa-2026")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ucp signing: invalid key at %s: %v", path, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
reg := profile.NewProfileMutationRegistry()
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
poolConfig := actor.GrainPoolConfig[profile.ProfileGrain]{
|
|
||||||
MutationRegistry: reg,
|
|
||||||
Storage: diskStorage,
|
|
||||||
Spawn: func(ctx context.Context, id uint64) (actor.Grain[profile.ProfileGrain], error) {
|
|
||||||
ret := profile.NewProfileGrain(id, time.Now())
|
|
||||||
err := diskStorage.LoadEvents(ctx, id, ret)
|
|
||||||
return ret, err
|
|
||||||
},
|
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
pool, err := actor.NewSimpleGrainPool(poolConfig)
|
|
||||||
if err != nil {
|
|
||||||
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 := 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)
|
|
||||||
|
|
||||||
// UCP Customer REST adapter
|
|
||||||
auditLogPath := filepath.Join(profileDir, "audit.log")
|
|
||||||
customerUCP := ucp.CustomerHandler(pool, auditLogPath, credStore)
|
|
||||||
if signer := loadUCPProfileSigner(); signer != nil {
|
|
||||||
customerUCP = ucp.WithSigning(customerUCP, signer)
|
|
||||||
log.Print("ucp customer signing enabled")
|
|
||||||
}
|
|
||||||
// StripPrefix is required because the sub-mux (CustomerHandler) registers
|
|
||||||
// routes like "GET /{id}" which expect a relative path; Go's ServeMux
|
|
||||||
// passes the full request path without stripping the prefix.
|
|
||||||
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
|
|
||||||
|
|
||||||
// Customer auth: password signup/login + session cookies + identity linking.
|
|
||||||
authHandler := customerauth.NewServer(credStore, pool, customerauth.NewSigner(authSecret()), 0, customerauth.Options{
|
|
||||||
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) {
|
|
||||||
grainCount, capacity := pool.LocalUsage()
|
|
||||||
if grainCount >= capacity {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("grain pool at capacity"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !pool.IsHealthy() {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
w.Write([]byte("pool not healthy"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
|
|
||||||
mux.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("1.0.0"))
|
|
||||||
})
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: config.EnvString("PROFILE_ADDR", ":8080"),
|
|
||||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
|
||||||
ReadTimeout: 10 * time.Second,
|
|
||||||
WriteTimeout: 20 * time.Second,
|
|
||||||
Handler: otelhttp.NewHandler(mux, "/"),
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
fmt.Println("Shutting down profile service")
|
|
||||||
otelShutdown(context.Background())
|
|
||||||
diskStorage.Close()
|
|
||||||
pool.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
srvErr := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
srvErr <- srv.ListenAndServe()
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Print("Profile server started at port 8080")
|
|
||||||
|
|
||||||
go http.ListenAndServe(":8081", debugMux)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err = <-srvErr:
|
|
||||||
log.Fatalf("Unable to start server: %v", err)
|
|
||||||
case <-ctx.Done():
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
|||||||
{"type":"AddItem","timestamp":"2026-06-14T12:06:33.026264929+02:00","mutation":{"item_id":147528,"quantity":1,"price":18559,"orgPrice":23199,"sku":"A0161291","name":"SF vridfönster 2380x680mm 2-luft, insida trä utsida aluminium, 3-glas ","image":"/A0161291.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NywiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIyIiwibHVmdF90aXRsZSI6IjItTHVmdCAodHbDpSDDtnBwbmluZ3NiYXJhIGLDpWdhciBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjI0MDciLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoyNCwid2lkdGhNYXJnaW4iOjIwfQ=="}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:06:41.00641672+02:00","mutation":{"Id":1,"quantity":2}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:06:50.402587934+02:00","mutation":{"Id":1,"quantity":1}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:08:25.714006052+02:00","mutation":{"Id":1,"quantity":2}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:11:38.722916072+02:00","mutation":{"Id":1,"quantity":1}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:14:12.485362998+02:00","mutation":{"Id":1,"quantity":2}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:14:13.259209602+02:00","mutation":{"Id":1,"quantity":1}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T12:14:19.560413353+02:00","mutation":{"item_id":146107,"quantity":1,"price":1812900,"orgPrice":2266125,"sku":"A0158995","name":"SF vridfönster 1680x1380mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0158995.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6MTQsImhlaWdodE1hcmdpbiI6MjAsImlzQWNjZXNzb3J5IjowLCJsdWZ0IjoiMSIsImx1ZnRfdGl0bGUiOiIxLUx1ZnQgKGVuIMO2cHBuaW5nc2JhciBiw6VnZSBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE3MTQiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxNywid2lkdGhNYXJnaW4iOjIwfQ=="}}
|
|
||||||
{"type":"RemoveItem","timestamp":"2026-06-14T12:16:35.832642257+02:00","mutation":{"Id":1}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T12:47:41.110158952+02:00","mutation":{"item_id":144047,"quantity":1,"price":690193,"orgPrice":862741,"sku":"A0162590","name":"SF vridfönster 480x580mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0162590.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMDUwNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjUsIndpZHRoTWFyZ2luIjoyMH0="}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T12:49:14.15670005+02:00","mutation":{"item_id":144047,"quantity":1,"price":690193,"orgPrice":862741,"sku":"A0162590","name":"SF vridfönster 480x580mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0162590.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMDUwNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjUsIndpZHRoTWFyZ2luIjoyMH0="}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:49:19.389741453+02:00","mutation":{"Id":3,"quantity":1}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:49:19.68037225+02:00","mutation":{"Id":3}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T12:49:20.250569359+02:00","mutation":{"Id":2}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T12:49:22.994875367+02:00","mutation":{"item_id":144047,"quantity":1,"price":690193,"orgPrice":862741,"sku":"A0162590","name":"SF vridfönster 480x580mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0162590.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6NiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMDUwNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjUsIndpZHRoTWFyZ2luIjoyMH0="}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T13:02:45.511064864+02:00","mutation":{"item_id":146248,"quantity":1,"price":1842370,"orgPrice":2302963,"sku":"A0159038","name":"SF vridfönster 1780x780mm 2-luft, insida trä utsida aluminium, 3-glas ","image":"/A0159038.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6OCwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIyIiwibHVmdF90aXRsZSI6IjItTHVmdCAodHbDpSDDtnBwbmluZ3NiYXJhIGLDpWdhciBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE4MDgiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxOCwid2lkdGhNYXJnaW4iOjIwfQ=="}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T13:02:48.965993343+02:00","mutation":{"Id":5}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T13:02:49.533960677+02:00","mutation":{"Id":4}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T13:03:02.543932484+02:00","mutation":{"item_id":146620,"quantity":1,"price":3043620,"orgPrice":3804525,"sku":"A0164975","name":"SF vridfönster 1880x1180mm 3-luft, insida trä utsida aluminium, 3-glas ","image":"/A0164975.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6MTIsImhlaWdodE1hcmdpbiI6MjAsImlzQWNjZXNzb3J5IjowLCJsdWZ0IjoiMyIsImx1ZnRfdGl0bGUiOiIzLUx1ZnQgKHRyZSDDtnBwbmluZ3NiYXJhIGLDpWdhciBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE5MTIiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxOSwid2lkdGhNYXJnaW4iOjIwfQ=="}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T13:06:48.388062792+02:00","mutation":{"Id":6}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T13:07:01.83789281+02:00","mutation":{"item_id":146387,"quantity":1,"price":1767450,"orgPrice":2524929,"sku":"A0164933","name":"SF vridfönster 1780x1180mm 1-luft, insida trä utsida aluminium, 3-glas ","image":"/A0164933.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJib3JkZXJIZWlnaHQiOjE5NCwiYm9yZGVyV2lkdGgiOjE5NCwiYnVsa3kiOjEsImNhdGVnb3J5IjowLCJkZWxldGUiOmZhbHNlLCJkZWxpdmVyeVN0cmluZyI6Ik5vcm1hbHQgNC02IGFyYmV0c3ZlY2tvciIsImRlbGl2ZXJ5V2VlayI6MTAsImRlc2NyaXB0aW9uIjoiS2FybXl0dGVybcOldHQgYnJlZGQgeCBow7ZqZC4iLCJkaXZpZGVyU2l6ZSI6MTY0LCJnbGFzc01hcmdpblBvc3QiOjcxLCJncm91cCI6IjEzNyIsImhlaWdodCI6MTIsImhlaWdodE1hcmdpbiI6MjAsImlzQWNjZXNzb3J5IjowLCJsdWZ0IjoiMSIsImx1ZnRfdGl0bGUiOiIxLUx1ZnQgKGVuIMO2cHBuaW5nc2JhciBiw6VnZSBpIHNhbW1hIGthcm0pIiwibW9kZWwiOiIxMzciLCJtb2RlbERlc2NyaXB0aW9uIjoiU1AgQmFsYW5zIGbDtm5zdGVyIHZyaWQiLCJwYWdlSWQiOjg4NDM2LCJwb3N0U2l6ZSI6NzEsInByZXZpb3VzRGlzY291bnQiOjMwLCJzcHJvanNTaXplIjozMSwic3RvcmxlayI6IjE4MTIiLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoIjoxOCwid2lkdGhNYXJnaW4iOjIwfQ=="}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T13:07:01.837946101+02:00","mutation":{"item_id":170852,"quantity":1,"sku":"A0190103","name":"Rundad profil på karm \u0026 båge, SP utseende","image":"/UserFiles/Svenska_Fonster/Diverse/Genomskarning_rund_profil.jpg","tax":2500,"sellerId":"7","sellerName":"Kaski","parentId":7,"extra_json":"eyJidWxreSI6MCwiY2F0ZWdvcnkiOjE3NCwiZGVsZXRlIjpmYWxzZSwiZGVsaXZlcnlTdHJpbmciOiJOb3JtYWx0IDQtNiBhcmJldHN2ZWNrb3IiLCJkZWxpdmVyeVdlZWsiOjEwLCJkZXNjcmlwdGlvbiI6IiIsImhlaWdodE1hcmdpbiI6MTAsImlzQWNjZXNzb3J5Ijp0cnVlLCJtb2RlbCI6IllEIiwibW9kZWxEZXNjcmlwdGlvbiI6Ikthc2tpIFl0dGVyZMO2cnJhciB0aGVybW8iLCJwYWdlSWQiOjAsInByZXZpb3VzRGlzY291bnQiOjIwLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoTWFyZ2luIjoxMH0="}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T13:07:01.837975176+02:00","mutation":{"item_id":123075,"quantity":1,"price":995278,"sku":"A0125821","name":"Ställkostnad egen kulör Ncs/Ral nr (ej lasyr) / leverans","image":"/UserFiles/Fargkarta.jpg","tax":2500,"sellerId":"0","parentId":7,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6NjUsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiIiwiZ3JvdXAiOiIiLCJpc0FjY2Vzc29yeSI6dHJ1ZSwibW9kZWwiOiIiLCJwYWdlSWQiOjAsInRheG9ub215IjpbXSwidW5pdCI6InN0In0="}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T13:07:01.837991607+02:00","mutation":{"item_id":123076,"quantity":1,"price":2420510,"sku":"A0125831","name":"Ställkostnad egen kulör utsida NCS S / leverans (väljs på en produkt)","image":"/UserFiles/Fargkarta.jpg","tax":2500,"sellerId":"0","parentId":7,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6NjYsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiIiwiZ3JvdXAiOiIiLCJpc0FjY2Vzc29yeSI6dHJ1ZSwibW9kZWwiOiIiLCJwYWdlSWQiOjAsInRheG9ub215IjpbXSwidW5pdCI6InN0In0="}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T13:28:50.254122718+02:00","mutation":{"Id":7}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T15:15:53.256709127+02:00","mutation":{"item_id":148722,"quantity":1,"price":2531850,"orgPrice":3164813,"sku":"A0166079","name":"SF vridfönster 1980x1580mm 1-luft, insida trä utsida aluminium, 2-glas pga storleken","image":"/A0166079.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJvcmRlckhlaWdodCI6MTk0LCJib3JkZXJXaWR0aCI6MTk0LCJidWxreSI6MSwiY2F0ZWdvcnkiOjAsImRlbGV0ZSI6ZmFsc2UsImRlbGl2ZXJ5U3RyaW5nIjoiTm9ybWFsdCA0LTYgYXJiZXRzdmVja29yIiwiZGVsaXZlcnlXZWVrIjoxMCwiZGVzY3JpcHRpb24iOiJLYXJteXR0ZXJtw6V0dCBicmVkZCB4IGjDtmpkLiIsImRpdmlkZXJTaXplIjoxNjQsImdsYXNzTWFyZ2luUG9zdCI6NzEsImdyb3VwIjoiMTM3IiwiaGVpZ2h0IjoxNiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMjAxNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjIwLCJ3aWR0aE1hcmdpbiI6MjB9"}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T15:15:53.256762929+02:00","mutation":{"item_id":170852,"quantity":1,"sku":"A0190103","name":"Rundad profil på karm \u0026 båge, SP utseende","image":"/UserFiles/Svenska_Fonster/Diverse/Genomskarning_rund_profil.jpg","tax":2500,"sellerId":"7","sellerName":"Kaski","parentId":11,"extra_json":"eyJidWxreSI6MCwiY2F0ZWdvcnkiOjE3NCwiZGVsZXRlIjpmYWxzZSwiZGVsaXZlcnlTdHJpbmciOiJOb3JtYWx0IDQtNiBhcmJldHN2ZWNrb3IiLCJkZWxpdmVyeVdlZWsiOjEwLCJkZXNjcmlwdGlvbiI6IiIsImhlaWdodE1hcmdpbiI6MTAsImlzQWNjZXNzb3J5Ijp0cnVlLCJtb2RlbCI6IllEIiwibW9kZWxEZXNjcmlwdGlvbiI6Ikthc2tpIFl0dGVyZMO2cnJhciB0aGVybW8iLCJwYWdlSWQiOjAsInByZXZpb3VzRGlzY291bnQiOjIwLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoTWFyZ2luIjoxMH0="}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T15:15:53.256793727+02:00","mutation":{"item_id":97103,"quantity":1,"price":4139744,"sku":"A0086072","name":"Brand klassat glas EI30 (välj även förankring)","image":"/UserFiles/Svenska_Fonster/Glas/Brand_Ei30.jpg","tax":2500,"sellerId":"0","parentId":11,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6MjAsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiRXR0IGdsYXMgc29tIGtsYXJhciBrcmF2ZW4gRUkzMCIsImdyb3VwIjoiIiwiaXNBY2Nlc3NvcnkiOnRydWUsIm1vZGVsIjoiIiwicGFnZUlkIjowLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCJ9"}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:03.078662993+02:00","mutation":{"Id":8,"quantity":1}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:03.54547595+02:00","mutation":{"Id":8}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:04.240661903+02:00","mutation":{"Id":9}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:05.581768231+02:00","mutation":{"Id":10}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:09.40673458+02:00","mutation":{"Id":11}}
|
|
||||||
{"type":"ChangeQuantity","timestamp":"2026-06-14T15:16:11.41069986+02:00","mutation":{"Id":12}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T15:24:17.09761136+02:00","mutation":{"item_id":148722,"quantity":1,"price":2531850,"orgPrice":3164813,"sku":"A0166079","name":"SF vridfönster 1980x1580mm 1-luft, insida trä utsida aluminium, 2-glas pga storleken","image":"/A0166079.wimg","tax":2500,"sellerId":"1","sellerName":"Svenska Fönster","extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJvcmRlckhlaWdodCI6MTk0LCJib3JkZXJXaWR0aCI6MTk0LCJidWxreSI6MSwiY2F0ZWdvcnkiOjAsImRlbGV0ZSI6ZmFsc2UsImRlbGl2ZXJ5U3RyaW5nIjoiTm9ybWFsdCA0LTYgYXJiZXRzdmVja29yIiwiZGVsaXZlcnlXZWVrIjoxMCwiZGVzY3JpcHRpb24iOiJLYXJteXR0ZXJtw6V0dCBicmVkZCB4IGjDtmpkLiIsImRpdmlkZXJTaXplIjoxNjQsImdsYXNzTWFyZ2luUG9zdCI6NzEsImdyb3VwIjoiMTM3IiwiaGVpZ2h0IjoxNiwiaGVpZ2h0TWFyZ2luIjoyMCwiaXNBY2Nlc3NvcnkiOjAsImx1ZnQiOiIxIiwibHVmdF90aXRsZSI6IjEtTHVmdCAoZW4gw7ZwcG5pbmdzYmFyIGLDpWdlIGkgc2FtbWEga2FybSkiLCJtb2RlbCI6IjEzNyIsIm1vZGVsRGVzY3JpcHRpb24iOiJTUCBCYWxhbnMgZsO2bnN0ZXIgdnJpZCIsInBhZ2VJZCI6ODg0MzYsInBvc3RTaXplIjo3MSwicHJldmlvdXNEaXNjb3VudCI6MzAsInNwcm9qc1NpemUiOjMxLCJzdG9ybGVrIjoiMjAxNiIsInRheG9ub215IjpbXSwidW5pdCI6InN0Iiwid2lkdGgiOjIwLCJ3aWR0aE1hcmdpbiI6MjB9"}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T15:24:17.097670772+02:00","mutation":{"item_id":170852,"quantity":1,"sku":"A0190103","name":"Rundad profil på karm \u0026 båge, SP utseende","image":"/UserFiles/Svenska_Fonster/Diverse/Genomskarning_rund_profil.jpg","tax":2500,"sellerId":"7","sellerName":"Kaski","parentId":13,"extra_json":"eyJidWxreSI6MCwiY2F0ZWdvcnkiOjE3NCwiZGVsZXRlIjpmYWxzZSwiZGVsaXZlcnlTdHJpbmciOiJOb3JtYWx0IDQtNiBhcmJldHN2ZWNrb3IiLCJkZWxpdmVyeVdlZWsiOjEwLCJkZXNjcmlwdGlvbiI6IiIsImhlaWdodE1hcmdpbiI6MTAsImlzQWNjZXNzb3J5Ijp0cnVlLCJtb2RlbCI6IllEIiwibW9kZWxEZXNjcmlwdGlvbiI6Ikthc2tpIFl0dGVyZMO2cnJhciB0aGVybW8iLCJwYWdlSWQiOjAsInByZXZpb3VzRGlzY291bnQiOjIwLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCIsIndpZHRoTWFyZ2luIjoxMH0="}}
|
|
||||||
{"type":"AddItem","timestamp":"2026-06-14T15:24:17.097710397+02:00","mutation":{"item_id":97103,"quantity":1,"price":4139744,"sku":"A0086072","name":"Brand klassat glas EI30 (välj även förankring)","image":"/UserFiles/Svenska_Fonster/Glas/Brand_Ei30.jpg","tax":2500,"sellerId":"0","parentId":13,"extra_json":"eyJhY2Nlc3NvcnlNYXRjaCI6IiIsImJ1bGt5IjowLCJjYXRlZ29yeSI6MjAsImRlbGV0ZSI6ZmFsc2UsImRlc2NyaXB0aW9uIjoiRXR0IGdsYXMgc29tIGtsYXJhciBrcmF2ZW4gRUkzMCIsImdyb3VwIjoiIiwiaXNBY2Nlc3NvcnkiOnRydWUsIm1vZGVsIjoiIiwicGFnZUlkIjowLCJ0YXhvbm9teSI6W10sInVuaXQiOiJzdCJ9"}}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"state": {
|
|
||||||
"promotions": [
|
|
||||||
{
|
|
||||||
"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%.",
|
|
||||||
"status": "active",
|
|
||||||
"priority": 100,
|
|
||||||
"startDate": "2024-01-01",
|
|
||||||
"endDate": null,
|
|
||||||
"conditions": [
|
|
||||||
{
|
|
||||||
"id": "min-cart-total",
|
|
||||||
"type": "cart_total",
|
|
||||||
"operator": ">=",
|
|
||||||
"value": 2000000,
|
|
||||||
"label": "Minst 20 000 kr i varukorgen"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"actions": [
|
|
||||||
{
|
|
||||||
"id": "volymrabatt-tiers",
|
|
||||||
"type": "tiered_discount",
|
|
||||||
"value": 0,
|
|
||||||
"config": {
|
|
||||||
"tiers": [
|
|
||||||
{ "minTotal": 2000000, "maxTotal": 4000000, "percent": 5 },
|
|
||||||
{ "minTotal": 4000000, "maxTotal": 6000000, "percent": 9 },
|
|
||||||
{ "minTotal": 6000000, "maxTotal": 9000000, "percent": 14 }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"label": "Volymrabatt 5–14%"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"usageCount": 0,
|
|
||||||
"createdAt": "2024-01-01T00:00:00Z",
|
|
||||||
"updatedAt": "2024-01-01T00:00:00Z",
|
|
||||||
"createdBy": "mats.tornberg@gmail.com",
|
|
||||||
"tags": ["volume", "volymrabatt", "test"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -76,9 +76,9 @@ spec:
|
|||||||
memory: "70Mi"
|
memory: "70Mi"
|
||||||
cpu: "1200m"
|
cpu: "1200m"
|
||||||
env:
|
env:
|
||||||
- name: CART_DIR
|
- name: DATA_DIR
|
||||||
value: "/data/cart-actor"
|
value: "/data/cart-actor"
|
||||||
- name: CHECKOUT_DIR
|
- name: CHECKOUT_DATA_DIR
|
||||||
value: "/data/checkout-actor"
|
value: "/data/checkout-actor"
|
||||||
- name: TZ
|
- name: TZ
|
||||||
value: "Europe/Stockholm"
|
value: "Europe/Stockholm"
|
||||||
@@ -184,9 +184,9 @@ spec:
|
|||||||
memory: "70Mi"
|
memory: "70Mi"
|
||||||
cpu: "1200m"
|
cpu: "1200m"
|
||||||
env:
|
env:
|
||||||
- name: CART_DIR
|
- name: DATA_DIR
|
||||||
value: "/data/cart-actor"
|
value: "/data/cart-actor"
|
||||||
- name: CHECKOUT_DIR
|
- name: CHECKOUT_DATA_DIR
|
||||||
value: "/data/checkout-actor"
|
value: "/data/checkout-actor"
|
||||||
- name: TZ
|
- name: TZ
|
||||||
value: "Europe/Stockholm"
|
value: "Europe/Stockholm"
|
||||||
|
|||||||
@@ -1,40 +1,42 @@
|
|||||||
module git.k6n.net/mats/go-cart-actor
|
module git.k6n.net/go-cart-actor
|
||||||
|
|
||||||
go 1.26.2
|
go 1.25.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.k6n.net/mats/platform v0.0.0-00010101000000-000000000000
|
|
||||||
github.com/adyen/adyen-go-api-library/v21 v21.1.0
|
github.com/adyen/adyen-go-api-library/v21 v21.1.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e
|
||||||
|
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/rabbitmq/amqp091-go v1.11.0
|
github.com/rabbitmq/amqp091-go v1.10.0
|
||||||
github.com/redis/go-redis/v9 v9.20.0
|
github.com/redis/go-redis/v9 v9.17.0
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
|
||||||
go.opentelemetry.io/otel v1.44.0
|
go.opentelemetry.io/otel v1.38.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0
|
||||||
go.opentelemetry.io/otel/log v0.20.0
|
go.opentelemetry.io/otel/log v0.14.0
|
||||||
go.opentelemetry.io/otel/metric v1.44.0
|
go.opentelemetry.io/otel/metric v1.38.0
|
||||||
go.opentelemetry.io/otel/sdk v1.44.0
|
go.opentelemetry.io/otel/sdk v1.38.0
|
||||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
go.opentelemetry.io/otel/sdk/log v0.14.0
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
go.opentelemetry.io/otel/sdk/metric v1.38.0
|
||||||
go.opentelemetry.io/otel/trace v1.44.0
|
go.opentelemetry.io/otel/trace v1.38.0
|
||||||
google.golang.org/grpc v1.81.1
|
google.golang.org/grpc v1.77.0
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.10
|
||||||
k8s.io/api v0.34.2
|
k8s.io/api v0.34.2
|
||||||
k8s.io/apimachinery v0.34.2
|
k8s.io/apimachinery v0.34.2
|
||||||
k8s.io/client-go v0.34.2
|
k8s.io/client-go v0.34.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/RoaringBitmap/roaring/v2 v2.18.2 // indirect
|
github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 // indirect
|
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 // indirect
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
@@ -60,7 +62,7 @@ require (
|
|||||||
github.com/google/gnostic-models v0.7.1 // indirect
|
github.com/google/gnostic-models v0.7.1 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/gorilla/schema v1.4.1 // indirect
|
github.com/gorilla/schema v1.4.1 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/mailru/easyjson v0.9.1 // indirect
|
github.com/mailru/easyjson v0.9.1 // indirect
|
||||||
@@ -75,8 +77,8 @@ require (
|
|||||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.68.0 // indirect
|
github.com/prometheus/common v0.67.4 // indirect
|
||||||
github.com/prometheus/procfs v0.20.1 // indirect
|
github.com/prometheus/procfs v0.19.2 // indirect
|
||||||
github.com/speakeasy-api/jsonpath v0.6.2 // indirect
|
github.com/speakeasy-api/jsonpath v0.6.2 // indirect
|
||||||
github.com/speakeasy-api/openapi-overlay v0.10.3 // indirect
|
github.com/speakeasy-api/openapi-overlay v0.10.3 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
@@ -84,23 +86,22 @@ require (
|
|||||||
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
github.com/woodsbury/decimal128 v1.4.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
|
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
|
||||||
golang.org/x/mod v0.35.0 // indirect
|
golang.org/x/mod v0.30.0 // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/term v0.43.0 // indirect
|
golang.org/x/term v0.37.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
golang.org/x/tools v0.44.0 // indirect
|
golang.org/x/tools v0.39.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
github.com/RoaringBitmap/roaring/v2 v2.18.2 h1:oPq3Cgx//iDuJQVp6xSInAKW34J9CEwE5GmLI2z+Eic=
|
github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw=
|
||||||
github.com/RoaringBitmap/roaring/v2 v2.18.2/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4=
|
github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+aoEUopyv5iH0u/+wbY=
|
||||||
github.com/adyen/adyen-go-api-library/v21 v21.1.0 h1:QIKtn99yoBdt2R4PhuMdmY/DTm6Ex5HYd0cB7Sh3y6Y=
|
github.com/adyen/adyen-go-api-library/v21 v21.1.0 h1:QIKtn99yoBdt2R4PhuMdmY/DTm6Ex5HYd0cB7Sh3y6Y=
|
||||||
github.com/adyen/adyen-go-api-library/v21 v21.1.0/go.mod h1:qsAGYetm761eDAz+f2OQoY4qC+tKNhZOHil1b4FO5zE=
|
github.com/adyen/adyen-go-api-library/v21 v21.1.0/go.mod h1:qsAGYetm761eDAz+f2OQoY4qC+tKNhZOHil1b4FO5zE=
|
||||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||||
@@ -20,6 +20,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 h1:VJ/jVUWr+r4MQA7U/cscbbXRuwh1PfPCUUItYAjlKN4=
|
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589 h1:VJ/jVUWr+r4MQA7U/cscbbXRuwh1PfPCUUItYAjlKN4=
|
||||||
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589/go.mod h1:IeI20psFPeg2n1jxwbkYCmkpYsXsJqB7qmoqCIlX80s=
|
github.com/dprotaso/go-yit v0.0.0-20251117151522-da16f3077589/go.mod h1:IeI20psFPeg2n1jxwbkYCmkpYsXsJqB7qmoqCIlX80s=
|
||||||
@@ -93,8 +95,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
@@ -106,8 +108,6 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
|
|||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -119,10 +119,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
|||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||||
git.k6n.net/mats/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e h1:Z7A73W6jsxFuFKWvB1efQmTjs0s7+x2B7IBM2ukkI6Y=
|
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e h1:Z7A73W6jsxFuFKWvB1efQmTjs0s7+x2B7IBM2ukkI6Y=
|
||||||
git.k6n.net/mats/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e/go.mod h1:9P52UwIlLWLZvObfO29aKTWUCA9Gm62IuPJ/qv4Xvs0=
|
github.com/matst80/go-redis-inventory v0.0.0-20251126173508-51b30de2d86e/go.mod h1:9P52UwIlLWLZvObfO29aKTWUCA9Gm62IuPJ/qv4Xvs0=
|
||||||
git.k6n.net/mats/slask-finder v0.0.0-20251125182907-9e57f193127a h1:EfUO5BNDK3a563zQlwJYTNNv46aJFT9gbSItAwZOZ/Y=
|
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a h1:EfUO5BNDK3a563zQlwJYTNNv46aJFT9gbSItAwZOZ/Y=
|
||||||
git.k6n.net/mats/slask-finder v0.0.0-20251125182907-9e57f193127a/go.mod h1:VIPNkIvU0dZKwbSuv75zZcB93MXISm2UyiIPly/ucXQ=
|
github.com/matst80/slask-finder v0.0.0-20251125182907-9e57f193127a/go.mod h1:VIPNkIvU0dZKwbSuv75zZcB93MXISm2UyiIPly/ucXQ=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -161,14 +161,14 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
|||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
github.com/prometheus/common v0.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA=
|
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||||
github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ=
|
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||||
github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8=
|
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
|
||||||
github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||||
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
|
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
|
||||||
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||||
@@ -199,48 +199,42 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
|||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
|
||||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
|
||||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
|
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
|
||||||
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
|
||||||
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
|
||||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
|
||||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
|
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
|
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
|
||||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||||
@@ -250,57 +244,57 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
|||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 h1:ZdyUkS9po3H7G0tuh955QVyyotWvOD4W0aEapeGeUYk=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846/go.mod h1:Fk4kyraUvqD7i5H6S43sj2W98fbZa75lpZz/eUyhfO0=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
|||||||
-170
@@ -1,170 +0,0 @@
|
|||||||
cloud.google.com/go/accessapproval v1.13.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI=
|
|
||||||
cloud.google.com/go/accesscontextmanager v1.14.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k=
|
|
||||||
cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI=
|
|
||||||
cloud.google.com/go/analytics v0.35.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc=
|
|
||||||
cloud.google.com/go/apigateway v1.12.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk=
|
|
||||||
cloud.google.com/go/apigeeconnect v1.12.0/go.mod h1:mYJekCKZHc2ia5yZX5lwtexTn9CzsOfb6+sh/2hi42Q=
|
|
||||||
cloud.google.com/go/apigeeregistry v1.0.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0=
|
|
||||||
cloud.google.com/go/appengine v1.14.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM=
|
|
||||||
cloud.google.com/go/area120 v0.15.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y=
|
|
||||||
cloud.google.com/go/artifactregistry v1.25.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8=
|
|
||||||
cloud.google.com/go/asset v1.27.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtCdvf7j4M=
|
|
||||||
cloud.google.com/go/assuredworkloads v1.18.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss=
|
|
||||||
cloud.google.com/go/automl v1.20.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI=
|
|
||||||
cloud.google.com/go/baremetalsolution v1.9.0/go.mod h1:o+stutiS8t+HmjNIG92Gkn8H9+5/q27d6lQp7e9GWdg=
|
|
||||||
cloud.google.com/go/batch v1.19.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZcWmSeILw=
|
|
||||||
cloud.google.com/go/beyondcorp v1.7.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY=
|
|
||||||
cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM=
|
|
||||||
cloud.google.com/go/bigtable v1.47.0/go.mod h1:GUM6PdkG3rrDse9kugqvX5+ktwo3ldfLtLi1VFn5Wj4=
|
|
||||||
cloud.google.com/go/billing v1.26.0/go.mod h1:axqDO1uHegh7u5qngkTfqN1djAeLGsWAFAblERgmgEk=
|
|
||||||
cloud.google.com/go/binaryauthorization v1.15.0/go.mod h1:+0CndCJPtcHuVCNok+qQskWvbP5Sp5m6eGL8Vpu5mss=
|
|
||||||
cloud.google.com/go/certificatemanager v1.14.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8=
|
|
||||||
cloud.google.com/go/channel v1.26.0/go.mod h1:04T5Wjq+mHlvEUNzExydnBW1vO64q3Q2Wsblp/dpBxY=
|
|
||||||
cloud.google.com/go/cloudbuild v1.30.0/go.mod h1:rg52xEmndQQPiC9NV/8sCaVtKxHMU9D9MeU+oE9VGKA=
|
|
||||||
cloud.google.com/go/clouddms v1.13.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk=
|
|
||||||
cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE=
|
|
||||||
cloud.google.com/go/compute v1.64.0 h1:7MmuzeAxlG5MOG5PQD2NLtyYR6bWjkvGljRu7pByoRU=
|
|
||||||
cloud.google.com/go/compute v1.64.0/go.mod h1:eHhcRZ6vf70fQCS3VEsiWSh+nQ+tLvSMb7mwLQskgN0=
|
|
||||||
cloud.google.com/go/contactcenterinsights v1.22.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ=
|
|
||||||
cloud.google.com/go/container v1.52.0/go.mod h1:EvqoT2eXfxLweXXUlhAMGR0sOAB00XPzEjoL01esSDs=
|
|
||||||
cloud.google.com/go/containeranalysis v0.19.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ=
|
|
||||||
cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM=
|
|
||||||
cloud.google.com/go/dataflow v0.16.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ=
|
|
||||||
cloud.google.com/go/dataform v1.0.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk=
|
|
||||||
cloud.google.com/go/datafusion v1.13.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc=
|
|
||||||
cloud.google.com/go/datalabeling v0.14.0/go.mod h1:DYjvP4RhQ0332YgO22APYlBjCebb+SCaS0e2KApDq/Q=
|
|
||||||
cloud.google.com/go/dataplex v1.34.0/go.mod h1:sOazL+Bs/PTxiMHQ5yBboBvEW9qPrpGogx3+RAgfIt8=
|
|
||||||
cloud.google.com/go/dataproc/v2 v2.22.0/go.mod h1:oARVSa38kAHvSuG+cozsrY2sE6UajGuvOOf9vS+ADHI=
|
|
||||||
cloud.google.com/go/dataqna v0.13.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs=
|
|
||||||
cloud.google.com/go/datastore v1.24.0/go.mod h1:cEkLhU6Ti/gauQ7DFrUrG8bQjiMIxi++b5ePiThi5So=
|
|
||||||
cloud.google.com/go/datastream v1.20.0/go.mod h1:uoWTtfP20W8MXuV2DPcl5zqnVsxQ9QEmmBHX858oYTQ=
|
|
||||||
cloud.google.com/go/deploy v1.32.0/go.mod h1:lUG7maG/NkoTXmQ8G1mtcVymnbizfDJh6ER7vljVa/U=
|
|
||||||
cloud.google.com/go/dialogflow v1.82.0/go.mod h1:UtuiGOq9gAlTz9u4Vt+q1syMrx9ANQzTk+lC3WDdSOw=
|
|
||||||
cloud.google.com/go/dlp v1.34.0/go.mod h1:+haQd/n0QTv5BK7wZnCk2qctd5sfKL50jjh9E6N0d/Q=
|
|
||||||
cloud.google.com/go/documentai v1.48.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c=
|
|
||||||
cloud.google.com/go/domains v0.15.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk=
|
|
||||||
cloud.google.com/go/edgecontainer v1.9.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE=
|
|
||||||
cloud.google.com/go/errorreporting v0.9.0/go.mod h1:V7ojx7z76JITDZNGyDNkIIa9nNEkQzF6Yj+VHl2YF84=
|
|
||||||
cloud.google.com/go/essentialcontacts v1.12.0/go.mod h1:W8fTL17jP6vmsPHQaCT5rOjWGohEssuqDUroxnjST0A=
|
|
||||||
cloud.google.com/go/eventarc v1.23.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU=
|
|
||||||
cloud.google.com/go/filestore v1.15.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM=
|
|
||||||
cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0=
|
|
||||||
cloud.google.com/go/gkebackup v1.13.0/go.mod h1:D2MDbHW4V/uKCmS9TnT8hNKX2tPkE/pWp9nSm0TQ9hY=
|
|
||||||
cloud.google.com/go/gkeconnect v1.0.0/go.mod h1:5iWSBQzMIRLwUHUWVhxxcNK45ZPE8ntyBgE0MkavlqQ=
|
|
||||||
cloud.google.com/go/gkehub v0.21.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0=
|
|
||||||
cloud.google.com/go/gkemulticloud v1.11.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM=
|
|
||||||
cloud.google.com/go/gsuiteaddons v1.12.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg=
|
|
||||||
cloud.google.com/go/iap v1.17.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0=
|
|
||||||
cloud.google.com/go/ids v1.10.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ=
|
|
||||||
cloud.google.com/go/iot v1.13.0/go.mod h1:62W4n2fe/Ct66NWJEfCB5suZ3XsL5Atx+MxFjScr+9s=
|
|
||||||
cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U=
|
|
||||||
cloud.google.com/go/language v1.18.0/go.mod h1:xSeiVB4UiA9wYmFy2GWjf1Mb1K3uR1Yi/80qoqTxH04=
|
|
||||||
cloud.google.com/go/lifesciences v0.15.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U=
|
|
||||||
cloud.google.com/go/managedidentities v1.12.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA=
|
|
||||||
cloud.google.com/go/maps v1.35.0/go.mod h1:HH1V8tduMn+b9oRMCdl3vok98uvHco/wElZXyJQ/9kU=
|
|
||||||
cloud.google.com/go/mediatranslation v0.13.0/go.mod h1:kjZrowuigFr+Bf1HM1TCtp1a3E3kfG1ovPK5VEuaNAQ=
|
|
||||||
cloud.google.com/go/memcache v1.16.0/go.mod h1:y/rXhJiieCF742K958dY29fSfM+Y3wh2thRmWspU2Dg=
|
|
||||||
cloud.google.com/go/metastore v1.19.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs=
|
|
||||||
cloud.google.com/go/networkconnectivity v1.26.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg=
|
|
||||||
cloud.google.com/go/networkmanagement v1.28.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU=
|
|
||||||
cloud.google.com/go/networksecurity v0.16.0/go.mod h1:LMn10eRVf4K85PMF33yRoKAra7VhCOetxFcLDMh9A74=
|
|
||||||
cloud.google.com/go/notebooks v1.17.0/go.mod h1:NScGIhfQCqLRIlVaUVbm595F6dhqiTl5XS1KaKgitKM=
|
|
||||||
cloud.google.com/go/optimization v1.11.0/go.mod h1:qCWskZMcynh0GBsUrCP6oPwwnUhbwg5UcXvVM9hzOD8=
|
|
||||||
cloud.google.com/go/orchestration v1.16.0/go.mod h1:H7MFVP8Z/dtml39nf43sWYPL/2o7J4tdSZAlJrBuqnQ=
|
|
||||||
cloud.google.com/go/orgpolicy v1.20.0/go.mod h1:9LHqEGx5P5dhansdKTNIEXpM+QbebAIOs66+HUID4aQ=
|
|
||||||
cloud.google.com/go/osconfig v1.21.0/go.mod h1:BofnHqjjvu6lZQv/hqo2+rLCUiY4O6A9UYwwvVrSBjk=
|
|
||||||
cloud.google.com/go/oslogin v1.18.0/go.mod h1:3Oa36T3781Mv+yCSVYlfasi7auHjfPFqvNOd1q92umc=
|
|
||||||
cloud.google.com/go/phishingprotection v0.13.0/go.mod h1:2gyYqwNjePPEocXDkDve3EuJPaRqN/E7fp28K3arR0k=
|
|
||||||
cloud.google.com/go/policytroubleshooter v1.15.0/go.mod h1:yNuROjN6h+2/TE2JOvBBJMjYIjC6j0UYHq8f2kVHlA4=
|
|
||||||
cloud.google.com/go/privatecatalog v0.15.0/go.mod h1:av2b5Rv+oG5ORxUqGlCAYO9s4pXjgc6q2qO9nkTcqT8=
|
|
||||||
cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w=
|
|
||||||
cloud.google.com/go/pubsub/v2 v2.5.1/go.mod h1:Pd+qeabMX+576vQJhTN7TelE4k6kJh15dLU/ptOQ/UA=
|
|
||||||
cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI=
|
|
||||||
cloud.google.com/go/recaptchaenterprise/v2 v2.26.0/go.mod h1:+ntF70/j7qBa6G/pwmYA0mkBcDeTCXV6WDqUL7GObfs=
|
|
||||||
cloud.google.com/go/recommendationengine v0.14.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k=
|
|
||||||
cloud.google.com/go/recommender v1.18.0/go.mod h1:INRBLfBQJCrgPqjBVFht4OjaFq/WhB/c5V1sqBOdX8g=
|
|
||||||
cloud.google.com/go/redis v1.23.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw=
|
|
||||||
cloud.google.com/go/resourcemanager v1.15.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE=
|
|
||||||
cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw=
|
|
||||||
cloud.google.com/go/retail v1.31.0/go.mod h1:sfq/cT+gfSLuURf/mdVAw5n0pav3hxSP1rT8RfL7Qxk=
|
|
||||||
cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU=
|
|
||||||
cloud.google.com/go/scheduler v1.16.0/go.mod h1:0hsZg0MZJADyke1lutI0FHAYJR8Dtm8oIivXkmpACkA=
|
|
||||||
cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU=
|
|
||||||
cloud.google.com/go/security v1.24.0/go.mod h1:XaB3p0SE7v2bBitsLBb1hM6R8/oI/k/IujpXFJalFK0=
|
|
||||||
cloud.google.com/go/securitycenter v1.44.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U=
|
|
||||||
cloud.google.com/go/servicedirectory v1.17.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk=
|
|
||||||
cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY=
|
|
||||||
cloud.google.com/go/spanner v1.91.0/go.mod h1:8NB5a7qgwIhGD19Ly+vkpKffPL78vIG9RcrgsuREha0=
|
|
||||||
cloud.google.com/go/speech v1.35.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8=
|
|
||||||
cloud.google.com/go/storagetransfer v1.18.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4=
|
|
||||||
cloud.google.com/go/talent v1.13.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y=
|
|
||||||
cloud.google.com/go/texttospeech v1.21.0/go.mod h1:p/UVJILAo/S5vsJaWZVdDRzNzA7wXIA+hTACvpMeOBk=
|
|
||||||
cloud.google.com/go/tpu v1.13.0/go.mod h1:F5gT5BL22Dhsr05JLHdMjAjj+wcTn3Xtuu4jvq9yFug=
|
|
||||||
cloud.google.com/go/translate v1.17.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU=
|
|
||||||
cloud.google.com/go/video v1.32.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk=
|
|
||||||
cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4=
|
|
||||||
cloud.google.com/go/vision/v2 v2.14.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ=
|
|
||||||
cloud.google.com/go/vmmigration v1.15.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY=
|
|
||||||
cloud.google.com/go/vmwareengine v1.8.0/go.mod h1:e66l90IZhm1yQfYZv+YCWjSNSklQZCRmuEvKL8n3Ua0=
|
|
||||||
cloud.google.com/go/vpcaccess v1.13.0/go.mod h1:4Uus6E/9FYUtIrwBE1wJ1RosKwb02H6kEd9puJ02TL8=
|
|
||||||
cloud.google.com/go/webrisk v1.16.0/go.mod h1:VIQw8smiaMOlget/xOk6niTkNJTiQc5skEmCuAksxJc=
|
|
||||||
cloud.google.com/go/websecurityscanner v1.12.0/go.mod h1:cZSc9HqoFdccL1mqZtPIInOd4R8PBGwI20wdnrz6AO8=
|
|
||||||
cloud.google.com/go/workflows v1.19.0/go.mod h1:TWsrDGgsJy7xAJ07byzHhKKehEWItJG3BivEHVhGH5g=
|
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
|
||||||
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
|
|
||||||
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
|
|
||||||
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
|
|
||||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
|
||||||
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
|
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
|
||||||
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
|
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
|
||||||
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
|
||||||
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/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=
|
|
||||||
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
|
||||||
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
|
||||||
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/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=
|
|
||||||
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
|
||||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
|
||||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
|
||||||
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
|
||||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
|
||||||
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
|
|
||||||
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=
|
|
||||||
google.golang.org/genproto/googleapis/bytestream v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE=
|
|
||||||
google.golang.org/grpc/examples v0.0.0-20250407062114-b368379ef8f6/go.mod h1:6ytKWczdvnpnO+m+JiG9NjEDzR1FJfsnmJdG7B8QVZ8=
|
|
||||||
gotest.tools/gotestsum v1.12.3/go.mod h1:Y1+e0Iig4xIRtdmYbEV7K7H6spnjc1fX4BOuUhWw2Wk=
|
|
||||||
k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU=
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package customerauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPasswordRoundTrip(t *testing.T) {
|
|
||||||
h, err := HashPassword("correct horse battery staple")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("hash: %v", err)
|
|
||||||
}
|
|
||||||
if !VerifyPassword("correct horse battery staple", h) {
|
|
||||||
t.Fatal("correct password did not verify")
|
|
||||||
}
|
|
||||||
if VerifyPassword("wrong", h) {
|
|
||||||
t.Fatal("wrong password verified")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPasswordHashesAreSalted(t *testing.T) {
|
|
||||||
a, _ := HashPassword("hunter2hunter2")
|
|
||||||
b, _ := HashPassword("hunter2hunter2")
|
|
||||||
if a == b {
|
|
||||||
t.Fatal("two hashes of the same password are identical — not salted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestVerifyRejectsMalformed(t *testing.T) {
|
|
||||||
for _, bad := range []string{"", "x", "pbkdf2-sha256$abc$salt$hash", "md5$1$a$b"} {
|
|
||||||
if VerifyPassword("whatever", bad) {
|
|
||||||
t.Fatalf("malformed hash %q verified", bad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionRoundTrip(t *testing.T) {
|
|
||||||
s := NewSigner([]byte("test-secret"))
|
|
||||||
tok := s.Issue(12345, time.Hour)
|
|
||||||
id, err := s.Parse(tok)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse: %v", err)
|
|
||||||
}
|
|
||||||
if id != 12345 {
|
|
||||||
t.Fatalf("got id %d, want 12345", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionExpired(t *testing.T) {
|
|
||||||
s := NewSigner([]byte("test-secret"))
|
|
||||||
tok := s.Issue(1, -time.Second)
|
|
||||||
if _, err := s.Parse(tok); err != ErrExpiredToken {
|
|
||||||
t.Fatalf("got %v, want ErrExpiredToken", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionTamperAndWrongKey(t *testing.T) {
|
|
||||||
s := NewSigner([]byte("test-secret"))
|
|
||||||
tok := s.Issue(7, time.Hour)
|
|
||||||
if _, err := s.Parse(tok + "x"); err != ErrInvalidToken {
|
|
||||||
t.Fatalf("tampered token: got %v, want ErrInvalidToken", err)
|
|
||||||
}
|
|
||||||
other := NewSigner([]byte("different-secret"))
|
|
||||||
if _, err := other.Parse(tok); err != ErrInvalidToken {
|
|
||||||
t.Fatalf("wrong key: got %v, want ErrInvalidToken", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStoreRegisterDuplicateAndReload(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)
|
|
||||||
}
|
|
||||||
// Duplicate (case-insensitive) is rejected.
|
|
||||||
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.
|
|
||||||
reloaded, err := LoadCredentialStore(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reload: %v", err)
|
|
||||||
}
|
|
||||||
rec, ok := reloaded.Get(ctx, "USER@EXAMPLE.COM")
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("record not found after reload")
|
|
||||||
}
|
|
||||||
if rec.ProfileID != 42 || rec.Hash != "hash1" {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
// Package customerauth adds password-based signup/login to the profile service.
|
|
||||||
//
|
|
||||||
// It deliberately uses only the standard library: PBKDF2-HMAC-SHA256 for
|
|
||||||
// password hashing (crypto/pbkdf2, Go 1.24+) and HMAC-SHA256-signed cookies for
|
|
||||||
// sessions (crypto/hmac). No password ever leaves the server in plaintext and
|
|
||||||
// the session token is opaque + tamper-evident.
|
|
||||||
package customerauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/pbkdf2"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// pbkdf2Iterations is the work factor for PBKDF2-HMAC-SHA256. 210_000 matches
|
|
||||||
// the current OWASP recommendation for PBKDF2-SHA256.
|
|
||||||
const pbkdf2Iterations = 210_000
|
|
||||||
|
|
||||||
const (
|
|
||||||
saltLen = 16
|
|
||||||
keyLen = 32
|
|
||||||
)
|
|
||||||
|
|
||||||
// HashPassword returns an encoded PBKDF2 hash of the form
|
|
||||||
//
|
|
||||||
// pbkdf2-sha256$<iterations>$<saltB64>$<hashB64>
|
|
||||||
//
|
|
||||||
// with a fresh random salt. The encoded string is self-describing so Verify can
|
|
||||||
// read the parameters back without external configuration.
|
|
||||||
func HashPassword(password string) (string, error) {
|
|
||||||
salt := make([]byte, saltLen)
|
|
||||||
if _, err := rand.Read(salt); err != nil {
|
|
||||||
return "", fmt.Errorf("customerauth: read salt: %w", err)
|
|
||||||
}
|
|
||||||
dk, err := pbkdf2.Key(sha256.New, password, salt, pbkdf2Iterations, keyLen)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("customerauth: pbkdf2: %w", err)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s",
|
|
||||||
pbkdf2Iterations,
|
|
||||||
base64.RawStdEncoding.EncodeToString(salt),
|
|
||||||
base64.RawStdEncoding.EncodeToString(dk),
|
|
||||||
), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyPassword reports whether password matches the encoded hash. It is
|
|
||||||
// constant-time in the hash comparison and returns false for any malformed
|
|
||||||
// encoding rather than erroring, so callers can treat it as a simple predicate.
|
|
||||||
func VerifyPassword(password, encoded string) bool {
|
|
||||||
parts := strings.Split(encoded, "$")
|
|
||||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
iter, err := strconv.Atoi(parts[1])
|
|
||||||
if err != nil || iter <= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
want, err := base64.RawStdEncoding.DecodeString(parts[3])
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want))
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return subtle.ConstantTimeCompare(got, want) == 1
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
package customerauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/mail"
|
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error)
|
|
||||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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 Credentials, applier ProfileApplier, signer *Signer, ttl time.Duration, opts Options) *Server {
|
|
||||||
if ttl <= 0 {
|
|
||||||
ttl = DefaultSessionTTL
|
|
||||||
}
|
|
||||||
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
|
|
||||||
// (the prefix is stripped by the caller).
|
|
||||||
func (s *Server) Handler() http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("POST /register", s.handleRegister)
|
|
||||||
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)
|
|
||||||
return mux
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Request/response payloads
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type registerRequest struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Phone string `json:"phone,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type loginRequest struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type linkCartRequest struct {
|
|
||||||
CartID string `json:"cartId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type linkCheckoutRequest struct {
|
|
||||||
CheckoutID string `json:"checkoutId"`
|
|
||||||
CartID string `json:"cartId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type linkOrderRequest struct {
|
|
||||||
OrderReference string `json:"orderReference"`
|
|
||||||
CartID string `json:"cartId"`
|
|
||||||
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"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Email string `json:"email,omitempty"`
|
|
||||||
Phone string `json:"phone,omitempty"`
|
|
||||||
Language string `json:"language,omitempty"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
|
||||||
Addresses []AddressResponse `json:"addresses"`
|
|
||||||
Orders []OrderRef `json:"orders"`
|
|
||||||
|
|
||||||
// EmailVerified reflects whether the email-verification flow has completed.
|
|
||||||
EmailVerified bool `json:"emailVerified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AddressResponse struct {
|
|
||||||
ID uint32 `json:"id"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
FullName string `json:"fullName,omitempty"`
|
|
||||||
AddressLine1 string `json:"addressLine1"`
|
|
||||||
AddressLine2 string `json:"addressLine2,omitempty"`
|
|
||||||
City string `json:"city"`
|
|
||||||
Zip string `json:"zip"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
IsDefaultShipping bool `json:"isDefaultShipping,omitempty"`
|
|
||||||
IsDefaultBilling bool `json:"isDefaultBilling,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OrderRef struct {
|
|
||||||
OrderReference string `json:"orderReference"`
|
|
||||||
Status string `json:"status,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req registerRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
email := NormalizeEmail(req.Email)
|
|
||||||
if !validEmail(email) {
|
|
||||||
writeErr(w, http.StatusBadRequest, "a valid email is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.Password) < minPasswordLen {
|
|
||||||
writeErr(w, http.StatusBadRequest, "password must be at least 8 characters")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, exists := s.store.Get(r.Context(), email); exists {
|
|
||||||
writeErr(w, http.StatusConflict, "an account with this email already exists")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := HashPassword(req.Password)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "could not hash password")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rawID, err := profile.NewProfileId()
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "could not generate id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := uint64(rawID)
|
|
||||||
|
|
||||||
set := &messages.SetProfile{Email: &email}
|
|
||||||
if req.Name != "" {
|
|
||||||
set.Name = &req.Name
|
|
||||||
}
|
|
||||||
if req.Phone != "" {
|
|
||||||
set.Phone = &req.Phone
|
|
||||||
}
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, set); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "could not create profile")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(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
|
|
||||||
}
|
|
||||||
writeErr(w, http.StatusInternalServerError, "could not store credentials")
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req loginRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ClearSession(w, r)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := s.requireSession(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req linkCartRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cartID, ok := parseBase62(req.CartID)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusBadRequest, "invalid cartId")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkCart{CartId: cartID, Label: "current cart"}); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "could not link cart")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleLinkCheckout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := s.requireSession(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req linkCheckoutRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
checkoutID, ok := parseBase62(req.CheckoutID)
|
|
||||||
if !ok {
|
|
||||||
writeErr(w, http.StatusBadRequest, "invalid checkoutId")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cartID, _ := parseBase62(req.CartID)
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkCheckout{CheckoutId: checkoutID, CartId: cartID}); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "could not link checkout")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleLinkOrder(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := s.requireSession(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req linkOrderRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(req.OrderReference) == "" {
|
|
||||||
writeErr(w, http.StatusBadRequest, "orderReference is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cartID, _ := parseBase62(req.CartID)
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, &messages.LinkOrder{
|
|
||||||
OrderReference: req.OrderReference,
|
|
||||||
CartId: cartID,
|
|
||||||
Status: req.Status,
|
|
||||||
}); err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "could not link order")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.writeCustomer(w, r, http.StatusOK, profile.ProfileId(id).String(), id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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) {
|
|
||||||
c, err := r.Cookie(SessionCookieName)
|
|
||||||
if err != nil || c.Value == "" {
|
|
||||||
writeErr(w, http.StatusUnauthorized, "not authenticated")
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
id, err := s.signer.Parse(c.Value)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusUnauthorized, "not authenticated")
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeCustomer loads the profile grain and writes it as a CustomerResponse.
|
|
||||||
func (s *Server) writeCustomer(w http.ResponseWriter, r *http.Request, status int, idStr string, id uint64) {
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeErr(w, http.StatusInternalServerError, "could not read profile")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
resp := CustomerResponse{
|
|
||||||
ID: id,
|
|
||||||
Name: g.Name,
|
|
||||||
Email: g.Email,
|
|
||||||
Phone: g.Phone,
|
|
||||||
Language: g.Language,
|
|
||||||
Currency: g.Currency,
|
|
||||||
AvatarURL: g.AvatarUrl,
|
|
||||||
Addresses: make([]AddressResponse, 0, len(g.Addresses)),
|
|
||||||
Orders: make([]OrderRef, 0, len(g.Orders)),
|
|
||||||
}
|
|
||||||
for _, a := range g.Addresses {
|
|
||||||
resp.Addresses = append(resp.Addresses, AddressResponse{
|
|
||||||
ID: a.Id,
|
|
||||||
Label: a.Label,
|
|
||||||
FullName: a.FullName,
|
|
||||||
AddressLine1: a.AddressLine1,
|
|
||||||
AddressLine2: a.AddressLine2,
|
|
||||||
City: a.City,
|
|
||||||
Zip: a.Zip,
|
|
||||||
Country: a.Country,
|
|
||||||
IsDefaultShipping: a.IsDefaultShipping,
|
|
||||||
IsDefaultBilling: a.IsDefaultBilling,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, o := range g.Orders {
|
|
||||||
resp.Orders = append(resp.Orders, OrderRef{OrderReference: o.OrderReference, Status: o.Status})
|
|
||||||
}
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseBase62 decodes a base62 id (cart/checkout/profile share the scheme).
|
|
||||||
func parseBase62(s string) (uint64, bool) {
|
|
||||||
if s == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
id, ok := profile.ParseProfileId(s)
|
|
||||||
return uint64(id), ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func validEmail(email string) bool {
|
|
||||||
if email == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, err := mail.ParseAddress(email)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeErr(w http.ResponseWriter, status int, msg string) {
|
|
||||||
writeJSON(w, status, map[string]string{"error": msg})
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
package customerauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SessionCookieName is the name of the HttpOnly cookie that carries the signed
|
|
||||||
// session token for a logged-in customer.
|
|
||||||
const SessionCookieName = "sid"
|
|
||||||
|
|
||||||
// DefaultSessionTTL is how long an issued session stays valid.
|
|
||||||
const DefaultSessionTTL = 30 * 24 * time.Hour
|
|
||||||
|
|
||||||
var (
|
|
||||||
// ErrInvalidToken is returned when a token is malformed or its signature
|
|
||||||
// does not verify.
|
|
||||||
ErrInvalidToken = errors.New("customerauth: invalid session token")
|
|
||||||
// ErrExpiredToken is returned when a token's signature is valid but it has
|
|
||||||
// passed its expiry.
|
|
||||||
ErrExpiredToken = errors.New("customerauth: expired session token")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Signer issues and verifies session tokens using an HMAC-SHA256 key.
|
|
||||||
type Signer struct {
|
|
||||||
secret []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSigner returns a Signer over the given secret key bytes.
|
|
||||||
func NewSigner(secret []byte) *Signer { return &Signer{secret: secret} }
|
|
||||||
|
|
||||||
// token format: base64url(<profileID>.<expUnix>) "." base64url(hmac)
|
|
||||||
// The payload is signed, not encrypted — it carries no secrets, only the
|
|
||||||
// profile id and expiry, and the HMAC makes it tamper-evident.
|
|
||||||
|
|
||||||
// Issue returns a signed token for profileID that expires after ttl.
|
|
||||||
func (s *Signer) Issue(profileID uint64, ttl time.Duration) string {
|
|
||||||
exp := time.Now().Add(ttl).Unix()
|
|
||||||
payload := fmt.Sprintf("%d.%d", profileID, exp)
|
|
||||||
b := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
|
||||||
return b + "." + s.sign(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse verifies a token and returns the profile id it carries. It returns
|
|
||||||
// ErrInvalidToken for a bad signature/format and ErrExpiredToken when expired.
|
|
||||||
func (s *Signer) Parse(token string) (uint64, error) {
|
|
||||||
b, sig, ok := strings.Cut(token, ".")
|
|
||||||
if !ok || b == "" || sig == "" {
|
|
||||||
return 0, ErrInvalidToken
|
|
||||||
}
|
|
||||||
if !hmac.Equal([]byte(sig), []byte(s.sign(b))) {
|
|
||||||
return 0, ErrInvalidToken
|
|
||||||
}
|
|
||||||
raw, err := base64.RawURLEncoding.DecodeString(b)
|
|
||||||
if err != nil {
|
|
||||||
return 0, ErrInvalidToken
|
|
||||||
}
|
|
||||||
idStr, expStr, ok := strings.Cut(string(raw), ".")
|
|
||||||
if !ok {
|
|
||||||
return 0, ErrInvalidToken
|
|
||||||
}
|
|
||||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, ErrInvalidToken
|
|
||||||
}
|
|
||||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, ErrInvalidToken
|
|
||||||
}
|
|
||||||
if time.Now().Unix() >= exp {
|
|
||||||
return 0, ErrExpiredToken
|
|
||||||
}
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Signer) sign(b string) string {
|
|
||||||
mac := hmac.New(sha256.New, s.secret)
|
|
||||||
mac.Write([]byte(b))
|
|
||||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSession writes the session cookie for token onto w. The Secure flag is set
|
|
||||||
// when the request arrived over TLS (directly or via an https-terminating
|
|
||||||
// proxy) so the cookie still works on plain-http localhost in dev.
|
|
||||||
func SetSession(w http.ResponseWriter, r *http.Request, token string, ttl time.Duration) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: SessionCookieName,
|
|
||||||
Value: token,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: isSecure(r),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
Expires: time.Now().Add(ttl),
|
|
||||||
MaxAge: int(ttl.Seconds()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearSession expires the session cookie on w.
|
|
||||||
func ClearSession(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: SessionCookieName,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: isSecure(r),
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
MaxAge: -1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSecure(r *http.Request) bool {
|
|
||||||
if r.TLS != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package customerauth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrEmailExists is returned by Register when the (normalized) email is already
|
|
||||||
// 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.
|
|
||||||
type Record struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
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.
|
|
||||||
// It is the email lookup that the event-sourced profile grains intentionally
|
|
||||||
// lack. Concurrency-safe; writes are atomic (temp file + rename).
|
|
||||||
type CredentialStore struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
path string
|
|
||||||
byMail map[string]Record // key: normalized email
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadCredentialStore opens (or initializes) the store backed by path. A
|
|
||||||
// missing file is treated as an empty store; the file is created on first write.
|
|
||||||
func LoadCredentialStore(path string) (*CredentialStore, error) {
|
|
||||||
s := &CredentialStore{path: path, byMail: make(map[string]Record)}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("customerauth: read %s: %w", path, err)
|
|
||||||
}
|
|
||||||
var records []Record
|
|
||||||
if len(data) > 0 {
|
|
||||||
if err := json.Unmarshal(data, &records); err != nil {
|
|
||||||
return nil, fmt.Errorf("customerauth: parse %s: %w", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range records {
|
|
||||||
s.byMail[NormalizeEmail(r.Email)] = r
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizeEmail lower-cases and trims an email for use as a lookup key.
|
|
||||||
func NormalizeEmail(email string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(email))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)]
|
|
||||||
return r, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register adds a new credential record and persists the store. It returns
|
|
||||||
// ErrEmailExists if the email is already taken.
|
|
||||||
func (s *CredentialStore) Register(_ context.Context, email string, profileID uint64, hash, createdAt string) error {
|
|
||||||
key := NormalizeEmail(email)
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if _, exists := s.byMail[key]; exists {
|
|
||||||
return ErrEmailExists
|
|
||||||
}
|
|
||||||
s.byMail[key] = Record{Email: key, ProfileID: profileID, Hash: hash, CreatedAt: createdAt}
|
|
||||||
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))
|
|
||||||
for _, r := range s.byMail {
|
|
||||||
records = append(records, r)
|
|
||||||
}
|
|
||||||
data, err := json.MarshalIndent(records, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("customerauth: marshal store: %w", err)
|
|
||||||
}
|
|
||||||
if dir := filepath.Dir(s.path); dir != "" {
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return fmt.Errorf("customerauth: mkdir %s: %w", dir, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".credentials-*.tmp")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("customerauth: temp file: %w", err)
|
|
||||||
}
|
|
||||||
tmpName := tmp.Name()
|
|
||||||
if _, err := tmp.Write(data); err != nil {
|
|
||||||
tmp.Close()
|
|
||||||
os.Remove(tmpName)
|
|
||||||
return fmt.Errorf("customerauth: write temp: %w", err)
|
|
||||||
}
|
|
||||||
if err := tmp.Close(); err != nil {
|
|
||||||
os.Remove(tmpName)
|
|
||||||
return fmt.Errorf("customerauth: close temp: %w", err)
|
|
||||||
}
|
|
||||||
if err := os.Rename(tmpName, s.path); err != nil {
|
|
||||||
os.Remove(tmpName)
|
|
||||||
return fmt.Errorf("customerauth: rename temp: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package ucp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// UCP-Agent header parsing (RFC 8941 Dictionary Structured Field)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ucpAgentProfileKey is the context key for the UCP-Agent profile URI.
|
|
||||||
type ucpAgentProfileKey struct{}
|
|
||||||
|
|
||||||
// UCPAgentProfileFromContext returns the UCP-Agent profile URI stored in the
|
|
||||||
// request context, or empty string if none was advertised.
|
|
||||||
func UCPAgentProfileFromContext(ctx context.Context) string {
|
|
||||||
v, _ := ctx.Value(ucpAgentProfileKey{}).(string)
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseUCPAgentProfile parses a single "profile" member value from an RFC 8941
|
|
||||||
// Dictionary Structured Field header. It only handles the string-value form:
|
|
||||||
//
|
|
||||||
// UCP-Agent: profile="https://agent.example/profiles/shopping-agent.json"
|
|
||||||
//
|
|
||||||
// Returns the profile URI (without surrounding quotes) or empty string.
|
|
||||||
func parseUCPAgentProfile(header string) string {
|
|
||||||
if header == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk the dictionary entries looking for "profile".
|
|
||||||
// RFC 8941 §3.1.2: Dict = ((im-key | key) value) *(SP "," ((im-key | key) value))
|
|
||||||
i := 0
|
|
||||||
bs := []byte(header)
|
|
||||||
n := len(bs)
|
|
||||||
|
|
||||||
for i < n {
|
|
||||||
// Skip whitespace.
|
|
||||||
for i < n && (bs[i] == ' ' || bs[i] == '\t') {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
if i >= n {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read key (im-key or key). Lowercase letters, digits, "_", "-", "*".
|
|
||||||
start := i
|
|
||||||
for i < n && isKeyChar(bs[i]) {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
if i == start {
|
|
||||||
// No key found; skip to next comma or end.
|
|
||||||
for i < n && bs[i] != ',' {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
i++ // skip comma
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key := string(bs[start:i])
|
|
||||||
|
|
||||||
// Check for "=" separator. RFC 8941 allows bare names (boolean true)
|
|
||||||
// when "=? " follows — but we only care about key=value entries.
|
|
||||||
if i < n && bs[i] == '=' {
|
|
||||||
i++ // skip '='
|
|
||||||
|
|
||||||
// Read the value. For our case it's a string (starts with '"').
|
|
||||||
if i < n && bs[i] == '"' {
|
|
||||||
i++ // skip opening quote
|
|
||||||
valStart := i
|
|
||||||
for i < n && bs[i] != '"' {
|
|
||||||
if bs[i] == '\\' {
|
|
||||||
i++ // skip escape
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
val := string(bs[valStart:i])
|
|
||||||
if i < n {
|
|
||||||
i++ // skip closing quote
|
|
||||||
}
|
|
||||||
if strings.EqualFold(key, "profile") {
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip to next comma or end.
|
|
||||||
for i < n && bs[i] != ',' {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
i++ // skip comma
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// isKeyChar reports whether b is valid in an RFC 8941 key (lc-ltr, DIGIT, "_", "-", "*").
|
|
||||||
func isKeyChar(b byte) bool {
|
|
||||||
return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '_' || b == '-' || b == '*'
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithUCPAgent is HTTP middleware that reads the UCP-Agent header from incoming
|
|
||||||
// requests, parses it per RFC 8941, and stores the profile URI in the request
|
|
||||||
// context so downstream handlers can inspect the calling platform's identity.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// mux.Handle("/ucp/v1/carts", ucp.WithUCPAgent(ucp.CartHandler(pool)))
|
|
||||||
func WithUCPAgent(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if profile := parseUCPAgentProfile(r.Header.Get("UCP-Agent")); profile != "" {
|
|
||||||
ctx := context.WithValue(r.Context(), ucpAgentProfileKey{}, profile)
|
|
||||||
r = r.WithContext(ctx)
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultUCPAgentProfile is the profile URI sent on outgoing requests from
|
|
||||||
// this commerce platform. Override via SetDefaultUCPAgentProfile or the
|
|
||||||
// UCP_AGENT_PROFILE environment variable at init time.
|
|
||||||
var DefaultUCPAgentProfile = "https://cart.k6n.net/.well-known/ucp"
|
|
||||||
|
|
||||||
// SetDefaultUCPAgentProfile overrides the default profile URI used for outgoing
|
|
||||||
// UCP-Agent headers.
|
|
||||||
func SetDefaultUCPAgentProfile(profile string) {
|
|
||||||
if profile != "" {
|
|
||||||
DefaultUCPAgentProfile = profile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UCPAgentHeaderValue returns the RFC 8941 Dictionary value for the UCP-Agent
|
|
||||||
// header, encoding the given profile URI.
|
|
||||||
func UCPAgentHeaderValue(profile string) string {
|
|
||||||
if profile == "" {
|
|
||||||
profile = DefaultUCPAgentProfile
|
|
||||||
}
|
|
||||||
return `profile="` + profile + `"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithUCPAgentOnRequest adds the UCP-Agent header to an outgoing HTTP request.
|
|
||||||
// It sets the header to the default profile unless a custom profile is provided.
|
|
||||||
func WithUCPAgentOnRequest(req *http.Request, profile ...string) {
|
|
||||||
p := DefaultUCPAgentProfile
|
|
||||||
if len(profile) > 0 && profile[0] != "" {
|
|
||||||
p = profile[0]
|
|
||||||
}
|
|
||||||
req.Header.Set("UCP-Agent", `profile="`+p+`"`)
|
|
||||||
}
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
package ucp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CartServer is the UCP REST adapter over the cart grain pool.
|
|
||||||
type CartServer struct {
|
|
||||||
applier CartApplier
|
|
||||||
profileApplier ProfileApplier // optional; when set, links carts to customer profiles
|
|
||||||
newCartId func() (cart.CartId, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCartServer builds a UCP REST adapter over a cart grain pool.
|
|
||||||
func NewCartServer(applier CartApplier) *CartServer {
|
|
||||||
return &CartServer{
|
|
||||||
applier: applier,
|
|
||||||
newCartId: cart.NewCartId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetProfileApplier enables identity linking on this server.
|
|
||||||
func (s *CartServer) SetProfileApplier(pa ProfileApplier) {
|
|
||||||
s.profileApplier = pa
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// UCP Cart endpoints
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// handleCreateCart handles POST /carts.
|
|
||||||
func (s *CartServer) handleCreateCart(w http.ResponseWriter, r *http.Request) {
|
|
||||||
cid, err := s.newCartId()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to generate cart id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := uint64(cid)
|
|
||||||
|
|
||||||
// Seed the cart grain by calling Get once (spawns if new).
|
|
||||||
if _, err := s.applier.Get(r.Context(), id); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create cart: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If items were supplied, add them.
|
|
||||||
var req CreateCartRequest
|
|
||||||
hasBody := false
|
|
||||||
if r.ContentLength > 0 {
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
|
|
||||||
hasBody = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasBody && len(req.Items) > 0 {
|
|
||||||
groups := buildItemGroups(r.Context(), req.Items, "")
|
|
||||||
if _, err := applyItemGroups(r.Context(), s.applier, id, groups); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to add items: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to read cart: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusCreated, cartGrainToResponse(cid.String(), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetCart handles GET /carts/{id}.
|
|
||||||
func (s *CartServer) handleGetCart(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseCartID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid cart id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "cart not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, cartGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateCart handles PUT /carts/{id}.
|
|
||||||
func (s *CartServer) handleUpdateCart(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseCartID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid cart id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req UpdateCartRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear first, then apply.
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, &messages.ClearCartRequest{}); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to clear cart: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add items.
|
|
||||||
if len(req.Items) > 0 {
|
|
||||||
groups := buildItemGroups(r.Context(), req.Items, req.Country)
|
|
||||||
if _, err := applyItemGroups(r.Context(), s.applier, id, groups); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to add items: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set user ID and auto-link cart to customer profile.
|
|
||||||
if req.UserId != nil && *req.UserId != "" {
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, &messages.SetUserId{UserId: *req.UserId}); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to set user: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-link this cart to the customer's profile if identity linking is enabled.
|
|
||||||
if s.profileApplier != nil {
|
|
||||||
if pid, ok := profile.ParseProfileId(*req.UserId); ok {
|
|
||||||
if err := linkCartToProfile(s.profileApplier, r.Context(), uint64(pid), id); err != nil {
|
|
||||||
log.Printf("identity: failed to link cart %d to profile %d: %v", id, uint64(pid), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to read cart: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, cartGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCancelCart handles POST /carts/{id}/cancel.
|
|
||||||
func (s *CartServer) handleCancelCart(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseCartID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid cart id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, &messages.ClearCartRequest{}); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to clear cart: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to read cart: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, cartGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parseCartID(r *http.Request) (uint64, bool) {
|
|
||||||
raw := r.PathValue("id")
|
|
||||||
if raw == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
cid, ok := cart.ParseCartId(raw)
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return uint64(cid), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func cartGrainToResponse(id string, g *cart.CartGrain) CartResponse {
|
|
||||||
resp := CartResponse{
|
|
||||||
Id: id,
|
|
||||||
Items: make([]CartItem, 0, len(g.Items)),
|
|
||||||
Totals: Totals{Currency: g.Currency},
|
|
||||||
Status: "active",
|
|
||||||
}
|
|
||||||
if g.CheckoutStatus != nil && *g.CheckoutStatus != "" {
|
|
||||||
resp.Status = string(*g.CheckoutStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, it := range g.Items {
|
|
||||||
if it == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
item := CartItem{
|
|
||||||
Sku: it.Sku,
|
|
||||||
Quantity: int(it.Quantity),
|
|
||||||
UnitPrice: it.Price.IncVat,
|
|
||||||
TotalPrice: it.TotalPrice.IncVat,
|
|
||||||
TaxRate: it.Tax,
|
|
||||||
ItemId: it.Id,
|
|
||||||
Fields: it.CustomFields,
|
|
||||||
}
|
|
||||||
if it.Meta != nil {
|
|
||||||
item.Name = it.Meta.Name
|
|
||||||
item.Image = it.Meta.Image
|
|
||||||
}
|
|
||||||
resp.Items = append(resp.Items, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
if g.TotalPrice != nil {
|
|
||||||
resp.Totals.TotalIncVat = g.TotalPrice.IncVat
|
|
||||||
resp.Totals.TotalExVat = g.TotalPrice.ValueExVat()
|
|
||||||
resp.Totals.TotalVat = g.TotalPrice.TotalVat()
|
|
||||||
}
|
|
||||||
if g.TotalDiscount != nil {
|
|
||||||
resp.Totals.Discount = g.TotalDiscount.IncVat
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Item group building (adapted from cmd/cart/pool-server.go)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type itemGroup struct {
|
|
||||||
parent *messages.AddItem
|
|
||||||
children []*messages.AddItem
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildItemGroups(_ context.Context, items []CartItemInput, _ string) []itemGroup {
|
|
||||||
groups := make([]itemGroup, len(items))
|
|
||||||
var mu sync.Mutex
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i, itm := range items {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(i int, itm CartItemInput) {
|
|
||||||
defer wg.Done()
|
|
||||||
parentMsg := buildAddItem(itm, 0)
|
|
||||||
mu.Lock()
|
|
||||||
groups[i].parent = parentMsg
|
|
||||||
mu.Unlock()
|
|
||||||
if len(itm.Children) > 0 {
|
|
||||||
children := make([]*messages.AddItem, len(itm.Children))
|
|
||||||
for j, child := range itm.Children {
|
|
||||||
children[j] = buildAddItem(child, parentMsg.ItemId)
|
|
||||||
}
|
|
||||||
mu.Lock()
|
|
||||||
groups[i].children = children
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
}(i, itm)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildAddItem(input CartItemInput, parentItemId uint32) *messages.AddItem {
|
|
||||||
qty := int32(input.Quantity)
|
|
||||||
if qty <= 0 {
|
|
||||||
qty = 1
|
|
||||||
}
|
|
||||||
msg := &messages.AddItem{
|
|
||||||
Sku: input.Sku,
|
|
||||||
Quantity: qty,
|
|
||||||
}
|
|
||||||
if input.StoreId != nil {
|
|
||||||
msg.StoreId = input.StoreId
|
|
||||||
}
|
|
||||||
if input.Sku != "" {
|
|
||||||
// Pass along the item id so findParentLineId can match.
|
|
||||||
msg.ItemId = parentItemId
|
|
||||||
}
|
|
||||||
if len(input.Fields) > 0 {
|
|
||||||
msg.CustomFields = input.Fields
|
|
||||||
}
|
|
||||||
if parentItemId > 0 {
|
|
||||||
msg.ParentId = &parentItemId
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyItemGroups(ctx context.Context, applier CartApplier, id uint64, groups []itemGroup) (*actor.MutationResult[cart.CartGrain], error) {
|
|
||||||
var last *actor.MutationResult[cart.CartGrain]
|
|
||||||
for _, g := range groups {
|
|
||||||
if g.parent == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
res, err := applier.Apply(ctx, id, g.parent)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
last = res
|
|
||||||
|
|
||||||
if len(g.children) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parentLineId, ok := findParentLineId(&res.Result, g.parent)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
childMsgs := make([]proto.Message, len(g.children))
|
|
||||||
for i, c := range g.children {
|
|
||||||
c.ParentId = &parentLineId
|
|
||||||
childMsgs[i] = c
|
|
||||||
}
|
|
||||||
res, err = applier.Apply(ctx, id, childMsgs...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
last = res
|
|
||||||
}
|
|
||||||
return last, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findParentLineId(grain *cart.CartGrain, parent *messages.AddItem) (uint32, bool) {
|
|
||||||
for _, it := range grain.Items {
|
|
||||||
if it == nil || it.ParentId != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if it.ItemId != parent.ItemId {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sameStore := (it.StoreId == nil && parent.StoreId == nil) ||
|
|
||||||
(it.StoreId != nil && parent.StoreId != nil && *it.StoreId == *parent.StoreId)
|
|
||||||
if sameStore {
|
|
||||||
return it.Id, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package ucp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testApplier implements CartApplier with an in-memory map of grains.
|
|
||||||
type testApplier struct {
|
|
||||||
grains map[uint64]*cart.CartGrain
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestApplier() *testApplier {
|
|
||||||
return &testApplier{
|
|
||||||
grains: make(map[uint64]*cart.CartGrain),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *testApplier) Get(_ context.Context, id uint64) (*cart.CartGrain, error) {
|
|
||||||
g, ok := a.grains[id]
|
|
||||||
if !ok {
|
|
||||||
g = cart.NewCartGrain(id, time.UnixMilli(1000000))
|
|
||||||
a.grains[id] = g
|
|
||||||
}
|
|
||||||
return g, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *testApplier) Apply(_ context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
|
|
||||||
g, err := a.Get(nil, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
results := make([]actor.ApplyResult, len(msgs))
|
|
||||||
for i, msg := range msgs {
|
|
||||||
results[i] = actor.ApplyResult{Type: string(msg.ProtoReflect().Descriptor().FullName())}
|
|
||||||
switch m := msg.(type) {
|
|
||||||
case *messages.ClearCartRequest:
|
|
||||||
g.Items = nil
|
|
||||||
g.Vouchers = nil
|
|
||||||
g.TotalDiscount = cart.NewPrice()
|
|
||||||
g.TotalPrice = cart.NewPrice()
|
|
||||||
case *messages.SetUserId:
|
|
||||||
// userId is unexported; rely on serialization to verify.
|
|
||||||
_ = m
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &actor.MutationResult[cart.CartGrain]{
|
|
||||||
Result: *g,
|
|
||||||
Mutations: results,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mustParseID returns the uint64 representation of a base62 cart id string.
|
|
||||||
func mustParseID(s string) uint64 {
|
|
||||||
id, _ := cart.ParseCartId(s)
|
|
||||||
return uint64(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCartHandler_CreateCart(t *testing.T) {
|
|
||||||
applier := newTestApplier()
|
|
||||||
handler := CartHandler(applier)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("expected 201 Created, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp CartResponse
|
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Id == "" {
|
|
||||||
t.Fatal("expected non-empty cart id")
|
|
||||||
}
|
|
||||||
if resp.Status != "active" {
|
|
||||||
t.Fatalf("expected status 'active', got %q", resp.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCartHandler_GetCartNotFound(t *testing.T) {
|
|
||||||
applier := newTestApplier()
|
|
||||||
handler := CartHandler(applier)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/nonexistent", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
// /nonexistent is a valid base62 string, so it parses, but the cart
|
|
||||||
// auto-creates on first Get, so we get a 200 with an empty cart.
|
|
||||||
// This is expected behavior (UCP auto-creates carts on read).
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 (auto-create), got %d", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCartHandler_CreateAndGet(t *testing.T) {
|
|
||||||
applier := newTestApplier()
|
|
||||||
handler := CartHandler(applier)
|
|
||||||
|
|
||||||
// Create a cart.
|
|
||||||
req := httptest.NewRequest("POST", "/", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("create failed: %d", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var created CartResponse
|
|
||||||
json.NewDecoder(rec.Body).Decode(&created)
|
|
||||||
|
|
||||||
// GET it back.
|
|
||||||
req = httptest.NewRequest("GET", "/"+created.Id, nil)
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var got CartResponse
|
|
||||||
json.NewDecoder(rec.Body).Decode(&got)
|
|
||||||
if got.Id != created.Id {
|
|
||||||
t.Fatalf("expected id %q, got %q", created.Id, got.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCartHandler_CancelCart(t *testing.T) {
|
|
||||||
applier := newTestApplier()
|
|
||||||
handler := CartHandler(applier)
|
|
||||||
|
|
||||||
// Create a cart.
|
|
||||||
req := httptest.NewRequest("POST", "/", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
var created CartResponse
|
|
||||||
json.NewDecoder(rec.Body).Decode(&created)
|
|
||||||
|
|
||||||
// Cancel it.
|
|
||||||
req = httptest.NewRequest("POST", "/"+created.Id+"/cancel", nil)
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
var cancelled CartResponse
|
|
||||||
json.NewDecoder(rec.Body).Decode(&cancelled)
|
|
||||||
if cancelled.Id != created.Id {
|
|
||||||
t.Fatalf("expected same id %q, got %q", created.Id, cancelled.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCartHandler_UpdateCart(t *testing.T) {
|
|
||||||
applier := newTestApplier()
|
|
||||||
handler := CartHandler(applier)
|
|
||||||
|
|
||||||
// Create a cart.
|
|
||||||
req := httptest.NewRequest("POST", "/", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
var created CartResponse
|
|
||||||
json.NewDecoder(rec.Body).Decode(&created)
|
|
||||||
|
|
||||||
// Update with items (test applier doesn't process AddItem, but the
|
|
||||||
// handler still returns 200 — items are populated by the real grain pool).
|
|
||||||
updateBody := `{"items": [{"sku": "test-sku", "quantity": 2, "name": "Test Item"}]}`
|
|
||||||
req = httptest.NewRequest("PUT", "/"+created.Id, strings.NewReader(updateBody))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var updated CartResponse
|
|
||||||
json.NewDecoder(rec.Body).Decode(&updated)
|
|
||||||
if updated.Id != created.Id {
|
|
||||||
t.Fatalf("expected same id %q, got %q", created.Id, updated.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCartResponse_Conversion(t *testing.T) {
|
|
||||||
id := mustParseID("ABCD")
|
|
||||||
g := cart.NewCartGrain(id, time.UnixMilli(1000000))
|
|
||||||
g.Currency = "SEK"
|
|
||||||
g.Items = append(g.Items, &cart.CartItem{
|
|
||||||
Sku: "test-1",
|
|
||||||
Quantity: 2,
|
|
||||||
Price: *mustPrice(10000, 2500),
|
|
||||||
TotalPrice: *mustPrice(20000, 5000),
|
|
||||||
Tax: 2500,
|
|
||||||
Meta: &cart.ItemMeta{Name: "Test Item"},
|
|
||||||
})
|
|
||||||
g.TotalPrice = mustPrice(20000, 5000)
|
|
||||||
|
|
||||||
resp := cartGrainToResponse(g.Id.String(), g)
|
|
||||||
if len(resp.Items) != 1 {
|
|
||||||
t.Fatalf("expected 1 item, got %d", len(resp.Items))
|
|
||||||
}
|
|
||||||
if resp.Items[0].Sku != "test-1" {
|
|
||||||
t.Fatalf("expected sku 'test-1', got %q", resp.Items[0].Sku)
|
|
||||||
}
|
|
||||||
if resp.Items[0].Name != "Test Item" {
|
|
||||||
t.Fatalf("expected name 'Test Item', got %q", resp.Items[0].Name)
|
|
||||||
}
|
|
||||||
if resp.Totals.TotalIncVat != 20000 {
|
|
||||||
t.Fatalf("expected total 20000, got %d", resp.Totals.TotalIncVat)
|
|
||||||
}
|
|
||||||
if resp.Totals.Currency != "SEK" {
|
|
||||||
t.Fatalf("expected currency SEK, got %q", resp.Totals.Currency)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustPrice(incVat int64, totalVat int64) *cart.Price {
|
|
||||||
return &cart.Price{
|
|
||||||
IncVat: money.Cents(incVat),
|
|
||||||
VatRates: map[int]money.Cents{2500: money.Cents(totalVat)}, // 25% in basis points
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,692 +0,0 @@
|
|||||||
package ucp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CheckoutServer is the UCP REST adapter over the checkout grain pool.
|
|
||||||
type CheckoutServer struct {
|
|
||||||
applier CheckoutApplier
|
|
||||||
profileApplier ProfileApplier // optional; when set links checkouts/orders to profiles
|
|
||||||
orderSvc OrderApplier // optional; when set creates real orders on complete
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCheckoutServer builds a UCP REST adapter over a checkout grain pool.
|
|
||||||
func NewCheckoutServer(applier CheckoutApplier, orderSvc ...OrderApplier) *CheckoutServer {
|
|
||||||
s := &CheckoutServer{applier: applier}
|
|
||||||
if len(orderSvc) > 0 {
|
|
||||||
s.orderSvc = orderSvc[0]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetProfileApplier enables identity linking on this server.
|
|
||||||
func (s *CheckoutServer) SetProfileApplier(pa ProfileApplier) {
|
|
||||||
s.profileApplier = pa
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// UCP Checkout endpoints
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// handleCreateCheckout handles POST /checkout-sessions.
|
|
||||||
//
|
|
||||||
// Initiates a checkout session from a cart. The cart id is required.
|
|
||||||
func (s *CheckoutServer) handleCreateCheckout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req CreateCheckoutRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := req.Items
|
|
||||||
if len(items) == 0 && len(req.LineItems) > 0 {
|
|
||||||
items = req.LineItems
|
|
||||||
}
|
|
||||||
|
|
||||||
var cartIdStr string
|
|
||||||
if req.CartId != "" {
|
|
||||||
cartIdStr = req.CartId
|
|
||||||
} else if len(items) > 0 {
|
|
||||||
// Fetch cart base URL
|
|
||||||
cartBaseURL := os.Getenv("CART_INTERNAL_URL")
|
|
||||||
if cartBaseURL == "" {
|
|
||||||
cartBaseURL = "http://cart:8080"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare POST request to /ucp/v1/carts/ to create and seed the cart
|
|
||||||
createReqBody := CreateCartRequest{
|
|
||||||
Items: items,
|
|
||||||
}
|
|
||||||
bodyBytes, err := json.Marshal(createReqBody)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to marshal create cart request: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
cartReq, err := http.NewRequestWithContext(r.Context(), "POST", fmt.Sprintf("%s/ucp/v1/carts/", cartBaseURL), bytes.NewReader(bodyBytes))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create post cart request: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cartReq.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
profile := os.Getenv("UCP_AGENT_PROFILE")
|
|
||||||
if profile == "" {
|
|
||||||
profile = DefaultUCPAgentProfile
|
|
||||||
}
|
|
||||||
cartReq.Header.Set("UCP-Agent", `profile="`+profile+`"`)
|
|
||||||
|
|
||||||
cartResp, err := client.Do(cartReq)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create implicit cart: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer cartResp.Body.Close()
|
|
||||||
|
|
||||||
if cartResp.StatusCode != http.StatusCreated && cartResp.StatusCode != http.StatusOK {
|
|
||||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("cart service returned status: %d when creating implicit cart", cartResp.StatusCode))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var cartRespBody CartResponse
|
|
||||||
if err := json.NewDecoder(cartResp.Body).Decode(&cartRespBody); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to decode implicit cart response: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cartIdStr = cartRespBody.Id
|
|
||||||
} else {
|
|
||||||
writeError(w, http.StatusBadRequest, "cartId or items/line_items is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cartId, ok := cart.ParseCartId(cartIdStr)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid cartId")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new checkout session id.
|
|
||||||
checkoutId, err := cart.NewCartId()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to generate checkout id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch cart state from cart service
|
|
||||||
cartBaseURL := os.Getenv("CART_INTERNAL_URL")
|
|
||||||
if cartBaseURL == "" {
|
|
||||||
cartBaseURL = "http://cart:8080"
|
|
||||||
}
|
|
||||||
url := fmt.Sprintf("%s/cart/byid/%s", cartBaseURL, cartId.String())
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
cartReq, err := http.NewRequestWithContext(r.Context(), "GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create cart request: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
profile := os.Getenv("UCP_AGENT_PROFILE")
|
|
||||||
if profile == "" {
|
|
||||||
profile = DefaultUCPAgentProfile
|
|
||||||
}
|
|
||||||
cartReq.Header.Set("UCP-Agent", `profile="`+profile+`"`)
|
|
||||||
|
|
||||||
cartResp, err := client.Do(cartReq)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to fetch cart: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer cartResp.Body.Close()
|
|
||||||
|
|
||||||
if cartResp.StatusCode != http.StatusOK {
|
|
||||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("cart service returned status: %d", cartResp.StatusCode))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cartStateBytes, err := io.ReadAll(cartResp.Body)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to read cart state: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize checkout with cart reference and state.
|
|
||||||
initMsg := &checkoutMessages.InitializeCheckout{
|
|
||||||
OrderId: "",
|
|
||||||
CartId: uint64(cartId),
|
|
||||||
CartState: &anypb.Any{
|
|
||||||
TypeUrl: "type.googleapis.com/cart.CartGrain",
|
|
||||||
Value: cartStateBytes,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.applier.Apply(r.Context(), uint64(checkoutId), initMsg); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to initialize checkout: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), uint64(checkoutId))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to read checkout: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Link checkout to customer profile if identity linking is enabled.
|
|
||||||
if s.profileApplier != nil && req.CustomerId != "" {
|
|
||||||
if pid, ok := profilePkg.ParseProfileId(req.CustomerId); ok {
|
|
||||||
if err := linkCheckoutToProfile(s.profileApplier, r.Context(), uint64(pid), uint64(checkoutId), uint64(cartId)); err != nil {
|
|
||||||
log.Printf("identity: failed to link checkout %d to profile %s: %v", uint64(checkoutId), req.CustomerId, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, checkoutGrainToResponse(checkoutId.String(), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetCheckout handles GET /checkout-sessions/{id}.
|
|
||||||
func (s *CheckoutServer) handleGetCheckout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseSessionID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid checkout session id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "checkout session not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateCheckout handles PUT /checkout-sessions/{id}.
|
|
||||||
//
|
|
||||||
// Updates checkout with delivery, contact, or pickup point info.
|
|
||||||
func (s *CheckoutServer) handleUpdateCheckout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseSessionID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid checkout session id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req UpdateCheckoutRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply delivery.
|
|
||||||
if req.Delivery != nil {
|
|
||||||
msg := &checkoutMessages.SetDelivery{
|
|
||||||
Provider: req.Delivery.Provider,
|
|
||||||
}
|
|
||||||
for _, itemId := range req.Delivery.ItemIds {
|
|
||||||
msg.Items = append(msg.Items, itemId)
|
|
||||||
}
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to set delivery: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply pickup point.
|
|
||||||
if req.PickupPoint != nil {
|
|
||||||
pp := &checkoutMessages.SetPickupPoint{
|
|
||||||
DeliveryId: req.PickupPoint.DeliveryId,
|
|
||||||
PickupPoint: &checkoutMessages.PickupPoint{
|
|
||||||
Id: req.PickupPoint.Id,
|
|
||||||
Name: req.PickupPoint.Name,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, pp); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to set pickup point: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply contact details.
|
|
||||||
if req.Contact != nil {
|
|
||||||
msg := &checkoutMessages.ContactDetailsUpdated{
|
|
||||||
Email: req.Contact.Email,
|
|
||||||
Phone: req.Contact.Phone,
|
|
||||||
Name: req.Contact.Name,
|
|
||||||
PostalCode: req.Contact.PostalCode,
|
|
||||||
}
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update contact details: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to read checkout: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCompleteCheckout handles POST /checkout-sessions/{id}/complete.
|
|
||||||
//
|
|
||||||
// Completes the checkout session and creates an order. When an OrderApplier
|
|
||||||
// is configured on the server, a real order is created by calling the order
|
|
||||||
// service. Otherwise, a simplified order reference is generated locally.
|
|
||||||
func (s *CheckoutServer) handleCompleteCheckout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseSessionID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid checkout session id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req CompleteCheckoutRequest
|
|
||||||
json.NewDecoder(r.Body).Decode(&req) // best-effort
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "checkout session not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default currency/country from the request or fall back to cart/cart state.
|
|
||||||
currency := req.Currency
|
|
||||||
country := req.Country
|
|
||||||
if currency == "" && g.CartState != nil {
|
|
||||||
currency = g.CartState.Currency
|
|
||||||
}
|
|
||||||
if currency == "" {
|
|
||||||
currency = "SEK"
|
|
||||||
}
|
|
||||||
|
|
||||||
var orderID string
|
|
||||||
|
|
||||||
if s.orderSvc != nil {
|
|
||||||
// Real order creation via the configured OrderApplier.
|
|
||||||
orderID, err = s.orderSvc.CreateOrder(r.Context(), id, g, req.Provider, req.PaymentToken, currency, country)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create order: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback: generate a local order reference (simplified).
|
|
||||||
var ref cart.CartId
|
|
||||||
ref, err = cart.NewCartId()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to generate order reference")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
orderID = ref.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record the order on the checkout grain.
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id,
|
|
||||||
&checkoutMessages.OrderCreated{
|
|
||||||
OrderId: orderID,
|
|
||||||
CreatedAt: timestamppb.New(time.Now()),
|
|
||||||
},
|
|
||||||
); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to record order: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err = s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to read checkout: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-link order to customer profile if identity linking is enabled.
|
|
||||||
if s.profileApplier != nil && orderID != "" && req.CustomerId != "" {
|
|
||||||
if pid, ok := profilePkg.ParseProfileId(req.CustomerId); ok {
|
|
||||||
if err := linkOrderToProfile(s.profileApplier, r.Context(), uint64(pid), orderID, uint64(g.CartId), "placed"); err != nil {
|
|
||||||
log.Printf("identity: failed to link order %s to profile %s: %v", orderID, req.CustomerId, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCancelCheckout handles POST /checkout-sessions/{id}/cancel.
|
|
||||||
//
|
|
||||||
// Cancels the checkout session.
|
|
||||||
func (s *CheckoutServer) handleCancelCheckout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseSessionID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid checkout session id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel all active payments.
|
|
||||||
for {
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
openPayments := g.OpenPayments()
|
|
||||||
if len(openPayments) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
for _, p := range openPayments {
|
|
||||||
s.applier.Apply(r.Context(), id, &checkoutMessages.CancelPayment{
|
|
||||||
PaymentId: p.PaymentId,
|
|
||||||
CancelledAt: timestamppb.New(time.Now()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "checkout session not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// OrderHTTPClient — an OrderApplier backed by the order service HTTP API
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// OrderHTTPClient implements OrderApplier by POSTing to an order service's
|
|
||||||
// /api/orders/from-checkout endpoint. It mirrors the existing -> order handoff
|
|
||||||
// used by the checkout service's own Klarna/Adyen callbacks.
|
|
||||||
type OrderHTTPClient struct {
|
|
||||||
baseURL string
|
|
||||||
httpClient *http.Client
|
|
||||||
agentProfile string // UCP-Agent profile URI
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOrderHTTPClient returns an order applier that creates orders by calling
|
|
||||||
// the order service at baseURL (e.g. "http://order-service:8092"). It reads
|
|
||||||
// the UCP_AGENT_PROFILE env var for the profile URI, falling back to
|
|
||||||
// DefaultUCPAgentProfile from the ucp package.
|
|
||||||
func NewOrderHTTPClient(baseURL string) *OrderHTTPClient {
|
|
||||||
profile := os.Getenv("UCP_AGENT_PROFILE")
|
|
||||||
if profile == "" {
|
|
||||||
profile = DefaultUCPAgentProfile
|
|
||||||
}
|
|
||||||
return &OrderHTTPClient{
|
|
||||||
baseURL: baseURL,
|
|
||||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
agentProfile: profile,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fromCheckoutReq mirrors cmd/order/handlers_checkout.go's internal type.
|
|
||||||
type fromCheckoutReq struct {
|
|
||||||
CheckoutId string `json:"checkoutId"`
|
|
||||||
IdempotencyKey string `json:"idempotencyKey"`
|
|
||||||
CartId string `json:"cartId"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
|
||||||
CustomerName string `json:"customerName,omitempty"`
|
|
||||||
Lines []orderLine `json:"lines"`
|
|
||||||
Payment orderPayment `json:"payment"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type orderLine struct {
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Sku string `json:"sku"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Quantity int32 `json:"quantity"`
|
|
||||||
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 money.Cents `json:"amount"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateOrder implements OrderApplier. It builds a from-checkout request from
|
|
||||||
// the checkout grain's state and POSTs it to the order service.
|
|
||||||
func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g *checkout.CheckoutGrain, provider, reference, currency, country string) (string, error) {
|
|
||||||
if g.CartState == nil {
|
|
||||||
return "", fmt.Errorf("order: checkout %d has no cart state", checkoutID)
|
|
||||||
}
|
|
||||||
|
|
||||||
var customerEmail, customerName string
|
|
||||||
if g.ContactDetails != nil {
|
|
||||||
if g.ContactDetails.Email != nil {
|
|
||||||
customerEmail = *g.ContactDetails.Email
|
|
||||||
}
|
|
||||||
if g.ContactDetails.Name != nil {
|
|
||||||
customerName = *g.ContactDetails.Name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute total from cart items + deliveries.
|
|
||||||
var totalAmount money.Cents
|
|
||||||
lines := buildOrderLines(g)
|
|
||||||
for _, l := range lines {
|
|
||||||
totalAmount += l.UnitPrice.Mul(int64(l.Quantity))
|
|
||||||
}
|
|
||||||
|
|
||||||
req := fromCheckoutReq{
|
|
||||||
CheckoutId: fmt.Sprintf("%d", checkoutID),
|
|
||||||
IdempotencyKey: fmt.Sprintf("ucp-checkout-%d", checkoutID),
|
|
||||||
CartId: g.CartId.String(),
|
|
||||||
Currency: currency,
|
|
||||||
Country: country,
|
|
||||||
CustomerEmail: customerEmail,
|
|
||||||
CustomerName: customerName,
|
|
||||||
Lines: lines,
|
|
||||||
Payment: orderPayment{
|
|
||||||
Provider: provider,
|
|
||||||
Reference: reference,
|
|
||||||
Amount: totalAmount,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("order: marshal: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
url := c.baseURL + "/api/orders/from-checkout"
|
|
||||||
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("order: new request: %w", err)
|
|
||||||
}
|
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
|
||||||
// Advertise this platform's UCP profile on all outgoing requests.
|
|
||||||
WithUCPAgentOnRequest(httpReq, c.agentProfile)
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(httpReq)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("order: do: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Both 201 and 409 (idempotent hit) are valid.
|
|
||||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
|
|
||||||
return "", fmt.Errorf("order: %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
OrderId string `json:"orderId"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return "", fmt.Errorf("order: decode: %w", err)
|
|
||||||
}
|
|
||||||
if result.OrderId == "" {
|
|
||||||
return "", fmt.Errorf("order: empty orderId in response")
|
|
||||||
}
|
|
||||||
return result.OrderId, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
lines := make([]orderLine, 0, len(g.CartState.Items)+len(g.Deliveries))
|
|
||||||
for _, it := range g.CartState.Items {
|
|
||||||
if it == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := ""
|
|
||||||
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 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, orderLine{
|
|
||||||
Reference: d.Provider,
|
|
||||||
Sku: d.Provider,
|
|
||||||
Name: "Delivery",
|
|
||||||
Quantity: 1,
|
|
||||||
UnitPrice: d.Price.IncVat,
|
|
||||||
TaxRate: 2500, // 25% in basis points
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper: parse session id from path
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parseSessionID(r *http.Request) (uint64, bool) {
|
|
||||||
raw := r.PathValue("id")
|
|
||||||
if raw == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
cid, ok := cart.ParseCartId(raw)
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return uint64(cid), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helper: convert CheckoutGrain → UCP CheckoutResponse
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func checkoutGrainToResponse(id string, g *checkout.CheckoutGrain) CheckoutResponse {
|
|
||||||
resp := CheckoutResponse{
|
|
||||||
Id: id,
|
|
||||||
CartId: g.CartId.String(),
|
|
||||||
Status: "active",
|
|
||||||
Deliveries: make([]DeliveryResponse, 0, len(g.Deliveries)),
|
|
||||||
Payments: make([]PaymentResponse, 0, len(g.Payments)),
|
|
||||||
Totals: Totals{Currency: "SEK"},
|
|
||||||
}
|
|
||||||
|
|
||||||
if g.OrderId != nil && *g.OrderId != "" {
|
|
||||||
resp.OrderId = g.OrderId
|
|
||||||
resp.Status = "completed"
|
|
||||||
}
|
|
||||||
|
|
||||||
if g.CartState != nil {
|
|
||||||
for _, it := range g.CartState.Items {
|
|
||||||
if it == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
item := CartItem{
|
|
||||||
Sku: it.Sku,
|
|
||||||
Quantity: int(it.Quantity),
|
|
||||||
UnitPrice: it.Price.IncVat,
|
|
||||||
TotalPrice: it.TotalPrice.IncVat,
|
|
||||||
TaxRate: it.Tax,
|
|
||||||
}
|
|
||||||
if it.Meta != nil {
|
|
||||||
item.Name = it.Meta.Name
|
|
||||||
item.Image = it.Meta.Image
|
|
||||||
}
|
|
||||||
resp.Items = append(resp.Items, item)
|
|
||||||
}
|
|
||||||
if g.CartState.TotalPrice != nil {
|
|
||||||
resp.Totals.TotalIncVat = g.CartState.TotalPrice.IncVat
|
|
||||||
resp.Totals.TotalExVat = g.CartState.TotalPrice.ValueExVat()
|
|
||||||
resp.Totals.TotalVat = g.CartState.TotalPrice.TotalVat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, d := range g.Deliveries {
|
|
||||||
if d == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dr := DeliveryResponse{
|
|
||||||
Id: d.Id,
|
|
||||||
Provider: d.Provider,
|
|
||||||
Price: d.Price.IncVat,
|
|
||||||
Items: d.Items,
|
|
||||||
}
|
|
||||||
if d.PickupPoint != nil {
|
|
||||||
dr.PickupPoint = &PickupPointResp{
|
|
||||||
Id: d.PickupPoint.Id,
|
|
||||||
Name: d.PickupPoint.Name,
|
|
||||||
Address: d.PickupPoint.Address,
|
|
||||||
City: d.PickupPoint.City,
|
|
||||||
Zip: d.PickupPoint.Zip,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp.Deliveries = append(resp.Deliveries, dr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if g.ContactDetails != nil {
|
|
||||||
resp.Contact = &ContactResponse{
|
|
||||||
Email: g.ContactDetails.Email,
|
|
||||||
Phone: g.ContactDetails.Phone,
|
|
||||||
Name: g.ContactDetails.Name,
|
|
||||||
PostalCode: g.ContactDetails.PostalCode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range g.Payments {
|
|
||||||
if p == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resp.Payments = append(resp.Payments, PaymentResponse{
|
|
||||||
Id: p.PaymentId,
|
|
||||||
Provider: p.Provider,
|
|
||||||
Amount: money.Cents(p.Amount),
|
|
||||||
Currency: p.Currency,
|
|
||||||
Status: string(p.Status),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
package ucp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testCheckoutApplier struct {
|
|
||||||
grains map[uint64]*checkout.CheckoutGrain
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestCheckoutApplier() *testCheckoutApplier {
|
|
||||||
return &testCheckoutApplier{
|
|
||||||
grains: make(map[uint64]*checkout.CheckoutGrain),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *testCheckoutApplier) Get(_ context.Context, id uint64) (*checkout.CheckoutGrain, error) {
|
|
||||||
g, ok := a.grains[id]
|
|
||||||
if !ok {
|
|
||||||
g = checkout.NewCheckoutGrain(id, 0, 0, time.UnixMilli(1000000), nil)
|
|
||||||
a.grains[id] = g
|
|
||||||
}
|
|
||||||
return g, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *testCheckoutApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[checkout.CheckoutGrain], error) {
|
|
||||||
g, err := a.Get(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, msg := range msgs {
|
|
||||||
switch m := msg.(type) {
|
|
||||||
case *checkoutMessages.InitializeCheckout:
|
|
||||||
g.CartId = cart.CartId(m.CartId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &actor.MutationResult[checkout.CheckoutGrain]{
|
|
||||||
Result: *g,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckoutHandler_CreateWithCartId(t *testing.T) {
|
|
||||||
// Start a mock Cart server.
|
|
||||||
mockCartServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !strings.Contains(r.URL.Path, "/cart/byid/") {
|
|
||||||
t.Errorf("expected path to contain /cart/byid/, got %q", r.URL.Path)
|
|
||||||
}
|
|
||||||
// Return dummy cart state bytes.
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(`{}`))
|
|
||||||
}))
|
|
||||||
defer mockCartServer.Close()
|
|
||||||
|
|
||||||
os.Setenv("CART_INTERNAL_URL", mockCartServer.URL)
|
|
||||||
defer os.Unsetenv("CART_INTERNAL_URL")
|
|
||||||
|
|
||||||
applier := newTestCheckoutApplier()
|
|
||||||
handler := CheckoutHandler(applier)
|
|
||||||
|
|
||||||
// Create a dummy cart ID.
|
|
||||||
dummyCartId, _ := cart.NewCartId()
|
|
||||||
|
|
||||||
reqBody := fmt.Sprintf(`{"cartId": %q}`, dummyCartId.String())
|
|
||||||
req := httptest.NewRequest("POST", "/", strings.NewReader(reqBody))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("expected 201 Created, got %d. Body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp CheckoutResponse
|
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Id == "" {
|
|
||||||
t.Fatal("expected non-empty checkout session id")
|
|
||||||
}
|
|
||||||
if resp.CartId != dummyCartId.String() {
|
|
||||||
t.Fatalf("expected cartId %q, got %q", dummyCartId.String(), resp.CartId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckoutHandler_CreateWithLineItemsFallback(t *testing.T) {
|
|
||||||
// Start a mock Cart server that supports both creating a cart and getting it.
|
|
||||||
var createdCartId cart.CartId
|
|
||||||
mockCartServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == "POST" && strings.Contains(r.URL.Path, "/ucp/v1/carts/") {
|
|
||||||
var err error
|
|
||||||
createdCartId, err = cart.NewCartId()
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
w.Write([]byte(fmt.Sprintf(`{"id": %q, "status": "active"}`, createdCartId.String())))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == "GET" && strings.Contains(r.URL.Path, "/cart/byid/") {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte(`{}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}))
|
|
||||||
defer mockCartServer.Close()
|
|
||||||
|
|
||||||
os.Setenv("CART_INTERNAL_URL", mockCartServer.URL)
|
|
||||||
defer os.Unsetenv("CART_INTERNAL_URL")
|
|
||||||
|
|
||||||
applier := newTestCheckoutApplier()
|
|
||||||
handler := CheckoutHandler(applier)
|
|
||||||
|
|
||||||
// POST with line_items and no cartId.
|
|
||||||
reqBody := `{"line_items": [{"sku": "SKU123", "quantity": 2}]}`
|
|
||||||
req := httptest.NewRequest("POST", "/", strings.NewReader(reqBody))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("expected 201 Created, got %d. Body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp CheckoutResponse
|
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Id == "" {
|
|
||||||
t.Fatal("expected non-empty checkout session id")
|
|
||||||
}
|
|
||||||
if resp.CartId != createdCartId.String() {
|
|
||||||
t.Fatalf("expected cartId %q to match created implicit cart %q", resp.CartId, createdCartId.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
package ucp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CustomerServer is the UCP REST adapter over the profile grain pool.
|
|
||||||
type CustomerServer struct {
|
|
||||||
applier ProfileApplier
|
|
||||||
deleter CredentialDeleter
|
|
||||||
auditLogPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
|
|
||||||
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}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// UCP Customer endpoints
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// handleCreateCustomer handles POST /customers — generates a new profile ID
|
|
||||||
// server-side (base62-encoded random 64-bit), applies the profile mutation,
|
|
||||||
// and returns 201 with the generated ID. Unlike PUT /customers/{id}, the
|
|
||||||
// caller does not choose the identifier.
|
|
||||||
func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req CustomerUpdateRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rawId, err := profile.NewProfileId()
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to generate id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := uint64(rawId)
|
|
||||||
|
|
||||||
msg := &messages.SetProfile{
|
|
||||||
Name: req.Name,
|
|
||||||
Email: req.Email,
|
|
||||||
Phone: req.Phone,
|
|
||||||
Language: req.Language,
|
|
||||||
Currency: req.Currency,
|
|
||||||
AvatarUrl: req.AvatarUrl,
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to create customer: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, customerGrainToResponse(rawId.String(), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetCustomer handles GET /customers/{id} with ETag-based conditional
|
|
||||||
// GET support. When the client sends If-None-Match matching the current ETag,
|
|
||||||
// a 304 Not Modified is returned with no body — the caller reuses their cached
|
|
||||||
// response. The ETag is derived from the grain's lastChange timestamp and
|
|
||||||
// customer data hash, so it changes on any mutation.
|
|
||||||
func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseCustomerID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid customer id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "customer not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
etag := computeCustomerETag(r.PathValue("id"), g)
|
|
||||||
|
|
||||||
if match := r.Header.Get("If-None-Match"); match != "" {
|
|
||||||
if match == etag || match == "*" {
|
|
||||||
w.Header().Set("ETag", etag)
|
|
||||||
w.Header().Set("Vary", "Accept-Encoding")
|
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("ETag", etag)
|
|
||||||
w.Header().Set("Vary", "Accept-Encoding")
|
|
||||||
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateCustomer handles PUT /customers/{id}.
|
|
||||||
func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseCustomerID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid customer id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req CustomerUpdateRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := &messages.SetProfile{
|
|
||||||
Name: req.Name,
|
|
||||||
Email: req.Email,
|
|
||||||
Phone: req.Phone,
|
|
||||||
Language: req.Language,
|
|
||||||
Currency: req.Currency,
|
|
||||||
AvatarUrl: req.AvatarUrl,
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleAddAddress handles POST /customers/{id}/addresses.
|
|
||||||
func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseCustomerID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid customer id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req AddAddressRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.AddressLine1 == "" || req.City == "" || req.Zip == "" || req.Country == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "addressLine1, city, zip, and country are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := &messages.Address{
|
|
||||||
Label: req.Label,
|
|
||||||
FullName: req.FullName,
|
|
||||||
AddressLine1: req.AddressLine1,
|
|
||||||
City: req.City,
|
|
||||||
State: req.State,
|
|
||||||
Zip: req.Zip,
|
|
||||||
Country: req.Country,
|
|
||||||
IsDefaultShipping: req.IsDefaultShipping,
|
|
||||||
IsDefaultBilling: req.IsDefaultBilling,
|
|
||||||
}
|
|
||||||
if req.AddressLine2 != "" {
|
|
||||||
addr.AddressLine2 = &req.AddressLine2
|
|
||||||
}
|
|
||||||
if req.Phone != "" {
|
|
||||||
addr.Phone = &req.Phone
|
|
||||||
}
|
|
||||||
msg := &messages.AddAddress{Address: addr}
|
|
||||||
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to add address: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, customerGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleUpdateAddress handles PUT /customers/{id}/addresses/{addressId}.
|
|
||||||
func (s *CustomerServer) handleUpdateAddress(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseCustomerID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid customer id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
addressID, ok := parseAddressID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid address id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req UpdateAddressRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := &messages.UpdateAddress{
|
|
||||||
Id: addressID,
|
|
||||||
Label: req.Label,
|
|
||||||
FullName: req.FullName,
|
|
||||||
AddressLine1: req.AddressLine1,
|
|
||||||
AddressLine2: req.AddressLine2,
|
|
||||||
City: req.City,
|
|
||||||
State: req.State,
|
|
||||||
Zip: req.Zip,
|
|
||||||
Country: req.Country,
|
|
||||||
Phone: req.Phone,
|
|
||||||
IsDefaultShipping: req.IsDefaultShipping,
|
|
||||||
IsDefaultBilling: req.IsDefaultBilling,
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to update address: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleRemoveAddress handles DELETE /customers/{id}/addresses/{addressId}.
|
|
||||||
func (s *CustomerServer) handleRemoveAddress(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, ok := parseCustomerID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid customer id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
addressID, ok := parseAddressID(r)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid address id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := &messages.RemoveAddress{Id: addressID}
|
|
||||||
|
|
||||||
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
|
||||||
// Check if the error is a "not found" error.
|
|
||||||
errStr := err.Error()
|
|
||||||
if containsNotFound(errStr) {
|
|
||||||
writeError(w, http.StatusNotFound, fmt.Sprintf("address %d not found", addressID))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to remove address: "+errStr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Identity linking helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// linkCartToProfile links a cart to the given profile by applying a LinkCart mutation.
|
|
||||||
func linkCartToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, cartID uint64) error {
|
|
||||||
if applier == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := applier.Apply(ctx, profileID, &messages.LinkCart{
|
|
||||||
CartId: cartID,
|
|
||||||
Label: "current cart",
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// linkCheckoutToProfile links a checkout to the given profile by applying a LinkCheckout mutation.
|
|
||||||
func linkCheckoutToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, checkoutID uint64, cartID uint64) error {
|
|
||||||
if applier == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := applier.Apply(ctx, profileID, &messages.LinkCheckout{
|
|
||||||
CheckoutId: checkoutID,
|
|
||||||
CartId: cartID,
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// linkOrderToProfile links an order to the given profile by applying a LinkOrder mutation.
|
|
||||||
func linkOrderToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, orderReference string, cartID uint64, status string) error {
|
|
||||||
if applier == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := applier.Apply(ctx, profileID, &messages.LinkOrder{
|
|
||||||
OrderReference: orderReference,
|
|
||||||
CartId: cartID,
|
|
||||||
Status: status,
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// parseCustomerID parses the base62 customer id from the path.
|
|
||||||
func parseCustomerID(r *http.Request) (uint64, bool) {
|
|
||||||
raw := r.PathValue("id")
|
|
||||||
if raw == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
cid, ok := profile.ParseProfileId(raw)
|
|
||||||
if !ok {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return uint64(cid), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseAddressID parses the numeric address id from the path.
|
|
||||||
func parseAddressID(r *http.Request) (uint32, bool) {
|
|
||||||
raw := r.PathValue("addressId")
|
|
||||||
if raw == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
// Address IDs are simple numeric strings.
|
|
||||||
var id uint32
|
|
||||||
for _, c := range raw {
|
|
||||||
if c < '0' || c > '9' {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
id = id*10 + uint32(c-'0')
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// containsNotFound reports whether errStr indicates a "not found" condition.
|
|
||||||
func containsNotFound(errStr string) bool {
|
|
||||||
// Check for common not-found patterns in the error message.
|
|
||||||
for _, pattern := range []string{"not found", "not_found", "notfound"} {
|
|
||||||
if len(errStr) >= len(pattern) {
|
|
||||||
for i := 0; i <= len(errStr)-len(pattern); i++ {
|
|
||||||
match := true
|
|
||||||
for j := 0; j < len(pattern); j++ {
|
|
||||||
c1 := errStr[i+j]
|
|
||||||
c2 := pattern[j]
|
|
||||||
if c1 >= 'A' && c1 <= 'Z' {
|
|
||||||
c1 += 32
|
|
||||||
}
|
|
||||||
if c1 != c2 {
|
|
||||||
match = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if match {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeCustomerETag returns a strong ETag for the customer's current state,
|
|
||||||
// derived from the grain's lastChange timestamp and a hash of the ID + change
|
|
||||||
// time. Any mutation (profile update, address change, linking) advances
|
|
||||||
// lastChange, so the ETag changes when data changes.
|
|
||||||
func computeCustomerETag(id string, g *profile.ProfileGrain) string {
|
|
||||||
h := sha256.Sum256([]byte(id + "\x00" + g.GetLastChange().Format(time.RFC3339Nano)))
|
|
||||||
return `"` + hex.EncodeToString(h[:16]) + `"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// customerGrainToResponse converts a ProfileGrain to a CustomerResponse.
|
|
||||||
func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerResponse {
|
|
||||||
resp := CustomerResponse{
|
|
||||||
Id: id,
|
|
||||||
Name: g.Name,
|
|
||||||
Email: g.Email,
|
|
||||||
Phone: g.Phone,
|
|
||||||
Language: g.Language,
|
|
||||||
Currency: g.Currency,
|
|
||||||
AvatarUrl: g.AvatarUrl,
|
|
||||||
Addresses: make([]AddressResponse, 0, len(g.Addresses)),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, a := range g.Addresses {
|
|
||||||
resp.Addresses = append(resp.Addresses, AddressResponse{
|
|
||||||
Id: a.Id,
|
|
||||||
Label: a.Label,
|
|
||||||
FullName: a.FullName,
|
|
||||||
AddressLine1: a.AddressLine1,
|
|
||||||
AddressLine2: a.AddressLine2,
|
|
||||||
City: a.City,
|
|
||||||
State: a.State,
|
|
||||||
Zip: a.Zip,
|
|
||||||
Country: a.Country,
|
|
||||||
Phone: a.Phone,
|
|
||||||
IsDefaultShipping: a.IsDefaultShipping,
|
|
||||||
IsDefaultBilling: a.IsDefaultBilling,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
@@ -1,575 +0,0 @@
|
|||||||
package ucp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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/profile"
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testProfileApplier implements ProfileApplier with in-memory grains.
|
|
||||||
type testProfileApplier struct {
|
|
||||||
grains map[uint64]*profile.ProfileGrain
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestProfileApplier() *testProfileApplier {
|
|
||||||
return &testProfileApplier{
|
|
||||||
grains: make(map[uint64]*profile.ProfileGrain),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *testProfileApplier) Get(_ context.Context, id uint64) (*profile.ProfileGrain, error) {
|
|
||||||
g, ok := a.grains[id]
|
|
||||||
if !ok {
|
|
||||||
g = profile.NewProfileGrain(id, time.UnixMilli(1000000))
|
|
||||||
a.grains[id] = g
|
|
||||||
}
|
|
||||||
return g, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *testProfileApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) {
|
|
||||||
g, err := a.Get(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, msg := range msgs {
|
|
||||||
switch m := msg.(type) {
|
|
||||||
case *messages.SetProfile:
|
|
||||||
if m.Name != nil {
|
|
||||||
g.Name = *m.Name
|
|
||||||
}
|
|
||||||
if m.Email != nil {
|
|
||||||
g.Email = *m.Email
|
|
||||||
}
|
|
||||||
if m.Phone != nil {
|
|
||||||
g.Phone = *m.Phone
|
|
||||||
}
|
|
||||||
if m.Language != nil {
|
|
||||||
g.Language = *m.Language
|
|
||||||
}
|
|
||||||
if m.Currency != nil {
|
|
||||||
g.Currency = *m.Currency
|
|
||||||
}
|
|
||||||
if m.AvatarUrl != nil {
|
|
||||||
g.AvatarUrl = *m.AvatarUrl
|
|
||||||
}
|
|
||||||
case *messages.AddAddress:
|
|
||||||
if m.Address == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
g.Addresses = append(g.Addresses, profile.StoredAddress{
|
|
||||||
Id: g.NextAddrId(),
|
|
||||||
Label: m.Address.Label,
|
|
||||||
FullName: m.Address.FullName,
|
|
||||||
AddressLine1: m.Address.AddressLine1,
|
|
||||||
AddressLine2: m.Address.GetAddressLine2(),
|
|
||||||
City: m.Address.City,
|
|
||||||
State: m.Address.State,
|
|
||||||
Zip: m.Address.Zip,
|
|
||||||
Country: m.Address.Country,
|
|
||||||
Phone: m.Address.GetPhone(),
|
|
||||||
IsDefaultShipping: m.Address.IsDefaultShipping,
|
|
||||||
IsDefaultBilling: m.Address.IsDefaultBilling,
|
|
||||||
})
|
|
||||||
case *messages.UpdateAddress:
|
|
||||||
for i := range g.Addresses {
|
|
||||||
if g.Addresses[i].Id == m.Id {
|
|
||||||
if m.Label != nil {
|
|
||||||
g.Addresses[i].Label = *m.Label
|
|
||||||
}
|
|
||||||
if m.AddressLine1 != nil {
|
|
||||||
g.Addresses[i].AddressLine1 = *m.AddressLine1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case *messages.RemoveAddress:
|
|
||||||
for i := range g.Addresses {
|
|
||||||
if g.Addresses[i].Id == m.Id {
|
|
||||||
g.Addresses = append(g.Addresses[:i], g.Addresses[i+1:]...)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case *messages.LinkCart:
|
|
||||||
g.Carts = append(g.Carts, profile.LinkedCart{CartId: m.CartId, Label: m.Label})
|
|
||||||
case *messages.LinkCheckout:
|
|
||||||
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]{
|
|
||||||
Result: *g,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// testCartApplier is a minimal CartApplier mock for combined handler tests.
|
|
||||||
type testCartApplier struct{}
|
|
||||||
|
|
||||||
func (a *testCartApplier) Get(ctx context.Context, id uint64) (*cart.CartGrain, error) {
|
|
||||||
return nil, fmt.Errorf("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *testCartApplier) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
|
|
||||||
return nil, fmt.Errorf("not implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to get a test profile ID as a base62 string.
|
|
||||||
func testProfileID(t *testing.T, applier *testProfileApplier) string {
|
|
||||||
t.Helper()
|
|
||||||
pid, err := profile.NewProfileId()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to generate profile id: %v", err)
|
|
||||||
}
|
|
||||||
// Seed the grain.
|
|
||||||
applier.Get(context.Background(), uint64(pid))
|
|
||||||
return pid.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetCustomer(t *testing.T) {
|
|
||||||
applier := newTestProfileApplier()
|
|
||||||
id := testProfileID(t, applier)
|
|
||||||
handler := CustomerHandler(applier, "")
|
|
||||||
|
|
||||||
// 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"),
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /customers/{id}
|
|
||||||
req := httptest.NewRequest("GET", fmt.Sprintf("/%s", id), nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp CustomerResponse
|
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Id != id {
|
|
||||||
t.Fatalf("expected id %q, got %q", id, resp.Id)
|
|
||||||
}
|
|
||||||
if resp.Name != "Alice" {
|
|
||||||
t.Fatalf("expected Name 'Alice', got %q", resp.Name)
|
|
||||||
}
|
|
||||||
if resp.Email != "alice@example.com" {
|
|
||||||
t.Fatalf("expected Email 'alice@example.com', got %q", resp.Email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetCustomerNotFound(t *testing.T) {
|
|
||||||
applier := newTestProfileApplier()
|
|
||||||
handler := CustomerHandler(applier, "")
|
|
||||||
|
|
||||||
// "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet.
|
|
||||||
req := httptest.NewRequest("GET", "/!invalid!", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("expected 400 Bad Request for invalid id, got %d. Body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateCustomer(t *testing.T) {
|
|
||||||
applier := newTestProfileApplier()
|
|
||||||
id := testProfileID(t, 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))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp CustomerResponse
|
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Name != "Bob" {
|
|
||||||
t.Fatalf("expected Name 'Bob', got %q", resp.Name)
|
|
||||||
}
|
|
||||||
if resp.Email != "bob@example.com" {
|
|
||||||
t.Fatalf("expected Email 'bob@example.com', got %q", resp.Email)
|
|
||||||
}
|
|
||||||
if resp.Phone != "+46701234567" {
|
|
||||||
t.Fatalf("expected Phone '+46701234567', got %q", resp.Phone)
|
|
||||||
}
|
|
||||||
if resp.Language != "sv" {
|
|
||||||
t.Fatalf("expected Language 'sv', got %q", resp.Language)
|
|
||||||
}
|
|
||||||
if resp.Currency != "SEK" {
|
|
||||||
t.Fatalf("expected Currency 'SEK', got %q", resp.Currency)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddAddress(t *testing.T) {
|
|
||||||
applier := newTestProfileApplier()
|
|
||||||
id := testProfileID(t, applier)
|
|
||||||
handler := CustomerHandler(applier, "")
|
|
||||||
|
|
||||||
body := `{
|
|
||||||
"label": "Home",
|
|
||||||
"fullName": "Alice",
|
|
||||||
"addressLine1": "Storgatan 1",
|
|
||||||
"addressLine2": "Lgh 101",
|
|
||||||
"city": "Stockholm",
|
|
||||||
"zip": "111 22",
|
|
||||||
"country": "SE",
|
|
||||||
"phone": "+46701234567"
|
|
||||||
}`
|
|
||||||
req := httptest.NewRequest("POST", fmt.Sprintf("/%s/addresses", id), strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("expected 201 Created, got %d. Body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp CustomerResponse
|
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
|
||||||
}
|
|
||||||
if len(resp.Addresses) != 1 {
|
|
||||||
t.Fatalf("expected 1 address, got %d", len(resp.Addresses))
|
|
||||||
}
|
|
||||||
if resp.Addresses[0].Label != "Home" {
|
|
||||||
t.Fatalf("expected address label 'Home', got %q", resp.Addresses[0].Label)
|
|
||||||
}
|
|
||||||
if resp.Addresses[0].AddressLine1 != "Storgatan 1" {
|
|
||||||
t.Fatalf("expected addressLine1 'Storgatan 1', got %q", resp.Addresses[0].AddressLine1)
|
|
||||||
}
|
|
||||||
if resp.Addresses[0].AddressLine2 != "Lgh 101" {
|
|
||||||
t.Fatalf("expected addressLine2 'Lgh 101', got %q", resp.Addresses[0].AddressLine2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddAddressMissingRequired(t *testing.T) {
|
|
||||||
applier := newTestProfileApplier()
|
|
||||||
id := testProfileID(t, applier)
|
|
||||||
handler := CustomerHandler(applier, "")
|
|
||||||
|
|
||||||
// Missing addressLine1
|
|
||||||
body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}`
|
|
||||||
req := httptest.NewRequest("POST", fmt.Sprintf("/%s/addresses", id), strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("expected 400 Bad Request for missing required fields, got %d. Body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateAddress(t *testing.T) {
|
|
||||||
applier := newTestProfileApplier()
|
|
||||||
id := testProfileID(t, applier)
|
|
||||||
handler := CustomerHandler(applier, "")
|
|
||||||
|
|
||||||
// First add an address
|
|
||||||
pid, _ := profile.ParseProfileId(id)
|
|
||||||
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
|
|
||||||
Address: &messages.Address{
|
|
||||||
Label: "Old Home",
|
|
||||||
FullName: "Alice",
|
|
||||||
AddressLine1: "Old Street 1",
|
|
||||||
City: "Old City",
|
|
||||||
Zip: "000 00",
|
|
||||||
Country: "SE",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Get the address id from the grain
|
|
||||||
grain, _ := applier.Get(context.Background(), uint64(pid))
|
|
||||||
if len(grain.Addresses) == 0 {
|
|
||||||
t.Fatal("expected at least one address")
|
|
||||||
}
|
|
||||||
addrID := grain.Addresses[0].Id
|
|
||||||
|
|
||||||
body := fmt.Sprintf(`{"label": "New Home", "addressLine1": "New Street 2"}`)
|
|
||||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/%d", id, addrID), strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp CustomerResponse
|
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
|
||||||
}
|
|
||||||
if len(resp.Addresses) != 1 {
|
|
||||||
t.Fatalf("expected 1 address, got %d", len(resp.Addresses))
|
|
||||||
}
|
|
||||||
if resp.Addresses[0].Label != "New Home" {
|
|
||||||
t.Fatalf("expected label 'New Home', got %q", resp.Addresses[0].Label)
|
|
||||||
}
|
|
||||||
if resp.Addresses[0].AddressLine1 != "New Street 2" {
|
|
||||||
t.Fatalf("expected addressLine1 'New Street 2', got %q", resp.Addresses[0].AddressLine1)
|
|
||||||
}
|
|
||||||
// Unchanged fields should still be there
|
|
||||||
if resp.Addresses[0].City != "Old City" {
|
|
||||||
t.Fatalf("expected City 'Old City', got %q", resp.Addresses[0].City)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateAddressInvalidID(t *testing.T) {
|
|
||||||
applier := newTestProfileApplier()
|
|
||||||
id := testProfileID(t, applier)
|
|
||||||
handler := CustomerHandler(applier, "")
|
|
||||||
|
|
||||||
body := `{"label": "New Home"}`
|
|
||||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/abc", id), strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("expected 400 Bad Request for invalid address id, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveAddress(t *testing.T) {
|
|
||||||
applier := newTestProfileApplier()
|
|
||||||
id := testProfileID(t, applier)
|
|
||||||
handler := CustomerHandler(applier, "")
|
|
||||||
|
|
||||||
// First add an address
|
|
||||||
pid, _ := profile.ParseProfileId(id)
|
|
||||||
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
|
|
||||||
Address: &messages.Address{
|
|
||||||
Label: "Remove Me",
|
|
||||||
FullName: "Alice",
|
|
||||||
AddressLine1: "Storgatan 1",
|
|
||||||
City: "Stockholm",
|
|
||||||
Zip: "111 22",
|
|
||||||
Country: "SE",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
grain, _ := applier.Get(context.Background(), uint64(pid))
|
|
||||||
if len(grain.Addresses) == 0 {
|
|
||||||
t.Fatal("expected at least one address")
|
|
||||||
}
|
|
||||||
addrID := grain.Addresses[0].Id
|
|
||||||
|
|
||||||
// DELETE /customers/{id}/addresses/{addressId}
|
|
||||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/%d", id, addrID), nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
grain, _ = applier.Get(context.Background(), uint64(pid))
|
|
||||||
if len(grain.Addresses) != 0 {
|
|
||||||
t.Fatalf("expected 0 addresses after removal, got %d", len(grain.Addresses))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemoveAddressNotFound(t *testing.T) {
|
|
||||||
applier := newTestProfileApplier()
|
|
||||||
pid, err := profile.NewProfileId()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to generate profile id: %v", err)
|
|
||||||
}
|
|
||||||
applier.Get(context.Background(), uint64(pid))
|
|
||||||
|
|
||||||
id := pid.String()
|
|
||||||
handler := CustomerHandler(applier, "")
|
|
||||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
// Test applier silently ignores not-found removals (returns no error).
|
|
||||||
// In production, HandleRemoveAddress returns an error which maps to 404.
|
|
||||||
// Accept 200 here since the test applier is a simplified mock.
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 (test applier limitation), got %d. Body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRemoveAddressNotFoundRealistic tests the real mutation handler path via the registry.
|
|
||||||
// It uses the real HandleRemoveAddress mutation to verify 404 behavior.
|
|
||||||
func TestRemoveAddressNotFoundMutation(t *testing.T) {
|
|
||||||
// Test the real HandleRemoveAddress mutation directly.
|
|
||||||
g := profile.NewProfileGrain(1, time.Now())
|
|
||||||
|
|
||||||
// Add an address via the real handler
|
|
||||||
if err := profile.HandleAddAddress(g, &messages.AddAddress{
|
|
||||||
Address: &messages.Address{
|
|
||||||
Label: "Test",
|
|
||||||
FullName: "Alice",
|
|
||||||
AddressLine1: "Storgatan 1",
|
|
||||||
City: "Stockholm",
|
|
||||||
Zip: "111 22",
|
|
||||||
Country: "SE",
|
|
||||||
},
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("HandleAddAddress failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to remove address 999 (doesn't exist)
|
|
||||||
err := profile.HandleRemoveAddress(g, &messages.RemoveAddress{Id: 999})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for non-existent address, got nil")
|
|
||||||
}
|
|
||||||
expected := "RemoveAddress: address 999 not found"
|
|
||||||
if err.Error() != expected {
|
|
||||||
t.Fatalf("expected error %q, got %q", expected, err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCustomerHandlerInvalidID(t *testing.T) {
|
|
||||||
applier := newTestProfileApplier()
|
|
||||||
handler := CustomerHandler(applier, "")
|
|
||||||
|
|
||||||
// Path "/" now has POST / registered (create customer), so GET / is 405.
|
|
||||||
// Paths with invalid base62 characters should get 400.
|
|
||||||
tests := []struct {
|
|
||||||
path string
|
|
||||||
expectedStatus int
|
|
||||||
}{
|
|
||||||
{"/", http.StatusMethodNotAllowed}, // POST / exists, GET doesn't
|
|
||||||
{"/!invalid!", http.StatusBadRequest}, // has non-base62 chars
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
req := httptest.NewRequest("GET", tt.path, nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != tt.expectedStatus {
|
|
||||||
t.Fatalf("for path %q: expected %d, got %d. Body: %s", tt.path, tt.expectedStatus, rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCustomerHandlerThroughCombinedMount(t *testing.T) {
|
|
||||||
// Test that the combined Handler correctly mounts customer endpoints.
|
|
||||||
// Use a mock CartApplier (bare minimum — never called in this test).
|
|
||||||
mockCart := &testCartApplier{}
|
|
||||||
profileApplier := newTestProfileApplier()
|
|
||||||
|
|
||||||
pid, _ := profile.NewProfileId()
|
|
||||||
profileApplier.Get(context.Background(), uint64(pid))
|
|
||||||
|
|
||||||
handler := Handler(mockCart, Options{
|
|
||||||
ProfileApplier: profileApplier,
|
|
||||||
})
|
|
||||||
|
|
||||||
// GET /customers/{id} should work
|
|
||||||
id := pid.String()
|
|
||||||
req := httptest.NewRequest("GET", fmt.Sprintf("/customers/%s", id), nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp CustomerResponse
|
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
|
||||||
}
|
|
||||||
if resp.Id != id {
|
|
||||||
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, 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
package ucp
|
|
||||||
|
|
||||||
import "net/http"
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Order HTTP handler mounting
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// OrderHandler returns an http.Handler that routes UCP order REST endpoints.
|
|
||||||
// Mount it under any prefix (e.g. /ucp/v1) in a ServeMux:
|
|
||||||
//
|
|
||||||
// mux.Handle("/ucp/v1/orders", ucp.OrderHandler(pool))
|
|
||||||
// mux.Handle("/ucp/v1/orders/", ucp.OrderHandler(pool))
|
|
||||||
//
|
|
||||||
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
|
|
||||||
//
|
|
||||||
// mux.Handle("/ucp/v1/orders", ucp.WithSigning(ucp.OrderHandler(pool), signer))
|
|
||||||
func OrderHandler(applier OrderReadApplier) http.Handler {
|
|
||||||
s := NewOrderServer(applier)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("GET /{id}", s.handleGetOrder)
|
|
||||||
mux.HandleFunc("POST /{id}/cancel", s.handleCancelOrder)
|
|
||||||
mux.HandleFunc("POST /{id}/fulfillments", s.handleCreateFulfillment)
|
|
||||||
mux.HandleFunc("POST /{id}/returns", s.handleRequestReturn)
|
|
||||||
mux.HandleFunc("POST /{id}/refunds", s.handleIssueRefund)
|
|
||||||
return withUCPAgent(withJSONContentType(mux))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cart HTTP handler mounting
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// CartHandler returns an http.Handler that routes UCP cart REST endpoints.
|
|
||||||
// Mount it under any prefix (e.g. /ucp/v1) in a ServeMux:
|
|
||||||
//
|
|
||||||
// mux.Handle("/ucp/v1/carts", ucp.CartHandler(pool))
|
|
||||||
// mux.Handle("/ucp/v1/carts/", ucp.CartHandler(pool))
|
|
||||||
//
|
|
||||||
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
|
|
||||||
//
|
|
||||||
// mux.Handle("/ucp/v1/carts", ucp.WithSigning(ucp.CartHandler(pool), signer))
|
|
||||||
func CartHandler(applier CartApplier) http.Handler {
|
|
||||||
s := NewCartServer(applier)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("POST /", s.handleCreateCart)
|
|
||||||
mux.HandleFunc("GET /{id}", s.handleGetCart)
|
|
||||||
mux.HandleFunc("PUT /{id}", s.handleUpdateCart)
|
|
||||||
mux.HandleFunc("POST /{id}/cancel", s.handleCancelCart)
|
|
||||||
return withUCPAgent(withJSONContentType(mux))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Checkout HTTP handler mounting
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// CheckoutHandler returns an http.Handler that routes UCP checkout endpoints.
|
|
||||||
// An optional OrderApplier can be provided for real order creation on complete.
|
|
||||||
//
|
|
||||||
// mux.Handle("/ucp/v1/checkout-sessions", ucp.CheckoutHandler(pool))
|
|
||||||
// mux.Handle("/ucp/v1/checkout-sessions/", ucp.CheckoutHandler(pool, orderSvc))
|
|
||||||
//
|
|
||||||
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
|
|
||||||
//
|
|
||||||
// mux.Handle("/ucp/v1/checkout-sessions", ucp.WithSigning(ucp.CheckoutHandler(pool), signer))
|
|
||||||
func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Handler {
|
|
||||||
s := NewCheckoutServer(applier, orderSvc...)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("POST /", s.handleCreateCheckout)
|
|
||||||
mux.HandleFunc("GET /{id}", s.handleGetCheckout)
|
|
||||||
mux.HandleFunc("PUT /{id}", s.handleUpdateCheckout)
|
|
||||||
mux.HandleFunc("POST /{id}/complete", s.handleCompleteCheckout)
|
|
||||||
mux.HandleFunc("POST /{id}/cancel", s.handleCancelCheckout)
|
|
||||||
return withUCPAgent(withJSONContentType(mux))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Customer HTTP handler mounting
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// CustomerHandler returns an http.Handler that routes UCP customer/profile
|
|
||||||
// REST endpoints. Mount it under any prefix (e.g. /ucp/v1) in a ServeMux:
|
|
||||||
//
|
|
||||||
// mux.Handle("/ucp/v1/customers", ucp.CustomerHandler(pool))
|
|
||||||
// mux.Handle("/ucp/v1/customers/", ucp.CustomerHandler(pool))
|
|
||||||
//
|
|
||||||
// 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, auditLogPath string, deleter ...CredentialDeleter) http.Handler {
|
|
||||||
s := NewCustomerServer(applier, auditLogPath, deleter...)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("POST /", s.handleCreateCustomer)
|
|
||||||
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
|
|
||||||
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)
|
|
||||||
return withUCPAgent(withJSONContentType(mux))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Combined handler for mounting all UCP endpoints under one prefix
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Options controls optional features of the combined handler.
|
|
||||||
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
|
|
||||||
// a single prefix. Usage:
|
|
||||||
//
|
|
||||||
// mux.Handle("/ucp/v1/", ucp.Handler(pool, ucp.Options{
|
|
||||||
// CheckoutApplier: checkoutPool,
|
|
||||||
// ProfileApplier: profilePool,
|
|
||||||
// }))
|
|
||||||
func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
|
|
||||||
// Cart endpoints
|
|
||||||
cartSrv := NewCartServer(cartApplier)
|
|
||||||
if opts.ProfileApplier != nil {
|
|
||||||
cartSrv.SetProfileApplier(opts.ProfileApplier)
|
|
||||||
}
|
|
||||||
mux.HandleFunc("POST /carts", cartSrv.handleCreateCart)
|
|
||||||
mux.HandleFunc("GET /carts/{id}", cartSrv.handleGetCart)
|
|
||||||
mux.HandleFunc("PUT /carts/{id}", cartSrv.handleUpdateCart)
|
|
||||||
mux.HandleFunc("POST /carts/{id}/cancel", cartSrv.handleCancelCart)
|
|
||||||
|
|
||||||
// Checkout endpoints (optional)
|
|
||||||
if opts.CheckoutApplier != nil {
|
|
||||||
var orderOpts []OrderApplier
|
|
||||||
if opts.OrderApplier != nil {
|
|
||||||
orderOpts = []OrderApplier{opts.OrderApplier}
|
|
||||||
}
|
|
||||||
checkoutSrv := NewCheckoutServer(opts.CheckoutApplier, orderOpts...)
|
|
||||||
if opts.ProfileApplier != nil {
|
|
||||||
checkoutSrv.SetProfileApplier(opts.ProfileApplier)
|
|
||||||
}
|
|
||||||
mux.HandleFunc("POST /checkout-sessions", checkoutSrv.handleCreateCheckout)
|
|
||||||
mux.HandleFunc("GET /checkout-sessions/{id}", checkoutSrv.handleGetCheckout)
|
|
||||||
mux.HandleFunc("PUT /checkout-sessions/{id}", checkoutSrv.handleUpdateCheckout)
|
|
||||||
mux.HandleFunc("POST /checkout-sessions/{id}/complete", checkoutSrv.handleCompleteCheckout)
|
|
||||||
mux.HandleFunc("POST /checkout-sessions/{id}/cancel", checkoutSrv.handleCancelCheckout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Customer endpoints (optional)
|
|
||||||
if opts.ProfileApplier != nil {
|
|
||||||
customerSrv := NewCustomerServer(opts.ProfileApplier, opts.ProfileAuditLog, opts.CredentialDeleter)
|
|
||||||
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
|
|
||||||
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return withUCPAgent(withJSONContentType(mux))
|
|
||||||
}
|
|
||||||
|
|
||||||
// withUCPAgent wraps a handler with UCP-Agent header parsing middleware.
|
|
||||||
func withUCPAgent(next http.Handler) http.Handler {
|
|
||||||
return WithUCPAgent(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
// withJSONContentType is a middleware that ensures all responses have Content-Type: application/json.
|
|
||||||
func withJSONContentType(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
package ucp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
|
||||||
orderMessages "git.k6n.net/mats/go-cart-actor/proto/order"
|
|
||||||
)
|
|
||||||
|
|
||||||
// OrderServer is the UCP REST adapter over the order grain pool.
|
|
||||||
type OrderServer struct {
|
|
||||||
applier OrderReadApplier
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOrderServer builds a UCP REST adapter over an order grain pool.
|
|
||||||
func NewOrderServer(applier OrderReadApplier) *OrderServer {
|
|
||||||
return &OrderServer{applier: applier}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// UCP Order endpoints
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// handleGetOrder handles GET /orders/{id}.
|
|
||||||
func (s *OrderServer) handleGetOrder(w http.ResponseWriter, r *http.Request) {
|
|
||||||
g, err := s.loadOrder(w, r)
|
|
||||||
if err != nil || g == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCancelOrder handles POST /orders/{id}/cancel.
|
|
||||||
func (s *OrderServer) handleCancelOrder(w http.ResponseWriter, r *http.Request) {
|
|
||||||
g, err := s.loadOrder(w, r)
|
|
||||||
if err != nil || g == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req struct {
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
|
|
||||||
res, applyErr := s.applier.Apply(r.Context(), g.Id, &orderMessages.CancelOrder{
|
|
||||||
Reason: req.Reason,
|
|
||||||
AtMs: time.Now().UnixMilli(),
|
|
||||||
})
|
|
||||||
if applyErr != nil || res == nil || mutationRejected(res) {
|
|
||||||
msg := "failed to cancel order"
|
|
||||||
if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil {
|
|
||||||
msg += ": " + res.Mutations[0].Error.Error()
|
|
||||||
} else if applyErr != nil {
|
|
||||||
msg += ": " + applyErr.Error()
|
|
||||||
}
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, _ := s.applier.Get(r.Context(), g.Id)
|
|
||||||
if updated != nil {
|
|
||||||
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated))
|
|
||||||
} else {
|
|
||||||
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), g))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleCreateFulfillment handles POST /orders/{id}/fulfillments.
|
|
||||||
func (s *OrderServer) handleCreateFulfillment(w http.ResponseWriter, r *http.Request) {
|
|
||||||
g, err := s.loadOrder(w, r)
|
|
||||||
if err != nil || g == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Carrier string `json:"carrier,omitempty"`
|
|
||||||
TrackingNumber string `json:"trackingNumber,omitempty"`
|
|
||||||
TrackingURI string `json:"trackingUri,omitempty"`
|
|
||||||
Lines []struct {
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Quantity int32 `json:"quantity"`
|
|
||||||
} `json:"lines,omitempty"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fid, _ := order.NewOrderId()
|
|
||||||
msg := &orderMessages.CreateFulfillment{
|
|
||||||
Id: "f_" + fid.String(),
|
|
||||||
Carrier: req.Carrier,
|
|
||||||
TrackingNumber: req.TrackingNumber,
|
|
||||||
TrackingUri: req.TrackingURI,
|
|
||||||
AtMs: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
for _, l := range req.Lines {
|
|
||||||
msg.Lines = append(msg.Lines, &orderMessages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
|
||||||
}
|
|
||||||
|
|
||||||
res, applyErr := s.applier.Apply(r.Context(), g.Id, msg)
|
|
||||||
if applyErr != nil || res == nil || mutationRejected(res) {
|
|
||||||
msg := "failed to create fulfillment"
|
|
||||||
if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil {
|
|
||||||
msg += ": " + res.Mutations[0].Error.Error()
|
|
||||||
} else if applyErr != nil {
|
|
||||||
msg += ": " + applyErr.Error()
|
|
||||||
}
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, _ := s.applier.Get(r.Context(), g.Id)
|
|
||||||
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleRequestReturn handles POST /orders/{id}/returns.
|
|
||||||
func (s *OrderServer) handleRequestReturn(w http.ResponseWriter, r *http.Request) {
|
|
||||||
g, err := s.loadOrder(w, r)
|
|
||||||
if err != nil || g == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
Lines []struct {
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Quantity int32 `json:"quantity"`
|
|
||||||
} `json:"lines,omitempty"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rid, _ := order.NewOrderId()
|
|
||||||
msg := &orderMessages.RequestReturn{
|
|
||||||
Id: "r_" + rid.String(),
|
|
||||||
Reason: req.Reason,
|
|
||||||
AtMs: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
for _, l := range req.Lines {
|
|
||||||
msg.Lines = append(msg.Lines, &orderMessages.FulfillmentLine{Reference: l.Reference, Quantity: l.Quantity})
|
|
||||||
}
|
|
||||||
|
|
||||||
res, applyErr := s.applier.Apply(r.Context(), g.Id, msg)
|
|
||||||
if applyErr != nil || res == nil || mutationRejected(res) {
|
|
||||||
msg := "failed to request return"
|
|
||||||
if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil {
|
|
||||||
msg += ": " + res.Mutations[0].Error.Error()
|
|
||||||
} else if applyErr != nil {
|
|
||||||
msg += ": " + applyErr.Error()
|
|
||||||
}
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, _ := s.applier.Get(r.Context(), g.Id)
|
|
||||||
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleIssueRefund handles POST /orders/{id}/refunds.
|
|
||||||
func (s *OrderServer) handleIssueRefund(w http.ResponseWriter, r *http.Request) {
|
|
||||||
g, err := s.loadOrder(w, r)
|
|
||||||
if err != nil || g == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
Amount int64 `json:"amount,omitempty"` // minor units; omit for full remaining
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
|
|
||||||
amount := req.Amount
|
|
||||||
if amount <= 0 {
|
|
||||||
amount = (g.CapturedAmount - g.RefundedAmount).Int64()
|
|
||||||
}
|
|
||||||
if amount <= 0 {
|
|
||||||
writeError(w, http.StatusBadRequest, "no captured amount to refund")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
idStr := order.OrderId(g.Id).String()
|
|
||||||
res, applyErr := s.applier.Apply(r.Context(), g.Id, &orderMessages.IssueRefund{
|
|
||||||
Provider: "ucp",
|
|
||||||
Amount: amount,
|
|
||||||
Reference: "ref-ucp-" + idStr,
|
|
||||||
AtMs: time.Now().UnixMilli(),
|
|
||||||
})
|
|
||||||
if applyErr != nil || res == nil || mutationRejected(res) {
|
|
||||||
msg := "failed to issue refund"
|
|
||||||
if res != nil && len(res.Mutations) > 0 && res.Mutations[0].Error != nil {
|
|
||||||
msg += ": " + res.Mutations[0].Error.Error()
|
|
||||||
} else if applyErr != nil {
|
|
||||||
msg += ": " + applyErr.Error()
|
|
||||||
}
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, _ := s.applier.Get(r.Context(), g.Id)
|
|
||||||
writeJSON(w, http.StatusOK, orderGrainToResponse(r.PathValue("id"), updated))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// mutationRejected returns true if the Apply result indicates the mutation was
|
|
||||||
// rejected (business-rule violation rather than infrastructure error).
|
|
||||||
func mutationRejected(res *actor.MutationResult[order.OrderGrain]) bool {
|
|
||||||
return len(res.Mutations) > 0 && res.Mutations[0].Error != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadOrder parses the id from the path and returns the grain, or writes an
|
|
||||||
// error response and returns nil.
|
|
||||||
func (s *OrderServer) loadOrder(w http.ResponseWriter, r *http.Request) (*order.OrderGrain, error) {
|
|
||||||
raw := r.PathValue("id")
|
|
||||||
if raw == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "missing order id")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
id, ok := order.ParseOrderId(raw)
|
|
||||||
if !ok {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid order id")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
g, err := s.applier.Get(r.Context(), uint64(id))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusNotFound, "order not found")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if g.Status == order.StatusNew {
|
|
||||||
writeError(w, http.StatusNotFound, "order not found")
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return g, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// orderGrainToResponse converts an OrderGrain to an OrderResponse.
|
|
||||||
func orderGrainToResponse(id string, g *order.OrderGrain) OrderResponse {
|
|
||||||
resp := OrderResponse{
|
|
||||||
OrderId: id,
|
|
||||||
Reference: g.OrderReference,
|
|
||||||
Status: string(g.Status),
|
|
||||||
Currency: g.Currency,
|
|
||||||
TotalAmount: g.TotalAmount,
|
|
||||||
TotalTax: g.TotalTax,
|
|
||||||
Lines: make([]OrderLineResp, 0, len(g.Lines)),
|
|
||||||
Payments: make([]OrderPaymentResp, 0, len(g.Payments)),
|
|
||||||
Fulfillments: make([]OrderFulfillmentResp, 0, len(g.Fulfillments)),
|
|
||||||
Returns: make([]OrderReturnResp, 0, len(g.Returns)),
|
|
||||||
Refunds: make([]OrderRefundResp, 0, len(g.Refunds)),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, l := range g.Lines {
|
|
||||||
resp.Lines = append(resp.Lines, OrderLineResp{
|
|
||||||
Reference: l.Reference,
|
|
||||||
Sku: l.Sku,
|
|
||||||
Name: l.Name,
|
|
||||||
Quantity: l.Quantity,
|
|
||||||
UnitPrice: l.UnitPrice,
|
|
||||||
TaxRate: l.TaxRate,
|
|
||||||
TotalAmount: l.TotalAmount,
|
|
||||||
TotalTax: l.TotalTax,
|
|
||||||
Fulfilled: l.Fulfilled,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range g.Payments {
|
|
||||||
if p == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resp.Payments = append(resp.Payments, OrderPaymentResp{
|
|
||||||
Provider: p.Provider,
|
|
||||||
Authorized: p.Authorized,
|
|
||||||
Captured: p.Captured,
|
|
||||||
Refunded: p.Refunded,
|
|
||||||
AuthRef: p.AuthRef,
|
|
||||||
CaptureRef: p.CaptureRef,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range g.Fulfillments {
|
|
||||||
lines := make([]OrderLineEntry, 0, len(f.Lines))
|
|
||||||
for _, l := range f.Lines {
|
|
||||||
lines = append(lines, OrderLineEntry{Reference: l.Reference, Quantity: l.Quantity})
|
|
||||||
}
|
|
||||||
resp.Fulfillments = append(resp.Fulfillments, OrderFulfillmentResp{
|
|
||||||
Id: f.ID,
|
|
||||||
Carrier: f.Carrier,
|
|
||||||
TrackingNumber: f.TrackingNumber,
|
|
||||||
TrackingURI: f.TrackingURI,
|
|
||||||
Lines: lines,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range g.Returns {
|
|
||||||
lines := make([]OrderLineEntry, 0, len(r.Lines))
|
|
||||||
for _, l := range r.Lines {
|
|
||||||
lines = append(lines, OrderLineEntry{Reference: l.Reference, Quantity: l.Quantity})
|
|
||||||
}
|
|
||||||
resp.Returns = append(resp.Returns, OrderReturnResp{
|
|
||||||
Id: r.ID,
|
|
||||||
Reason: r.Reason,
|
|
||||||
Lines: lines,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range g.Refunds {
|
|
||||||
resp.Refunds = append(resp.Refunds, OrderRefundResp{
|
|
||||||
Provider: r.Provider,
|
|
||||||
Amount: r.Amount,
|
|
||||||
Reference: r.Reference,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
resp.CapturedAmount = g.CapturedAmount
|
|
||||||
resp.RefundedAmount = g.RefundedAmount
|
|
||||||
resp.CustomerEmail = g.CustomerEmail
|
|
||||||
resp.CustomerName = g.CustomerName
|
|
||||||
resp.CartId = g.CartId
|
|
||||||
resp.PlacedAt = g.PlacedAt
|
|
||||||
resp.UpdatedAt = g.UpdatedAt
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
@@ -1,301 +0,0 @@
|
|||||||
package ucp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testOrderApplier implements OrderReadApplier with an in-memory map of grains.
|
|
||||||
type testOrderApplier struct {
|
|
||||||
grains map[uint64]*order.OrderGrain
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestOrderApplier() *testOrderApplier {
|
|
||||||
return &testOrderApplier{
|
|
||||||
grains: make(map[uint64]*order.OrderGrain),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *testOrderApplier) Get(_ context.Context, id uint64) (*order.OrderGrain, error) {
|
|
||||||
g, ok := a.grains[id]
|
|
||||||
if !ok {
|
|
||||||
// Auto-create so non-existent orders return StatusNew (expected 404).
|
|
||||||
g = order.NewOrderGrain(id, time.UnixMilli(1000000))
|
|
||||||
a.grains[id] = g
|
|
||||||
}
|
|
||||||
return g, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *testOrderApplier) Apply(_ context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) {
|
|
||||||
g, err := a.Get(nil, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
results := make([]actor.ApplyResult, len(msgs))
|
|
||||||
for i, msg := range msgs {
|
|
||||||
results[i] = actor.ApplyResult{Type: string(msg.ProtoReflect().Descriptor().FullName())}
|
|
||||||
switch m := msg.(type) {
|
|
||||||
case *messages.CancelOrder:
|
|
||||||
if g.Status != order.StatusPending && g.Status != order.StatusAuthorized {
|
|
||||||
results[i].Error = errRejected("cannot cancel in status " + string(g.Status))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
g.Status = order.StatusCancelled
|
|
||||||
g.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
|
||||||
case *messages.CreateFulfillment:
|
|
||||||
if g.Status != order.StatusCaptured && g.Status != order.StatusPartiallyFulfilled {
|
|
||||||
results[i].Error = errRejected("cannot fulfill in status " + string(g.Status))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
fulfillment := order.Fulfillment{
|
|
||||||
ID: m.Id,
|
|
||||||
Carrier: m.Carrier,
|
|
||||||
TrackingNumber: m.TrackingNumber,
|
|
||||||
TrackingURI: m.TrackingUri,
|
|
||||||
}
|
|
||||||
for _, fl := range m.Lines {
|
|
||||||
fulfillment.Lines = append(fulfillment.Lines, order.FulfillmentEntry{
|
|
||||||
Reference: fl.Reference,
|
|
||||||
Quantity: int(fl.Quantity),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
g.Fulfillments = append(g.Fulfillments, fulfillment)
|
|
||||||
g.Status = order.StatusFulfilled
|
|
||||||
case *messages.RequestReturn:
|
|
||||||
ret := order.Return{
|
|
||||||
ID: m.Id,
|
|
||||||
Reason: m.Reason,
|
|
||||||
}
|
|
||||||
for _, fl := range m.Lines {
|
|
||||||
ret.Lines = append(ret.Lines, order.FulfillmentEntry{
|
|
||||||
Reference: fl.Reference,
|
|
||||||
Quantity: int(fl.Quantity),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
g.Returns = append(g.Returns, ret)
|
|
||||||
case *messages.IssueRefund:
|
|
||||||
g.Refunds = append(g.Refunds, order.Refund{
|
|
||||||
Provider: m.Provider,
|
|
||||||
Amount: money.Cents(m.Amount),
|
|
||||||
Reference: m.Reference,
|
|
||||||
})
|
|
||||||
g.RefundedAmount += money.Cents(m.Amount)
|
|
||||||
if g.RefundedAmount >= g.CapturedAmount {
|
|
||||||
g.Status = order.StatusRefunded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &actor.MutationResult[order.OrderGrain]{
|
|
||||||
Result: *g,
|
|
||||||
Mutations: results,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func errRejected(msg string) *actor.MutationError {
|
|
||||||
return &actor.MutationError{Message: msg}
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedOrder places an order in the test applier with a given status.
|
|
||||||
func seedOrder(a *testOrderApplier, status order.Status) string {
|
|
||||||
id, _ := order.NewOrderId()
|
|
||||||
g, _ := a.Get(nil, uint64(id))
|
|
||||||
g.Status = status
|
|
||||||
g.Currency = "SEK"
|
|
||||||
g.TotalAmount = 10000
|
|
||||||
g.TotalTax = 2000
|
|
||||||
g.Lines = []order.Line{
|
|
||||||
{Reference: "test-1", Sku: "test-1", Name: "Test Item", Quantity: 2, UnitPrice: 5000, TaxRate: 2500, TotalAmount: 10000, TotalTax: 2000},
|
|
||||||
}
|
|
||||||
if status == order.StatusCaptured {
|
|
||||||
g.CapturedAmount = 10000
|
|
||||||
}
|
|
||||||
return id.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrderHandler_GetOrder(t *testing.T) {
|
|
||||||
a := newTestOrderApplier()
|
|
||||||
h := OrderHandler(a)
|
|
||||||
oid := seedOrder(a, order.StatusCaptured)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/"+oid, nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp OrderResponse
|
|
||||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
|
||||||
t.Fatalf("decode: %v", err)
|
|
||||||
}
|
|
||||||
if resp.OrderId != oid {
|
|
||||||
t.Fatalf("expected orderId %q, got %q", oid, resp.OrderId)
|
|
||||||
}
|
|
||||||
if resp.Status != "captured" {
|
|
||||||
t.Fatalf("expected status 'captured', got %q", resp.Status)
|
|
||||||
}
|
|
||||||
if len(resp.Lines) != 1 {
|
|
||||||
t.Fatalf("expected 1 line, got %d", len(resp.Lines))
|
|
||||||
}
|
|
||||||
if resp.Lines[0].Sku != "test-1" {
|
|
||||||
t.Fatalf("expected sku 'test-1', got %q", resp.Lines[0].Sku)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrderHandler_GetOrderNotFound(t *testing.T) {
|
|
||||||
a := newTestOrderApplier()
|
|
||||||
h := OrderHandler(a)
|
|
||||||
|
|
||||||
// Non-existent order (not seeded) returns 404.
|
|
||||||
req := httptest.NewRequest("GET", "/nonexistent", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("expected 404 for non-existent order (StatusNew -> 404), got %d", rec.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrderHandler_CancelOrder(t *testing.T) {
|
|
||||||
a := newTestOrderApplier()
|
|
||||||
h := OrderHandler(a)
|
|
||||||
oid := seedOrder(a, order.StatusAuthorized)
|
|
||||||
|
|
||||||
body := `{"reason": "customer request"}`
|
|
||||||
req := httptest.NewRequest("POST", "/"+oid+"/cancel", strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp OrderResponse
|
|
||||||
json.NewDecoder(rec.Body).Decode(&resp)
|
|
||||||
if resp.Status != "cancelled" {
|
|
||||||
t.Fatalf("expected status 'cancelled', got %q", resp.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrderHandler_CancelOrder_IllegalState(t *testing.T) {
|
|
||||||
a := newTestOrderApplier()
|
|
||||||
h := OrderHandler(a)
|
|
||||||
oid := seedOrder(a, order.StatusFulfilled) // can't cancel from fulfilled
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/"+oid+"/cancel", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusUnprocessableEntity {
|
|
||||||
t.Fatalf("expected 422 for illegal transition, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrderHandler_CreateFulfillment(t *testing.T) {
|
|
||||||
a := newTestOrderApplier()
|
|
||||||
h := OrderHandler(a)
|
|
||||||
oid := seedOrder(a, order.StatusCaptured)
|
|
||||||
|
|
||||||
body := `{"carrier":"PostNord","trackingNumber":"ABC123","lines":[{"reference":"test-1","quantity":2}]}`
|
|
||||||
req := httptest.NewRequest("POST", "/"+oid+"/fulfillments", strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp OrderResponse
|
|
||||||
json.NewDecoder(rec.Body).Decode(&resp)
|
|
||||||
if len(resp.Fulfillments) != 1 {
|
|
||||||
t.Fatalf("expected 1 fulfillment, got %d", len(resp.Fulfillments))
|
|
||||||
}
|
|
||||||
if resp.Fulfillments[0].Carrier != "PostNord" {
|
|
||||||
t.Fatalf("expected carrier 'PostNord', got %q", resp.Fulfillments[0].Carrier)
|
|
||||||
}
|
|
||||||
if resp.Fulfillments[0].TrackingNumber != "ABC123" {
|
|
||||||
t.Fatalf("expected tracking 'ABC123', got %q", resp.Fulfillments[0].TrackingNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrderHandler_RequestReturn(t *testing.T) {
|
|
||||||
a := newTestOrderApplier()
|
|
||||||
h := OrderHandler(a)
|
|
||||||
oid := seedOrder(a, order.StatusFulfilled)
|
|
||||||
|
|
||||||
body := `{"reason":"defective","lines":[{"reference":"test-1","quantity":1}]}`
|
|
||||||
req := httptest.NewRequest("POST", "/"+oid+"/returns", strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp OrderResponse
|
|
||||||
json.NewDecoder(rec.Body).Decode(&resp)
|
|
||||||
if len(resp.Returns) != 1 {
|
|
||||||
t.Fatalf("expected 1 return, got %d", len(resp.Returns))
|
|
||||||
}
|
|
||||||
if resp.Returns[0].Reason != "defective" {
|
|
||||||
t.Fatalf("expected reason 'defective', got %q", resp.Returns[0].Reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrderHandler_IssueRefund(t *testing.T) {
|
|
||||||
a := newTestOrderApplier()
|
|
||||||
h := OrderHandler(a)
|
|
||||||
oid := seedOrder(a, order.StatusCaptured)
|
|
||||||
|
|
||||||
body := `{"amount":3000}`
|
|
||||||
req := httptest.NewRequest("POST", "/"+oid+"/refunds", strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp OrderResponse
|
|
||||||
json.NewDecoder(rec.Body).Decode(&resp)
|
|
||||||
if len(resp.Refunds) != 1 {
|
|
||||||
t.Fatalf("expected 1 refund, got %d", len(resp.Refunds))
|
|
||||||
}
|
|
||||||
if resp.Refunds[0].Amount != 3000 {
|
|
||||||
t.Fatalf("expected amount 3000, got %d", resp.Refunds[0].Amount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrderHandler_SignatureIntegration(t *testing.T) {
|
|
||||||
cfg := mustLoadTestKey(t)
|
|
||||||
a := newTestOrderApplier()
|
|
||||||
oid := seedOrder(a, order.StatusCaptured)
|
|
||||||
|
|
||||||
h := WithSigning(OrderHandler(a), cfg)
|
|
||||||
req := httptest.NewRequest("GET", "/"+oid, nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
if rec.Header().Get("Signature-Input") == "" {
|
|
||||||
t.Fatal("expected Signature-Input with signing enabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package ucp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/ecdsa"
|
|
||||||
"crypto/elliptic"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SigningConfig — ECDSA P-256 key for RFC 9421 HTTP Message Signatures
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// SigningConfig holds the ECDSA P-256 private key and key ID for signing UCP
|
|
||||||
// HTTP responses with RFC 9421 HTTP Message Signatures. The corresponding
|
|
||||||
// public key is published in the UCP profile (signing_keys) and served at
|
|
||||||
// /.well-known/ucp so any UCP platform can verify response authenticity.
|
|
||||||
type SigningConfig struct {
|
|
||||||
PrivateKey *ecdsa.PrivateKey
|
|
||||||
KeyID string // matches the kid in the UCP profile signing_keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSigningConfigFromPEM parses a PEM-encoded ECDSA P-256 private key and
|
|
||||||
// returns a SigningConfig ready for use.
|
|
||||||
//
|
|
||||||
// data — PEM block containing an EC PRIVATE KEY (PKCS#8 or SEC1)
|
|
||||||
// keyID — the kid matched by the UCP profile signing_keys entry
|
|
||||||
func NewSigningConfigFromPEM(pemData []byte, keyID string) (*SigningConfig, error) {
|
|
||||||
block, _ := pem.Decode(pemData)
|
|
||||||
if block == nil {
|
|
||||||
return nil, fmt.Errorf("ucp signing: no PEM block found")
|
|
||||||
}
|
|
||||||
|
|
||||||
var priv any
|
|
||||||
var err error
|
|
||||||
switch block.Type {
|
|
||||||
case "EC PRIVATE KEY":
|
|
||||||
// SEC1 format (openssl ecparam -genkey -name prime256v1)
|
|
||||||
priv, err = x509.ParseECPrivateKey(block.Bytes)
|
|
||||||
case "PRIVATE KEY":
|
|
||||||
// PKCS#8 format
|
|
||||||
priv, err = x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("ucp signing: unsupported PEM type %q", block.Type)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ucp signing: parse key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ecKey, ok := priv.(*ecdsa.PrivateKey)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("ucp signing: key is not ECDSA")
|
|
||||||
}
|
|
||||||
if ecKey.Curve != elliptic.P256() {
|
|
||||||
return nil, fmt.Errorf("ucp signing: key is not P-256 (got %s)", ecKey.Curve.Params().Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &SigningConfig{PrivateKey: ecKey, KeyID: keyID}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// WithSigning — HTTP middleware wrapper for RFC 9421 response signing
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// 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, x-ucp-timestamp, and content-digest,
|
|
||||||
// signed with the configured ECDSA P-256 key.
|
|
||||||
//
|
|
||||||
// Mount it on any existing UCP handler:
|
|
||||||
//
|
|
||||||
// mux.Handle("/ucp/v1/carts", ucp.WithSigning(ucp.CartHandler(pool), cfg))
|
|
||||||
func WithSigning(h http.Handler, cfg *SigningConfig) http.Handler {
|
|
||||||
if cfg == nil || cfg.PrivateKey == nil {
|
|
||||||
return h // pass through without signing
|
|
||||||
}
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
sw := newSignedWriter(w, cfg)
|
|
||||||
h.ServeHTTP(sw, r)
|
|
||||||
sw.flush()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// signedWriter — response interceptor that adds signature headers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type signedWriter struct {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *signedWriter) Write(p []byte) (int, error) {
|
|
||||||
if !w.wroteHeader {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
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.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
|
|
||||||
// and MUST be quoted.
|
|
||||||
components := []string{
|
|
||||||
"@status",
|
|
||||||
`"content-type"`,
|
|
||||||
`"x-ucp-timestamp"`,
|
|
||||||
`"content-digest"`,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the signature base string (RFC 9421 §2.2).
|
|
||||||
var base strings.Builder
|
|
||||||
base.WriteString(fmt.Sprintf(`"@status": %d`, w.statusCode))
|
|
||||||
base.WriteByte('\n')
|
|
||||||
base.WriteString(fmt.Sprintf(`"content-type": %s`, contentType))
|
|
||||||
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, " ")
|
|
||||||
base.WriteString(fmt.Sprintf(`"@signature-params": (%s);created=%d;keyid=%q;alg="ecdsa-p256"`,
|
|
||||||
compList, created, w.cfg.KeyID))
|
|
||||||
|
|
||||||
// Sign the SHA-256 digest.
|
|
||||||
digest := sha256.Sum256([]byte(base.String()))
|
|
||||||
r, s, err := ecdsa.Sign(rand.Reader, w.cfg.PrivateKey, digest[:])
|
|
||||||
if err != nil {
|
|
||||||
// Fail open — response is sent without signature headers.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ECDSA P-256 signature raw bytes: r||s concatenation, each 32 bytes.
|
|
||||||
rBytes := r.Bytes()
|
|
||||||
sBytes := s.Bytes()
|
|
||||||
rawSig := make([]byte, 64)
|
|
||||||
copy(rawSig[32-len(rBytes):32], rBytes)
|
|
||||||
copy(rawSig[64-len(sBytes):64], sBytes)
|
|
||||||
|
|
||||||
sigB64 := base64.RawURLEncoding.EncodeToString(rawSig)
|
|
||||||
|
|
||||||
// Signature-Input: sig1=(components);created=N;keyid="...";alg="ecdsa-p256"
|
|
||||||
sigInput := fmt.Sprintf(`sig1=(%s);created=%d;keyid=%q;alg="ecdsa-p256"`,
|
|
||||||
compList, created, w.cfg.KeyID)
|
|
||||||
|
|
||||||
// Signature: sig1=:base64url:
|
|
||||||
sigValue := fmt.Sprintf("sig1=:%s:", sigB64)
|
|
||||||
|
|
||||||
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[:]) + ":"
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
package ucp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSigningConfig_FromPEM(t *testing.T) {
|
|
||||||
// PEM from the generated key pair (openssl ecparam -genkey -name prime256v1)
|
|
||||||
pemData := `-----BEGIN EC PRIVATE KEY-----
|
|
||||||
MHcCAQEEIDTssEctnrEqym80fx2jEVolTW+tWrHyo8fmbi8hMFWmoAoGCCqGSM49
|
|
||||||
AwEHoUQDQgAEpnPksDSSMcNgUYT4++Z6XuS0V2p6TNMSjWBsgXOu7mo5Esoux+Cm
|
|
||||||
y+C84ty5VUAdEpoDyNfWMeaAlHKGo+FjiQ==
|
|
||||||
-----END EC PRIVATE KEY-----`
|
|
||||||
|
|
||||||
cfg, err := NewSigningConfigFromPEM([]byte(pemData), "k6n-ecdsa-2026")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewSigningConfigFromPEM failed: %v", err)
|
|
||||||
}
|
|
||||||
if cfg.KeyID != "k6n-ecdsa-2026" {
|
|
||||||
t.Fatalf("expected key ID 'k6n-ecdsa-2026', got %q", cfg.KeyID)
|
|
||||||
}
|
|
||||||
if cfg.PrivateKey == nil {
|
|
||||||
t.Fatal("expected non-nil private key")
|
|
||||||
}
|
|
||||||
if cfg.PrivateKey.Curve.Params().Name != "P-256" {
|
|
||||||
t.Fatalf("expected P-256 curve, got %s", cfg.PrivateKey.Curve.Params().Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSigningConfig_InvalidPEM(t *testing.T) {
|
|
||||||
_, err := NewSigningConfigFromPEM([]byte("not a pem"), "test")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for invalid PEM")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithSigning_AddsHeaders(t *testing.T) {
|
|
||||||
// Load the test key.
|
|
||||||
cfg := mustLoadTestKey(t)
|
|
||||||
|
|
||||||
// A simple handler that returns a JSON response.
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
||||||
})
|
|
||||||
|
|
||||||
wrapped := WithSigning(handler, cfg)
|
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
wrapped.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
// Check the response body.
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that signature headers are present.
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
if sig == "" {
|
|
||||||
t.Fatal("expected Signature header")
|
|
||||||
}
|
|
||||||
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" "content-digest");`) {
|
|
||||||
t.Fatalf("unexpected Signature-Input format: %q", sigInput)
|
|
||||||
}
|
|
||||||
if !strings.Contains(sigInput, `keyid="k6n-ecdsa-2026"`) {
|
|
||||||
t.Fatalf("Signature-Input missing keyid: %q", sigInput)
|
|
||||||
}
|
|
||||||
if !strings.Contains(sigInput, `alg="ecdsa-p256"`) {
|
|
||||||
t.Fatalf("Signature-Input missing alg: %q", sigInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the signature format.
|
|
||||||
if !strings.HasPrefix(sig, "sig1=:") || !strings.HasSuffix(sig, ":") {
|
|
||||||
t.Fatalf("unexpected Signature format: %q", sig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithSigning_NilConfig(t *testing.T) {
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write([]byte("ok"))
|
|
||||||
})
|
|
||||||
|
|
||||||
wrapped := WithSigning(handler, nil)
|
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
wrapped.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
if rec.Header().Get("Signature-Input") != "" {
|
|
||||||
t.Fatal("expected no Signature-Input when signing is nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithSigning_DifferentStatuses(t *testing.T) {
|
|
||||||
cfg := mustLoadTestKey(t)
|
|
||||||
|
|
||||||
for _, status := range []int{200, 201, 400, 404, 500} {
|
|
||||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(status)
|
|
||||||
w.Write([]byte(`{}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
wrapped := WithSigning(handler, cfg)
|
|
||||||
req := httptest.NewRequest("GET", "/test", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
wrapped.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != status {
|
|
||||||
t.Fatalf("expected status %d, got %d", status, rec.Code)
|
|
||||||
}
|
|
||||||
if rec.Header().Get("Signature-Input") == "" {
|
|
||||||
t.Fatalf("expected Signature-Input for status %d", status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithSigning_WithUCPCartHandler(t *testing.T) {
|
|
||||||
cfg := mustLoadTestKey(t)
|
|
||||||
applier := newTestApplier()
|
|
||||||
handler := CartHandler(applier)
|
|
||||||
wrapped := WithSigning(handler, cfg)
|
|
||||||
|
|
||||||
// Create a cart through the wrapped handler.
|
|
||||||
req := httptest.NewRequest("POST", "/", nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
wrapped.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("expected 201, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify signed.
|
|
||||||
if rec.Header().Get("Signature-Input") == "" {
|
|
||||||
t.Fatal("expected Signature-Input on UCP cart response")
|
|
||||||
}
|
|
||||||
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.
|
|
||||||
func mustLoadTestKey(t testing.TB) *SigningConfig {
|
|
||||||
t.Helper()
|
|
||||||
pemData := `-----BEGIN EC PRIVATE KEY-----
|
|
||||||
MHcCAQEEIDTssEctnrEqym80fx2jEVolTW+tWrHyo8fmbi8hMFWmoAoGCCqGSM49
|
|
||||||
AwEHoUQDQgAEpnPksDSSMcNgUYT4++Z6XuS0V2p6TNMSjWBsgXOu7mo5Esoux+Cm
|
|
||||||
y+C84ty5VUAdEpoDyNfWMeaAlHKGo+FjiQ==
|
|
||||||
-----END EC PRIVATE KEY-----`
|
|
||||||
cfg, err := NewSigningConfigFromPEM([]byte(pemData), "k6n-ecdsa-2026")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("mustLoadTestKey: %v", err)
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
@@ -1,496 +0,0 @@
|
|||||||
// Package ucp provides a Universal Commerce Protocol (UCP) REST adapter for
|
|
||||||
// the cart and checkout grain pools. It translates UCP-standard cart/checkout
|
|
||||||
// endpoints (POST /carts, GET /carts/{id}, POST /checkout-sessions, …) into
|
|
||||||
// grain pool Get/Apply calls, so any UCP-compatible platform or AI agent can
|
|
||||||
// interact with the business without a bespoke integration.
|
|
||||||
//
|
|
||||||
// Transport: REST (JSON over HTTP, following the UCP Shopping Service spec).
|
|
||||||
//
|
|
||||||
// Current capabilities:
|
|
||||||
// - Cart: create, read, update items, cancel/clear
|
|
||||||
// - Checkout: initiate session, read, update, complete, cancel
|
|
||||||
//
|
|
||||||
// Mount on any existing HTTP mux via ucp.NewCartHandler(…) and
|
|
||||||
// ucp.NewCheckoutHandler(…).
|
|
||||||
package ucp
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"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/platform/money"
|
|
||||||
"google.golang.org/protobuf/proto"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Applier interfaces (same pattern as the MCP packages)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// CartApplier is the minimal grain-pool interface the UCP cart adapter needs.
|
|
||||||
type CartApplier interface {
|
|
||||||
Get(ctx context.Context, id uint64) (*cart.CartGrain, error)
|
|
||||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckoutApplier is the minimal grain-pool interface the UCP checkout adapter needs.
|
|
||||||
type CheckoutApplier interface {
|
|
||||||
Get(ctx context.Context, id uint64) (*checkout.CheckoutGrain, error)
|
|
||||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[checkout.CheckoutGrain], error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderReadApplier is the minimal grain-pool interface the UCP order adapter
|
|
||||||
// needs to read and mutate order grains.
|
|
||||||
type OrderReadApplier interface {
|
|
||||||
Get(ctx context.Context, id uint64) (*order.OrderGrain, error)
|
|
||||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProfileApplier is the minimal grain-pool interface the UCP customer adapter needs.
|
|
||||||
type ProfileApplier interface {
|
|
||||||
Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error)
|
|
||||||
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.
|
|
||||||
//
|
|
||||||
// The checkout adapter calls CreateOrder in handleCompleteCheckout when the
|
|
||||||
// optional order applier is configured. Without it, the endpoint falls back to
|
|
||||||
// a simplified no-op order reference.
|
|
||||||
type OrderApplier interface {
|
|
||||||
// CreateOrder creates a real order from the checkout session after payment
|
|
||||||
// has been completed. It returns the order ID string.
|
|
||||||
//
|
|
||||||
// checkoutID — the checkout grain's numeric id
|
|
||||||
// g — the checkout grain's full state (items, deliveries, contact, …)
|
|
||||||
// provider — payment provider that settled the payment (e.g. "adyen", "klarna")
|
|
||||||
// reference — PSP / payment reference
|
|
||||||
// currency — ISO 4217 currency code (e.g. "SEK")
|
|
||||||
// country — ISO 3166-1 alpha-2 country code (e.g. "SE")
|
|
||||||
CreateOrder(ctx context.Context, checkoutID uint64, g *checkout.CheckoutGrain, provider, reference, currency, country string) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// UCP Cart types (mapped from the UCP Shopping Service REST spec)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// CreateCartRequest is the body for POST /carts. Optional — when empty, a fresh
|
|
||||||
// empty cart is created. When items are supplied the cart is seeded.
|
|
||||||
type CreateCartRequest struct {
|
|
||||||
Items []CartItemInput `json:"items,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateCartRequest is the body for PUT /carts/{id}. It carries the full or
|
|
||||||
// partial cart state to replace.
|
|
||||||
type UpdateCartRequest struct {
|
|
||||||
Items []CartItemInput `json:"items,omitempty"`
|
|
||||||
Vouchers []VoucherInput `json:"vouchers,omitempty"`
|
|
||||||
UserId *string `json:"userId,omitempty"`
|
|
||||||
Country string `json:"country,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CartItemInput is the UCP wire format for an item being added/updated.
|
|
||||||
type CartItemInput struct {
|
|
||||||
Sku string `json:"sku"`
|
|
||||||
Quantity int `json:"quantity,omitempty"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
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"`
|
|
||||||
Fields map[string]string `json:"customFields,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// VoucherInput is the UCP wire format for a voucher code to apply.
|
|
||||||
type VoucherInput struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Value money.Cents `json:"value,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CartResponse is the UCP cart response body.
|
|
||||||
type CartResponse struct {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Items []CartItem `json:"items,omitempty"`
|
|
||||||
Totals Totals `json:"totals"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CartItem is the UCP wire format for a cart line item in responses.
|
|
||||||
type CartItem struct {
|
|
||||||
Sku string `json:"sku"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
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"`
|
|
||||||
Fields map[string]string `json:"customFields,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Totals is the UCP wire format for price breakdown.
|
|
||||||
type Totals struct {
|
|
||||||
TotalIncVat money.Cents `json:"totalIncVat"`
|
|
||||||
TotalExVat money.Cents `json:"totalExVat"`
|
|
||||||
TotalVat money.Cents `json:"totalVat"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
Discount money.Cents `json:"discount,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// UCP Checkout types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// CreateCheckoutRequest is the body for POST /checkout-sessions.
|
|
||||||
type CreateCheckoutRequest struct {
|
|
||||||
CartId string `json:"cartId"`
|
|
||||||
Items []CartItemInput `json:"items,omitempty"`
|
|
||||||
LineItems []CartItemInput `json:"line_items,omitempty"`
|
|
||||||
Country string `json:"country,omitempty"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
CustomerId string `json:"customerId,omitempty"` // profile ID for identity linking
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateCheckoutRequest is the body for PUT /checkout-sessions/{id}.
|
|
||||||
type UpdateCheckoutRequest struct {
|
|
||||||
Delivery *DeliveryInput `json:"delivery,omitempty"`
|
|
||||||
Contact *ContactInput `json:"contact,omitempty"`
|
|
||||||
PickupPoint *PickupPointInput `json:"pickupPoint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeliveryInput carries a delivery option for a checkout session.
|
|
||||||
type DeliveryInput struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
ItemIds []uint32 `json:"itemIds,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContactInput carries contact details for a checkout session.
|
|
||||||
type ContactInput struct {
|
|
||||||
Email *string `json:"email,omitempty"`
|
|
||||||
Phone *string `json:"phone,omitempty"`
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
PostalCode *string `json:"postalCode,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PickupPointInput carries a pickup point for a delivery.
|
|
||||||
type PickupPointInput struct {
|
|
||||||
DeliveryId uint32 `json:"deliveryId"`
|
|
||||||
Id string `json:"id"`
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
Address *string `json:"address,omitempty"`
|
|
||||||
City *string `json:"city,omitempty"`
|
|
||||||
Zip *string `json:"zip,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompleteCheckoutRequest is the body for POST /checkout-sessions/{id}/complete.
|
|
||||||
type CompleteCheckoutRequest struct {
|
|
||||||
PaymentToken string `json:"paymentToken,omitempty"`
|
|
||||||
Provider string `json:"provider,omitempty"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
Country string `json:"country,omitempty"`
|
|
||||||
CustomerId string `json:"customerId,omitempty"` // profile ID for identity linking
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckoutResponse is the UCP checkout session response.
|
|
||||||
type CheckoutResponse struct {
|
|
||||||
Id string `json:"id"`
|
|
||||||
CartId string `json:"cartId"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Items []CartItem `json:"items,omitempty"`
|
|
||||||
Totals Totals `json:"totals"`
|
|
||||||
Deliveries []DeliveryResponse `json:"deliveries,omitempty"`
|
|
||||||
Contact *ContactResponse `json:"contact,omitempty"`
|
|
||||||
OrderId *string `json:"orderId,omitempty"`
|
|
||||||
Payments []PaymentResponse `json:"payments,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeliveryResponse is the UCP wire format for a delivery option.
|
|
||||||
type DeliveryResponse struct {
|
|
||||||
Id uint32 `json:"id"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Price money.Cents `json:"price"`
|
|
||||||
Items []uint32 `json:"items"`
|
|
||||||
PickupPoint *PickupPointResp `json:"pickupPoint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PickupPointResp is the UCP wire format for a pickup point.
|
|
||||||
type PickupPointResp struct {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
Address *string `json:"address,omitempty"`
|
|
||||||
City *string `json:"city,omitempty"`
|
|
||||||
Zip *string `json:"zip,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContactResponse is the UCP wire format for contact details.
|
|
||||||
type ContactResponse struct {
|
|
||||||
Email *string `json:"email,omitempty"`
|
|
||||||
Phone *string `json:"phone,omitempty"`
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
PostalCode *string `json:"postalCode,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PaymentResponse is the UCP wire format for a payment record.
|
|
||||||
type PaymentResponse struct {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Amount money.Cents `json:"amount"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// UCP Order types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// OrderResponse is the UCP order response body.
|
|
||||||
type OrderResponse struct {
|
|
||||||
OrderId string `json:"orderId"`
|
|
||||||
Reference string `json:"reference,omitempty"`
|
|
||||||
CartId string `json:"cartId,omitempty"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
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 money.Cents `json:"capturedAmount"`
|
|
||||||
RefundedAmount money.Cents `json:"refundedAmount"`
|
|
||||||
CustomerEmail string `json:"customerEmail,omitempty"`
|
|
||||||
CustomerName string `json:"customerName,omitempty"`
|
|
||||||
PlacedAt string `json:"placedAt,omitempty"`
|
|
||||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderLineResp represents a single order line item in responses.
|
|
||||||
type OrderLineResp struct {
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Sku string `json:"sku"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
UnitPrice money.Cents `json:"unitPrice"`
|
|
||||||
TaxRate int `json:"taxRate"`
|
|
||||||
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 money.Cents `json:"authorized"`
|
|
||||||
Captured money.Cents `json:"captured"`
|
|
||||||
Refunded money.Cents `json:"refunded"`
|
|
||||||
AuthRef string `json:"authRef,omitempty"`
|
|
||||||
CaptureRef string `json:"captureRef,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderFulfillmentResp represents a shipment record.
|
|
||||||
type OrderFulfillmentResp struct {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Carrier string `json:"carrier,omitempty"`
|
|
||||||
TrackingNumber string `json:"trackingNumber,omitempty"`
|
|
||||||
TrackingURI string `json:"trackingUri,omitempty"`
|
|
||||||
Lines []OrderLineEntry `json:"lines,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderReturnResp represents a return request (RMA).
|
|
||||||
type OrderReturnResp struct {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
Lines []OrderLineEntry `json:"lines,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderRefundResp represents a refund record.
|
|
||||||
type OrderRefundResp struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
Amount money.Cents `json:"amount"`
|
|
||||||
Reference string `json:"reference,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderLineEntry is a lightweight reference+quantity pair used in fulfillment
|
|
||||||
// and return line lists.
|
|
||||||
type OrderLineEntry struct {
|
|
||||||
Reference string `json:"reference"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// UCP Customer types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// CustomerResponse is the UCP customer profile response body.
|
|
||||||
type CustomerResponse struct {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Email string `json:"email,omitempty"`
|
|
||||||
Phone string `json:"phone,omitempty"`
|
|
||||||
Language string `json:"language,omitempty"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
AvatarUrl string `json:"avatarUrl,omitempty"`
|
|
||||||
Addresses []AddressResponse `json:"addresses,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddressResponse is the UCP wire format for an address.
|
|
||||||
type AddressResponse struct {
|
|
||||||
Id uint32 `json:"id"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
FullName string `json:"fullName,omitempty"`
|
|
||||||
AddressLine1 string `json:"addressLine1"`
|
|
||||||
AddressLine2 string `json:"addressLine2,omitempty"`
|
|
||||||
City string `json:"city"`
|
|
||||||
State string `json:"state,omitempty"`
|
|
||||||
Zip string `json:"zip"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
Phone string `json:"phone,omitempty"`
|
|
||||||
IsDefaultShipping bool `json:"isDefaultShipping"`
|
|
||||||
IsDefaultBilling bool `json:"isDefaultBilling"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CustomerUpdateRequest is the body for PUT /customers/{id}.
|
|
||||||
type CustomerUpdateRequest struct {
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
Email *string `json:"email,omitempty"`
|
|
||||||
Phone *string `json:"phone,omitempty"`
|
|
||||||
Language *string `json:"language,omitempty"`
|
|
||||||
Currency *string `json:"currency,omitempty"`
|
|
||||||
AvatarUrl *string `json:"avatarUrl,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddAddressRequest is the body for POST /customers/{id}/addresses.
|
|
||||||
type AddAddressRequest struct {
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
FullName string `json:"fullName,omitempty"`
|
|
||||||
AddressLine1 string `json:"addressLine1"`
|
|
||||||
AddressLine2 string `json:"addressLine2,omitempty"`
|
|
||||||
City string `json:"city"`
|
|
||||||
State string `json:"state,omitempty"`
|
|
||||||
Zip string `json:"zip"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
Phone string `json:"phone,omitempty"`
|
|
||||||
IsDefaultShipping bool `json:"isDefaultShipping,omitempty"`
|
|
||||||
IsDefaultBilling bool `json:"isDefaultBilling,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateAddressRequest is the body for PUT /customers/{id}/addresses/{addressId}.
|
|
||||||
type UpdateAddressRequest struct {
|
|
||||||
Label *string `json:"label,omitempty"`
|
|
||||||
FullName *string `json:"fullName,omitempty"`
|
|
||||||
AddressLine1 *string `json:"addressLine1,omitempty"`
|
|
||||||
AddressLine2 *string `json:"addressLine2,omitempty"`
|
|
||||||
City *string `json:"city,omitempty"`
|
|
||||||
State *string `json:"state,omitempty"`
|
|
||||||
Zip *string `json:"zip,omitempty"`
|
|
||||||
Country *string `json:"country,omitempty"`
|
|
||||||
Phone *string `json:"phone,omitempty"`
|
|
||||||
IsDefaultShipping *bool `json:"isDefaultShipping,omitempty"`
|
|
||||||
IsDefaultBilling *bool `json:"isDefaultBilling,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// UCP Profile handler (/.well-known/ucp)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
type Profile struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
Business BusinessInfo `json:"business,omitempty"`
|
|
||||||
Services map[string][]ServiceBinding `json:"services"`
|
|
||||||
Capabilities map[string][]CapabilityDecl `json:"capabilities"`
|
|
||||||
PaymentHandlers map[string][]PaymentHandlerDecl `json:"payment_handlers,omitempty"`
|
|
||||||
SupportedVersions []string `json:"supported_versions,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BusinessInfo describes the business behind a UCP profile.
|
|
||||||
type BusinessInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceBinding describes a transport binding for a service namespace.
|
|
||||||
type ServiceBinding struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
Spec string `json:"spec"`
|
|
||||||
Transport string `json:"transport"`
|
|
||||||
Schema string `json:"schema"`
|
|
||||||
Endpoint string `json:"endpoint"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CapabilityDecl describes a single capability version.
|
|
||||||
type CapabilityDecl struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
Spec string `json:"spec"`
|
|
||||||
Schema string `json:"schema"`
|
|
||||||
Extends string `json:"extends,omitempty"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Operations []OpDef `json:"operations,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpDef describes an operation method+path for a capability.
|
|
||||||
type OpDef struct {
|
|
||||||
Method string `json:"method"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PaymentHandlerDecl describes a payment handler specification.
|
|
||||||
type PaymentHandlerDecl struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
Spec string `json:"spec"`
|
|
||||||
Schema string `json:"schema"`
|
|
||||||
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
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// writeJSON encodes v as JSON and writes it with the given status code.
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
json.NewEncoder(w).Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// errorBody is a simple {"error": "…"} response.
|
|
||||||
type errorBody struct {
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeError writes a JSON error response.
|
|
||||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
||||||
writeJSON(w, status, errorBody{Error: msg})
|
|
||||||
}
|
|
||||||
+82
-17
@@ -1,24 +1,38 @@
|
|||||||
package actor
|
package actor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.k6n.net/mats/platform/uid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GrainId is a 64-bit grain identifier with a compact base62 string form.
|
type GrainId uint64
|
||||||
type GrainId uid.ID
|
|
||||||
|
|
||||||
// String returns the canonical base62 encoding.
|
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
func (id GrainId) String() string { return uid.ID(id).String() }
|
|
||||||
|
|
||||||
// MarshalJSON encodes the id as a JSON string.
|
// Reverse lookup (0xFF marks invalid)
|
||||||
func (id GrainId) MarshalJSON() ([]byte, error) {
|
var base62Rev [256]byte
|
||||||
return json.Marshal(uid.ID(id).String())
|
|
||||||
|
func init() {
|
||||||
|
for i := range base62Rev {
|
||||||
|
base62Rev[i] = 0xFF
|
||||||
|
}
|
||||||
|
for i := 0; i < len(base62Alphabet); i++ {
|
||||||
|
base62Rev[base62Alphabet[i]] = byte(i)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalJSON decodes from a base62 JSON string.
|
// 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.
|
||||||
|
func (id GrainId) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(id.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
||||||
func (id *GrainId) UnmarshalJSON(data []byte) error {
|
func (id *GrainId) UnmarshalJSON(data []byte) error {
|
||||||
var s string
|
var s string
|
||||||
if err := json.Unmarshal(data, &s); err != nil {
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
@@ -26,7 +40,7 @@ func (id *GrainId) UnmarshalJSON(data []byte) error {
|
|||||||
}
|
}
|
||||||
parsed, ok := ParseGrainId(s)
|
parsed, ok := ParseGrainId(s)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("invalid grain id: %q", s)
|
return fmt.Errorf("invalid cart id: %q", s)
|
||||||
}
|
}
|
||||||
*id = parsed
|
*id = parsed
|
||||||
return nil
|
return nil
|
||||||
@@ -34,11 +48,23 @@ func (id *GrainId) UnmarshalJSON(data []byte) error {
|
|||||||
|
|
||||||
// NewGrainId generates a new cryptographically random non-zero 64-bit id.
|
// NewGrainId generates a new cryptographically random non-zero 64-bit id.
|
||||||
func NewGrainId() (GrainId, error) {
|
func NewGrainId() (GrainId, error) {
|
||||||
id, err := uid.New()
|
var b [8]byte
|
||||||
if err != nil {
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
return 0, fmt.Errorf("NewGrainId: %w", err)
|
return 0, fmt.Errorf("NewGrainId: %w", err)
|
||||||
}
|
}
|
||||||
return GrainId(id), nil
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustNewGrainId panics if generation fails.
|
// MustNewGrainId panics if generation fails.
|
||||||
@@ -51,16 +77,55 @@ func MustNewGrainId() GrainId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseGrainId parses a base62 string into a GrainId.
|
// ParseGrainId parses a base62 string into a GrainId.
|
||||||
|
// Returns (0,false) for invalid input.
|
||||||
func ParseGrainId(s string) (GrainId, bool) {
|
func ParseGrainId(s string) (GrainId, bool) {
|
||||||
id, ok := uid.Parse(s)
|
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
||||||
return GrainId(id), ok
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustParseGrainId panics on invalid base62 input.
|
// MustParseGrainId panics on invalid base62 input.
|
||||||
func MustParseGrainId(s string) GrainId {
|
func MustParseGrainId(s string) GrainId {
|
||||||
id, ok := ParseGrainId(s)
|
id, ok := ParseGrainId(s)
|
||||||
if !ok {
|
if !ok {
|
||||||
panic(fmt.Sprintf("invalid grain id: %q", s))
|
panic(fmt.Sprintf("invalid cart id: %q", s))
|
||||||
}
|
}
|
||||||
return id
|
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
|
||||||
|
}
|
||||||
|
|||||||
+32
-167
@@ -13,31 +13,16 @@ import (
|
|||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultWriterIdleTTL = 5 * time.Minute
|
|
||||||
|
|
||||||
type QueueEvent struct {
|
type QueueEvent struct {
|
||||||
TimeStamp time.Time
|
TimeStamp time.Time
|
||||||
Message proto.Message
|
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 {
|
type DiskStorage[V any] struct {
|
||||||
*StateStorage
|
*StateStorage
|
||||||
path string
|
path string
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
queue *sync.Map // map[uint64][]QueueEvent
|
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 {
|
type LogStorage[V any] interface {
|
||||||
@@ -47,141 +32,10 @@ type LogStorage[V any] interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
|
func NewDiskStorage[V any](path string, registry MutationRegistry) *DiskStorage[V] {
|
||||||
s := &DiskStorage[V]{
|
return &DiskStorage[V]{
|
||||||
StateStorage: NewState(registry),
|
StateStorage: NewState(registry),
|
||||||
path: path,
|
path: path,
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
writers: make(map[uint64]*diskLogWriter),
|
|
||||||
writerIdleTTL: defaultWriterIdleTTL,
|
|
||||||
}
|
|
||||||
go s.purgeLoop()
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
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 {
|
|
||||||
if err := s.Append(w.file, evt.Message, evt.TimeStamp); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
if err := s.Append(w.file, m, now); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,16 +59,22 @@ func (s *DiskStorage[V]) save() {
|
|||||||
lines := 0
|
lines := 0
|
||||||
s.queue.Range(func(key, value any) bool {
|
s.queue.Range(func(key, value any) bool {
|
||||||
id := key.(uint64)
|
id := key.(uint64)
|
||||||
qe, ok := value.([]QueueEvent)
|
path := s.logPath(id)
|
||||||
if !ok {
|
fh, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
s.queue.Delete(id)
|
if err != nil {
|
||||||
|
log.Printf("failed to open event log file: %v", err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if err := s.writeEvents(id, qe); err != nil {
|
defer fh.Close()
|
||||||
log.Printf("failed to append events for grain %d: %v", id, err)
|
|
||||||
return true
|
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++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lines += len(qe)
|
|
||||||
carts++
|
carts++
|
||||||
s.queue.Delete(id)
|
s.queue.Delete(id)
|
||||||
return true
|
return true
|
||||||
@@ -270,7 +130,6 @@ func (s *DiskStorage[V]) Close() {
|
|||||||
if s.queue != nil {
|
if s.queue != nil {
|
||||||
s.save()
|
s.save()
|
||||||
}
|
}
|
||||||
s.closeWriters()
|
|
||||||
close(s.done)
|
close(s.done)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,12 +145,18 @@ func (s *DiskStorage[V]) AppendMutations(id uint64, msg ...proto.Message) error
|
|||||||
}
|
}
|
||||||
s.queue.Store(id, queue)
|
s.queue.Store(id, queue)
|
||||||
return nil
|
return nil
|
||||||
|
} else {
|
||||||
|
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)
|
||||||
|
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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,8 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/control"
|
messages "git.k6n.net/go-cart-actor/proto/control"
|
||||||
|
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/metric"
|
"go.opentelemetry.io/otel/metric"
|
||||||
@@ -30,6 +31,7 @@ const name = "grpc_server"
|
|||||||
var (
|
var (
|
||||||
tracer = otel.Tracer(name)
|
tracer = otel.Tracer(name)
|
||||||
meter = otel.Meter(name)
|
meter = otel.Meter(name)
|
||||||
|
logger = otelslog.NewLogger(name)
|
||||||
pingCalls metric.Int64Counter
|
pingCalls metric.Int64Counter
|
||||||
negotiateCalls metric.Int64Counter
|
negotiateCalls metric.Int64Counter
|
||||||
getLocalActorIdsCalls metric.Int64Counter
|
getLocalActorIdsCalls metric.Int64Counter
|
||||||
@@ -86,6 +88,7 @@ func (s *ControlServer[V]) AnnounceOwnership(ctx context.Context, req *messages.
|
|||||||
attribute.String("host", req.Host),
|
attribute.String("host", req.Host),
|
||||||
attribute.Int("id_count", len(req.Ids)),
|
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)))
|
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)
|
||||||
@@ -136,6 +139,7 @@ func (s *ControlServer[V]) AnnounceExpiry(ctx context.Context, req *messages.Exp
|
|||||||
attribute.String("host", req.Host),
|
attribute.String("host", req.Host),
|
||||||
attribute.Int("id_count", len(req.Ids)),
|
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)))
|
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)
|
||||||
@@ -211,6 +215,7 @@ func (s *ControlServer[V]) Negotiate(ctx context.Context, req *messages.Negotiat
|
|||||||
attribute.String("component", "controlplane"),
|
attribute.String("component", "controlplane"),
|
||||||
attribute.Int("known_hosts_count", len(req.KnownHosts)),
|
attribute.Int("known_hosts_count", len(req.KnownHosts)),
|
||||||
)
|
)
|
||||||
|
logger.InfoContext(ctx, "negotiate", "known_hosts_count", len(req.KnownHosts))
|
||||||
negotiateCalls.Add(ctx, 1)
|
negotiateCalls.Add(ctx, 1)
|
||||||
|
|
||||||
s.pool.Negotiate(req.KnownHosts)
|
s.pool.Negotiate(req.KnownHosts)
|
||||||
@@ -226,6 +231,7 @@ func (s *ControlServer[V]) GetLocalActorIds(ctx context.Context, req *messages.E
|
|||||||
attribute.String("component", "controlplane"),
|
attribute.String("component", "controlplane"),
|
||||||
attribute.Int("id_count", len(ids)),
|
attribute.Int("id_count", len(ids)),
|
||||||
)
|
)
|
||||||
|
logger.InfoContext(ctx, "get local actor ids", "id_count", len(ids))
|
||||||
getLocalActorIdsCalls.Add(ctx, 1)
|
getLocalActorIdsCalls.Add(ctx, 1)
|
||||||
|
|
||||||
return &messages.ActorIdsReply{Ids: ids}, nil
|
return &messages.ActorIdsReply{Ids: ids}, nil
|
||||||
@@ -240,6 +246,7 @@ func (s *ControlServer[V]) Closing(ctx context.Context, req *messages.ClosingNot
|
|||||||
attribute.String("component", "controlplane"),
|
attribute.String("component", "controlplane"),
|
||||||
attribute.String("host", host),
|
attribute.String("host", host),
|
||||||
)
|
)
|
||||||
|
logger.InfoContext(ctx, "closing notice", "host", host)
|
||||||
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
closingCalls.Add(ctx, 1, metric.WithAttributes(attribute.String("host", host)))
|
||||||
|
|
||||||
if host != "" {
|
if host != "" {
|
||||||
@@ -284,14 +291,9 @@ func NewControlServer[V any](config ServerConfig, pool GrainPool[V]) (*grpc.Serv
|
|||||||
reflection.Register(grpcServer)
|
reflection.Register(grpcServer)
|
||||||
|
|
||||||
log.Printf("gRPC server listening as %s on %s", pool.Hostname(), config.Addr)
|
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() {
|
go func() {
|
||||||
if err := grpcServer.Serve(lis); err != nil {
|
if err := grpcServer.Serve(lis); err != nil {
|
||||||
log.Printf("gRPC server %s serve error: %v", config.Addr, err)
|
log.Fatalf("failed to serve gRPC: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||||
control_plane_messages "git.k6n.net/mats/go-cart-actor/proto/control"
|
control_plane_messages "git.k6n.net/go-cart-actor/proto/control"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package actor
|
|
||||||
|
|
||||||
import "sync"
|
|
||||||
|
|
||||||
// keyedMutex provides one logical mutex per key (grain id). It is the
|
|
||||||
// mechanism that enforces the core actor guarantee: a single grain only ever
|
|
||||||
// processes one message (spawn / mutation / read) at a time, while distinct
|
|
||||||
// grains run fully in parallel.
|
|
||||||
//
|
|
||||||
// Locks are reference counted and removed once no caller holds or waits on
|
|
||||||
// them, so memory stays proportional to in-flight grains rather than the total
|
|
||||||
// number of grains ever touched.
|
|
||||||
type keyedMutex struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
locks map[uint64]*keyedMutexEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
type keyedMutexEntry struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
refs int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newKeyedMutex() *keyedMutex {
|
|
||||||
return &keyedMutex{locks: make(map[uint64]*keyedMutexEntry)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// lock acquires the mutex for id and returns an unlock function. The returned
|
|
||||||
// function must be called exactly once.
|
|
||||||
func (k *keyedMutex) lock(id uint64) func() {
|
|
||||||
k.mu.Lock()
|
|
||||||
entry, ok := k.locks[id]
|
|
||||||
if !ok {
|
|
||||||
entry = &keyedMutexEntry{}
|
|
||||||
k.locks[id] = entry
|
|
||||||
}
|
|
||||||
entry.refs++
|
|
||||||
k.mu.Unlock()
|
|
||||||
|
|
||||||
entry.mu.Lock()
|
|
||||||
|
|
||||||
return func() {
|
|
||||||
entry.mu.Unlock()
|
|
||||||
k.mu.Lock()
|
|
||||||
entry.refs--
|
|
||||||
if entry.refs == 0 {
|
|
||||||
delete(k.locks, id)
|
|
||||||
}
|
|
||||||
k.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+15
-56
@@ -1,88 +1,47 @@
|
|||||||
package actor
|
package actor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/platform/event"
|
"github.com/matst80/slask-finder/pkg/messaging"
|
||||||
"git.k6n.net/mats/platform/rabbit"
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
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 {
|
type LogListener interface {
|
||||||
AppendMutations(id uint64, msg ...ApplyResult)
|
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 {
|
type AmqpListener struct {
|
||||||
conn *amqp.Connection
|
conn *amqp.Connection
|
||||||
grain string
|
transformer func(id uint64, msg []ApplyResult) (any, error)
|
||||||
typ event.Type
|
|
||||||
transform func(id uint64, msg []ApplyResult) (any, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAmqpListener(conn *amqp.Connection, grain string, transform func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
func NewAmqpListener(conn *amqp.Connection, transformer func(id uint64, msg []ApplyResult) (any, error)) *AmqpListener {
|
||||||
return &AmqpListener{conn: conn, grain: grain, typ: MutationType(grain), transform: transform}
|
return &AmqpListener{
|
||||||
|
conn: conn,
|
||||||
|
transformer: transformer,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefineTopics declares the mutations topic exchange so publishes succeed even
|
|
||||||
// before any consumer binds. Call once at startup.
|
|
||||||
func (l *AmqpListener) DefineTopics() {
|
func (l *AmqpListener) DefineTopics() {
|
||||||
ch, err := l.conn.Channel()
|
ch, err := l.conn.Channel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("mutation feed (%s): open channel: %v", l.grain, err)
|
log.Fatalf("Failed to open a channel: %v", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer ch.Close()
|
defer ch.Close()
|
||||||
if err := ch.ExchangeDeclare(MutationExchange, "topic", true, false, false, false, nil); err != nil {
|
if err := messaging.DefineTopic(ch, "cart", "mutation"); err != nil {
|
||||||
log.Printf("mutation feed (%s): declare exchange: %v", l.grain, err)
|
log.Fatalf("Failed to declare topic mutation: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
func (l *AmqpListener) AppendMutations(id uint64, msg ...ApplyResult) {
|
||||||
payload, err := l.transform(id, msg)
|
data, err := l.transformer(id, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("mutation feed (%s): transform: %v", l.grain, err)
|
log.Printf("Failed to transform mutation event: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
body, err := json.Marshal(payload)
|
err = messaging.SendChange(l.conn, "cart", "mutation", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("mutation feed (%s): marshal: %v", l.grain, err)
|
log.Printf("Failed to send mutation event: %v", 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,14 +41,16 @@ type MutationRegistry interface {
|
|||||||
Create(typeName string) (proto.Message, bool)
|
Create(typeName string) (proto.Message, bool)
|
||||||
GetTypeName(msg proto.Message) (string, bool)
|
GetTypeName(msg proto.Message) (string, bool)
|
||||||
RegisterProcessor(processor ...MutationProcessor)
|
RegisterProcessor(processor ...MutationProcessor)
|
||||||
//GetStorageEvent(msg proto.Message) StorageEvent
|
RegisterTrigger(trigger ...TriggerHandler)
|
||||||
//FromStorageEvent(event StorageEvent) (proto.Message, error)
|
SetEventChannel(ch chan<- ApplyResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProtoMutationRegistry struct {
|
type ProtoMutationRegistry struct {
|
||||||
mutationRegistryMu sync.RWMutex
|
mutationRegistryMu sync.RWMutex
|
||||||
mutationRegistry map[reflect.Type]MutationHandler
|
mutationRegistry map[reflect.Type]MutationHandler
|
||||||
|
triggers map[reflect.Type][]TriggerHandler
|
||||||
processors []MutationProcessor
|
processors []MutationProcessor
|
||||||
|
eventChannel chan<- ApplyResult
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -84,6 +86,26 @@ func WithTotals() MutationOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TriggerHandler interface {
|
||||||
|
Handle(state any, msg proto.Message) []proto.Message
|
||||||
|
Name() string
|
||||||
|
Type() reflect.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisteredTrigger[V any, I proto.Message] struct {
|
||||||
|
name string
|
||||||
|
handler func(state any, msg proto.Message) []proto.Message
|
||||||
|
msgType reflect.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTrigger[V any, I proto.Message](name string, handler func(state any, msg proto.Message) []proto.Message) *RegisteredTrigger[V, I] {
|
||||||
|
return &RegisteredTrigger[V, I]{
|
||||||
|
name: name,
|
||||||
|
handler: handler,
|
||||||
|
msgType: reflect.TypeOf((*I)(nil)).Elem(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type MutationHandler interface {
|
type MutationHandler interface {
|
||||||
Handle(state any, msg proto.Message) error
|
Handle(state any, msg proto.Message) error
|
||||||
Name() string
|
Name() string
|
||||||
@@ -99,21 +121,6 @@ type RegisteredMutation[V any, T proto.Message] struct {
|
|||||||
msgType reflect.Type
|
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] {
|
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().
|
// 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)
|
// This avoids relying on reflect.TypeFor (which can yield unexpected results in some toolchains)
|
||||||
@@ -124,7 +131,8 @@ func NewMutation[V any, T proto.Message](handler func(*V, T) error) *RegisteredM
|
|||||||
if rt != nil && rt.Kind() == reflect.Pointer {
|
if rt != nil && rt.Kind() == reflect.Pointer {
|
||||||
return reflect.New(rt.Elem()).Interface().(proto.Message)
|
return reflect.New(rt.Elem()).Interface().(proto.Message)
|
||||||
}
|
}
|
||||||
panic(fmt.Sprintf("NewMutation: T must be a pointer to a proto message, got %v", rt))
|
log.Fatalf("expected to create proto message got %+v", rt)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
instance := create()
|
instance := create()
|
||||||
rt := reflect.TypeOf(instance)
|
rt := reflect.TypeOf(instance)
|
||||||
@@ -159,6 +167,7 @@ func NewMutationRegistry() MutationRegistry {
|
|||||||
return &ProtoMutationRegistry{
|
return &ProtoMutationRegistry{
|
||||||
mutationRegistry: make(map[reflect.Type]MutationHandler),
|
mutationRegistry: make(map[reflect.Type]MutationHandler),
|
||||||
mutationRegistryMu: sync.RWMutex{},
|
mutationRegistryMu: sync.RWMutex{},
|
||||||
|
triggers: make(map[reflect.Type][]TriggerHandler),
|
||||||
processors: make([]MutationProcessor, 0),
|
processors: make([]MutationProcessor, 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,6 +185,24 @@ func (r *ProtoMutationRegistry) RegisterMutations(handlers ...MutationHandler) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ProtoMutationRegistry) RegisterTrigger(triggers ...TriggerHandler) {
|
||||||
|
r.mutationRegistryMu.Lock()
|
||||||
|
defer r.mutationRegistryMu.Unlock()
|
||||||
|
|
||||||
|
for _, trigger := range triggers {
|
||||||
|
existingTriggers, ok := r.triggers[trigger.Type()]
|
||||||
|
if !ok {
|
||||||
|
r.triggers[trigger.Type()] = []TriggerHandler{trigger}
|
||||||
|
} else {
|
||||||
|
r.triggers[trigger.Type()] = append(existingTriggers, trigger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProtoMutationRegistry) SetEventChannel(ch chan<- ApplyResult) {
|
||||||
|
r.eventChannel = ch
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ProtoMutationRegistry) GetTypeName(msg proto.Message) (string, bool) {
|
func (r *ProtoMutationRegistry) GetTypeName(msg proto.Message) (string, bool) {
|
||||||
r.mutationRegistryMu.RLock()
|
r.mutationRegistryMu.RLock()
|
||||||
defer r.mutationRegistryMu.RUnlock()
|
defer r.mutationRegistryMu.RUnlock()
|
||||||
@@ -258,6 +285,25 @@ func (r *ProtoMutationRegistry) Apply(ctx context.Context, grain any, msg ...pro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
msgSpan.RecordError(err)
|
msgSpan.RecordError(err)
|
||||||
}
|
}
|
||||||
|
if r.eventChannel != nil {
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
// Handle panic from sending to closed channel
|
||||||
|
log.Printf("event channel closed: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for _, tr := range r.triggers[rt] {
|
||||||
|
for _, msg := range tr.Handle(grain, m) {
|
||||||
|
select {
|
||||||
|
case r.eventChannel <- msg:
|
||||||
|
default:
|
||||||
|
// Channel full or no receiver, skip to avoid blocking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
|
results = append(results, ApplyResult{Error: err, Type: rt.Name(), Mutation: m})
|
||||||
}
|
}
|
||||||
msgSpan.End()
|
msgSpan.End()
|
||||||
@@ -280,6 +326,7 @@ func (r *ProtoMutationRegistry) Apply(ctx context.Context, grain any, msg ...pro
|
|||||||
return results, res.Error
|
return results, res.Error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
cart_messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
cart_messages "git.k6n.net/go-cart-actor/proto/cart"
|
||||||
)
|
)
|
||||||
|
|
||||||
type cartState struct {
|
type cartState struct {
|
||||||
@@ -104,31 +105,103 @@ func TestRegisteredMutationBasics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// func TestConcurrentSafeRegistrationLookup(t *testing.T) {
|
func TestEventChannel(t *testing.T) {
|
||||||
// // This test is light-weight; it ensures locks don't deadlock under simple concurrent access.
|
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
||||||
// reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
|
||||||
// mut := NewMutation[cartState, *messages.Noop](
|
|
||||||
// func(state *cartState, msg *messages.Noop) error { state.calls++; return nil },
|
|
||||||
// func() *messages.Noop { return &messages.Noop{} },
|
|
||||||
// )
|
|
||||||
// reg.RegisterMutations(mut)
|
|
||||||
|
|
||||||
// done := make(chan struct{})
|
addItemMutation := NewMutation(
|
||||||
// const workers = 25
|
func(state *cartState, msg *cart_messages.AddItem) error {
|
||||||
// for i := 0; i < workers; i++ {
|
state.calls++
|
||||||
// go func() {
|
return nil
|
||||||
// for j := 0; j < 100; j++ {
|
},
|
||||||
// _, _ = reg.Create("Noop")
|
)
|
||||||
// _, _ = reg.GetTypeName(&messages.Noop{})
|
|
||||||
// _ = reg.Apply(&cartState{}, &messages.Noop{})
|
|
||||||
// }
|
|
||||||
// done <- struct{}{}
|
|
||||||
// }()
|
|
||||||
// }
|
|
||||||
|
|
||||||
// for i := 0; i < workers; i++ {
|
reg.RegisterMutations(addItemMutation)
|
||||||
// <-done
|
|
||||||
// }
|
eventCh := make(chan ApplyResult, 10)
|
||||||
// }
|
reg.SetEventChannel(eventCh)
|
||||||
|
|
||||||
|
state := &cartState{}
|
||||||
|
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
||||||
|
results, err := reg.Apply(context.Background(), state, add)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Apply returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("expected 1 result, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive from channel with timeout
|
||||||
|
select {
|
||||||
|
case res := <-eventCh:
|
||||||
|
if res.Type != "AddItem" {
|
||||||
|
t.Fatalf("expected type AddItem, got %s", res.Type)
|
||||||
|
}
|
||||||
|
if res.Error != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", res.Error)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatalf("expected to receive event on channel within timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventChannelClosed(t *testing.T) {
|
||||||
|
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
||||||
|
|
||||||
|
addItemMutation := NewMutation(
|
||||||
|
func(state *cartState, msg *cart_messages.AddItem) error {
|
||||||
|
state.calls++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
reg.RegisterMutations(addItemMutation)
|
||||||
|
|
||||||
|
eventCh := make(chan ApplyResult, 10)
|
||||||
|
reg.SetEventChannel(eventCh)
|
||||||
|
|
||||||
|
close(eventCh) // Close the channel to simulate external close
|
||||||
|
|
||||||
|
state := &cartState{}
|
||||||
|
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
||||||
|
// This should not panic due to recover in goroutine
|
||||||
|
results, err := reg.Apply(context.Background(), state, add)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Apply returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("expected 1 result, got %d", len(results))
|
||||||
|
}
|
||||||
|
// Test passes if no panic occurs
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventChannelUnbufferedNoListener(t *testing.T) {
|
||||||
|
reg := NewMutationRegistry().(*ProtoMutationRegistry)
|
||||||
|
|
||||||
|
addItemMutation := NewMutation(
|
||||||
|
func(state *cartState, msg *cart_messages.AddItem) error {
|
||||||
|
state.calls++
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
reg.RegisterMutations(addItemMutation)
|
||||||
|
|
||||||
|
eventCh := make(chan ApplyResult) // unbuffered
|
||||||
|
reg.SetEventChannel(eventCh)
|
||||||
|
|
||||||
|
// No goroutine reading from eventCh
|
||||||
|
|
||||||
|
state := &cartState{}
|
||||||
|
add := &cart_messages.AddItem{ItemId: 42, Quantity: 3, Sku: "ABC"}
|
||||||
|
results, err := reg.Apply(context.Background(), state, add)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Apply returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("expected 1 result, got %d", len(results))
|
||||||
|
}
|
||||||
|
// Since no listener, the send should go to default and not block
|
||||||
|
// Test passes if Apply completes without hanging
|
||||||
|
}
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ type SimpleGrainPool[V any] struct {
|
|||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
poolSize int
|
poolSize int
|
||||||
|
|
||||||
// grainLocks serializes spawn + mutation + read per grain id so that each
|
|
||||||
// grain processes a single message at a time (the actor guarantee).
|
|
||||||
grainLocks *keyedMutex
|
|
||||||
|
|
||||||
// Cluster coordination --------------------------------------------------
|
// Cluster coordination --------------------------------------------------
|
||||||
hostname string
|
hostname string
|
||||||
remoteMu sync.RWMutex
|
remoteMu sync.RWMutex
|
||||||
@@ -43,9 +39,6 @@ type GrainPoolConfig[V any] struct {
|
|||||||
Hostname string
|
Hostname string
|
||||||
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
Spawn func(ctx context.Context, id uint64) (Grain[V], error)
|
||||||
SpawnHost func(host string) (Host[V], error)
|
SpawnHost func(host string) (Host[V], error)
|
||||||
// Destroy is an optional cleanup hook called when a grain is evicted on TTL
|
|
||||||
// (e.g. to detach inventory subscriptions wired into its mutations). Leave
|
|
||||||
// nil when there is nothing to clean up — purge() skips a nil Destroy.
|
|
||||||
Destroy func(grain Grain[V]) error
|
Destroy func(grain Grain[V]) error
|
||||||
TTL time.Duration
|
TTL time.Duration
|
||||||
PoolSize int
|
PoolSize int
|
||||||
@@ -66,7 +59,6 @@ func NewSimpleGrainPool[V any](config GrainPoolConfig[V]) (*SimpleGrainPool[V],
|
|||||||
hostname: config.Hostname,
|
hostname: config.Hostname,
|
||||||
remoteOwners: make(map[uint64]Host[V]),
|
remoteOwners: make(map[uint64]Host[V]),
|
||||||
remoteHosts: make(map[string]Host[V]),
|
remoteHosts: make(map[string]Host[V]),
|
||||||
grainLocks: newKeyedMutex(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
p.purgeTicker = time.NewTicker(time.Minute)
|
p.purgeTicker = time.NewTicker(time.Minute)
|
||||||
@@ -99,10 +91,8 @@ func (p *SimpleGrainPool[V]) purge() {
|
|||||||
for id, grain := range p.grains {
|
for id, grain := range p.grains {
|
||||||
if grain.GetLastAccess().Before(purgeLimit) {
|
if grain.GetLastAccess().Before(purgeLimit) {
|
||||||
purgedIds = append(purgedIds, id)
|
purgedIds = append(purgedIds, id)
|
||||||
if p.destroy != nil {
|
if err := p.destroy(grain); err != nil {
|
||||||
if err := p.destroy(grain); err != nil {
|
log.Printf("failed to destroy grain %d: %v", id, err)
|
||||||
log.Printf("failed to destroy grain %d: %v", id, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(p.grains, id)
|
delete(p.grains, id)
|
||||||
@@ -197,17 +187,7 @@ func (p *SimpleGrainPool[V]) AddRemote(host string) (Host[V], error) {
|
|||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-check under the write lock: spawnHost opened a real connection above
|
|
||||||
// without holding the lock, so a concurrent AddRemote for the same host may
|
|
||||||
// have already registered one. Keep the existing entry and close ours to
|
|
||||||
// avoid leaking the gRPC connection / HTTP transport (and its file handles).
|
|
||||||
p.remoteMu.Lock()
|
p.remoteMu.Lock()
|
||||||
if existing, found := p.remoteHosts[host]; found {
|
|
||||||
p.remoteMu.Unlock()
|
|
||||||
go remote.Close()
|
|
||||||
return existing, nil
|
|
||||||
}
|
|
||||||
p.remoteHosts[host] = remote
|
p.remoteHosts[host] = remote
|
||||||
p.remoteMu.Unlock()
|
p.remoteMu.Unlock()
|
||||||
// connectedRemotes.Set(float64(p.RemoteCount()))
|
// connectedRemotes.Set(float64(p.RemoteCount()))
|
||||||
@@ -241,6 +221,7 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
|||||||
remote, exists := p.remoteHosts[host]
|
remote, exists := p.remoteHosts[host]
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
|
go remote.Close()
|
||||||
delete(p.remoteHosts, host)
|
delete(p.remoteHosts, host)
|
||||||
}
|
}
|
||||||
count := 0
|
count := 0
|
||||||
@@ -253,7 +234,6 @@ func (p *SimpleGrainPool[V]) RemoveHost(host string) {
|
|||||||
log.Printf("Removing host %s, grains: %d", host, count)
|
log.Printf("Removing host %s, grains: %d", host, count)
|
||||||
p.remoteMu.Unlock()
|
p.remoteMu.Unlock()
|
||||||
|
|
||||||
// Close once, outside the lock.
|
|
||||||
if exists {
|
if exists {
|
||||||
remote.Close()
|
remote.Close()
|
||||||
}
|
}
|
||||||
@@ -295,9 +275,7 @@ func (p *SimpleGrainPool[V]) pingLoop(remote Host[V]) {
|
|||||||
if !remote.Ping() {
|
if !remote.Ping() {
|
||||||
if !remote.IsHealthy() {
|
if !remote.IsHealthy() {
|
||||||
log.Printf("Remote %s unhealthy, removing", remote.Name())
|
log.Printf("Remote %s unhealthy, removing", remote.Name())
|
||||||
// Remove only this host. Previously this called p.Close(),
|
p.Close()
|
||||||
// which tore down every remote connection and stopped the
|
|
||||||
// purge ticker for the whole pool.
|
|
||||||
p.RemoveHost(remote.Name())
|
p.RemoveHost(remote.Name())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -366,9 +344,10 @@ func (p *SimpleGrainPool[V]) forAllHosts(fn func(Host[V])) {
|
|||||||
|
|
||||||
wg := sync.WaitGroup{}
|
wg := sync.WaitGroup{}
|
||||||
for _, host := range rh {
|
for _, host := range rh {
|
||||||
|
|
||||||
wg.Go(func() { fn(host) })
|
wg.Go(func() { fn(host) })
|
||||||
|
|
||||||
}
|
}
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
for name, host := range rh {
|
for name, host := range rh {
|
||||||
if !host.IsHealthy() {
|
if !host.IsHealthy() {
|
||||||
@@ -418,12 +397,6 @@ func (p *SimpleGrainPool[V]) getOrClaimGrain(ctx context.Context, id uint64) (Gr
|
|||||||
|
|
||||||
// Apply applies a mutation to a grain.
|
// 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) (*MutationResult[V], error) {
|
||||||
// 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.
|
|
||||||
unlock := p.grainLocks.lock(id)
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
grain, err := p.getOrClaimGrain(ctx, id)
|
grain, err := p.getOrClaimGrain(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -456,9 +429,6 @@ func (p *SimpleGrainPool[V]) Apply(ctx context.Context, id uint64, mutation ...p
|
|||||||
|
|
||||||
// Get returns the current state of a grain.
|
// 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) (*V, error) {
|
||||||
unlock := p.grainLocks.lock(id)
|
|
||||||
defer unlock()
|
|
||||||
|
|
||||||
grain, err := p.getOrClaimGrain(ctx, id)
|
grain, err := p.getOrClaimGrain(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
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) {
|
|
||||||
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) {
|
|
||||||
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 })
|
|
||||||
|
|
||||||
pool.localMu.Lock()
|
|
||||||
pool.grains[7] = &testGrain{id: 7, accessed: time.Now()}
|
|
||||||
pool.localMu.Unlock()
|
|
||||||
|
|
||||||
if err := pool.HandleOwnershipChange(host.name, []uint64{7}); 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
// Package backofficeadmin is the commerce control-plane admin surface for the
|
|
||||||
// cart-actor system: read-only cart/checkout history (replayed from event logs),
|
|
||||||
// inventory CRUD (Redis), promotions/vouchers JSON, and a WebSocket feed of cart
|
|
||||||
// mutations consumed from RabbitMQ.
|
|
||||||
//
|
|
||||||
// It is extracted from the former cmd/backoffice main package so the same admin
|
|
||||||
// surface can run standalone (cmd/backoffice) or be mounted into the all-in-one
|
|
||||||
// backoffice host. Because it imports the cart/checkout domain (and the private
|
|
||||||
// git.k6n.net module), the host only compiles it into its implementation-specific
|
|
||||||
// (commerce) build variant.
|
|
||||||
package backofficeadmin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"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/platform/rabbit"
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CartFileInfo is the metadata returned by the cart listing endpoint.
|
|
||||||
type CartFileInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CartId cart.CartId `json:"cartId"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
Modified time.Time `json:"modified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckoutFileInfo is the metadata returned by the checkout listing endpoint.
|
|
||||||
type CheckoutFileInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
CheckoutId checkout.CheckoutId `json:"checkoutId"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
Modified time.Time `json:"modified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config parameterizes the commerce admin App.
|
|
||||||
type Config struct {
|
|
||||||
// DataDir holds the cart event logs (default "data").
|
|
||||||
DataDir string
|
|
||||||
// CheckoutDataDir holds the checkout event logs (default "checkout-data").
|
|
||||||
CheckoutDataDir string
|
|
||||||
// ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled).
|
|
||||||
ProfileDataDir string
|
|
||||||
}
|
|
||||||
|
|
||||||
// App is the commerce admin control plane. Construct with New, register its
|
|
||||||
// HTTP surface with RegisterRoutes, run background work with Start.
|
|
||||||
type App struct {
|
|
||||||
fs *FileServer
|
|
||||||
hub *Hub
|
|
||||||
cs *CustomerServer
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
if cfg.CheckoutDataDir == "" {
|
|
||||||
cfg.CheckoutDataDir = "checkout-data"
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = os.MkdirAll(cfg.DataDir, 0755)
|
|
||||||
reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil))
|
|
||||||
diskStorage := actor.NewDiskStorage[cart.CartGrain](cfg.DataDir, reg)
|
|
||||||
|
|
||||||
_ = os.MkdirAll(cfg.CheckoutDataDir, 0755)
|
|
||||||
regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
|
|
||||||
diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](cfg.CheckoutDataDir, regCheckout)
|
|
||||||
|
|
||||||
// Optional customer/profile storage.
|
|
||||||
var customerSrv *CustomerServer
|
|
||||||
if cfg.ProfileDataDir != "" {
|
|
||||||
_ = os.MkdirAll(cfg.ProfileDataDir, 0755)
|
|
||||||
regProfile := profile.NewProfileMutationRegistry()
|
|
||||||
profileStorage := actor.NewDiskStorage[profile.ProfileGrain](cfg.ProfileDataDir, regProfile)
|
|
||||||
customerSrv = NewCustomerServer(cfg.ProfileDataDir, profileStorage)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &App{
|
|
||||||
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, diskStorage, diskStorageCheckout),
|
|
||||||
hub: NewHub(),
|
|
||||||
cs: customerSrv,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterRoutes registers the commerce admin HTTP surface on mux. It does not
|
|
||||||
// apply auth or CORS — the composing host (or standalone main) wraps the mux
|
|
||||||
// with those.
|
|
||||||
func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
|
||||||
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("/promotions", a.fs.PromotionsHandler)
|
|
||||||
mux.HandleFunc("/vouchers", a.fs.VoucherHandler)
|
|
||||||
mux.HandleFunc("/promotion/{id}", a.fs.PromotionPartHandler)
|
|
||||||
mux.HandleFunc("/ws", a.hub.ServeWS)
|
|
||||||
// Optional customer endpoints (only mounted when ProfileDataDir is configured).
|
|
||||||
if a.cs != nil {
|
|
||||||
a.cs.RegisterRoutes(mux)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Start runs the WebSocket hub loop and, when conn is non-nil, the RabbitMQ
|
|
||||||
// mutation consumer that broadcasts cart mutations to connected clients. The
|
|
||||||
// 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 {
|
|
||||||
startMutationConsumer(ctx, conn, a.hub)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// startMutationConsumer subscribes to the cart "mutation" topic and forwards
|
|
||||||
// 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 {
|
|
||||||
log.Printf("mutation consumer: open channel: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 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 <- d.Body:
|
|
||||||
default:
|
|
||||||
// hub queue full: drop to avoid blocking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("mutation consumer: subscribe: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
package backofficeadmin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CustomerFileInfo is the metadata returned by the customer listing endpoint.
|
|
||||||
type CustomerFileInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
Modified time.Time `json:"modified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CustomerServer serves backoffice admin endpoints for customer profiles.
|
|
||||||
type CustomerServer struct {
|
|
||||||
dataDir string
|
|
||||||
storage actor.LogStorage[profile.ProfileGrain]
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCustomerServer builds a CustomerServer that lists and reads profile event logs.
|
|
||||||
func NewCustomerServer(dataDir string, storage actor.LogStorage[profile.ProfileGrain]) *CustomerServer {
|
|
||||||
return &CustomerServer{
|
|
||||||
dataDir: dataDir,
|
|
||||||
storage: storage,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RegisterRoutes mounts the customer admin endpoints on the given mux.
|
|
||||||
func (s *CustomerServer) RegisterRoutes(mux *http.ServeMux) {
|
|
||||||
mux.HandleFunc("GET /customers", s.handleListCustomers)
|
|
||||||
mux.HandleFunc("GET /customer/{id}", s.handleGetCustomer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CustomerInfoResponse is the per-customer info for the listing endpoint.
|
|
||||||
type CustomerInfoResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Email string `json:"email,omitempty"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
Modified string `json:"modified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CustomerDetailResponse is the full customer detail including linked carts/checkouts/orders/addresses.
|
|
||||||
type CustomerDetailResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
Email string `json:"email,omitempty"`
|
|
||||||
Phone string `json:"phone,omitempty"`
|
|
||||||
Language string `json:"language,omitempty"`
|
|
||||||
Currency string `json:"currency,omitempty"`
|
|
||||||
AvatarUrl string `json:"avatarUrl,omitempty"`
|
|
||||||
Carts []profile.LinkedCart `json:"carts,omitempty"`
|
|
||||||
Checkouts []profile.LinkedCheckout `json:"checkouts,omitempty"`
|
|
||||||
Orders []profile.LinkedOrder `json:"orders,omitempty"`
|
|
||||||
Addresses []profile.StoredAddress `json:"addresses,omitempty"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
Modified string `json:"modified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleListCustomers lists all customer profiles.
|
|
||||||
func (s *CustomerServer) handleListCustomers(w http.ResponseWriter, r *http.Request) {
|
|
||||||
list, err := s.listProfileFiles()
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"count": len(list),
|
|
||||||
"customers": list,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetCustomer returns full detail for a single customer profile.
|
|
||||||
func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Request) {
|
|
||||||
idStr := r.PathValue("id")
|
|
||||||
if idStr == "" {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, ok := isValidProfileId(idStr)
|
|
||||||
if !ok {
|
|
||||||
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconstruct profile from event log.
|
|
||||||
grain := profile.NewProfileGrain(id, time.Now())
|
|
||||||
if err := s.storage.LoadEvents(r.Context(), id, grain); err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(s.dataDir, fmt.Sprintf("%d.events.log", id))
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
|
||||||
writeJSON(w, http.StatusNotFound, JsonError{Error: "customer not found"})
|
|
||||||
return
|
|
||||||
} else if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := CustomerDetailResponse{
|
|
||||||
ID: idStr,
|
|
||||||
Name: grain.Name,
|
|
||||||
Email: grain.Email,
|
|
||||||
Phone: grain.Phone,
|
|
||||||
Language: grain.Language,
|
|
||||||
Currency: grain.Currency,
|
|
||||||
AvatarUrl: grain.AvatarUrl,
|
|
||||||
Carts: grain.Carts,
|
|
||||||
Checkouts: grain.Checkouts,
|
|
||||||
Orders: grain.Orders,
|
|
||||||
Addresses: grain.Addresses,
|
|
||||||
Size: info.Size(),
|
|
||||||
Modified: info.ModTime().Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"customer": resp,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidProfileId parses a profile ID string that is either a bare decimal
|
|
||||||
// number (legacy backoffice IDs) or a base62-encoded identifier (generated by
|
|
||||||
// the UCP profile service via profile.NewProfileId). It uses profile.ParseProfileId
|
|
||||||
// for the base62 case so both formats are handled consistently.
|
|
||||||
func isValidProfileId(id string) (uint64, bool) {
|
|
||||||
// Bare decimal (legacy backoffice listing returns numeric IDs).
|
|
||||||
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
|
|
||||||
return nr, true
|
|
||||||
}
|
|
||||||
// Base62 — the format used by the UCP profile service (cmd/profile).
|
|
||||||
if pid, ok := profile.ParseProfileId(id); ok {
|
|
||||||
return uint64(pid), true
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// listProfileFiles lists all profile event log files in the data directory.
|
|
||||||
func (s *CustomerServer) listProfileFiles() ([]CustomerInfoResponse, error) {
|
|
||||||
entries, err := os.ReadDir(s.dataDir)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return []CustomerInfoResponse{}, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make([]CustomerInfoResponse, 0)
|
|
||||||
for _, e := range entries {
|
|
||||||
if e.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := e.Name()
|
|
||||||
parts := strings.Split(name, ".")
|
|
||||||
if len(parts) < 2 || parts[1] != "events" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id, err := strconv.ParseUint(parts[0], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := e.Info()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Quick peek at the profile for email/name by loading the grain
|
|
||||||
grain := profile.NewProfileGrain(id, info.ModTime())
|
|
||||||
_ = s.storage.LoadEvents(context.Background(), id, grain)
|
|
||||||
|
|
||||||
out = append(out, CustomerInfoResponse{
|
|
||||||
ID: fmt.Sprintf("%d", id),
|
|
||||||
Email: grain.Email,
|
|
||||||
Name: grain.Name,
|
|
||||||
Size: info.Size(),
|
|
||||||
Modified: info.ModTime().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
return out[i].Modified > out[j].Modified
|
|
||||||
})
|
|
||||||
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
+32
-75
@@ -5,8 +5,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
"git.k6n.net/go-cart-actor/pkg/voucher"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Legacy padded [16]byte CartId and its helper methods removed.
|
// Legacy padded [16]byte CartId and its helper methods removed.
|
||||||
@@ -28,40 +28,29 @@ type ItemMeta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CartItem struct {
|
type CartItem struct {
|
||||||
Id uint32 `json:"id"`
|
Id uint32 `json:"id"`
|
||||||
ItemId uint32 `json:"itemId,omitempty"`
|
ItemId uint32 `json:"itemId,omitempty"`
|
||||||
ParentId *uint32 `json:"parentId,omitempty"`
|
ParentId *uint32 `json:"parentId,omitempty"`
|
||||||
Sku string `json:"sku"`
|
Sku string `json:"sku"`
|
||||||
Price Price `json:"price"`
|
Price Price `json:"price"`
|
||||||
TotalPrice Price `json:"totalPrice"`
|
TotalPrice Price `json:"totalPrice"`
|
||||||
SellerId string `json:"sellerId,omitempty"`
|
SellerId string `json:"sellerId,omitempty"`
|
||||||
OrgPrice *Price `json:"orgPrice,omitempty"`
|
OrgPrice *Price `json:"orgPrice,omitempty"`
|
||||||
Cgm string `json:"cgm,omitempty"`
|
Cgm string `json:"cgm,omitempty"`
|
||||||
Tax int `json:"tax"`
|
Tax int
|
||||||
Stock uint16 `json:"stock"`
|
Stock uint16 `json:"stock"`
|
||||||
Quantity uint16 `json:"qty"`
|
Quantity uint16 `json:"qty"`
|
||||||
Discount *Price `json:"discount,omitempty"`
|
Discount *Price `json:"discount,omitempty"`
|
||||||
Disclaimer string `json:"disclaimer,omitempty"`
|
Disclaimer string `json:"disclaimer,omitempty"`
|
||||||
ArticleType string `json:"type,omitempty"`
|
ArticleType string `json:"type,omitempty"`
|
||||||
StoreId *string `json:"storeId,omitempty"`
|
StoreId *string `json:"storeId,omitempty"`
|
||||||
Meta *ItemMeta `json:"meta,omitempty"`
|
Meta *ItemMeta `json:"meta,omitempty"`
|
||||||
SaleStatus string `json:"saleStatus"`
|
SaleStatus string `json:"saleStatus"`
|
||||||
Marking *Marking `json:"marking,omitempty"`
|
Marking *Marking `json:"marking,omitempty"`
|
||||||
// CustomFields holds optional user-supplied input fields for this line
|
SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"`
|
||||||
// (engraving text, configurator notes, ...), keyed by field name.
|
OrderReference string `json:"orderReference,omitempty"`
|
||||||
CustomFields map[string]string `json:"customFields,omitempty"`
|
IsSubscribed bool `json:"isSubscribed,omitempty"`
|
||||||
SubscriptionDetailsId string `json:"subscriptionDetailsId,omitempty"`
|
ReservationEndTime *time.Time `json:"reservationEndTime,omitempty"`
|
||||||
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
|
|
||||||
// over the API alongside the typed fields. Typed fields win on key
|
|
||||||
// collisions.
|
|
||||||
Extra map[string]json.RawMessage `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CartNotification struct {
|
type CartNotification struct {
|
||||||
@@ -99,31 +88,6 @@ type Marking struct {
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppliedPromotion records a single promotion action's effect on the cart. It is
|
|
||||||
// rebuilt every time promotions are (re-)applied so the API result exposes a
|
|
||||||
// per-promotion breakdown rather than only the aggregate TotalDiscount.
|
|
||||||
//
|
|
||||||
// An entry is one of two kinds, both living in the same list:
|
|
||||||
// - applied (Pending=false): the action took effect now. Discount holds the
|
|
||||||
// amount taken off (nil for non-monetary effects such as free_shipping).
|
|
||||||
// - pending (Pending=true): the action has NOT qualified yet but is within
|
|
||||||
// reach. Progress carries effect-specific data describing what's left to
|
|
||||||
// unlock it — e.g. {"remaining": 120000, "threshold": 500000} for a
|
|
||||||
// cart-total nudge. Keeping it an open map lets new effect types surface
|
|
||||||
// their own progress fields without changing this struct. This powers
|
|
||||||
// "spend X more for ..." nudges generically.
|
|
||||||
type AppliedPromotion struct {
|
|
||||||
PromotionId string `json:"promotionId,omitempty"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
ActionId string `json:"actionId,omitempty"`
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
Discount *Price `json:"discount,omitempty"`
|
|
||||||
|
|
||||||
Pending bool `json:"pending,omitempty"`
|
|
||||||
Progress map[string]interface{} `json:"progress,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CartGrain struct {
|
type CartGrain struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
lastItemId uint32
|
lastItemId uint32
|
||||||
@@ -144,7 +108,6 @@ type CartGrain struct {
|
|||||||
OrderReference string `json:"orderReference,omitempty"`
|
OrderReference string `json:"orderReference,omitempty"`
|
||||||
|
|
||||||
Vouchers []*Voucher `json:"vouchers,omitempty"`
|
Vouchers []*Voucher `json:"vouchers,omitempty"`
|
||||||
AppliedPromotions []AppliedPromotion `json:"appliedPromotions,omitempty"`
|
|
||||||
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
Notifications []CartNotification `json:"cartNotification,omitempty"`
|
||||||
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
SubscriptionDetails map[string]*SubscriptionDetails `json:"subscriptionDetails,omitempty"`
|
||||||
|
|
||||||
@@ -154,13 +117,12 @@ type CartGrain struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Voucher struct {
|
type Voucher struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Applied bool `json:"applied"`
|
Applied bool `json:"applied"`
|
||||||
Rules []string `json:"rules"`
|
Rules []string `json:"rules"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Id uint32 `json:"id"`
|
Id uint32 `json:"id"`
|
||||||
Value int64 `json:"value"`
|
Value int64 `json:"value"`
|
||||||
BypassedByPromotions bool `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
func (v *Voucher) AppliesTo(cart *CartGrain) ([]*CartItem, bool) {
|
||||||
@@ -274,7 +236,6 @@ func (c *CartGrain) FindItemWithSku(sku string) (*CartItem, bool) {
|
|||||||
func (c *CartGrain) UpdateTotals() {
|
func (c *CartGrain) UpdateTotals() {
|
||||||
c.TotalPrice = NewPrice()
|
c.TotalPrice = NewPrice()
|
||||||
c.TotalDiscount = NewPrice()
|
c.TotalDiscount = NewPrice()
|
||||||
c.AppliedPromotions = nil
|
|
||||||
|
|
||||||
for _, item := range c.Items {
|
for _, item := range c.Items {
|
||||||
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
|
rowTotal := MultiplyPrice(item.Price, int64(item.Quantity))
|
||||||
@@ -301,11 +262,7 @@ func (c *CartGrain) UpdateTotals() {
|
|||||||
_, ok := voucher.AppliesTo(c)
|
_, ok := voucher.AppliesTo(c)
|
||||||
voucher.Applied = false
|
voucher.Applied = false
|
||||||
if ok {
|
if ok {
|
||||||
if voucher.BypassedByPromotions {
|
value := NewPriceFromIncVat(voucher.Value, 25)
|
||||||
voucher.Applied = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value := NewPriceFromIncVat(voucher.Value, 2500) // 25% in basis points
|
|
||||||
if c.TotalPrice.IncVat <= value.IncVat {
|
if c.TotalPrice.IncVat <= value.IncVat {
|
||||||
// don't apply discounts to more than the total price
|
// don't apply discounts to more than the total price
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/platform/inventory"
|
"github.com/matst80/go-redis-inventory/pkg/inventory"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CartMutationContext struct {
|
type CartMutationContext struct {
|
||||||
reservationService inventory.ReservationPolicy
|
reservationService inventory.CartReservationService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCartMutationContext(reservationService inventory.ReservationPolicy) *CartMutationContext {
|
func NewCartMutationContext(reservationService inventory.CartReservationService) *CartMutationContext {
|
||||||
return &CartMutationContext{
|
return &CartMutationContext{
|
||||||
reservationService: reservationService,
|
reservationService: reservationService,
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ func (c *CartMutationContext) ReserveItem(ctx context.Context, cartId CartId, sk
|
|||||||
}
|
}
|
||||||
ttl := time.Minute * 15
|
ttl := time.Minute * 15
|
||||||
endTime := time.Now().Add(ttl)
|
endTime := time.Now().Add(ttl)
|
||||||
err := c.reservationService.Reserve(ctx, inventory.CartReserveRequest{
|
err := c.reservationService.ReserveForCart(ctx, inventory.CartReserveRequest{
|
||||||
CartID: inventory.CartID(cartId.String()),
|
CartID: inventory.CartID(cartId.String()),
|
||||||
InventoryReference: &inventory.InventoryReference{
|
InventoryReference: &inventory.InventoryReference{
|
||||||
SKU: inventory.SKU(sku),
|
SKU: inventory.SKU(sku),
|
||||||
@@ -51,13 +51,7 @@ func (c *CartMutationContext) UseReservations(item *CartItem) bool {
|
|||||||
if item.ReservationEndTime != nil {
|
if item.ReservationEndTime != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if item.DropShip {
|
return item.Cgm == "55010"
|
||||||
return false
|
|
||||||
}
|
|
||||||
if item.InventoryTracked {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sku string, locationId *string) error {
|
func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sku string, locationId *string) error {
|
||||||
@@ -68,7 +62,7 @@ func (c *CartMutationContext) ReleaseItem(ctx context.Context, cartId CartId, sk
|
|||||||
if locationId != nil {
|
if locationId != nil {
|
||||||
l = inventory.LocationID(*locationId)
|
l = inventory.LocationID(*locationId)
|
||||||
}
|
}
|
||||||
return c.reservationService.Release(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
|
return c.reservationService.ReleaseForCart(ctx, inventory.SKU(sku), l, inventory.CartID(cartId.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
|
func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegistry {
|
||||||
@@ -85,7 +79,6 @@ func NewCartMultationRegistry(context *CartMutationContext) actor.MutationRegist
|
|||||||
actor.NewMutation(SetUserId),
|
actor.NewMutation(SetUserId),
|
||||||
actor.NewMutation(LineItemMarking),
|
actor.NewMutation(LineItemMarking),
|
||||||
actor.NewMutation(RemoveLineItemMarking),
|
actor.NewMutation(RemoveLineItemMarking),
|
||||||
actor.NewMutation(SetLineItemCustomFields),
|
|
||||||
actor.NewMutation(SubscriptionAdded),
|
actor.NewMutation(SubscriptionAdded),
|
||||||
// actor.NewMutation(SubscriptionRemoved),
|
// actor.NewMutation(SubscriptionRemoved),
|
||||||
)
|
)
|
||||||
|
|||||||
+78
-12
@@ -1,10 +1,11 @@
|
|||||||
package cart
|
package cart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"git.k6n.net/mats/platform/uid"
|
"git.k6n.net/go-cart-actor/pkg/actor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cart_id.go
|
// cart_id.go
|
||||||
@@ -35,16 +36,30 @@ import (
|
|||||||
//
|
//
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// CartId is a 64-bit cart identifier with a compact base62 string form,
|
type CartId actor.GrainId
|
||||||
// backed by the shared platform/uid package.
|
|
||||||
type CartId uid.ID
|
|
||||||
|
|
||||||
// String returns the canonical base62 encoding.
|
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
func (id CartId) 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 CartId) String() string {
|
||||||
|
return encodeBase62(uint64(id))
|
||||||
|
}
|
||||||
|
|
||||||
// MarshalJSON encodes the cart id as a JSON string.
|
// MarshalJSON encodes the cart id as a JSON string.
|
||||||
func (id CartId) MarshalJSON() ([]byte, error) {
|
func (id CartId) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(uid.ID(id).String())
|
return json.Marshal(id.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
// UnmarshalJSON decodes a cart id from a JSON string containing base62 text.
|
||||||
@@ -63,11 +78,23 @@ func (id *CartId) UnmarshalJSON(data []byte) error {
|
|||||||
|
|
||||||
// NewCartId generates a new cryptographically random non-zero 64-bit id.
|
// NewCartId generates a new cryptographically random non-zero 64-bit id.
|
||||||
func NewCartId() (CartId, error) {
|
func NewCartId() (CartId, error) {
|
||||||
id, err := uid.New()
|
var b [8]byte
|
||||||
if err != nil {
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
return 0, fmt.Errorf("NewCartId: %w", err)
|
return 0, fmt.Errorf("NewCartId: %w", err)
|
||||||
}
|
}
|
||||||
return CartId(id), nil
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustNewCartId panics if generation fails.
|
// MustNewCartId panics if generation fails.
|
||||||
@@ -80,9 +107,19 @@ func MustNewCartId() CartId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseCartId parses a base62 string into a CartId.
|
// ParseCartId parses a base62 string into a CartId.
|
||||||
|
// Returns (0,false) for invalid input.
|
||||||
func ParseCartId(s string) (CartId, bool) {
|
func ParseCartId(s string) (CartId, bool) {
|
||||||
id, ok := uid.Parse(s)
|
// Accept length 1..11 (11 sufficient for 64 bits). Reject >11 immediately.
|
||||||
return CartId(id), ok
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// MustParseCartId panics on invalid base62 input.
|
// MustParseCartId panics on invalid base62 input.
|
||||||
@@ -93,3 +130,32 @@ func MustParseCartId(s string) CartId {
|
|||||||
}
|
}
|
||||||
return id
|
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
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user