Files
commerce/routes_wired_test.go
zooqueenandHanzo Dev a265087b13 No route makes or records a refund; a refund or dispute is recorded only from the processor's signed webhook, once, and an inquiry that closes without a chargeback restores its period
Commerce never initiates a refund. POST /v1/billing/refunds, POST /v1/billing/refund,
POST /v1/order/:orderid/refund and POST /v1/payment/:paymentid/refund are gone with
their handlers (engine.CreateRefund, checkout Refund, square.Refund) and the helpers
only they used; GET /v1/billing/refunds stays read-only. A claim is settled by a
replacement order, never a refund.

A refund or dispute of a plan's payment reaches its invoice only from the
processor's webhook, under the processor's own refund id, through one locked
writer that reads the invoice afresh (engine.RecordRefund, engine.RecordDispute).
Every state a dispute reports reaches the invoice; INQUIRY_CLOSED holds the
payment no longer, WON stays final and LOST stays disputed.

No backfill of the payment-to-invoice index is run: the only plan payments made
before v1.50.139 are the team's own card purchases (z24, zk) and one Square
record.

Co-authored-by: Hanzo Dev <[email protected]>
2026-10-01 17:04:54 -07:00

173 lines
6.0 KiB
Go

// Package commerce — integration test proving P8-H1 is fixed: the store-
// backed /v1/commerce/org (public) route
// are wired into the production commerce binary, not just attached inside a
// per-test router.
//
// We boot the real commerce App via NewWithConfig → Bootstrap. Bootstrap
// initializes the hanzo/base-backed CommerceStore, IAM middleware (disabled
// in this test via IAM.Enabled=false), gin router, and calls setupRoutes. No
// mocks, no test-only wiring — this is the shape that ships.
package commerce
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
)
// bootTestCommerce constructs a real App with the lightest viable config and
// runs Bootstrap. Infra services that fail to connect degrade — KMS disabled,
// NATS absent, Stripe seed gated on STRIPE_SECRET_KEY (unset), IAM disabled.
// The only thing that MUST work is the commerce/store bootstrap + route wire.
func bootTestCommerce(t *testing.T) *App {
t.Helper()
dir := t.TempDir()
cfg := &Config{
DataDir: filepath.Join(dir, "data"),
Dev: false,
Secret: "test-secret",
HTTPAddr: "127.0.0.1:0",
QueryTimeout: 30e9,
}
cfg.IAM.Enabled = false // no JWKS fetch; admin routes still register, caller JWT absent → 401
cfg.KMS.Enabled = false
// Ensure we do not touch the environment's STRIPE / SQL vars.
t.Setenv("STRIPE_SECRET_KEY", "")
t.Setenv("COMMERCE_STRIPE_SEED", "false")
t.Setenv("SQL_URL", "")
t.Setenv("COMMERCE_DATA_DIR", cfg.DataDir)
t.Setenv("COMMERCE_BASE_URL", "")
app := NewWithConfig(cfg)
if err := app.Bootstrap(); err != nil {
t.Fatalf("Bootstrap: %v", err)
}
t.Cleanup(func() {
_ = app.Shutdown()
})
return app
}
// probe issues a request directly against the booted router, bypassing the
// HTTP server listen step but exercising the exact same gin.Engine.
func probe(t *testing.T, app *App, method, path string, host string, body []byte) *http.Response {
t.Helper()
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req := httptest.NewRequest(method, path, rdr)
if host != "" {
req.Host = host
}
resp, terr := app.Router.Test(req)
if terr != nil {
t.Fatalf("Test: %v", terr)
}
return resp
}
// TestRoutes_WiredInProduction proves P8-H1 is fixed: the store-backed
// /v1/commerce/org endpoint is wired in the
// booted commerce binary's router. If either returns the NoRoute 404
// body `{"error":"not found"}` (MountSPA's isAPIPath branch) the wiring is
// missing.
//
// We do NOT assert specific success semantics — authentication is disabled
// and the org collection is empty — only that the router recognizes the
// paths and dispatches to a registered handler rather than NoRoute.
func TestRoutes_WiredInProduction(t *testing.T) {
app := bootTestCommerce(t)
cases := []struct {
name string
method string
path string
host string
body []byte
}{
{name: "public_org_lookup", method: http.MethodGet, path: "/v1/commerce/org", host: "pay.example.test"},
{name: "billing_plans", method: http.MethodGet, path: "/v1/billing/plans", host: "pay.example.test"},
{name: "billing_settings", method: http.MethodGet, path: "/v1/billing/settings", host: "pay.example.test"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp := probe(t, app, tc.method, tc.path, tc.host, tc.body)
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
body := string(bodyBytes)
// NoRoute SPA fallback for API paths returns exactly
// `{"error":"not found"}`. If we see that, the endpoint is NOT
// wired. Any other body proves the route reached a handler.
if body == `{"error":"not found"}` {
t.Fatalf("%s %s reached NoRoute SPA fallback — route not wired. status=%d body=%s",
tc.method, tc.path, resp.StatusCode, body)
}
// Sanity: must NOT be 405 Method Not Allowed.
if resp.StatusCode == http.StatusMethodNotAllowed {
t.Fatalf("%s %s returned 405 — route wiring method mismatch", tc.method, tc.path)
}
})
}
}
// TestRoutes_PublicTenantResolvesOrg proves GET /v1/commerce/org resolves
// the org from the IAM org (host→brand→org), returning 200 with an
// org JSON — NOT a 404 against a separate org registry. Even with
// no org row and no Square env in the test binary, resolution degrades to the
// deployment default brand + env, so the pay SPA always receives a usable
// org carrying `name` (the org slug) and a `square` block. This is the fix
// for the pay.hanzo.ai "unknown tenant" 404 + dead card form.
func TestRoutes_PublicTenantResolvesOrg(t *testing.T) {
app := bootTestCommerce(t)
resp := probe(t, app, http.MethodGet, "/v1/commerce/org", "pay.p8.test", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("status = %d, want 200 (org). body=%s", resp.StatusCode, string(bodyBytes))
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("org JSON decode: %v", err)
}
if name, _ := body["name"].(string); name == "" {
t.Fatalf("org JSON missing name (org slug): %v", body)
}
if _, ok := body["square"]; !ok {
t.Fatalf("org JSON missing square block (card form needs it): %v", body)
}
}
// TestRoutes_NoRouteRefunds proves no route initiates or records a refund: each
// former refund path reaches the NoRoute fallback (or a 405), never a handler. A
// refund is recorded only from the processor's signed webhook.
func TestRoutes_NoRouteRefunds(t *testing.T) {
app := bootTestCommerce(t)
for _, path := range []string{
"/v1/billing/refunds",
"/v1/billing/refund",
"/v1/order/ord_x/refund",
"/v1/payment/pay_x/refund",
} {
t.Run(path, func(t *testing.T) {
resp := probe(t, app, http.MethodPost, path, "pay.example.test", []byte(`{"amount":100}`))
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if body := string(bodyBytes); body != `{"error":"not found"}` && resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("POST %s reached a handler: status=%d body=%s", path, resp.StatusCode, body)
}
})
}
}