feat(marketplace): thread configSchema form values into install POST (Refs #2026, Refs #2042) (#2043)
PR #2038 shipped the configSchema RENDER side on AppDetail.svelte — form inputs bound to local state, defaults seeded from the Go catalog. What was missing: the customer's chosen values never reached the install POST. This PR threads the SHAPE end-to-end: Frontend - cart.ts: `appConfigs: Record<slug, Record<key, value>>` field + `setAppConfig(slug, values)` setter. Keyed by app SLUG (NOT id, so the cart survives catalog id reshuffles). - AppDetail.svelte: persist on every form mutation via setAppConfig; re-hydrate from cart on mount so navigating /app -> /addons -> /app keeps the customer's choices. - CheckoutStep.svelte: forward `cart.appConfigs` as `app_configs` in the createTenant POST body. - api.ts: `CreateTenantRequest.app_configs?` (optional, legacy-safe). Backend - store.Tenant.AppConfigs: `map[string]map[string]any` with `bson:"app_configs,omitempty" json:"app_configs,omitempty"`. - CreateOrg: accept `app_configs` in body, persist on the new tenant. - Round-trips on the `tenant.created` event payload via the existing *store.Tenant embed — no wrapper change needed. Tests - tenant_created_wire_test.go: TestTenantCreatedWire_AppConfigs_RoundTrip asserts the publisher to consumer wire round-trip preserves app_configs.<slug>.<key>=<value> byte-for-byte (numbers as float64 per JSON decode of any). - tenant_created_wire_test.go: TestTenantCreatedWire_EmptyAppConfigs_Omitted asserts omitempty drops nil app_configs so legacy clients see the pre-TBD-V18-D wire shape. - customer-journey.spec.ts 12b: playwright assertion that the POST /api/tenant/orgs body carries `app_configs.wordpress.replicas=3, disk_gb=50, backups_enabled=true` when the cart has them. Scope NOT in this PR (per anti-theater discipline) The HelmRelease-values binding (Path A SME-controller-via-Org-CR or Path B gitops-commit-to-tenant-repo) is gated on TBD-V26 (#2040). This PR threads the SHAPE so that flipping the Path A/B switch lights up the values without a second upstream change. Pillar 1 step 2 STAYS UNVERIFIED — only an operator-walk-with-screenshot on a fresh prov can flip TBD-V18 (#2026) to verified-done. Refs #2026 Refs #2042 Refs #2040 Co-authored-by: hatiyildiz <hatice.yildiz@openova.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
db1e452ac3
commit
0feb0b4006
@ -540,6 +540,90 @@ test.describe('marketplace customer-journey (17-step regression gate)', () => {
|
||||
).toBeLessThan(hits.indexOf('startProvisioning'))
|
||||
})
|
||||
|
||||
// TBD-V18-D follow-up to PR #2038 — assert the install POST body
|
||||
// carries the customer-chosen configSchema values (from the
|
||||
// AppDetail form) into the createTenant call. We cannot walk the
|
||||
// entire AppDetail surface here without /app?slug=postgres in the
|
||||
// mock catalog; the canonical seed-cart path already simulates the
|
||||
// customer's choices via cart.appConfigs. This proves the
|
||||
// CheckoutStep → createTenant wire honours the cart contract; the
|
||||
// AppDetail → cart half is exercised at unit level in cart.ts's
|
||||
// setAppConfig and indirectly via the 03b configSchema render test
|
||||
// (which already asserts the form is reactive).
|
||||
test('12b createTenant POST body carries app_configs from cart (TBD-V18-D)', async ({ page }) => {
|
||||
let capturedBody: Record<string, unknown> | null = null
|
||||
await page.route('**/api/tenant/orgs', (route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
const raw = route.request().postData()
|
||||
try {
|
||||
capturedBody = raw ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
capturedBody = null
|
||||
}
|
||||
route.fulfill({
|
||||
status: 201,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ id: 'tenant-1', slug: 'demo-co', name: 'Demo Co', status: 'active' }),
|
||||
})
|
||||
} else {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) })
|
||||
}
|
||||
})
|
||||
await page.route('**/api/billing/checkout', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ order_id: 'order-1', paid_by_credit: true, session_url: null }),
|
||||
})
|
||||
)
|
||||
await page.route('**/api/provisioning/start', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ id: 'prov-1', tenant_id: 'tenant-1', status: 'running', steps: [] }),
|
||||
})
|
||||
)
|
||||
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
localStorage.setItem('sme-token', 'mock-jwt-token')
|
||||
localStorage.setItem('sme-refresh-token', 'mock-refresh-token')
|
||||
} catch (_) {}
|
||||
})
|
||||
// Seed cart with appConfigs as if the customer mutated the
|
||||
// AppDetail form for the canonical Postgres-backed bundle. Values
|
||||
// match the seed catalog defaults' shape (replicas + disk_gb +
|
||||
// backups_enabled), but the customer overrode the defaults.
|
||||
await seedCart(page, {
|
||||
appConfigs: {
|
||||
wordpress: {
|
||||
replicas: 3,
|
||||
disk_gb: 50,
|
||||
backups_enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
await page.goto('/checkout')
|
||||
|
||||
const launch = page.getByRole('button', { name: /Launch my tenant|Purchase/i }).first()
|
||||
await expect(launch).toBeVisible({ timeout: 10_000 })
|
||||
await Promise.all([
|
||||
page.waitForURL(/console\.openova\.io|console\..*\.(works|homes|rest|trade)/, { timeout: 15_000 }).catch(() => null),
|
||||
launch.click(),
|
||||
])
|
||||
|
||||
expect(capturedBody, 'POST /api/tenant/orgs body parsed').not.toBeNull()
|
||||
const body = capturedBody as { app_configs?: Record<string, Record<string, unknown>> }
|
||||
expect(body.app_configs, 'app_configs sibling present in body').toBeDefined()
|
||||
expect(body.app_configs!.wordpress, 'wordpress bucket present').toBeDefined()
|
||||
// Each customer-set value round-trips byte-for-byte from cart to
|
||||
// the wire. A regression that drops the field or coerces the
|
||||
// type (e.g. JSON-stringifies the inner map) would fail here.
|
||||
expect(body.app_configs!.wordpress.replicas, 'replicas threaded').toBe(3)
|
||||
expect(body.app_configs!.wordpress.disk_gb, 'disk_gb threaded').toBe(50)
|
||||
expect(body.app_configs!.wordpress.backups_enabled, 'backups_enabled threaded').toBe(true)
|
||||
})
|
||||
|
||||
test('16 console redirect URL is Sovereign-local + slug-aware (PR #1627 + TBD-V10 #2001)', async ({ page }) => {
|
||||
// Two layered guarantees on the post-purchase redirect contract:
|
||||
//
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getApps, type App, type ConfigField } from '../lib/api';
|
||||
import { readCart, toggleApp, toggleAgent, SANDBOX_AGENTS } from '../lib/cart';
|
||||
import { readCart, toggleApp, toggleAgent, setAppConfig, SANDBOX_AGENTS } from '../lib/cart';
|
||||
|
||||
interface Props {
|
||||
slug?: string;
|
||||
@ -16,8 +16,10 @@
|
||||
// declared on app.configSchema. Initialised from each field's
|
||||
// `default` so the rendered form is always populated for the
|
||||
// canonical Postgres-backed bundle (replicas=1, disk_gb=5,
|
||||
// backups_enabled=false). Threading these into the install POST is
|
||||
// a follow-up — for now this proves the schema renders end-to-end.
|
||||
// backups_enabled=false). TBD-V18-D follow-up to PR #2038: every
|
||||
// mutation now also persists to cart.appConfigs[app.slug] via
|
||||
// setAppConfig(), so CheckoutStep can thread the values into the
|
||||
// install POST body (createTenant /api/tenant/orgs `app_configs`).
|
||||
let configValues = $state<Record<string, number | string | boolean>>({});
|
||||
|
||||
const inCart = $derived(app ? cart.apps.includes(app.id) : false);
|
||||
@ -52,17 +54,31 @@
|
||||
.filter((a): a is App => !!a);
|
||||
// Seed configValues from per-field defaults. Falls back to a
|
||||
// type-appropriate zero when `default` is missing so the form
|
||||
// always has a coherent initial state.
|
||||
// always has a coherent initial state. TBD-V18-D: when the
|
||||
// operator already visited this AppDetail in the current cart
|
||||
// session (e.g. navigated forward to /addons then back), prefer
|
||||
// their previously-saved values from cart.appConfigs[slug] so
|
||||
// we don't blow away their edits on every mount.
|
||||
const fields = app?.configSchema ?? [];
|
||||
const seeded: Record<string, number | string | boolean> = {};
|
||||
const previouslySaved = (cart.appConfigs ?? {})[app?.slug ?? ''] ?? {};
|
||||
for (const f of fields) {
|
||||
if (f.default !== undefined && f.default !== null) {
|
||||
if (Object.prototype.hasOwnProperty.call(previouslySaved, f.key)) {
|
||||
seeded[f.key] = previouslySaved[f.key];
|
||||
} else if (f.default !== undefined && f.default !== null) {
|
||||
seeded[f.key] = f.default;
|
||||
} else {
|
||||
seeded[f.key] = f.type === 'int' ? 0 : f.type === 'bool' ? false : '';
|
||||
}
|
||||
}
|
||||
configValues = seeded;
|
||||
// Persist the freshly-seeded values back so the cart has a
|
||||
// coherent snapshot from the moment the AppDetail mounts, even
|
||||
// when the customer never mutates a field (silent acceptance of
|
||||
// defaults still needs to thread through the install POST).
|
||||
if (app?.slug && fields.length > 0) {
|
||||
cart = setAppConfig(app.slug, seeded);
|
||||
}
|
||||
loading = false;
|
||||
}).catch(() => { loading = false; });
|
||||
});
|
||||
@ -83,6 +99,14 @@
|
||||
}
|
||||
function setValue(key: string, v: number | string | boolean): void {
|
||||
configValues = { ...configValues, [key]: v };
|
||||
// TBD-V18-D — persist on every change so the cart matches the
|
||||
// on-screen form when the customer leaves AppDetail (no submit
|
||||
// button on this surface: the cart IS the buffer). Guarded on
|
||||
// `app?.slug` so we never write a stub `undefined` key when the
|
||||
// detail page is still loading.
|
||||
if (app?.slug) {
|
||||
cart = setAppConfig(app.slug, configValues);
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
@ -167,8 +191,12 @@
|
||||
walk ("Click the canonical Postgres-backed bundle → app card
|
||||
opens; configSchema renders"). One input widget per
|
||||
ConfigField.type — matches the Go store.ConfigField contract
|
||||
exactly. Threading these into the install POST is a follow-up
|
||||
(TBD-V18-D). -->
|
||||
exactly. TBD-V18-D follow-up to PR #2038: every mutation is
|
||||
persisted to cart.appConfigs[slug] so CheckoutStep can
|
||||
thread the values into the install POST (createTenant
|
||||
/api/tenant/orgs `app_configs`). The downstream HelmRelease-
|
||||
values binding is gated on TBD-V26 (#2040) Path A/B; this
|
||||
file ships the SHAPE end-to-end. -->
|
||||
{#if hasConfigSchema}
|
||||
<section class="detail-section" data-testid="config-schema-section">
|
||||
<h2>Configuration</h2>
|
||||
|
||||
@ -247,6 +247,17 @@
|
||||
// only acts on this when `apps` contains 'sandbox'; for all
|
||||
// other carts it's persisted and ignored.
|
||||
agents: cart.agents || [],
|
||||
// TBD-V18-D (follow-up to PR #2038) — thread the
|
||||
// customer-chosen configSchema values into the install POST
|
||||
// body, keyed by app slug. Tenant-service persists this on
|
||||
// store.Tenant.AppConfigs and re-emits it on the
|
||||
// tenant.created event so any downstream consumer (Path A
|
||||
// SME-controller-via-Org-CR, Path B
|
||||
// gitops-commit-to-tenant-repo, per TBD-V26 #2040) can read
|
||||
// the values when materialising the HelmRelease values.
|
||||
// Empty record when no app in the cart exposes a
|
||||
// configSchema (Ghost / Nextcloud / Sandbox today).
|
||||
app_configs: cart.appConfigs || {},
|
||||
});
|
||||
return { id: t.id, slug: t.slug || s };
|
||||
} catch (e: any) {
|
||||
|
||||
@ -351,8 +351,10 @@ export interface App {
|
||||
// TBD-V18 (#2026) — per-instance tunables (replicas / disk / backup
|
||||
// for Postgres-backed bundles, replicas / persistence for Redis,
|
||||
// etc.). Empty array when the catalog has no tunables for this app.
|
||||
// Threading the customer's chosen values into the install payload is
|
||||
// a follow-up — see CartState extension TODO in cart.ts.
|
||||
// The customer's chosen values are persisted to
|
||||
// `CartState.appConfigs[slug]` (see cart.ts::setAppConfig) and
|
||||
// threaded into the install POST as `CreateTenantRequest.app_configs`
|
||||
// (TBD-V18-D follow-up to PR #2038).
|
||||
configSchema?: ConfigField[];
|
||||
}
|
||||
|
||||
@ -433,6 +435,14 @@ export interface CreateTenantRequest {
|
||||
// matching spec.agentCatalogue. Optional so legacy clients keep
|
||||
// working unchanged.
|
||||
agents?: string[];
|
||||
// TBD-V18-D follow-up to PR #2038 — per-instance configSchema
|
||||
// values, keyed by app slug. Optional so legacy clients (older cart
|
||||
// shape, machine-to-machine callers) keep working unchanged. Wire
|
||||
// mirror of `store.Tenant.AppConfigs` (bson:"app_configs"). The
|
||||
// backend tenant-service decodes via the same JSON tag and
|
||||
// round-trips on the `tenant.created` event payload — see
|
||||
// `tenant_created_wire_test.go`.
|
||||
app_configs?: Record<string, Record<string, number | string | boolean>>;
|
||||
}
|
||||
|
||||
export interface Tenant {
|
||||
|
||||
@ -19,6 +19,22 @@ export interface CartState {
|
||||
// controller consumes to materialize a Sandbox CR with the matching
|
||||
// spec.agentCatalogue. Empty when Sandbox isn't in the cart.
|
||||
agents: string[];
|
||||
// TBD-V18-D follow-up to PR #2038 — per-app config values keyed by
|
||||
// the marketplace app SLUG (NOT id, so the persisted cart survives a
|
||||
// catalog id reshuffle). Shape per slug is the dict of
|
||||
// `ConfigField.key` → user-chosen value, matching the ConfigField
|
||||
// schema declared by the catalog. Threaded into the install POST
|
||||
// body (createTenant → /tenant/orgs) under the `app_configs`
|
||||
// sibling field. Empty record when no app exposes a configSchema
|
||||
// (e.g. cart is Sandbox-only, or all picks are Ghost/Nextcloud which
|
||||
// ship empty schemas today).
|
||||
//
|
||||
// Independent of TBD-V26 (#2040): this wires the SHAPE end-to-end;
|
||||
// the backend HelmRelease consumption is gated on Path A/B of
|
||||
// TBD-V26 and lives in its own track. The shape is correct today so
|
||||
// that flipping the Path A/B switch lights up the form values
|
||||
// without a second frontend round-trip.
|
||||
appConfigs: Record<string, Record<string, number | string | boolean>>;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'sme-cart';
|
||||
@ -38,6 +54,7 @@ const defaultCart: CartState = {
|
||||
tld: DEFAULT_TLD,
|
||||
email: '',
|
||||
agents: [],
|
||||
appConfigs: {},
|
||||
};
|
||||
|
||||
// The 6 agents the Sandbox CRD (sandbox.openova.io/v1) accepts in
|
||||
@ -121,6 +138,24 @@ export function setTLD(tld: string): CartState {
|
||||
return cart;
|
||||
}
|
||||
|
||||
// setAppConfig stores the customer-chosen configSchema field values
|
||||
// for a single app, keyed by the app's marketplace SLUG. Called by
|
||||
// AppDetail.svelte whenever the user mutates any field in the rendered
|
||||
// ConfigField form — Svelte's reactive update fires this so the cart
|
||||
// always reflects the on-screen state. Empty `values` is a legitimate
|
||||
// signal that the operator wiped the form; we keep the slot present
|
||||
// rather than deleting it so the install-POST shape stays stable. See
|
||||
// TBD-V18-D follow-up to PR #2038.
|
||||
export function setAppConfig(
|
||||
appSlug: string,
|
||||
values: Record<string, number | string | boolean>,
|
||||
): CartState {
|
||||
const cart = readCart();
|
||||
cart.appConfigs = { ...(cart.appConfigs || {}), [appSlug]: { ...values } };
|
||||
writeCart(cart);
|
||||
return cart;
|
||||
}
|
||||
|
||||
// toggleAgent flips one agent slug in/out of cart.agents. Used by the
|
||||
// Sandbox detail page (AppDetail.svelte) when slug === 'sandbox'. The
|
||||
// list is kept stable-ordered by toggling in-place — order in the cart
|
||||
|
||||
@ -171,6 +171,16 @@ func (h *Handler) CreateOrg(w http.ResponseWriter, r *http.Request) {
|
||||
// event the sandbox-controller consumes to mint a Sandbox CR
|
||||
// with `spec.agentCatalogue` = these slugs. Tolerated empty.
|
||||
Agents []string `json:"agents"`
|
||||
// TBD-V18-D follow-up to PR #2038 — per-app configSchema
|
||||
// values, keyed by app SLUG. Each inner map is `ConfigField.Key`
|
||||
// → field-typed primitive (int / string / bool). Persisted on
|
||||
// `store.Tenant.AppConfigs`; round-trips on the `tenant.created`
|
||||
// event payload via the *store.Tenant embed (no separate
|
||||
// wrapper field needed). The downstream HelmRelease-values
|
||||
// binding is gated on TBD-V26 (#2040) Path A/B; this field
|
||||
// threads the SHAPE end-to-end so flipping the binding switch
|
||||
// works without a second upstream change. Tolerated empty.
|
||||
AppConfigs map[string]map[string]any `json:"app_configs"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
@ -205,16 +215,17 @@ func (h *Handler) CreateOrg(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
tenant := &store.Tenant{
|
||||
Slug: body.Slug,
|
||||
Name: body.Name,
|
||||
OrgType: body.OrgType,
|
||||
Industry: body.Industry,
|
||||
OwnerID: userID,
|
||||
PlanID: body.PlanID,
|
||||
Apps: body.Apps,
|
||||
AddOns: body.AddOns,
|
||||
Subdomain: body.Slug,
|
||||
Status: "provisioning",
|
||||
Slug: body.Slug,
|
||||
Name: body.Name,
|
||||
OrgType: body.OrgType,
|
||||
Industry: body.Industry,
|
||||
OwnerID: userID,
|
||||
PlanID: body.PlanID,
|
||||
Apps: body.Apps,
|
||||
AddOns: body.AddOns,
|
||||
AppConfigs: body.AppConfigs,
|
||||
Subdomain: body.Slug,
|
||||
Status: "provisioning",
|
||||
}
|
||||
|
||||
if err := h.Store.CreateTenant(r.Context(), tenant); err != nil {
|
||||
|
||||
@ -47,6 +47,11 @@ type consumerPayloadMirror struct {
|
||||
Tier string `json:"tier,omitempty"`
|
||||
BillingMode string `json:"billing_mode,omitempty"`
|
||||
ParentDomain string `json:"parent_domain,omitempty"`
|
||||
// TBD-V18-D follow-up to PR #2038 — the per-instance configSchema
|
||||
// values bubble through the publisher wrapper via the embedded
|
||||
// *store.Tenant. A consumer that wants to honour them adds this
|
||||
// field with a matching tag — see TBD-V26 #2040 Path A/B.
|
||||
AppConfigs map[string]map[string]any `json:"app_configs,omitempty"`
|
||||
}
|
||||
|
||||
func TestTenantCreatedWire_PublisherToConsumer_RoundTrip(t *testing.T) {
|
||||
@ -148,3 +153,103 @@ func TestTenantCreatedWire_EmptyEmailOmitted_StillDecodes(t *testing.T) {
|
||||
t.Errorf("non-email fields lost on empty-email wire: id=%q slug=%q", got.ID, got.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenantCreatedWire_AppConfigs_RoundTrip — TBD-V18-D follow-up to
|
||||
// PR #2038. The publisher wrapper embeds *store.Tenant; the AppConfigs
|
||||
// field (json tag `app_configs`) MUST round-trip into the consumer's
|
||||
// flat decode shape so a downstream HelmRelease-values binding can
|
||||
// read replicas / disk_gb / backups_enabled without a second hop.
|
||||
// A regression that drops the bson/json tag, renames the field, or
|
||||
// wraps the Tenant in a nested key would fail here before reaching
|
||||
// staging.
|
||||
func TestTenantCreatedWire_AppConfigs_RoundTrip(t *testing.T) {
|
||||
tenant := &store.Tenant{
|
||||
ID: "tenant-abc",
|
||||
Slug: "acme",
|
||||
Name: "ACME Corp",
|
||||
OwnerID: "user-xyz",
|
||||
PlanID: "sme-pool-basic",
|
||||
Apps: []string{"wordpress", "postgres"},
|
||||
Status: "provisioning",
|
||||
Subdomain: "acme",
|
||||
AppConfigs: map[string]map[string]any{
|
||||
"postgres": {
|
||||
"replicas": float64(3),
|
||||
"disk_gb": float64(50),
|
||||
"backups_enabled": true,
|
||||
},
|
||||
"wordpress": {
|
||||
"replicas": float64(2),
|
||||
},
|
||||
},
|
||||
}
|
||||
tenantCreatedPayload := struct {
|
||||
*store.Tenant
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
}{Tenant: tenant, OwnerEmail: "owner@example.com"}
|
||||
|
||||
wire, err := json.Marshal(tenantCreatedPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("publisher marshal failed: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(wire), `"app_configs"`) {
|
||||
t.Errorf("wire bytes missing app_configs sibling: %s", string(wire))
|
||||
}
|
||||
|
||||
var got consumerPayloadMirror
|
||||
if err := json.Unmarshal(wire, &got); err != nil {
|
||||
t.Fatalf("consumer decode failed: %v\nwire=%s", err, string(wire))
|
||||
}
|
||||
if got.AppConfigs == nil {
|
||||
t.Fatalf("app_configs nil on consumer side; wire=%s", string(wire))
|
||||
}
|
||||
pg := got.AppConfigs["postgres"]
|
||||
if pg == nil {
|
||||
t.Fatalf("postgres bucket missing from app_configs; got=%+v", got.AppConfigs)
|
||||
}
|
||||
// JSON numbers decode as float64 into `any`; equality compares the
|
||||
// canonical decoded shape, not the publisher's int literal.
|
||||
if pg["replicas"] != float64(3) {
|
||||
t.Errorf("postgres.replicas: got %v (%T) want 3 (float64)", pg["replicas"], pg["replicas"])
|
||||
}
|
||||
if pg["disk_gb"] != float64(50) {
|
||||
t.Errorf("postgres.disk_gb: got %v want 50", pg["disk_gb"])
|
||||
}
|
||||
if pg["backups_enabled"] != true {
|
||||
t.Errorf("postgres.backups_enabled: got %v want true", pg["backups_enabled"])
|
||||
}
|
||||
wp := got.AppConfigs["wordpress"]
|
||||
if wp == nil || wp["replicas"] != float64(2) {
|
||||
t.Errorf("wordpress.replicas lost; got=%+v", wp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenantCreatedWire_EmptyAppConfigs_Omitted — omitempty tag drops
|
||||
// an empty `app_configs` field so legacy clients keep observing the
|
||||
// pre-TBD-V18-D wire shape. The consumer's flat decode tolerates the
|
||||
// absence: app_configs == nil is a legitimate signal that the
|
||||
// customer never opened an AppDetail surface with a non-empty
|
||||
// configSchema.
|
||||
func TestTenantCreatedWire_EmptyAppConfigs_Omitted(t *testing.T) {
|
||||
tenant := &store.Tenant{
|
||||
ID: "tenant-abc",
|
||||
Slug: "acme",
|
||||
Name: "ACME Corp",
|
||||
OwnerID: "user-xyz",
|
||||
PlanID: "sme-pool-basic",
|
||||
// AppConfigs deliberately nil.
|
||||
}
|
||||
tenantCreatedPayload := struct {
|
||||
*store.Tenant
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
}{Tenant: tenant, OwnerEmail: "owner@example.com"}
|
||||
|
||||
wire, err := json.Marshal(tenantCreatedPayload)
|
||||
if err != nil {
|
||||
t.Fatalf("publisher marshal failed: %v", err)
|
||||
}
|
||||
if strings.Contains(string(wire), `"app_configs"`) {
|
||||
t.Errorf("expected omitempty to drop nil app_configs, but field present: %s", string(wire))
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,6 +26,20 @@ type Tenant struct {
|
||||
// "installing" | "uninstalling" | "failed". Absent means the app is in
|
||||
// its steady state (installed when the ID is in Apps; gone otherwise).
|
||||
AppStates map[string]string `bson:"app_states,omitempty" json:"app_states,omitempty"`
|
||||
// AppConfigs carries per-instance configSchema values chosen by
|
||||
// the customer on the marketplace AppDetail surface, keyed by app
|
||||
// SLUG (e.g. "postgres" or "wordpress"). The inner map keys are
|
||||
// `ConfigField.Key` names (e.g. "replicas", "disk_gb",
|
||||
// "backups_enabled") and values are the field-typed primitives
|
||||
// (int / string / bool). Empty when no app in the cart shipped a
|
||||
// configSchema (Ghost / Nextcloud today) or when the cart predates
|
||||
// TBD-V18-D (#2026 follow-up to PR #2038). Down-stream consumers
|
||||
// (provisioning, blueprint-controller) read this when rendering
|
||||
// HelmRelease values — the actual binding lands behind the
|
||||
// TBD-V26 (#2040) Path A/B decision; this field threads the
|
||||
// SHAPE end-to-end so the binding lights up without a second
|
||||
// upstream change.
|
||||
AppConfigs map[string]map[string]any `bson:"app_configs,omitempty" json:"app_configs,omitempty"`
|
||||
AddOns []string `bson:"addons" json:"addons"`
|
||||
Subdomain string `bson:"subdomain" json:"subdomain"`
|
||||
CustomDomains []string `bson:"custom_domains" json:"custom_domains"`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user