smoke-flow.ts268 lines · main
1/**
2 * End-to-end smoke test — drives the customer journey through the prod
3 * api without any UI clicks. Day 2 G step automation; faster + more
4 * repeatable than the manual checklist in the plan.
5 *
6 * Covers the parts of the flow we can drive purely via HTTP:
7 * 1. /info — api reachable, build sha known
8 * 2. /v1/me — session cookie valid
9 * 3. /v1/me/orgs — personal org auto-created
10 * 4. /v1/projects — list owned projects
11 * 5. POST /v1/projects — create a fresh project
12 * 6. POST /v1/projects/:id/studio/tables — create one table
13 * 7. POST /v1/projects/:id/studio/tables/:t/rows — insert one row
14 * 8. GET /v1/projects/:id/studio/tables/:t/rows — read it back
15 * 9. POST /v1/projects/:id/studio/query — sql editor smoke
16 * 10. DELETE /v1/projects/:id — soft-delete the test project
17 *
18 * Out of band (UI-only, run those manually): OAuth provider flow,
19 * polar checkout, account deletion form. The harness validates that
20 * the api supports each step end-to-end with a real session.
21 *
22 * Usage:
23 * BRIVEN_API_ORIGIN=https://api.briven.tech \
24 * BRIVEN_SESSION_COOKIE='better-auth.session_token=...' \
25 * bun infra/load-tests/smoke-flow.ts
26 *
27 * Capture the session cookie from your browser devtools after signing
28 * in via the dashboard. The harness never sees your password.
29 *
30 * Exits 0 on full pass, 1 on any GO/NO-GO failure. Each step prints
31 * GO or NO-GO with the relevant diagnostics so a failed run can be
32 * shipped to support as-is.
33 */
34
35interface Ctx {
36 origin: string;
37 cookie: string;
38 startedAt: number;
39}
40
41interface StepResult {
42 name: string;
43 ok: boolean;
44 ms: number;
45 detail?: string;
46}
47
48const TABLE_NAME = 'smoke_notes';
49
50async function call<T = unknown>(
51 ctx: Ctx,
52 method: string,
53 path: string,
54 body?: unknown,
55): Promise<{ status: number; data: T | null; text: string }> {
56 const res = await fetch(`${ctx.origin}${path}`, {
57 method,
58 headers: {
59 cookie: ctx.cookie,
60 'content-type': 'application/json',
61 accept: 'application/json',
62 },
63 body: body ? JSON.stringify(body) : undefined,
64 });
65 const text = await res.text();
66 let data: T | null = null;
67 try {
68 data = text ? (JSON.parse(text) as T) : null;
69 } catch {
70 data = null;
71 }
72 return { status: res.status, data, text };
73}
74
75async function step<T>(
76 ctx: Ctx,
77 name: string,
78 fn: () => Promise<T>,
79 check: (v: T) => string | null,
80): Promise<StepResult> {
81 const t0 = performance.now();
82 try {
83 const v = await fn();
84 const err = check(v);
85 const ms = Math.round(performance.now() - t0);
86 if (err) return { name, ok: false, ms, detail: err };
87 return { name, ok: true, ms };
88 } catch (e) {
89 const ms = Math.round(performance.now() - t0);
90 return { name, ok: false, ms, detail: e instanceof Error ? e.message : String(e) };
91 }
92}
93
94function print(r: StepResult): void {
95 const tag = r.ok ? '\x1b[32m GO\x1b[0m' : '\x1b[31mNO-GO\x1b[0m';
96 const detail = r.detail ? ` · ${r.detail}` : '';
97 console.log(`${tag} ${r.name.padEnd(38)} ${String(r.ms).padStart(5)}ms${detail}`);
98}
99
100async function main(): Promise<number> {
101 const origin = process.env.BRIVEN_API_ORIGIN;
102 const cookie = process.env.BRIVEN_SESSION_COOKIE;
103 if (!origin || !cookie) {
104 console.error('set BRIVEN_API_ORIGIN + BRIVEN_SESSION_COOKIE');
105 return 2;
106 }
107
108 const ctx: Ctx = { origin, cookie, startedAt: Date.now() };
109 const results: StepResult[] = [];
110 let projectId: string | null = null;
111
112 results.push(
113 await step(
114 ctx,
115 '1. /info reachable',
116 () => call<{ buildSha: string }>(ctx, 'GET', '/info'),
117 (r) => (r.status === 200 && r.data?.buildSha ? null : `status=${r.status}`),
118 ),
119 );
120
121 results.push(
122 await step(
123 ctx,
124 '2. /v1/me session valid',
125 () => call<{ email: string }>(ctx, 'GET', '/v1/me'),
126 (r) => (r.status === 200 && r.data?.email ? null : `status=${r.status} body=${r.text.slice(0, 120)}`),
127 ),
128 );
129
130 results.push(
131 await step(
132 ctx,
133 '3. /v1/me/orgs has personal org',
134 () => call<{ orgs: Array<{ id: string; personal: boolean }> }>(ctx, 'GET', '/v1/me/orgs'),
135 (r) => {
136 if (r.status !== 200 || !r.data) return `status=${r.status}`;
137 const personal = r.data.orgs.find((o) => o.personal);
138 return personal ? null : 'no personal org';
139 },
140 ),
141 );
142
143 results.push(
144 await step(
145 ctx,
146 '4. list projects',
147 () => call<{ projects: unknown[] }>(ctx, 'GET', '/v1/projects'),
148 (r) => (r.status === 200 ? null : `status=${r.status}`),
149 ),
150 );
151
152 const created = await step(
153 ctx,
154 '5. create project (briven-smoke)',
155 () =>
156 call<{ project: { id: string; slug: string } }>(ctx, 'POST', '/v1/projects', {
157 name: `briven-smoke-${Date.now()}`,
158 region: 'eu-west-1',
159 }),
160 (r) => {
161 if (r.status !== 200 || !r.data?.project) return `status=${r.status} body=${r.text.slice(0, 120)}`;
162 projectId = r.data.project.id;
163 return null;
164 },
165 );
166 results.push(created);
167
168 if (projectId) {
169 results.push(
170 await step(
171 ctx,
172 '6. CREATE TABLE smoke_notes',
173 () =>
174 call<{ name: string }>(
175 ctx,
176 'POST',
177 `/v1/projects/${projectId}/studio/tables`,
178 {
179 tableName: TABLE_NAME,
180 columns: [
181 { name: 'id', type: 'text', primaryKey: true },
182 { name: 'body', type: 'text', notNull: true },
183 {
184 name: 'createdAt',
185 type: 'timestamptz',
186 notNull: true,
187 defaultExpr: 'now()',
188 },
189 ],
190 },
191 ),
192 (r) => (r.status === 201 ? null : `status=${r.status} body=${r.text.slice(0, 200)}`),
193 ),
194 );
195
196 const id = `smk_${Math.random().toString(36).slice(2, 10)}`;
197 results.push(
198 await step(
199 ctx,
200 '7. INSERT row',
201 () =>
202 call(
203 ctx,
204 'POST',
205 `/v1/projects/${projectId}/studio/tables/${TABLE_NAME}/rows`,
206 { values: { id, body: 'hello from smoke flow' } },
207 ),
208 (r) => (r.status === 201 ? null : `status=${r.status} body=${r.text.slice(0, 200)}`),
209 ),
210 );
211
212 results.push(
213 await step(
214 ctx,
215 '8. SELECT rows · expect 1',
216 () =>
217 call<{ rows: unknown[] }>(
218 ctx,
219 'GET',
220 `/v1/projects/${projectId}/studio/tables/${TABLE_NAME}/rows?limit=10`,
221 ),
222 (r) => {
223 if (r.status !== 200 || !r.data) return `status=${r.status}`;
224 if (r.data.rows.length === 0) return 'no rows returned';
225 return null;
226 },
227 ),
228 );
229
230 results.push(
231 await step(
232 ctx,
233 '9. sql editor: count(*)',
234 () =>
235 call<{ rows: unknown[] }>(
236 ctx,
237 'POST',
238 `/v1/projects/${projectId}/studio/query`,
239 { sql: `SELECT count(*) FROM ${TABLE_NAME}` },
240 ),
241 (r) => (r.status === 200 ? null : `status=${r.status} body=${r.text.slice(0, 200)}`),
242 ),
243 );
244
245 results.push(
246 await step(
247 ctx,
248 '10. soft-delete test project',
249 () => call(ctx, 'DELETE', `/v1/projects/${projectId}`),
250 (r) => (r.status === 200 ? null : `status=${r.status} body=${r.text.slice(0, 200)}`),
251 ),
252 );
253 } else {
254 // Skip the rest if project creation failed.
255 results.push({ name: '6–10. skipped (no project)', ok: false, ms: 0 });
256 }
257
258 console.log('\nbriven smoke flow · summary');
259 console.log('─'.repeat(60));
260 for (const r of results) print(r);
261 const failed = results.filter((r) => !r.ok).length;
262 console.log('─'.repeat(60));
263 console.log(`${results.length - failed} / ${results.length} GO`);
264 return failed > 0 ? 1 : 0;
265}
266
267const code = await main();
268process.exit(code);