org-graph-service.test.js
15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
/**
* Unit Tests — org-graph-service.js (P2 / T9)
*
* 覆盖 27-p2-org-graph.md §3.3 的四项校验(各至少一条负例)+ §4.3 的读写语义:
* writeGraph 覆盖式写入、removeNode 级联失效边、patchGraph 破坏合法性时整体回滚。
*
* 与 graph-db.test.js / graph-service.test.js 同一套隔离手法:先设 GRAPH_DB_PATH
* 再 require,真实的 data/graph.sqlite 全程不被碰到。
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import fs from 'fs';
describe('org-graph-service', () => {
let graphDb;
let svc;
const TEST_DB_PATH = '/tmp/test-org-graph-service.sqlite';
const CO = 'test-co';
beforeAll(() => {
if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
delete require.cache[require.resolve('../../services/graph-db')];
delete require.cache[require.resolve('../../services/graph-service')];
delete require.cache[require.resolve('../../services/org-graph-service')];
graphDb = require('../../services/graph-db');
svc = require('../../services/org-graph-service');
});
afterAll(() => {
try { fs.unlinkSync(TEST_DB_PATH); } catch {}
try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {}
try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {}
delete process.env.GRAPH_DB_PATH;
});
beforeEach(() => {
// 外键开着,删除顺序必须 边 → 节点 → agent
graphDb.getDb().exec(
'DELETE FROM org_graph_edges; DELETE FROM org_graph_nodes; DELETE FROM system_agents;'
);
});
/** 断言 errors 里确实报出了某条规则;失败时把全部 rule 打出来便于定位 */
const rules = (verdict) => verdict.errors.map((e) => e.rule);
// 合法样板图:planner → coder → validator,外加 validator --fail--> coder 的回退边
const DEV_NODES = ['code-planner', 'code-coder', 'code-validator'];
const DEV_EDGES = [
{ from: 'code-planner', to: 'code-coder', condition: 'success' },
{ from: 'code-coder', to: 'code-validator', condition: 'success' },
{ from: 'code-validator', to: 'code-coder', condition: 'fail', edgeKind: 'backward' },
];
// ──────────────────────────────────────────────────────────────
// §3.3 四项校验
// ──────────────────────────────────────────────────────────────
describe('validateGraph', () => {
it('合法图通过校验(planner → coder → validator + backward 回退边)', () => {
const v = svc.validateGraph(DEV_NODES, DEV_EDGES);
expect(v.errors).toEqual([]);
expect(v.ok).toBe(true);
});
it('负例 acyclic:forward 边成环的图被拒', () => {
const v = svc.validateGraph(['a', 'b', 'c'], [
{ from: 'a', to: 'b', condition: 'success' },
{ from: 'b', to: 'c', condition: 'success' },
{ from: 'c', to: 'a', condition: 'success' }, // 没标 backward,构成 forward 环
]);
expect(v.ok).toBe(false);
expect(rules(v)).toContain('acyclic');
});
it('负例 single-entry:两个入度为 0 的节点被拒', () => {
const v = svc.validateGraph(['entry-a', 'entry-b', 'sink'], [
{ from: 'entry-a', to: 'sink', condition: 'success' },
{ from: 'entry-b', to: 'sink', condition: 'success' },
]);
expect(v.ok).toBe(false);
expect(rules(v)).toContain('single-entry');
// 报错信息要点出是哪两个入口,供规划 agent 自行修正
const err = v.errors.find((e) => e.rule === 'single-entry');
expect(err.detail).toContain('entry-a');
expect(err.detail).toContain('entry-b');
});
it('负例 reachable:孤立节点被拒', () => {
const v = svc.validateGraph(['code-planner', 'code-coder', 'code-orphan'], [
{ from: 'code-planner', to: 'code-coder', condition: 'success' },
]);
expect(v.ok).toBe(false);
expect(rules(v)).toContain('reachable');
expect(v.errors.find((e) => e.rule === 'reachable').detail).toContain('code-orphan');
});
it('负例 backward-labeled:回退边没标 edgeKind=backward 被拒', () => {
const v = svc.validateGraph(DEV_NODES, [
{ from: 'code-planner', to: 'code-coder', condition: 'success' },
{ from: 'code-coder', to: 'code-validator', condition: 'success' },
// 与 DEV_EDGES 的唯一差别:漏标 backward
{ from: 'code-validator', to: 'code-coder', condition: 'fail' },
]);
expect(v.ok).toBe(false);
expect(rules(v)).toContain('backward-labeled');
// 环上每条边都被独立复核,所以漏标的那条一定在报错里(同环的另一条边也会被点名,
// 因为两条都标 forward 时无从判断哪条才是回退边——这是规则 4 的预期行为)
const details = v.errors.filter((e) => e.rule === 'backward-labeled').map((e) => e.detail).join('\n');
expect(details).toContain('code-validator -> code-coder');
});
it('负例 backward-labeled(反向):不构成回路的边被误标 backward 也被拒', () => {
const v = svc.validateGraph(DEV_NODES, [
{ from: 'code-planner', to: 'code-coder', condition: 'success' },
{ from: 'code-coder', to: 'code-validator', condition: 'success', edgeKind: 'backward' },
]);
expect(v.ok).toBe(false);
expect(rules(v)).toContain('backward-labeled');
});
it('负例 reference:边引用了图中不存在的节点', () => {
const v = svc.validateGraph(['a'], [{ from: 'a', to: 'ghost', condition: 'success' }]);
expect(v.ok).toBe(false);
expect(rules(v)).toContain('reference');
});
it('空图合法(patchGraph 要能把一张图清空)', () => {
expect(svc.validateGraph([], []).ok).toBe(true);
});
});
// ──────────────────────────────────────────────────────────────
// writeGraph
// ──────────────────────────────────────────────────────────────
describe('writeGraph / getGraph', () => {
it('合法图能写入,getGraph 按 id 读回节点与边', () => {
const res = svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES });
expect(res).toMatchObject({ ok: true, nodeCount: 3, edgeCount: 3 });
const g = svc.getGraph(CO);
expect(g.nodes.map((n) => n.roleName)).toEqual(DEV_NODES);
expect(g.nodes.every((n) => n.status === 'active')).toBe(true);
const byName = new Map(g.nodes.map((n) => [n.roleName, n.id]));
expect(g.edges).toEqual([
{ from: byName.get('code-planner'), to: byName.get('code-coder'), condition: 'success', edgeKind: 'forward' },
{ from: byName.get('code-coder'), to: byName.get('code-validator'), condition: 'success', edgeKind: 'forward' },
{ from: byName.get('code-validator'), to: byName.get('code-coder'), condition: 'fail', edgeKind: 'backward' },
]);
});
it('非法图一行都不落库,并返回具体违反了哪条规则', () => {
const res = svc.writeGraph(CO, {
nodes: ['a', 'b', 'orphan'],
edges: [{ from: 'a', to: 'b', condition: 'success' }],
});
expect(res.ok).toBe(false);
expect(res.errors.map((e) => e.rule)).toContain('reachable');
expect(svc.getGraph(CO)).toEqual({ nodes: [], edges: [] });
});
it('重复写同一张图是幂等的(节点/边不翻倍)', () => {
svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES });
svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES });
const g = svc.getGraph(CO);
expect(g.nodes).toHaveLength(3);
expect(g.edges).toHaveLength(3);
});
it('写第二张图会把旧图整体置 removed,getGraph 只返回新图', () => {
svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES });
const oldIds = svc.getGraph(CO).nodes.map((n) => n.id);
const res = svc.writeGraph(CO, {
nodes: ['content-planner', 'content-writer'],
edges: [{ from: 'content-planner', to: 'content-writer', condition: 'success' }],
});
expect(res.ok).toBe(true);
const g = svc.getGraph(CO);
expect(g.nodes.map((n) => n.roleName)).toEqual(['content-planner', 'content-writer']);
expect(g.edges).toHaveLength(1);
// 旧节点还在库里,只是不再 active(软删除保审计轨迹)
const db = graphDb.getDb();
for (const id of oldIds) {
expect(db.prepare('SELECT status FROM org_graph_nodes WHERE id = ?').get(id).status).toBe('removed');
}
expect(
db.prepare(`SELECT COUNT(*) c FROM org_graph_edges WHERE company_id = ? AND status = 'active'`).get(CO).c
).toBe(1);
});
it('不同公司的图互不影响', () => {
svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES });
svc.writeGraph('other-co', { nodes: ['ops-runner'], edges: [] });
expect(svc.getGraph(CO).nodes).toHaveLength(3);
expect(svc.getGraph('other-co').nodes.map((n) => n.roleName)).toEqual(['ops-runner']);
});
it('没有图的公司返回空图而不是抛错', () => {
expect(svc.getGraph('never-created')).toEqual({ nodes: [], edges: [] });
});
});
// ──────────────────────────────────────────────────────────────
// removeNode(fire 用,级联失效进出边)
// ──────────────────────────────────────────────────────────────
describe('removeNode', () => {
// planner → coder → validator → shipper,外加 validator --fail--> coder
const FOUR_NODES = ['code-planner', 'code-coder', 'code-validator', 'code-shipper'];
const FOUR_EDGES = [
{ from: 'code-planner', to: 'code-coder', condition: 'success' },
{ from: 'code-coder', to: 'code-validator', condition: 'success' },
{ from: 'code-validator', to: 'code-shipper', condition: 'success' },
{ from: 'code-validator', to: 'code-coder', condition: 'fail', edgeKind: 'backward' },
];
it('删中间节点后,以它为 from 和为 to 的边都不再出现在 getGraph', () => {
expect(svc.writeGraph(CO, { nodes: FOUR_NODES, edges: FOUR_EDGES }).ok).toBe(true);
const res = svc.removeNode(CO, 'code-coder');
expect(res.ok).toBe(true);
expect(res.removedEdges).toBe(3); // planner→coder, coder→validator, validator→coder
const g = svc.getGraph(CO);
expect(g.nodes.map((n) => n.roleName)).toEqual(['code-planner', 'code-validator', 'code-shipper']);
const nameById = new Map(g.nodes.map((n) => [n.id, n.roleName]));
// 幸存的只有 validator → shipper,且没有任何一端指向被删的节点
expect(g.edges.map((e) => `${nameById.get(e.from)}->${nameById.get(e.to)}`)).toEqual([
'code-validator->code-shipper',
]);
expect(g.edges.some((e) => !nameById.has(e.from) || !nameById.has(e.to))).toBe(false);
});
it('删完的图即使不再合法也照删(fire 是既成事实)', () => {
svc.writeGraph(CO, { nodes: FOUR_NODES, edges: FOUR_EDGES });
expect(svc.removeNode(CO, 'code-coder').ok).toBe(true);
// 现在是三入口的非法图,但仍然被接受
expect(svc.getGraph(CO).nodes).toHaveLength(3);
});
it('删不存在的节点返回 ok:false 而不是抛错', () => {
svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES });
const res = svc.removeNode(CO, 'code-ghost');
expect(res.ok).toBe(false);
expect(res.errors[0].rule).toBe('reference');
expect(svc.getGraph(CO).nodes).toHaveLength(3);
});
it('被删的角色可以重新写回来(部分唯一索引只约束 active 行)', () => {
svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES });
svc.removeNode(CO, 'code-coder');
expect(svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES }).ok).toBe(true);
expect(svc.getGraph(CO).nodes.map((n) => n.roleName)).toEqual(DEV_NODES);
});
});
// ──────────────────────────────────────────────────────────────
// patchGraph
// ──────────────────────────────────────────────────────────────
describe('patchGraph', () => {
const snapshot = (companyId) => JSON.stringify(svc.getGraph(companyId));
beforeEach(() => {
expect(svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES }).ok).toBe(true);
});
it('合法的增量修改能落库(追加一个末端节点 + 一条边)', () => {
const res = svc.patchGraph(CO, {
addNodes: [{ roleName: 'code-shipper' }],
addEdges: [{ from: 'code-validator', to: 'code-shipper', condition: 'success' }],
});
expect(res.ok).toBe(true);
const g = svc.getGraph(CO);
expect(g.nodes.map((n) => n.roleName)).toContain('code-shipper');
expect(g.edges).toHaveLength(4);
});
it('破坏合法性的 patch 整体回滚:加一个孤立节点后图还是修改前的样子', () => {
const before = snapshot(CO);
const res = svc.patchGraph(CO, { addNodes: [{ roleName: 'code-orphan' }] });
expect(res.ok).toBe(false);
expect(res.errors.map((e) => e.rule)).toEqual(
expect.arrayContaining(['single-entry', 'reachable'])
);
expect(snapshot(CO)).toBe(before);
});
it('删掉入口节点导致多入口时同样回滚', () => {
const before = snapshot(CO);
const res = svc.patchGraph(CO, { removeNodes: ['code-coder'] });
expect(res.ok).toBe(false);
expect(res.errors.map((e) => e.rule)).toContain('single-entry');
expect(snapshot(CO)).toBe(before);
});
it('加一条把回退边标成 forward 的边会回滚', () => {
const before = snapshot(CO);
const res = svc.patchGraph(CO, {
addEdges: [{ from: 'code-validator', to: 'code-planner', condition: 'fail' }],
});
expect(res.ok).toBe(false);
expect(res.errors.map((e) => e.rule)).toContain('backward-labeled');
expect(snapshot(CO)).toBe(before);
});
it('边引用不存在的 active 节点时回滚并报 reference', () => {
const before = snapshot(CO);
const res = svc.patchGraph(CO, {
addEdges: [{ from: 'code-validator', to: 'code-ghost', condition: 'success' }],
});
expect(res.ok).toBe(false);
expect(res.errors.map((e) => e.rule)).toContain('reference');
expect(snapshot(CO)).toBe(before);
});
it('删边后图仍合法则删边生效', () => {
const res = svc.patchGraph(CO, {
removeEdges: [{ from: 'code-validator', to: 'code-coder' }],
});
expect(res.ok).toBe(true);
expect(svc.getGraph(CO).edges).toHaveLength(2);
});
});
});