mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Multi-tenant commerce and billing: orders, subscriptions, invoices, plans, usage metering and a credit ledger. One Go binary. MIT OR Apache-2.0. Claude-Session: https://claude.ai/code/session_01SAtaDFm8VDiWrhYHNNtxMw
125 lines
4.4 KiB
Go
125 lines
4.4 KiB
Go
// Copyright (c) 2014-present Hanzo AI, Inc.
|
|
// Licensed under MIT OR Apache-2.0. See LICENSE-MIT and LICENSE-APACHE.
|
|
|
|
package order
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/datastore"
|
|
dsquery "github.com/hanzoai/commerce/datastore/query"
|
|
"github.com/hanzoai/commerce/db"
|
|
"github.com/hanzoai/commerce/models/order"
|
|
"github.com/hanzoai/commerce/models/organization"
|
|
"github.com/hanzoai/commerce/util/nscontext"
|
|
)
|
|
|
|
// TestStatus_ResolverInstalled_FindsPerOrgOrder is the Red MED-1 regression
|
|
// guard. It is the ONLY kind of test that can catch a datastore.New /
|
|
// NewNamespaced split, because the standard ae harness leaves orgDBResolver nil
|
|
// — with no resolver, NewNamespaced falls back to systemDB and is
|
|
// indistinguishable from New, so every other test in the suite passes whether a
|
|
// handler calls New or NewNamespaced.
|
|
//
|
|
// Here we install a REAL per-org resolver (as production does via
|
|
// datastore.SetOrgDBResolver(app.DB.Org)). Now New(ctx) binds the shared
|
|
// systemDB while NewNamespaced(ns) binds the caller org's OWN store — they
|
|
// diverge. We seed an order into org "acme"'s store via NewNamespaced (exactly
|
|
// how order creation writes it) and assert:
|
|
//
|
|
// 1. the Status handler (which we routed through NewNamespaced) FINDS it → 200
|
|
// with the right id. Had Status still used datastore.New, it would query
|
|
// systemDB, miss the per-org order, and return 404.
|
|
// 2. the same order is genuinely ABSENT from systemDB (New) — proving the split
|
|
// is real and the fix is load-bearing, not cosmetic.
|
|
func TestStatus_ResolverInstalled_FindsPerOrgOrder(t *testing.T) {
|
|
// Real manager with per-org SQLite + the resolver INSTALLED (unlike ae).
|
|
dir := t.TempDir()
|
|
cfg := db.DefaultConfig()
|
|
cfg.DataDir = dir
|
|
cfg.UserDataDir = filepath.Join(dir, "users")
|
|
cfg.EnableDatastore = false // SQLite only
|
|
cfg.EnableVectorSearch = false
|
|
|
|
mgr, err := db.NewManager(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewManager: %v", err)
|
|
}
|
|
sys, err := mgr.Org("system")
|
|
if err != nil {
|
|
t.Fatalf("Org(system): %v", err)
|
|
}
|
|
|
|
datastore.SetDefaultDB(sys)
|
|
dsquery.SetDefaultDB(sys)
|
|
datastore.SetOrgDBResolver(mgr.Org)
|
|
t.Cleanup(func() {
|
|
// Restore the "no resolver" state the rest of the suite expects, then drop
|
|
// the manager so a dangling resolver can't point at a closed store.
|
|
datastore.SetOrgDBResolver(nil)
|
|
mgr.Close()
|
|
})
|
|
|
|
const ns = "acme"
|
|
|
|
// Seed an order into acme's OWN store, exactly as order creation does
|
|
// (NewNamespaced → per-org store).
|
|
seedDB := datastore.NewNamespaced(nscontext.WithNamespace(context.Background(), ns))
|
|
ord := order.New(seedDB)
|
|
ord.Total = 5000
|
|
ord.Paid = 5000
|
|
if err := ord.Create(); err != nil {
|
|
t.Fatalf("seed per-org order: %v", err)
|
|
}
|
|
orderId := ord.Id()
|
|
if orderId == "" {
|
|
t.Fatal("seeded order has empty id")
|
|
}
|
|
|
|
// Guard #2: the order must NOT exist in systemDB (datastore.New). If this ever
|
|
// fails, New and NewNamespaced stopped diverging and the regression guard is
|
|
// toothless — fix the harness, not this assertion.
|
|
sysMiss := order.New(datastore.New(context.Background()))
|
|
if err := sysMiss.GetById(orderId); err == nil {
|
|
t.Fatal("order unexpectedly found in systemDB — resolver split not in effect; test cannot catch MED-1")
|
|
}
|
|
|
|
// Drive the real Status handler with an acme-scoped request. A thin middleware
|
|
// seeds the "organization" local exactly as the auth/namespace chain does in
|
|
// production, so middleware.GetOrganization(c) inside Status resolves to acme.
|
|
org := &organization.Organization{}
|
|
org.Name = ns
|
|
|
|
app := zip.New(zip.Config{DisableStartupMessage: true})
|
|
app.Raw(http.MethodGet, "/order/:orderid/status", func(c *zip.Ctx) error {
|
|
c.Locals("organization", org)
|
|
return c.Next()
|
|
}, Status)
|
|
|
|
resp, err := app.Test(httptest.NewRequest("GET", "/order/"+orderId+"/status", nil))
|
|
if err != nil {
|
|
t.Fatalf("test request: %v", err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
// Guard #1: handler routed to acme's store → found → 200 with the right id.
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("Status returned %d, want 200 — handler missed the per-org order (MED-1 regression: reading systemDB, not org store). body=%s", resp.StatusCode, body)
|
|
}
|
|
var res StatusResponse
|
|
if err := json.Unmarshal(body, &res); err != nil {
|
|
t.Fatalf("decode StatusResponse: %v (body=%s)", err, body)
|
|
}
|
|
if res.Id != orderId {
|
|
t.Fatalf("Status returned order id %q, want %q", res.Id, orderId)
|
|
}
|
|
}
|