Files
wehub-resource-sync fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

382 lines
13 KiB
TypeScript

/**
* Tier-B · workflow builder (nodes / edges / versioning)
*
* Covers B13: `POST /api/workflows`, `GET /api/workflows/:id`,
* `PUT /api/workflows/:id`, `DELETE /api/workflows/:id`, and the
* validate_workflow_structure negative cases that live in
* application/api/user/workflows/routes.py (missing start, duplicate case
* handles, condition without else).
*
* Contract quirks discovered while writing this spec:
* - The create route returns HTTP 200 with body
* `{"data":{"id":"<uuid>"},"message":201,"success":true}` — i.e.
* `success_response({"id": ...}, 201)` maps the second positional
* arg to `message`, not the HTTP status (see
* application/api/user/utils.py:39-48). The route uses the shape
* "success + data.id" — we assert on those, not on 201.
* - The PUT route DELETES other graph versions after writing the new
* one (WorkflowNodesRepository.delete_other_versions). The brief
* said "prior version retained until pruned" — the current
* implementation prunes immediately inside the same transaction, so
* we assert only the post-PUT invariants: (a) `current_graph_version`
* bumped, (b) only the new version's nodes/edges survive.
* - On validation failure the response is 400 with
* `{"success":false,"error":"Workflow validation failed","errors":[...]}`.
*/
import * as playwright from '@playwright/test';
const { expect, test } = playwright;
import { authedRequest } from '../../helpers/api.js';
import { newUserContext } from '../../helpers/auth.js';
import { pg } from '../../helpers/db.js';
import { resetDb } from '../../helpers/reset.js';
interface WorkflowCreateOk {
success: true;
data: { id: string };
message?: number | string;
}
interface WorkflowCreateErr {
success: false;
error: string;
errors?: string[];
}
interface WorkflowGetOk {
success: true;
data: {
workflow: { id: string; name: string; description: string | null };
nodes: Array<{ id: string; type: string; title?: string }>;
edges: Array<{ id: string; source: string; target: string; sourceHandle?: string }>;
};
}
/**
* Minimal valid graph: start → end, one edge. The validator at
* routes.py:201 requires exactly one start + at least one end + a
* start-outgoing edge.
*/
function basicGraph() {
return {
nodes: [
{ id: 'start-1', type: 'start', title: 'Start' },
{ id: 'end-1', type: 'end', title: 'End' },
],
edges: [{ id: 'e-1', source: 'start-1', target: 'end-1' }],
};
}
/** Count rows in workflow_nodes / workflow_edges for a workflow. */
async function countGraph(workflowId: string): Promise<{ nodes: number; edges: number }> {
const { rows: nodeRows } = await pg.query<{ n: string }>(
`SELECT count(*)::text AS n FROM workflow_nodes WHERE workflow_id = CAST($1 AS uuid)`,
[workflowId],
);
const { rows: edgeRows } = await pg.query<{ n: string }>(
`SELECT count(*)::text AS n FROM workflow_edges WHERE workflow_id = CAST($1 AS uuid)`,
[workflowId],
);
return {
nodes: Number(nodeRows[0]?.n ?? 0),
edges: Number(edgeRows[0]?.n ?? 0),
};
}
async function fetchWorkflow(workflowId: string): Promise<{
id: string;
user_id: string;
name: string;
current_graph_version: number;
} | null> {
const { rows } = await pg.query<{
id: string;
user_id: string;
name: string;
current_graph_version: number;
}>(
`SELECT id::text AS id, user_id, name, current_graph_version
FROM workflows WHERE id = CAST($1 AS uuid)`,
[workflowId],
);
return rows[0] ?? null;
}
test.describe('tier-b · workflow builder', () => {
test.beforeEach(async () => {
await resetDb();
});
test('create: POST /api/workflows writes workflows, workflow_nodes, and workflow_edges rows', async ({
browser,
}) => {
const { context, sub, token } = await newUserContext(browser);
const api = await authedRequest(playwright, token);
try {
const { nodes, edges } = basicGraph();
const res = await api.post('/api/workflows', {
data: { name: 'my wf', description: 'd', nodes, edges },
});
expect(
res.status(),
`POST /api/workflows expected 200, got ${res.status()} ${await res.text()}`,
).toBe(200);
const body = (await res.json()) as WorkflowCreateOk;
expect(body.success).toBe(true);
const workflowId = body.data.id;
expect(workflowId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
// DB: one workflows row + 2 nodes + 1 edge.
const row = await fetchWorkflow(workflowId);
expect(row).not.toBeNull();
expect(row!.user_id).toBe(sub);
expect(row!.name).toBe('my wf');
expect(row!.current_graph_version).toBe(1);
const counts = await countGraph(workflowId);
expect(counts.nodes).toBe(2);
expect(counts.edges).toBe(1);
} finally {
await api.dispose();
await context.close();
}
});
test('fetch: GET /api/workflows/:id returns the graph in the expected shape', async ({
browser,
}) => {
const { context, token } = await newUserContext(browser);
const api = await authedRequest(playwright, token);
try {
const { nodes, edges } = basicGraph();
const createRes = await api.post('/api/workflows', {
data: { name: 'fetchable', nodes, edges },
});
const { data: { id: workflowId } } = (await createRes.json()) as WorkflowCreateOk;
const getRes = await api.get(`/api/workflows/${workflowId}`);
expect(
getRes.status(),
`GET /api/workflows/:id expected 200, got ${getRes.status()} ${await getRes.text()}`,
).toBe(200);
const body = (await getRes.json()) as WorkflowGetOk;
expect(body.success).toBe(true);
expect(body.data.workflow.id).toBe(workflowId);
expect(body.data.workflow.name).toBe('fetchable');
const nodeIds = body.data.nodes.map((n) => n.id).sort();
expect(nodeIds).toEqual(['end-1', 'start-1']);
expect(body.data.edges).toHaveLength(1);
expect(body.data.edges[0]).toMatchObject({
id: 'e-1',
source: 'start-1',
target: 'end-1',
});
} finally {
await api.dispose();
await context.close();
}
});
test('update: PUT /api/workflows/:id bumps current_graph_version and replaces the graph', async ({
browser,
}) => {
const { context, token } = await newUserContext(browser);
const api = await authedRequest(playwright, token);
try {
const v1 = basicGraph();
const createRes = await api.post('/api/workflows', {
data: { name: 'v1', nodes: v1.nodes, edges: v1.edges },
});
const { data: { id: workflowId } } = (await createRes.json()) as WorkflowCreateOk;
expect((await fetchWorkflow(workflowId))!.current_graph_version).toBe(1);
// v2: add an intermediate node so row counts change.
const v2Nodes = [
{ id: 'start-1', type: 'start', title: 'Start' },
{ id: 'mid-1', type: 'end', title: 'Mid-end' },
{ id: 'end-1', type: 'end', title: 'End' },
];
const v2Edges = [
{ id: 'e-a', source: 'start-1', target: 'mid-1' },
{ id: 'e-b', source: 'start-1', target: 'end-1' },
];
const putRes = await api.put(`/api/workflows/${workflowId}`, {
data: { name: 'v2', description: 'updated', nodes: v2Nodes, edges: v2Edges },
});
expect(
putRes.status(),
`PUT expected 200, got ${putRes.status()} ${await putRes.text()}`,
).toBe(200);
// workflows row: name + current_graph_version updated.
const row = await fetchWorkflow(workflowId);
expect(row!.name).toBe('v2');
expect(row!.current_graph_version).toBe(2);
// Node/edge counts now reflect v2 (prior version is pruned by the
// `delete_other_versions` sweep that runs after the new write).
const counts = await countGraph(workflowId);
expect(counts.nodes).toBe(3);
expect(counts.edges).toBe(2);
// Fetch shows v2's node ids.
const getRes = await api.get(`/api/workflows/${workflowId}`);
const body = (await getRes.json()) as WorkflowGetOk;
expect(body.data.nodes.map((n) => n.id).sort()).toEqual([
'end-1',
'mid-1',
'start-1',
]);
} finally {
await api.dispose();
await context.close();
}
});
test('negative: workflow without a start node returns 400', async ({ browser }) => {
const { context, token } = await newUserContext(browser);
const api = await authedRequest(playwright, token);
try {
const res = await api.post('/api/workflows', {
data: {
name: 'no start',
nodes: [{ id: 'end-1', type: 'end' }],
edges: [],
},
});
expect(res.status()).toBe(400);
const body = (await res.json()) as WorkflowCreateErr;
expect(body.success).toBe(false);
expect(body.error).toBe('Workflow validation failed');
expect(body.errors ?? []).toEqual(
expect.arrayContaining([
expect.stringContaining('exactly one start node'),
]),
);
// No rows created.
const { rows } = await pg.query<{ n: string }>(
`SELECT count(*)::text AS n FROM workflows`,
);
expect(Number(rows[0]!.n)).toBe(0);
} finally {
await api.dispose();
await context.close();
}
});
test('negative: condition node without an else branch returns 400', async ({
browser,
}) => {
const { context, token } = await newUserContext(browser);
const api = await authedRequest(playwright, token);
try {
const nodes = [
{ id: 's', type: 'start' },
{
id: 'c',
type: 'condition',
data: { cases: [{ sourceHandle: 'a', expression: 'x > 0' }] },
},
{ id: 'e', type: 'end' },
];
const edges = [
{ id: 'e1', source: 's', target: 'c' },
{ id: 'e2', source: 'c', target: 'e', sourceHandle: 'a' },
];
const res = await api.post('/api/workflows', {
data: { name: 'no else', nodes, edges },
});
expect(res.status()).toBe(400);
const body = (await res.json()) as WorkflowCreateErr;
expect(body.errors ?? []).toEqual(
expect.arrayContaining([expect.stringContaining("'else' branch")]),
);
} finally {
await api.dispose();
await context.close();
}
});
test('negative: duplicate condition-case handles return 400', async ({
browser,
}) => {
const { context, token } = await newUserContext(browser);
const api = await authedRequest(playwright, token);
try {
const nodes = [
{ id: 's', type: 'start' },
{
id: 'c',
type: 'condition',
data: {
cases: [
{ sourceHandle: 'dup', expression: 'x > 0' },
{ sourceHandle: 'dup', expression: 'y > 0' },
],
},
},
{ id: 'e', type: 'end' },
];
const edges = [
{ id: 'e1', source: 's', target: 'c' },
{ id: 'e2', source: 'c', target: 'e', sourceHandle: 'dup' },
{ id: 'e3', source: 'c', target: 'e', sourceHandle: 'else' },
];
const res = await api.post('/api/workflows', {
data: { name: 'dup handles', nodes, edges },
});
expect(res.status()).toBe(400);
const body = (await res.json()) as WorkflowCreateErr;
expect(body.errors ?? []).toEqual(
expect.arrayContaining([
expect.stringContaining("duplicate case handle 'dup'"),
]),
);
} finally {
await api.dispose();
await context.close();
}
});
test('delete: DELETE /api/workflows/:id cascades nodes and edges', async ({
browser,
}) => {
const { context, token } = await newUserContext(browser);
const api = await authedRequest(playwright, token);
try {
const { nodes, edges } = basicGraph();
const createRes = await api.post('/api/workflows', {
data: { name: 'to delete', nodes, edges },
});
const { data: { id: workflowId } } = (await createRes.json()) as WorkflowCreateOk;
expect((await countGraph(workflowId)).nodes).toBe(2);
expect((await countGraph(workflowId)).edges).toBe(1);
const delRes = await api.delete(`/api/workflows/${workflowId}`);
expect(
delRes.status(),
`DELETE expected 200, got ${delRes.status()} ${await delRes.text()}`,
).toBe(200);
// workflows row is gone.
expect(await fetchWorkflow(workflowId)).toBeNull();
// ON DELETE CASCADE scrubbed child rows (see 0001_initial.py:507, 527).
const counts = await countGraph(workflowId);
expect(counts.nodes).toBe(0);
expect(counts.edges).toBe(0);
} finally {
await api.dispose();
await context.close();
}
});
});