1
14import type { APIRequestContext } from "@playwright/test";
15import { expect, request as pwRequest } from "@playwright/test";
16import type {
17 BlockDto,
18 CommentDto,
19 PageDto,
20 RecordDto,
21 Workspace,
22} from "../fixtures";
23
24
25const API_BASE = process.env.E2E_API_URL ?? "http://localhost:4000";
26
27
28
29
30
31export interface UserDto {
32 id: string;
33 email: string;
34 name: string;
35}
36
37
38
39
40
41export interface WorkspaceMemberDto {
42 userId: string;
43 email: string;
44 name: string;
45 displayName: string;
46 avatarUrl: string | null;
47 role: "OWNER" | "ADMIN" | "MEMBER" | "GUEST";
48 joinedAt: string | null;
49}
50
51export interface InvitationDto {
52 id: string;
53 workspaceId: string;
54 email: string;
55 role: "OWNER" | "ADMIN" | "MEMBER" | "GUEST";
56 token: string;
57 status: string;
58}
59
60export interface DatabaseDto {
61 id: string;
62 workspaceId: string;
63 pageId: string | null;
64 title: string;
65 properties: { id: string; name: string; type: string; config?: unknown }[];
66 views: { id: string; name: string; type: string }[];
67}
68
69export interface RecordQuery {
70 filter?: unknown;
71 sort?: unknown;
72 group?: unknown;
73 limit?: number;
74 offset?: number;
75}
76
77export interface CommentReplyDto {
78 id: string;
79 commentId: string;
80 body: string;
81 createdBy: string;
82}
83
84export interface FileDto {
85 id: string;
86 workspaceId: string;
87 name: string;
88 size: number;
89 mimeType: string;
90 status: "UPLOADING" | "SCANNING" | "READY" | "VIRUS_DETECTED" | "FAILED";
91
92 downloadUrl?: string;
93
94 previewUrl?: string;
95
96 thumbnailUrl?: string | null;
97 expiresAt?: string;
98}
99
100
101
102
103
104export async function ok<T>(
105 ctx: APIRequestContext,
106 res: Awaited<ReturnType<APIRequestContext["get"]>>,
107 what: string,
108): Promise<T> {
109 expect(
110 res.status(),
111 `${what} — expected success, got ${res.status()}: ${await res.text().catch(() => "")}`,
112 ).toBeGreaterThanOrEqual(200);
113 expect(
114 res.status(),
115 `${what} — expected 2xx, got ${res.status()}: ${await res.text().catch(() => "")}`,
116 ).toBeLessThan(300);
117 return (await res.json()) as T;
118}
119
120
121export async function write<T>(
122 ctx: APIRequestContext,
123 method: "POST" | "PUT" | "PATCH",
124 url: string,
125 data: unknown,
126 what: string,
127 headers?: Record<string, string>,
128): Promise<T> {
129 const res = await ctx[method.toLowerCase() as "post" | "put" | "patch"](url, {
130 data,
131 headers,
132 });
133 return ok<T>(ctx, res, what);
134}
135
136
137
138
139
140export async function register(
141 ctx: APIRequestContext,
142 email: string,
143 password: string,
144 name = "E2E User",
145): Promise<{ user: UserDto }> {
146 const res = await ctx.post("/api/v1/auth/register", {
147 data: { email, password, name },
148 });
149 expect(res.status(), `register ${email}: ${await res.text()}`).toBe(201);
150 return ok(ctx, res, "register");
151}
152
153export async function login(
154 ctx: APIRequestContext,
155 email: string,
156 password: string,
157): Promise<{ accessToken?: string; refreshToken?: string }> {
158 const res = await ctx.post("/api/v1/auth/login", { data: { email, password } });
159 expect(res.status(), `login ${email}: ${await res.text()}`).toBe(200);
160 return ok(ctx, res, "login");
161}
162
163export async function me(ctx: APIRequestContext): Promise<UserDto> {
164 const body = await ok<{ user: UserDto }>(ctx, await ctx.get("/api/v1/auth/me"), "GET /me");
165 return body.user;
166}
167
168
169
170
171
172export async function createWorkspace(
173 ctx: APIRequestContext,
174 name: string,
175): Promise<Workspace> {
176
177
178 return write<Workspace>(ctx, "POST", "/api/v1/workspaces", { name }, "createWorkspace");
179}
180
181export async function renameWorkspace(
182 ctx: APIRequestContext,
183 id: string,
184 name: string,
185): Promise<Workspace> {
186
187 return write<Workspace>(ctx, "PATCH", `/api/v1/workspaces/${id}`, { name }, "renameWorkspace");
188}
189
190export async function deleteWorkspace(ctx: APIRequestContext, id: string): Promise<void> {
191 const res = await ctx.delete(`/api/v1/workspaces/${id}`);
192 expect(res.status(), `deleteWorkspace: ${await res.text()}`).toBe(204);
193}
194
195export async function listMembers(
196 ctx: APIRequestContext,
197 workspaceId: string,
198): Promise<WorkspaceMemberDto[]> {
199
200
201 const body = await ok<{ data: WorkspaceMemberDto[] }>(
202 ctx,
203 await ctx.get(`/api/v1/workspaces/${workspaceId}/members`),
204 "listMembers",
205 );
206 return body.data;
207}
208
209
216export async function inviteMember(
217 ctx: APIRequestContext,
218 workspaceId: string,
219 email: string,
220 role: "ADMIN" | "MEMBER" | "GUEST" = "MEMBER",
221): Promise<{ id: string; email: string; role: string; expiresAt: string }> {
222 return write<{ id: string; email: string; role: string; expiresAt: string }>(
223 ctx,
224 "POST",
225 `/api/v1/workspaces/${workspaceId}/members/invites`,
226 { email, role },
227 "inviteMember",
228 );
229}
230
231
237export function seamServiceToken(): string {
238 const token = process.env.E2E_INTERNAL_SERVICE_TOKEN;
239 if (!token) {
240 throw new Error(
241 "E2E_INTERNAL_SERVICE_TOKEN is not set — the two-user seam needs the API " +
242 "started with INTERNAL_SERVICE_TOKEN + INTERNAL_TEST_SEAM=1 (see ci.yml e2e job).",
243 );
244 }
245 return token;
246}
247
248
255export async function addMemberViaSeam(
256 serviceToken: string,
257 workspaceId: string,
258 email: string,
259 role: "ADMIN" | "MEMBER" | "GUEST" = "MEMBER",
260): Promise<void> {
261 const res = await fetch(`${API_BASE}/internal/workspaces/${workspaceId}/members`, {
262 method: "POST",
263 headers: { "Content-Type": "application/json", Authorization: `Bearer ${serviceToken}` },
264 body: JSON.stringify({ email, role }),
265 });
266
267
268
269
270
271 const text = await res.text();
272 expect(
273 res.status,
274 `addMemberViaSeam(${email}, ${role}): ${res.status} ${text}`,
275 ).toBe(201);
276}
277
278
279
280
281
282
283
284
285
286
295export async function armAiMockFailures(
296 serviceToken: string,
297 workspaceId: string,
298 status: 429 | 500 | 502 | 503 | 504 = 500,
299 deltas = 2,
300): Promise<void> {
301 const res = await fetch(`${API_BASE}/internal/workspaces/${workspaceId}/ai/mock-fail`, {
302 method: "POST",
303 headers: { "Content-Type": "application/json", Authorization: `Bearer ${serviceToken}` },
304 body: JSON.stringify({ status, deltas }),
305 });
306 const text = await res.text().catch(() => "");
307 expect(
308 res.status,
309 `armAiMockFailures(${workspaceId}, ${status}, ${deltas}): ${res.status} ${text}`,
310 ).toBe(200);
311}
312
313
319export async function setAiBudgetLimit(
320 serviceToken: string,
321 workspaceId: string,
322 tokens: number | null,
323): Promise<void> {
324 const res = await fetch(`${API_BASE}/internal/workspaces/${workspaceId}/ai/usage-limits`, {
325 method: "POST",
326 headers: { "Content-Type": "application/json", Authorization: `Bearer ${serviceToken}` },
327 body: JSON.stringify({ aiMonthlyTokens: tokens }),
328 });
329 const text = await res.text().catch(() => "");
330 expect(
331 res.status,
332 `setAiBudgetLimit(${workspaceId}, ${tokens}): ${res.status} ${text}`,
333 ).toBe(200);
334}
335
336
337
338
339
340export async function createPage(
341 ctx: APIRequestContext,
342 workspaceId: string,
343 opts: { title?: string; parentId?: string | null } = {},
344): Promise<PageDto> {
345
346
347 return write<PageDto>(
348 ctx,
349 "POST",
350 `/api/v1/workspaces/${workspaceId}/pages`,
351 { title: opts.title ?? "", parentId: opts.parentId ?? null },
352 "createPage",
353 );
354}
355
356export async function getBlocks(
357 ctx: APIRequestContext,
358 pageId: string,
359): Promise<BlockDto[]> {
360
361
362
363
364
365 const body = await ok<{ data?: BlockDto[]; blocks?: BlockDto[]; nextCursor?: string | null }>(
366 ctx,
367 await ctx.get(`/api/v1/pages/${pageId}/blocks`),
368 "getBlocks",
369 );
370 return body.data ?? body.blocks ?? [];
371}
372
373
374
375
376
377
381export async function batchBlocks(
382 ctx: APIRequestContext,
383 pageId: string,
384 operations: Record<string, unknown>[],
385): Promise<{ blocks: BlockDto[]; version: number }> {
386 const body = await write<{ blocks: BlockDto[]; version: number }>(
387 ctx,
388 "POST",
389 `/api/v1/pages/${pageId}/blocks:batch`,
390 { operations },
391 "batchBlocks",
392 );
393 return body;
394}
395
396export async function createBlock(
397 ctx: APIRequestContext,
398 pageId: string,
399 block: {
400 type: string;
401 content?: unknown;
402 properties?: unknown;
403 position?: string;
404 parentId?: string | null;
405 },
406): Promise<BlockDto> {
407
408
409
410 const body = await write<{ block?: BlockDto } & BlockDto>(
411 ctx,
412 "POST",
413 `/api/v1/pages/${pageId}/blocks`,
414 block,
415 "createBlock",
416 );
417 return (body.block ?? body) as BlockDto;
418}
419
420
421
422
423
424export async function createDatabase(
425 ctx: APIRequestContext,
426 workspaceId: string,
427 opts: {
428 title?: string;
429 pageId?: string | null;
430 properties?: { name: string; type: string; config?: unknown; isTitle?: boolean }[];
431 } = {},
432): Promise<DatabaseDto> {
433 const body = await write<{ database: DatabaseDto }>(
434 ctx,
435 "POST",
436 `/api/v1/workspaces/${workspaceId}/databases`,
437 {
438 title: opts.title ?? "E2E DB",
439 pageId: opts.pageId ?? null,
440 properties: opts.properties,
441 },
442 "createDatabase",
443 );
444 return body.database;
445}
446
447export async function addProperty(
448 ctx: APIRequestContext,
449 databaseId: string,
450 property: { name: string; type: string; config?: unknown },
451): Promise<{ id: string; name: string; type: string }> {
452 const body = await write<{ property: { id: string; name: string; type: string } }>(
453 ctx,
454 "POST",
455 `/api/v1/databases/${databaseId}/properties`,
456 property,
457 "addProperty",
458 );
459 return body.property;
460}
461
462export async function createRecord(
463 ctx: APIRequestContext,
464 databaseId: string,
465 properties: Record<string, unknown> = {},
466): Promise<RecordDto> {
467 const body = await write<{ record: RecordDto }>(
468 ctx,
469 "POST",
470 `/api/v1/databases/${databaseId}/records`,
471 { properties },
472 "createRecord",
473 );
474 return body.record;
475}
476
477
482export async function queryRecords(
483 ctx: APIRequestContext,
484 databaseId: string,
485 query: RecordQuery = {},
486): Promise<{ records: RecordDto[]; total: number }> {
487 const params: Record<string, string | number> = {};
488 for (const [k, v] of Object.entries(query)) {
489 if (v === undefined) continue;
490 params[k] = typeof v === "object" ? JSON.stringify(v) : String(v);
491 }
492 const body = await ok<{ records: RecordDto[]; total: number }>(
493 ctx,
494 await ctx.get(`/api/v1/databases/${databaseId}/records`, { params }),
495 "queryRecords",
496 );
497 return body;
498}
499
500
501
502
503
504export async function createComment(
505 ctx: APIRequestContext,
506 pageId: string,
507 body: string,
508 opts: { blockId?: string | null; mentionIds?: string[] } = {},
509): Promise<CommentDto> {
510 const c = await write<{ comment: CommentDto }>(
511 ctx,
512 "POST",
513 `/api/v1/pages/${pageId}/comments`,
514 { body, blockId: opts.blockId ?? null, mentionIds: opts.mentionIds ?? [] },
515 "createComment",
516 );
517 return c.comment;
518}
519
520export async function replyToComment(
521 ctx: APIRequestContext,
522 commentId: string,
523 body: string,
524): Promise<CommentReplyDto> {
525 const c = await write<{ reply: CommentReplyDto }>(
526 ctx,
527 "POST",
528 `/api/v1/comments/${commentId}/replies`,
529 { body },
530 "replyToComment",
531 );
532 return c.reply;
533}
534
535export async function resolveComment(ctx: APIRequestContext, commentId: string): Promise<CommentDto> {
536 const c = await write<{ comment: CommentDto }>(
537 ctx,
538 "POST",
539 `/api/v1/comments/${commentId}/resolve`,
540 {},
541 "resolveComment",
542 );
543 return c.comment;
544}
545
546
547
548
549
550
558export async function putPresigned(
559 uploadUrl: string,
560 bytes: Buffer | Uint8Array,
561 contentType: string,
562): Promise<number> {
563 const putCtx = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 });
564 try {
565 const put = await putCtx.put(uploadUrl, {
566 data: bytes as never,
567 headers: { "Content-Type": contentType },
568 });
569 return put.status();
570 } finally {
571 await putCtx.dispose();
572 }
573}
574
575
591export async function uploadFile(
592 ctx: APIRequestContext,
593 name: string,
594 mimeType: string,
595 bytes: Buffer | Uint8Array,
596 workspaceId: string,
597 maxWaitMs = 30_000,
598): Promise<FileDto> {
599 const session = await write<{
600 upload: {
601 file: { id: string };
602 uploadUrl: string;
603 method: string;
604 headers: Record<string, string>;
605 };
606 }>(
607 ctx,
608 "POST",
609 "/api/v1/files/uploads",
610 { name, mimeType, size: bytes.length },
611 "upload-session",
612 workspaceHeader(workspaceId),
613 );
614 const fileId = session.upload.file.id;
615 const putStatus = await putPresigned(
616 session.upload.uploadUrl,
617 bytes,
618 session.upload.headers["Content-Type"] ?? mimeType,
619 );
620 expect(putStatus, `presigned PUT returned ${putStatus}`).toBeLessThan(300);
621
622 await write<{ id: string; status: FileDto["status"] }>(
623 ctx,
624 "PUT",
625 `/api/v1/files/uploads/${fileId}/complete`,
626 {},
627 "file-complete",
628 );
629
630 const deadline = Date.now() + maxWaitMs;
631 for (;;) {
632 const detail = await ok<FileDto>(
633 ctx,
634 await ctx.get(`/api/v1/files/${fileId}`, { headers: workspaceHeader(workspaceId) }),
635 "file-detail",
636 );
637 if (detail.status === "READY" || detail.status === "VIRUS_DETECTED" || detail.status === "FAILED") {
638 return detail;
639 }
640 if (Date.now() >= deadline) {
641 throw new Error(`uploadFile: ${name} still ${detail.status} after ${maxWaitMs} ms`);
642 }
643 await new Promise((r) => setTimeout(r, 1000));
644 }
645}
646
647
648export function workspaceHeader(workspaceId: string): Record<string, string> {
649 return { "x-factory-workspace": workspaceId };
650}
651
652
656export async function getFile(
657 ctx: APIRequestContext,
658 fileId: string,
659 workspaceId: string,
660): Promise<FileDto> {
661 return ok<FileDto>(
662 ctx,
663 await ctx.get(`/api/v1/files/${fileId}`, { headers: workspaceHeader(workspaceId) }),
664 "getFile",
665 );
666}
667
668
669export async function waitForFileStatus(
670 ctx: APIRequestContext,
671 fileId: string,
672 workspaceId: string,
673 status: FileDto["status"] = "READY",
674 maxWaitMs = 45_000,
675): Promise<FileDto> {
676 const deadline = Date.now() + maxWaitMs;
677 for (;;) {
678 const detail = await getFile(ctx, fileId, workspaceId);
679 if (detail.status === status) return detail;
680 if (detail.status === "FAILED" || detail.status === "VIRUS_DETECTED") {
681 throw new Error(`waitForFileStatus: ${fileId} ended ${detail.status}`);
682 }
683 if (Date.now() >= deadline) {
684 throw new Error(`waitForFileStatus: ${fileId} still ${detail.status} after ${maxWaitMs} ms`);
685 }
686 await new Promise((r) => setTimeout(r, 1000));
687 }
688}
689
690
695export async function openUploadSession(
696 ctx: APIRequestContext,
697 workspaceId: string,
698 name: string,
699 mimeType: string,
700 size: number,
701): Promise<{ status: number; code: string; body: unknown }> {
702 const res = await ctx.post("/api/v1/files/uploads", {
703 data: { name, mimeType, size },
704 headers: workspaceHeader(workspaceId),
705 });
706 const body = (await res.json().catch(() => ({}))) as {
707 error?: { code?: string; message?: string };
708 };
709 return { status: res.status(), code: body.error?.code ?? "", body };
710}
711
712
713
714
715
716export interface SearchResult {
717 id: string;
718 type: "page" | "record" | "database" | "file";
719 title: string;
720 snippet?: string;
721}
722
723export async function search(
724 ctx: APIRequestContext,
725 query: string,
726 filters: Record<string, unknown> = {},
727): Promise<{ results: SearchResult[]; tookMs?: number }> {
728 const body = await ok<{ results: SearchResult[]; tookMs?: number }>(
729 ctx,
730 await ctx.get("/api/v1/search", { params: { q: query, ...filters } }),
731 "search",
732 );
733 return body;
734}
735