smoke-flow.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  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. interface Ctx {
  35. origin: string;
  36. cookie: string;
  37. startedAt: number;
  38. }
  39. interface StepResult {
  40. name: string;
  41. ok: boolean;
  42. ms: number;
  43. detail?: string;
  44. }
  45. const TABLE_NAME = 'smoke_notes';
  46. async function call<T = unknown>(
  47. ctx: Ctx,
  48. method: string,
  49. path: string,
  50. body?: unknown,
  51. ): Promise<{ status: number; data: T | null; text: string }> {
  52. const res = await fetch(`${ctx.origin}${path}`, {
  53. method,
  54. headers: {
  55. cookie: ctx.cookie,
  56. 'content-type': 'application/json',
  57. accept: 'application/json',
  58. },
  59. body: body ? JSON.stringify(body) : undefined,
  60. });
  61. const text = await res.text();
  62. let data: T | null = null;
  63. try {
  64. data = text ? (JSON.parse(text) as T) : null;
  65. } catch {
  66. data = null;
  67. }
  68. return { status: res.status, data, text };
  69. }
  70. async function step<T>(
  71. ctx: Ctx,
  72. name: string,
  73. fn: () => Promise<T>,
  74. check: (v: T) => string | null,
  75. ): Promise<StepResult> {
  76. const t0 = performance.now();
  77. try {
  78. const v = await fn();
  79. const err = check(v);
  80. const ms = Math.round(performance.now() - t0);
  81. if (err) return { name, ok: false, ms, detail: err };
  82. return { name, ok: true, ms };
  83. } catch (e) {
  84. const ms = Math.round(performance.now() - t0);
  85. return { name, ok: false, ms, detail: e instanceof Error ? e.message : String(e) };
  86. }
  87. }
  88. function print(r: StepResult): void {
  89. const tag = r.ok ? '\x1b[32m GO\x1b[0m' : '\x1b[31mNO-GO\x1b[0m';
  90. const detail = r.detail ? ` · ${r.detail}` : '';
  91. console.log(`${tag} ${r.name.padEnd(38)} ${String(r.ms).padStart(5)}ms${detail}`);
  92. }
  93. async function main(): Promise<number> {
  94. const origin = process.env.BRIVEN_API_ORIGIN;
  95. const cookie = process.env.BRIVEN_SESSION_COOKIE;
  96. if (!origin || !cookie) {
  97. console.error('set BRIVEN_API_ORIGIN + BRIVEN_SESSION_COOKIE');
  98. return 2;
  99. }
  100. const ctx: Ctx = { origin, cookie, startedAt: Date.now() };
  101. const results: StepResult[] = [];
  102. let projectId: string | null = null;
  103. results.push(
  104. await step(
  105. ctx,
  106. '1. /info reachable',
  107. () => call<{ buildSha: string }>(ctx, 'GET', '/info'),
  108. (r) => (r.status === 200 && r.data?.buildSha ? null : `status=${r.status}`),
  109. ),
  110. );
  111. results.push(
  112. await step(
  113. ctx,
  114. '2. /v1/me session valid',
  115. () => call<{ email: string }>(ctx, 'GET', '/v1/me'),
  116. (r) => (r.status === 200 && r.data?.email ? null : `status=${r.status} body=${r.text.slice(0, 120)}`),
  117. ),
  118. );
  119. results.push(
  120. await step(
  121. ctx,
  122. '3. /v1/me/orgs has personal org',
  123. () => call<{ orgs: Array<{ id: string; personal: boolean }> }>(ctx, 'GET', '/v1/me/orgs'),
  124. (r) => {
  125. if (r.status !== 200 || !r.data) return `status=${r.status}`;
  126. const personal = r.data.orgs.find((o) => o.personal);
  127. return personal ? null : 'no personal org';
  128. },
  129. ),
  130. );
  131. results.push(
  132. await step(
  133. ctx,
  134. '4. list projects',
  135. () => call<{ projects: unknown[] }>(ctx, 'GET', '/v1/projects'),
  136. (r) => (r.status === 200 ? null : `status=${r.status}`),
  137. ),
  138. );
  139. const created = await step(
  140. ctx,
  141. '5. create project (briven-smoke)',
  142. () =>
  143. call<{ project: { id: string; slug: string } }>(ctx, 'POST', '/v1/projects', {
  144. name: `briven-smoke-${Date.now()}`,
  145. region: 'eu-west-1',
  146. }),
  147. (r) => {
  148. if (r.status !== 200 || !r.data?.project) return `status=${r.status} body=${r.text.slice(0, 120)}`;
  149. projectId = r.data.project.id;
  150. return null;
  151. },
  152. );
  153. results.push(created);
  154. if (projectId) {
  155. results.push(
  156. await step(
  157. ctx,
  158. '6. CREATE TABLE smoke_notes',
  159. () =>
  160. call<{ name: string }>(
  161. ctx,
  162. 'POST',
  163. `/v1/projects/${projectId}/studio/tables`,
  164. {
  165. tableName: TABLE_NAME,
  166. columns: [
  167. { name: 'id', type: 'text', primaryKey: true },
  168. { name: 'body', type: 'text', notNull: true },
  169. {
  170. name: 'createdAt',
  171. type: 'timestamptz',
  172. notNull: true,
  173. defaultExpr: 'now()',
  174. },
  175. ],
  176. },
  177. ),
  178. (r) => (r.status === 201 ? null : `status=${r.status} body=${r.text.slice(0, 200)}`),
  179. ),
  180. );
  181. const id = `smk_${Math.random().toString(36).slice(2, 10)}`;
  182. results.push(
  183. await step(
  184. ctx,
  185. '7. INSERT row',
  186. () =>
  187. call(
  188. ctx,
  189. 'POST',
  190. `/v1/projects/${projectId}/studio/tables/${TABLE_NAME}/rows`,
  191. { values: { id, body: 'hello from smoke flow' } },
  192. ),
  193. (r) => (r.status === 201 ? null : `status=${r.status} body=${r.text.slice(0, 200)}`),
  194. ),
  195. );
  196. results.push(
  197. await step(
  198. ctx,
  199. '8. SELECT rows · expect 1',
  200. () =>
  201. call<{ rows: unknown[] }>(
  202. ctx,
  203. 'GET',
  204. `/v1/projects/${projectId}/studio/tables/${TABLE_NAME}/rows?limit=10`,
  205. ),
  206. (r) => {
  207. if (r.status !== 200 || !r.data) return `status=${r.status}`;
  208. if (r.data.rows.length === 0) return 'no rows returned';
  209. return null;
  210. },
  211. ),
  212. );
  213. results.push(
  214. await step(
  215. ctx,
  216. '9. sql editor: count(*)',
  217. () =>
  218. call<{ rows: unknown[] }>(
  219. ctx,
  220. 'POST',
  221. `/v1/projects/${projectId}/studio/query`,
  222. { sql: `SELECT count(*) FROM ${TABLE_NAME}` },
  223. ),
  224. (r) => (r.status === 200 ? null : `status=${r.status} body=${r.text.slice(0, 200)}`),
  225. ),
  226. );
  227. results.push(
  228. await step(
  229. ctx,
  230. '10. soft-delete test project',
  231. () => call(ctx, 'DELETE', `/v1/projects/${projectId}`),
  232. (r) => (r.status === 200 ? null : `status=${r.status} body=${r.text.slice(0, 200)}`),
  233. ),
  234. );
  235. } else {
  236. // Skip the rest if project creation failed.
  237. results.push({ name: '6–10. skipped (no project)', ok: false, ms: 0 });
  238. }
  239. console.log('\nbriven smoke flow · summary');
  240. console.log('─'.repeat(60));
  241. for (const r of results) print(r);
  242. const failed = results.filter((r) => !r.ok).length;
  243. console.log('─'.repeat(60));
  244. console.log(`${results.length - failed} / ${results.length} GO`);
  245. return failed > 0 ? 1 : 0;
  246. }
  247. const code = await main();
  248. process.exit(code);