|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * production-flow.test.ts |
| 5 | + * |
| 6 | + * End-to-end verification of the full production deployment shape: |
| 7 | + * |
| 8 | + * Browser → DNS (project.hostname) |
| 9 | + * → apps/cloud (or apps/objectos as runtime node) |
| 10 | + * 1. EnvironmentRegistry.resolveByHostname(host) |
| 11 | + * → control-plane lookup of sys_project by hostname |
| 12 | + * 2. Per-project kernel created (or fetched from cache) |
| 13 | + * with the project's database driver + the bundle |
| 14 | + * loaded by the chosen template ('crm' here) |
| 15 | + * 3. Request dispatched to the project kernel; hooks |
| 16 | + * (e.g. account_protection.beforeInsert) execute |
| 17 | + * |
| 18 | + * In production, apps/cloud and apps/objectos can run as a single |
| 19 | + * unified binary (this test) or as two separate processes connected by |
| 20 | + * `OS_CLOUD_URL`. Both topologies share the *exact same code paths* |
| 21 | + * exercised here — the only difference is the transport between the |
| 22 | + * EnvironmentRegistry and the control-plane SQL driver (in-process |
| 23 | + * driver vs HTTP). This test validates the in-process flavour because |
| 24 | + * it covers all the framework-side code; cross-process transport is a |
| 25 | + * deployment concern. |
| 26 | + */ |
| 27 | + |
| 28 | +import { mkdtempSync } from 'node:fs'; |
| 29 | +import { tmpdir } from 'node:os'; |
| 30 | +import { join } from 'node:path'; |
| 31 | + |
| 32 | +const workdir = mkdtempSync(join(tmpdir(), 'objectstack-prod-flow-')); |
| 33 | +const controlDb = join(workdir, 'control.db'); |
| 34 | + |
| 35 | +process.env.OS_MODE = 'cloud'; |
| 36 | +process.env.OS_DATABASE_URL = `file:${controlDb}`; |
| 37 | +process.env.AUTH_SECRET = 'production-flow-test-secret-must-be-at-least-32-chars-long'; |
| 38 | +process.env.PORT = '0'; |
| 39 | +process.env.OS_KERNEL_CACHE_SIZE = '8'; |
| 40 | +delete process.env.OS_PROJECT_ARTIFACTS; |
| 41 | +delete process.env.OS_ARTIFACT_PATH; |
| 42 | + |
| 43 | +const { ensureApp, ensureBoot } = await import('../server/index.js'); |
| 44 | + |
| 45 | +type Init = { |
| 46 | + method?: string; |
| 47 | + body?: unknown; |
| 48 | + headers?: Record<string, string>; |
| 49 | + /** Sets the `Host` header — drives EnvironmentRegistry.resolveByHostname. */ |
| 50 | + host?: string; |
| 51 | +}; |
| 52 | + |
| 53 | +async function call(path: string, init: Init = {}): Promise<{ status: number; body: any }> { |
| 54 | + const app = await ensureApp(); |
| 55 | + const headers: Record<string, string> = { |
| 56 | + 'content-type': 'application/json', |
| 57 | + ...(init.headers ?? {}), |
| 58 | + }; |
| 59 | + const reqInit: RequestInit = { method: init.method ?? 'GET', headers }; |
| 60 | + if (init.body !== undefined) reqInit.body = JSON.stringify(init.body); |
| 61 | + // The `Host` header is forbidden for fetch() Requests — it's always |
| 62 | + // derived from the URL. To exercise hostname-based routing we have |
| 63 | + // to put the project's hostname into the URL itself; that's what |
| 64 | + // production does too (DNS resolves the vanity domain to the |
| 65 | + // runtime node, which then receives `Host: vanity.example.com`). |
| 66 | + const url = `http://${init.host ?? 'localhost'}${path}`; |
| 67 | + const res = await app.fetch(new Request(url, reqInit)); |
| 68 | + const text = await res.text(); |
| 69 | + let body: any = text; |
| 70 | + try { body = text ? JSON.parse(text) : null; } catch { /* leave as text */ } |
| 71 | + return { status: res.status, body }; |
| 72 | +} |
| 73 | + |
| 74 | +async function waitForActive(projectId: string, timeoutMs = 30_000): Promise<any> { |
| 75 | + const deadline = Date.now() + timeoutMs; |
| 76 | + while (Date.now() < deadline) { |
| 77 | + const { status, body } = await call(`/api/v1/cloud/projects/${projectId}`); |
| 78 | + if (status === 200 && body?.data?.project?.status === 'active') return body.data.project; |
| 79 | + if (status === 200 && body?.data?.project?.status === 'failed') { |
| 80 | + throw new Error( |
| 81 | + `Project ${projectId} failed to provision: ${JSON.stringify(body?.data?.project?.metadata)}`, |
| 82 | + ); |
| 83 | + } |
| 84 | + await new Promise((r) => setTimeout(r, 100)); |
| 85 | + } |
| 86 | + throw new Error(`Project ${projectId} did not become active within ${timeoutMs}ms`); |
| 87 | +} |
| 88 | + |
| 89 | +function assert(cond: any, msg: string) { |
| 90 | + if (!cond) throw new Error(`Assertion failed: ${msg}`); |
| 91 | +} |
| 92 | + |
| 93 | +const tests: Array<{ name: string; run: () => Promise<void> }> = []; |
| 94 | +function test(name: string, run: () => Promise<void>) { tests.push({ name, run }); } |
| 95 | + |
| 96 | +const state = { orgId: '', projectId: '', hostname: '' }; |
| 97 | + |
| 98 | +test('boot apps/cloud and seed organization', async () => { |
| 99 | + const boot = await ensureBoot(); |
| 100 | + const ql = (boot.kernel as any).getService('objectql'); |
| 101 | + if (!ql || typeof ql.insert !== 'function') { |
| 102 | + throw new Error('control-plane objectql unavailable on cloud kernel'); |
| 103 | + } |
| 104 | + state.orgId = (globalThis as any).crypto.randomUUID(); |
| 105 | + await ql.insert('sys_organization', { |
| 106 | + id: state.orgId, |
| 107 | + name: 'Production Flow Test Org', |
| 108 | + slug: `prod-${state.orgId.slice(0, 8)}`, |
| 109 | + created_at: new Date().toISOString(), |
| 110 | + updated_at: new Date().toISOString(), |
| 111 | + }); |
| 112 | +}); |
| 113 | + |
| 114 | +test('GET /cloud/templates exposes the CRM template', async () => { |
| 115 | + const { status, body } = await call('/api/v1/cloud/templates'); |
| 116 | + assert(status === 200, `templates GET expected 200, got ${status}`); |
| 117 | + const ids = (body?.data?.templates ?? []).map((t: any) => t.id); |
| 118 | + assert(ids.includes('crm'), `expected 'crm' in templates, got ${JSON.stringify(ids)}`); |
| 119 | +}); |
| 120 | + |
| 121 | +test('POST /cloud/projects with template_id=crm + hostname provisions an active project', async () => { |
| 122 | + state.hostname = `crm-${Date.now().toString(36)}.test.localhost`; |
| 123 | + const { status, body } = await call('/api/v1/cloud/projects', { |
| 124 | + method: 'POST', |
| 125 | + body: { |
| 126 | + organization_id: state.orgId, |
| 127 | + display_name: 'CRM Production Flow', |
| 128 | + driver: 'sqlite', |
| 129 | + hostname: state.hostname, |
| 130 | + template_id: 'crm', |
| 131 | + metadata: { __simulateDelayMs: 0 }, |
| 132 | + }, |
| 133 | + }); |
| 134 | + if (status < 200 || status >= 300) { |
| 135 | + throw new Error(`project create failed: ${status} ${JSON.stringify(body)}`); |
| 136 | + } |
| 137 | + state.projectId = body?.data?.project?.id ?? body?.data?.id; |
| 138 | + assert(state.projectId, `no project id returned: ${JSON.stringify(body)}`); |
| 139 | + const project = await waitForActive(state.projectId); |
| 140 | + assert( |
| 141 | + project?.hostname === state.hostname, |
| 142 | + `persisted hostname mismatch: expected ${state.hostname}, got ${project?.hostname}`, |
| 143 | + ); |
| 144 | +}); |
| 145 | + |
| 146 | +test('POST /api/v1/data/account with bad website (Host: <project hostname>) → 400 from CRM hook', async () => { |
| 147 | + const { status, body } = await call('/api/v1/data/account', { |
| 148 | + method: 'POST', |
| 149 | + host: state.hostname, |
| 150 | + body: { |
| 151 | + name: 'Production Flow Co.', |
| 152 | + website: 'bogus', |
| 153 | + account_number: 'pf-001', |
| 154 | + }, |
| 155 | + }); |
| 156 | + assert(status === 400, `expected 400 (hook), got ${status} body=${JSON.stringify(body)}`); |
| 157 | + assert( |
| 158 | + typeof body?.error === 'string' && /website must start with/i.test(body.error), |
| 159 | + `expected CRM hook error, got ${JSON.stringify(body)}`, |
| 160 | + ); |
| 161 | +}); |
| 162 | + |
| 163 | +test('POST /api/v1/data/account with valid payload → 201 + uppercased account_number', async () => { |
| 164 | + const { status, body } = await call('/api/v1/data/account', { |
| 165 | + method: 'POST', |
| 166 | + host: state.hostname, |
| 167 | + body: { |
| 168 | + name: 'Acme Hostname Inc.', |
| 169 | + website: 'https://acme.example.com', |
| 170 | + account_number: 'pf-002', |
| 171 | + }, |
| 172 | + }); |
| 173 | + assert(status === 200 || status === 201, `expected 2xx, got ${status} body=${JSON.stringify(body)}`); |
| 174 | + const record = body?.record ?? body?.data?.record ?? body?.data; |
| 175 | + assert(record, `no record returned: ${JSON.stringify(body)}`); |
| 176 | + assert( |
| 177 | + record.account_number === 'PF-002', |
| 178 | + `account_number not uppercased by hook (got ${JSON.stringify(record.account_number)})`, |
| 179 | + ); |
| 180 | +}); |
| 181 | + |
| 182 | +test('seeded CRM data is queryable through the hostname-routed kernel', async () => { |
| 183 | + const { status, body } = await call('/api/v1/data/account?limit=200', { host: state.hostname }); |
| 184 | + assert(status === 200, `query expected 200, got ${status}`); |
| 185 | + const rows: any[] = body?.data ?? body?.records ?? []; |
| 186 | + assert(Array.isArray(rows) && rows.length >= 1, `expected ≥1 account, got ${rows.length}`); |
| 187 | + const acme = rows.find((r) => /Acme/i.test(r.name)); |
| 188 | + assert(acme, `inserted account not found in list response`); |
| 189 | +}); |
| 190 | + |
| 191 | +let exitCode = 0; |
| 192 | +console.log(`[production-flow] workdir: ${workdir}`); |
| 193 | +for (const t of tests) { |
| 194 | + process.stdout.write(` • ${t.name} ... `); |
| 195 | + try { |
| 196 | + await t.run(); |
| 197 | + console.log('OK'); |
| 198 | + } catch (err) { |
| 199 | + exitCode = 1; |
| 200 | + console.log('FAIL'); |
| 201 | + console.error((err as Error).message); |
| 202 | + } |
| 203 | +} |
| 204 | +process.exit(exitCode); |
0 commit comments