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 | |
| 35 | interface Ctx { |
| 36 | origin: string; |
| 37 | cookie: string; |
| 38 | startedAt: number; |
| 39 | } |
| 40 | |
| 41 | interface StepResult { |
| 42 | name: string; |
| 43 | ok: boolean; |
| 44 | ms: number; |
| 45 | detail?: string; |
| 46 | } |
| 47 | |
| 48 | const TABLE_NAME = 'smoke_notes'; |
| 49 | |
| 50 | async 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 | |
| 75 | async 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 | |
| 94 | function 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 | |
| 100 | async 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 | |
| 267 | const code = await main(); |
| 268 | process.exit(code); |