From 0feb0b40069999f09b6a36e56f498feebdccedfb Mon Sep 17 00:00:00 2001 From: e3mrah <81884938+emrahbaysal@users.noreply.github.com> Date: Wed, 20 May 2026 06:30:24 +0400 Subject: [PATCH] feat(marketplace): thread configSchema form values into install POST (Refs #2026, Refs #2042) (#2043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>` 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..= 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 Co-authored-by: Claude Opus 4.7 (1M context) --- .../playwright/customer-journey.spec.ts | 84 ++++++++++++++ .../src/components/AppDetail.svelte | 42 +++++-- .../src/components/CheckoutStep.svelte | 11 ++ core/marketplace/src/lib/api.ts | 14 ++- core/marketplace/src/lib/cart.ts | 35 ++++++ core/services/tenant/handlers/handlers.go | 31 ++++-- .../handlers/tenant_created_wire_test.go | 105 ++++++++++++++++++ core/services/tenant/store/store.go | 14 +++ 8 files changed, 317 insertions(+), 19 deletions(-) diff --git a/core/marketplace/playwright/customer-journey.spec.ts b/core/marketplace/playwright/customer-journey.spec.ts index 06e84873..306069e3 100644 --- a/core/marketplace/playwright/customer-journey.spec.ts +++ b/core/marketplace/playwright/customer-journey.spec.ts @@ -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 | 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> } + 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: // diff --git a/core/marketplace/src/components/AppDetail.svelte b/core/marketplace/src/components/AppDetail.svelte index 2c35570c..c4f72cc2 100644 --- a/core/marketplace/src/components/AppDetail.svelte +++ b/core/marketplace/src/components/AppDetail.svelte @@ -1,6 +1,6 @@