Wiki/e2e/fixtures/baserow.ts
Corentin JOGUET e9695450ef
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
E2E Playwright / Playwright e2e (chromium) (push) Waiting to run
test(e2e): add Playwright cross-stack tests for R3.1.e database-view
7 scenarios covering the full bridge+DocAdenice+Baserow chain:
auth login, database-view insert, inline edit persistence, SSE realtime
update (no reload), RBAC write-denied, kanban drag-drop, calendar reschedule.

Includes docker-compose.e2e.yml (Postgres+Redis+Baserow+bridge+DocAdenice),
playwright.config.ts (3 projects: chromium/firefox/webkit), auth+baserow+cleanup
fixtures, global setup (API login + Baserow seed), and GitHub Actions e2e.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 00:37:23 +02:00

571 lines
16 KiB
TypeScript

/**
* Baserow pre-seed fixtures for e2e tests.
*
* Creates a minimal Baserow workspace + database + table + view + rows
* that the e2e tests can target. All entities are created via the Baserow REST
* API using the admin token obtained during the bootstrap sequence.
*
* Design choices:
* - Idempotent: checks for existence before creating (lookup by name).
* - Self-contained: all IDs are returned so tests do not have to hardcode them.
* - Cleanup: the cleanup fixture deletes everything created here to avoid test pollution.
*/
import { request, type APIRequestContext } from "@playwright/test";
const BASEROW_URL = process.env.E2E_BASEROW_URL ?? "http://localhost:8081";
export interface BaserowSeed {
workspaceId: number;
databaseId: number;
tableId: number;
gridViewId: number;
kanbanViewId: number;
calendarViewId: number;
/** Admin JWT for direct Baserow API calls. */
token: string;
/** Pre-seeded row IDs (5 rows). */
rowIds: number[];
/** Name of the single-select field used for kanban grouping. */
singleSelectFieldName: string;
/** Name of the date field used for calendar positioning. */
dateFieldName: string;
/** Name of the primary text field. */
primaryFieldName: string;
}
export interface BaserowAdminCredentials {
email: string;
password: string;
}
export const baserowAdminCredentials: BaserowAdminCredentials = {
email: process.env.E2E_BASEROW_ADMIN_EMAIL ?? "admin@acadenice-e2e.local",
password: process.env.E2E_BASEROW_ADMIN_PASSWORD ?? "E2eAdminPassword123!",
};
/**
* Login to Baserow and return the JWT token.
*/
async function loginBaserow(
apiContext: APIRequestContext,
credentials: BaserowAdminCredentials,
): Promise<string> {
const response = await apiContext.post(
`${BASEROW_URL}/api/user/token-auth/`,
{
data: credentials,
headers: { "Content-Type": "application/json" },
},
);
if (!response.ok()) {
const body = await response.text();
throw new Error(`Baserow login failed: HTTP ${response.status()}${body}`);
}
const data = (await response.json()) as { token?: string; access_token?: string };
const token = data.token ?? data.access_token;
if (!token) {
throw new Error(`Baserow login: no token in response ${JSON.stringify(data)}`);
}
return token;
}
/**
* Register the first Baserow admin account (only works on a fresh instance).
*/
async function registerBaserowAdmin(
apiContext: APIRequestContext,
credentials: BaserowAdminCredentials,
): Promise<string> {
const response = await apiContext.post(
`${BASEROW_URL}/api/user/`,
{
data: {
email: credentials.email,
password: credentials.password,
name: "E2E Admin",
authenticate: true,
},
headers: { "Content-Type": "application/json" },
},
);
if (!response.ok()) {
const body = await response.text();
throw new Error(
`Baserow admin registration failed: HTTP ${response.status()}${body}`,
);
}
const data = (await response.json()) as { token?: string; access_token?: string };
return data.token ?? data.access_token ?? "";
}
/**
* Obtain a Baserow JWT — register on first boot, login on subsequent boots.
*/
export async function getBaserowToken(
apiContext: APIRequestContext,
): Promise<string> {
// Try login first (most common case on subsequent runs).
try {
return await loginBaserow(apiContext, baserowAdminCredentials);
} catch {
// Login failed — probably a fresh Baserow instance with no users.
try {
const token = await registerBaserowAdmin(apiContext, baserowAdminCredentials);
if (token) return token;
// Registration succeeded but login required on a separate call.
return await loginBaserow(apiContext, baserowAdminCredentials);
} catch (registerError) {
throw new Error(
`Cannot obtain Baserow token: ${registerError instanceof Error ? registerError.message : String(registerError)}`,
);
}
}
}
/**
* Ensure a Baserow workspace named "E2E Workspace" exists and return its ID.
*/
async function ensureWorkspace(
apiContext: APIRequestContext,
token: string,
): Promise<number> {
const headers = { Authorization: `JWT ${token}` };
const listResponse = await apiContext.get(`${BASEROW_URL}/api/workspaces/`, { headers });
if (!listResponse.ok()) {
throw new Error(`List workspaces failed: ${listResponse.status()}`);
}
const workspaces = (await listResponse.json()) as Array<{ id: number; name: string }>;
const existing = workspaces.find((w) => w.name === "E2E Workspace");
if (existing) return existing.id;
const createResponse = await apiContext.post(`${BASEROW_URL}/api/workspaces/`, {
headers: { ...headers, "Content-Type": "application/json" },
data: { name: "E2E Workspace" },
});
if (!createResponse.ok()) {
throw new Error(`Create workspace failed: ${createResponse.status()}`);
}
const created = (await createResponse.json()) as { id: number };
return created.id;
}
/**
* Ensure a database named "E2E Database" exists in the given workspace.
*/
async function ensureDatabase(
apiContext: APIRequestContext,
token: string,
workspaceId: number,
): Promise<number> {
const headers = { Authorization: `JWT ${token}` };
const listResponse = await apiContext.get(
`${BASEROW_URL}/api/applications/workspace/${workspaceId}/`,
{ headers },
);
if (listResponse.ok()) {
const apps = (await listResponse.json()) as Array<{
id: number;
name: string;
type: string;
}>;
const existing = apps.find(
(a) => a.name === "E2E Database" && a.type === "database",
);
if (existing) return existing.id;
}
const createResponse = await apiContext.post(`${BASEROW_URL}/api/applications/workspace/${workspaceId}/`, {
headers: { ...headers, "Content-Type": "application/json" },
data: { name: "E2E Database", type: "database" },
});
if (!createResponse.ok()) {
throw new Error(`Create database failed: ${createResponse.status()}`);
}
const created = (await createResponse.json()) as { id: number };
return created.id;
}
/**
* Ensure a table named "E2E Table" exists in the given database.
* Returns the table ID and the IDs of the seeded fields.
*/
async function ensureTable(
apiContext: APIRequestContext,
token: string,
databaseId: number,
): Promise<{
tableId: number;
primaryFieldId: number;
primaryFieldName: string;
singleSelectFieldId: number;
singleSelectFieldName: string;
dateFieldId: number;
dateFieldName: string;
}> {
const headers = { Authorization: `JWT ${token}` };
const listResponse = await apiContext.get(
`${BASEROW_URL}/api/database/tables/database/${databaseId}/`,
{ headers },
);
if (listResponse.ok()) {
const tables = (await listResponse.json()) as Array<{ id: number; name: string }>;
const existing = tables.find((t) => t.name === "E2E Table");
if (existing) {
// Fetch fields for the existing table.
return await resolveTableFields(apiContext, token, existing.id);
}
}
const createResponse = await apiContext.post(
`${BASEROW_URL}/api/database/tables/database/${databaseId}/`,
{
headers: { ...headers, "Content-Type": "application/json" },
data: {
name: "E2E Table",
// Seed with column definitions upfront so we have a single_select + date field.
data: [["Task", "Status", "Due Date"]],
first_row_is_header: true,
},
},
);
if (!createResponse.ok()) {
const body = await createResponse.text();
throw new Error(`Create table failed: ${createResponse.status()}${body}`);
}
const created = (await createResponse.json()) as { id: number };
const tableId = created.id;
// Add the single_select and date fields via field endpoints.
await addSingleSelectField(apiContext, token, tableId);
await addDateField(apiContext, token, tableId);
return await resolveTableFields(apiContext, token, tableId);
}
async function addSingleSelectField(
apiContext: APIRequestContext,
token: string,
tableId: number,
): Promise<void> {
const headers = { Authorization: `JWT ${token}`, "Content-Type": "application/json" };
await apiContext.post(`${BASEROW_URL}/api/database/fields/table/${tableId}/`, {
headers,
data: {
name: "Status",
type: "single_select",
select_options: [
{ value: "Todo", color: "blue" },
{ value: "In Progress", color: "yellow" },
{ value: "Done", color: "green" },
],
},
});
}
async function addDateField(
apiContext: APIRequestContext,
token: string,
tableId: number,
): Promise<void> {
const headers = { Authorization: `JWT ${token}`, "Content-Type": "application/json" };
await apiContext.post(`${BASEROW_URL}/api/database/fields/table/${tableId}/`, {
headers,
data: {
name: "Due Date",
type: "date",
date_format: "ISO",
},
});
}
async function resolveTableFields(
apiContext: APIRequestContext,
token: string,
tableId: number,
): Promise<{
tableId: number;
primaryFieldId: number;
primaryFieldName: string;
singleSelectFieldId: number;
singleSelectFieldName: string;
dateFieldId: number;
dateFieldName: string;
}> {
const headers = { Authorization: `JWT ${token}` };
const response = await apiContext.get(
`${BASEROW_URL}/api/database/fields/table/${tableId}/`,
{ headers },
);
if (!response.ok()) {
throw new Error(`List fields failed for table ${tableId}: ${response.status()}`);
}
const fields = (await response.json()) as Array<{
id: number;
name: string;
type: string;
primary?: boolean;
}>;
const primaryField =
fields.find((f) => f.primary) ?? fields[0];
const singleSelectField = fields.find((f) => f.type === "single_select");
const dateField = fields.find((f) => f.type === "date");
if (!primaryField || !singleSelectField || !dateField) {
throw new Error(
`Table ${tableId} missing required fields. Fields: ${JSON.stringify(fields.map((f) => ({ id: f.id, name: f.name, type: f.type })))}`,
);
}
return {
tableId,
primaryFieldId: primaryField.id,
primaryFieldName: primaryField.name,
singleSelectFieldId: singleSelectField.id,
singleSelectFieldName: singleSelectField.name,
dateFieldId: dateField.id,
dateFieldName: dateField.name,
};
}
/**
* Create grid, kanban, and calendar views for the table (or reuse existing).
*/
async function ensureViews(
apiContext: APIRequestContext,
token: string,
tableId: number,
): Promise<{ gridViewId: number; kanbanViewId: number; calendarViewId: number }> {
const headers = { Authorization: `JWT ${token}` };
const listResponse = await apiContext.get(
`${BASEROW_URL}/api/database/views/table/${tableId}/`,
{ headers },
);
type ViewShape = { id: number; name: string; type: string };
let views: ViewShape[] = [];
if (listResponse.ok()) {
views = (await listResponse.json()) as ViewShape[];
}
async function ensureView(
name: string,
type: string,
extraData?: Record<string, unknown>,
): Promise<number> {
const existing = views.find((v) => v.name === name && v.type === type);
if (existing) return existing.id;
const createResponse = await apiContext.post(
`${BASEROW_URL}/api/database/views/table/${tableId}/`,
{
headers: { ...headers, "Content-Type": "application/json" },
data: { name, type, ...extraData },
},
);
if (!createResponse.ok()) {
const body = await createResponse.text();
throw new Error(`Create ${type} view failed: ${createResponse.status()}${body}`);
}
const created = (await createResponse.json()) as { id: number };
return created.id;
}
const gridViewId = await ensureView("E2E Grid", "grid");
const kanbanViewId = await ensureView("E2E Kanban", "gallery"); // Baserow uses "gallery" for kanban-like
const calendarViewId = await ensureView("E2E Calendar", "calendar");
return { gridViewId, kanbanViewId, calendarViewId };
}
/**
* Seed rows into the table. Returns the created row IDs.
* Idempotent: if rows already exist (count >= 5), returns first 5 IDs.
*/
async function seedRows(
apiContext: APIRequestContext,
token: string,
tableId: number,
fields: {
primaryFieldName: string;
singleSelectFieldName: string;
dateFieldName: string;
},
): Promise<number[]> {
const headers = { Authorization: `JWT ${token}` };
// Check existing rows first.
const listResponse = await apiContext.get(
`${BASEROW_URL}/api/database/rows/table/${tableId}/?user_field_names=true`,
{ headers },
);
if (listResponse.ok()) {
const listData = (await listResponse.json()) as {
count: number;
results: Array<{ id: number }>;
};
if (listData.count >= 5) {
return listData.results.slice(0, 5).map((r) => r.id);
}
}
const now = new Date();
const rowsToCreate = [
{
[fields.primaryFieldName]: "Task Alpha",
[fields.singleSelectFieldName]: "Todo",
[fields.dateFieldName]: new Date(now.getTime() + 86_400_000)
.toISOString()
.slice(0, 10),
},
{
[fields.primaryFieldName]: "Task Beta",
[fields.singleSelectFieldName]: "In Progress",
[fields.dateFieldName]: new Date(now.getTime() + 2 * 86_400_000)
.toISOString()
.slice(0, 10),
},
{
[fields.primaryFieldName]: "Task Gamma",
[fields.singleSelectFieldName]: "Done",
[fields.dateFieldName]: new Date(now.getTime() + 3 * 86_400_000)
.toISOString()
.slice(0, 10),
},
{
[fields.primaryFieldName]: "Task Delta",
[fields.singleSelectFieldName]: "Todo",
[fields.dateFieldName]: new Date(now.getTime() + 4 * 86_400_000)
.toISOString()
.slice(0, 10),
},
{
[fields.primaryFieldName]: "Task Epsilon",
[fields.singleSelectFieldName]: "In Progress",
[fields.dateFieldName]: new Date(now.getTime() + 5 * 86_400_000)
.toISOString()
.slice(0, 10),
},
];
const rowIds: number[] = [];
for (const rowData of rowsToCreate) {
const createResponse = await apiContext.post(
`${BASEROW_URL}/api/database/rows/table/${tableId}/?user_field_names=true`,
{
headers: { ...headers, "Content-Type": "application/json" },
data: rowData,
},
);
if (!createResponse.ok()) {
const body = await createResponse.text();
throw new Error(`Create row failed: ${createResponse.status()}${body}`);
}
const created = (await createResponse.json()) as { id: number };
rowIds.push(created.id);
}
return rowIds;
}
/**
* Full pre-seed: workspace + database + table + views + rows.
* Returns a BaserowSeed descriptor that tests use to target specific entities.
*/
export async function seedBaserow(
apiContext: APIRequestContext,
): Promise<BaserowSeed> {
const token = await getBaserowToken(apiContext);
const workspaceId = await ensureWorkspace(apiContext, token);
const databaseId = await ensureDatabase(apiContext, token, workspaceId);
const {
tableId,
primaryFieldName,
singleSelectFieldName,
dateFieldName,
} = await ensureTable(apiContext, token, databaseId);
const { gridViewId, kanbanViewId, calendarViewId } = await ensureViews(
apiContext,
token,
tableId,
);
const rowIds = await seedRows(apiContext, token, tableId, {
primaryFieldName,
singleSelectFieldName,
dateFieldName,
});
return {
workspaceId,
databaseId,
tableId,
gridViewId,
kanbanViewId,
calendarViewId,
token,
rowIds,
singleSelectFieldName,
dateFieldName,
primaryFieldName,
};
}
/**
* Update a single row directly via Baserow API (used in SSE tests).
*/
export async function updateRowViaBaserowApi(
apiContext: APIRequestContext,
token: string,
tableId: number,
rowId: number,
data: Record<string, unknown>,
): Promise<void> {
const response = await apiContext.patch(
`${BASEROW_URL}/api/database/rows/table/${tableId}/${rowId}/?user_field_names=true`,
{
headers: {
Authorization: `JWT ${token}`,
"Content-Type": "application/json",
},
data,
},
);
if (!response.ok()) {
const body = await response.text();
throw new Error(
`Baserow row update failed (table ${tableId}, row ${rowId}): ${response.status()}${body}`,
);
}
}