@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +930 -35
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +125 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3927 -323
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-requirements-"));
|
|
7
|
+
process.env.COMMHUB_DB = join(dir, "hub.db");
|
|
8
|
+
|
|
9
|
+
let server: { port: number; stop?: (force?: boolean) => void };
|
|
10
|
+
let base = "";
|
|
11
|
+
let ownerToken = "";
|
|
12
|
+
let viewerToken = "";
|
|
13
|
+
let otherToken = "";
|
|
14
|
+
let nodeToken = "";
|
|
15
|
+
let ownerNetwork = "";
|
|
16
|
+
|
|
17
|
+
async function api(token: string, path: string, init?: RequestInit) {
|
|
18
|
+
const res = await fetch(`${base}${path}`, {
|
|
19
|
+
...init,
|
|
20
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) },
|
|
21
|
+
});
|
|
22
|
+
return { status: res.status, body: await res.json() as any };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
const { db } = await import("./db.js");
|
|
27
|
+
const { addNetworkMember, createNetworkTokenForNode, register } = await import("./auth.js");
|
|
28
|
+
const owner = register(`req_owner_${Date.now()}`, "RequirementsOwner123!", undefined, "seed");
|
|
29
|
+
expect(owner.ok).toBe(true);
|
|
30
|
+
ownerToken = owner.token!;
|
|
31
|
+
ownerNetwork = owner.network_id!;
|
|
32
|
+
const networkId = owner.network_id!;
|
|
33
|
+
const ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId)!.owner_id;
|
|
34
|
+
const viewer = register(`req_viewer_${Date.now()}`, "RequirementsViewer123!", undefined, "seed");
|
|
35
|
+
expect(viewer.ok).toBe(true);
|
|
36
|
+
viewerToken = viewer.token!;
|
|
37
|
+
const viewerId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", viewer.user!.username)!.user_id;
|
|
38
|
+
// 这里的 viewer 代表 RFC-038 §9 之前的语义(能读全网、不能写);scoped viewer 在 task-access-http.test.ts 里测。
|
|
39
|
+
addNetworkMember(networkId, viewerId, "viewer", ownerId, { taskAccess: "all" });
|
|
40
|
+
const ntok = createNetworkTokenForNode(ownerId, networkId, "req-node");
|
|
41
|
+
expect(ntok.ok).toBe(true);
|
|
42
|
+
nodeToken = ntok.token!;
|
|
43
|
+
const other = register(`req_other_${Date.now()}`, "RequirementsOther123!", undefined, "seed");
|
|
44
|
+
expect(other.ok).toBe(true);
|
|
45
|
+
otherToken = other.token!;
|
|
46
|
+
const mod: any = await import("./server.js");
|
|
47
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
48
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
49
|
+
}, 30_000);
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
try { server?.stop?.(true); } catch {}
|
|
53
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("requirements stay on the hub", () => {
|
|
57
|
+
let id = "";
|
|
58
|
+
|
|
59
|
+
test("name limit is consistent across create, PATCH and both upsert paths", async () => {
|
|
60
|
+
const send = (path: string, method: string, body: unknown) => api(ownerToken, path, { method, body: JSON.stringify(body) });
|
|
61
|
+
const ref = 'github:test/name-limit#1';
|
|
62
|
+
const created = await send('/api/requirements/upsert', 'POST', { name: '字'.repeat(80), external_ref: ref });
|
|
63
|
+
expect(created.status).toBe(201);
|
|
64
|
+
const path = `/api/requirements/${created.body.requirement.id}`;
|
|
65
|
+
for (const name of ['a'.repeat(150), '字'.repeat(81), ' ', null, 12]) {
|
|
66
|
+
for (const [url, method, extra] of [
|
|
67
|
+
['/api/requirements', 'POST', {}],
|
|
68
|
+
[path, 'PATCH', {}],
|
|
69
|
+
['/api/requirements/upsert', 'POST', { external_ref: ref }],
|
|
70
|
+
['/api/requirements/upsert', 'POST', { external_ref: 'github:test/name-limit#2' }],
|
|
71
|
+
] as const) {
|
|
72
|
+
const result = await send(url, method, { ...extra, name });
|
|
73
|
+
expect(result.status).toBe(400);
|
|
74
|
+
expect(result.body.error).toBe('invalid_name');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
expect((await api(ownerToken, path)).body.requirement.name).toBe('字'.repeat(80));
|
|
78
|
+
const boundary = await send(path, 'PATCH', { name: ` ${'界'.repeat(80)} ` });
|
|
79
|
+
expect(boundary.status).toBe(200);
|
|
80
|
+
expect(boundary.body.requirement.name).toBe('界'.repeat(80));
|
|
81
|
+
expect((await send(path, 'PATCH', { priority: 'high' })).body.requirement.name).toBe('界'.repeat(80));
|
|
82
|
+
await api(ownerToken, path, { method: 'DELETE' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("archived=true returns only archived tasks and default excludes them", async () => {
|
|
86
|
+
const create = async (token: string, name: string) => api(token, '/api/requirements', { method: 'POST', body: JSON.stringify({ name }) });
|
|
87
|
+
const active = (await create(ownerToken, 'archive-filter-active')).body.requirement.id;
|
|
88
|
+
const archived = (await create(ownerToken, 'archive-filter-archived')).body.requirement.id;
|
|
89
|
+
const foreign = (await create(otherToken, 'archive-filter-foreign')).body.requirement.id;
|
|
90
|
+
for (const [token, taskId] of [[ownerToken, archived], [otherToken, foreign]]) {
|
|
91
|
+
expect((await api(token, `/api/requirements/${taskId}`, { method: 'PATCH', body: JSON.stringify({ archived: true }) })).status).toBe(200);
|
|
92
|
+
}
|
|
93
|
+
const ids = async (query: string) => {
|
|
94
|
+
const result = await api(ownerToken, `/api/requirements${query}${query ? '&' : '?'}network_id=${ownerNetwork}`);
|
|
95
|
+
expect(result.status).toBe(200);
|
|
96
|
+
return result.body.requirements.map((task: any) => task.id);
|
|
97
|
+
};
|
|
98
|
+
expect(await ids('?archived=true')).toEqual([archived]);
|
|
99
|
+
expect(await ids('?archived=true&include_archived=1')).toEqual([archived]);
|
|
100
|
+
const defaults = await ids('');
|
|
101
|
+
expect(defaults).toContain(active);
|
|
102
|
+
expect(defaults).not.toContain(archived);
|
|
103
|
+
expect(defaults).not.toContain(foreign);
|
|
104
|
+
expect(await ids('?archived=false')).toEqual(defaults);
|
|
105
|
+
const all = await ids('?include_archived=1');
|
|
106
|
+
expect(all).toContain(active);
|
|
107
|
+
expect(all).toContain(archived);
|
|
108
|
+
expect(all).not.toContain(foreign);
|
|
109
|
+
for (const [token, taskId] of [[ownerToken, active], [ownerToken, archived], [otherToken, foreign]]) {
|
|
110
|
+
await api(token, `/api/requirements/${taskId}`, { method: 'DELETE' });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("tags validate, aggregate within network, and survive old PATCH shapes", async () => {
|
|
115
|
+
const created = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: 'tagged task', tags: [' UI ', 'UI', '交付'] }) });
|
|
116
|
+
expect(created.status).toBe(201);
|
|
117
|
+
expect(created.body.requirement.tags).toEqual(['UI', '交付']);
|
|
118
|
+
const path = `/api/requirements/${created.body.requirement.id}`;
|
|
119
|
+
const old = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ column: 'doing', name: 'old app edit' }) });
|
|
120
|
+
expect(old.status).toBe(200);
|
|
121
|
+
expect(old.body.requirement.tags).toEqual(['UI', '交付']);
|
|
122
|
+
for (const tags of [null, 'UI', [12], [''], ['a'.repeat(21)], Array.from({ length: 11 }, (_, i) => `t${i}`)]) {
|
|
123
|
+
const invalid = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ tags }) });
|
|
124
|
+
expect(invalid.status).toBe(400);
|
|
125
|
+
expect(invalid.body.error).toBe('invalid_tags');
|
|
126
|
+
}
|
|
127
|
+
const own = await api(ownerToken, '/api/requirements/tags');
|
|
128
|
+
expect(own.body.tags).toEqual(['UI', '交付']);
|
|
129
|
+
expect((await api(otherToken, '/api/requirements/tags')).body.tags).toEqual([]);
|
|
130
|
+
expect((await api(nodeToken, '/api/requirements/tags')).body.tags).toEqual(['UI', '交付']);
|
|
131
|
+
expect((await api(viewerToken, `${path}?network_id=${ownerNetwork}`, { method: 'PATCH', body: JSON.stringify({ tags: [] }) })).status).toBe(403);
|
|
132
|
+
const cleared = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ tags: [] }) });
|
|
133
|
+
expect(cleared.body.requirement.tags).toEqual([]);
|
|
134
|
+
expect((await api(ownerToken, '/api/requirements/tags')).body.tags).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("typed assignments validate network membership, preserve kinds and clear explicitly", async () => {
|
|
138
|
+
const { db } = await import('./db.js');
|
|
139
|
+
const ownerId = db.get<{ owner_id: string }>('SELECT owner_id FROM networks WHERE network_id=?1', ownerNetwork)!.owner_id;
|
|
140
|
+
db.run('INSERT INTO nodes(node_id,node_name,alias,display_name,network_id) VALUES (?1,?2,?2,?3,?4)', [ownerId, 'same-name', '自定义 Agent 名', ownerNetwork]);
|
|
141
|
+
db.run('INSERT INTO nodes(node_id,node_name,network_id) VALUES (?1,?2,?3)', ['foreign-person-node', 'foreign-node', 'other-network']);
|
|
142
|
+
const people = await api(ownerToken, `/api/requirements/people?network_id=${ownerNetwork}`);
|
|
143
|
+
expect(people.status).toBe(200);
|
|
144
|
+
expect(people.body.people.some((p: any) => p.id === ownerId && p.kind === 'user')).toBe(true);
|
|
145
|
+
expect(people.body.people.some((p: any) => p.id === ownerId && p.kind === 'node' && p.name === '自定义 Agent 名')).toBe(true);
|
|
146
|
+
expect(people.body.people.every((p: any) => p.networkId === ownerNetwork)).toBe(true);
|
|
147
|
+
const user = { kind: 'user', id: ownerId };
|
|
148
|
+
const node = { kind: 'node', id: ownerId };
|
|
149
|
+
const created = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: 'typed people', owner: user, participants: [user, node, user] }) });
|
|
150
|
+
expect(created.status).toBe(201);
|
|
151
|
+
expect(created.body.requirement.owner).toEqual(user);
|
|
152
|
+
expect(created.body.requirement.participants).toEqual([user, node]);
|
|
153
|
+
const path = `/api/requirements/${created.body.requirement.id}`;
|
|
154
|
+
// 负责人只能是人类,负责 Agent 只能是节点(参与人两种都行)。
|
|
155
|
+
const humanAsAgent = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ agent_owner: user }) });
|
|
156
|
+
expect(humanAsAgent.status).toBe(400);
|
|
157
|
+
expect(humanAsAgent.body.error).toBe('agent_owner_must_be_agent');
|
|
158
|
+
// 新客户端(带了 agent_owner):节点当负责人严格拒绝
|
|
159
|
+
const agentAsOwner = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: node, agent_owner: null }) });
|
|
160
|
+
expect(agentAsOwner.status).toBe(400);
|
|
161
|
+
expect(agentAsOwner.body.error).toBe('owner_must_be_human');
|
|
162
|
+
const changed = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ agent_owner: node }) });
|
|
163
|
+
expect(changed.status).toBe(200);
|
|
164
|
+
expect(changed.body.requirement.owner).toEqual(user);
|
|
165
|
+
expect(changed.body.requirement.agent_owner).toEqual(node);
|
|
166
|
+
expect(changed.body.requirement.participants).toEqual([user, node]);
|
|
167
|
+
expect(changed.body.requirement.column).toBe('pool');
|
|
168
|
+
expect((await api(viewerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: null, participants: [] }) })).status).toBe(403);
|
|
169
|
+
expect((await api(otherToken, path, { method: 'PATCH', body: JSON.stringify({ owner: null }) })).status).toBe(404);
|
|
170
|
+
for (const invalid of [{ kind: 'node', id: 'foreign-person-node' }, { kind: 'user', id: 'unknown' }, { kind: 'admin', id: ownerId }]) {
|
|
171
|
+
expect((await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ participants: [invalid] }) })).status).toBe(400);
|
|
172
|
+
}
|
|
173
|
+
const listed = await api(ownerToken, '/api/requirements');
|
|
174
|
+
const row = listed.body.requirements.find((row: any) => row.id === created.body.requirement.id);
|
|
175
|
+
expect(row.owner).toEqual(user);
|
|
176
|
+
expect(row.agent_owner).toEqual(node);
|
|
177
|
+
const cleared = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: null, agent_owner: null, participants: [] }) });
|
|
178
|
+
expect(cleared.status).toBe(200);
|
|
179
|
+
expect(cleared.body.requirement.owner).toBeNull();
|
|
180
|
+
expect(cleared.body.requirement.agent_owner).toBeNull();
|
|
181
|
+
expect(cleared.body.requirement.participants).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a card is created with a human owner and an agent owner; each role rejects the other kind", async () => {
|
|
185
|
+
const { db } = await import('./db.js');
|
|
186
|
+
const ownerId = db.get<{ owner_id: string }>('SELECT owner_id FROM networks WHERE network_id=?1', ownerNetwork)!.owner_id;
|
|
187
|
+
db.run('INSERT OR IGNORE INTO nodes(node_id,node_name,alias,network_id) VALUES (?1,?2,?2,?3)', ['req-exec-node', 'req-exec-node', ownerNetwork]);
|
|
188
|
+
const human = { kind: 'user', id: ownerId };
|
|
189
|
+
const agent = { kind: 'node', id: 'req-exec-node' };
|
|
190
|
+
const both = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '两个角色', owner: human, agent_owner: agent }) });
|
|
191
|
+
expect(both.status).toBe(201);
|
|
192
|
+
expect(both.body.requirement.owner).toEqual(human);
|
|
193
|
+
expect(both.body.requirement.agent_owner).toEqual(agent);
|
|
194
|
+
const plain = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '都不填' }) });
|
|
195
|
+
expect(plain.body.requirement.agent_owner).toBeNull();
|
|
196
|
+
const wrongOwner = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '节点当负责人', owner: agent, agent_owner: null }) });
|
|
197
|
+
expect(wrongOwner.status).toBe(400);
|
|
198
|
+
expect(wrongOwner.body.error).toBe('owner_must_be_human');
|
|
199
|
+
const wrongAgent = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '人当负责 Agent', agent_owner: human }) });
|
|
200
|
+
expect(wrongAgent.status).toBe(400);
|
|
201
|
+
expect(wrongAgent.body.error).toBe('agent_owner_must_be_agent');
|
|
202
|
+
const foreign = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '外网节点', agent_owner: { kind: 'node', id: 'foreign-person-node' } }) });
|
|
203
|
+
expect(foreign.status).toBe(400);
|
|
204
|
+
// 只改 agent_owner 是一次有效 PATCH(不是 empty_patch),其余字段不动
|
|
205
|
+
const path = `/api/requirements/${both.body.requirement.id}`;
|
|
206
|
+
const onlyAgent = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ agent_owner: null }) });
|
|
207
|
+
expect(onlyAgent.status).toBe(200);
|
|
208
|
+
expect(onlyAgent.body.requirement.agent_owner).toBeNull();
|
|
209
|
+
expect(onlyAgent.body.requirement.owner).toEqual(human);
|
|
210
|
+
expect(onlyAgent.body.requirement.name).toBe('两个角色');
|
|
211
|
+
const viewerWrite = await api(viewerToken, path, { method: 'PATCH', body: JSON.stringify({ agent_owner: agent }) });
|
|
212
|
+
expect(viewerWrite.status).toBe(403);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("description (markdown) and checklist: create, replace-all PATCH, per-item done, limits", async () => {
|
|
216
|
+
const created = await api(ownerToken, "/api/requirements", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
body: JSON.stringify({ name: "带描述和子任务", description: "## 目标\r\n- 一\n- 二", checklist: [{ id: "a", text: "写接口" }, { text: "写测试", done: true }] }),
|
|
219
|
+
});
|
|
220
|
+
expect(created.status).toBe(201);
|
|
221
|
+
const card = created.body.requirement;
|
|
222
|
+
expect(card.description).toBe("## 目标\n- 一\n- 二");
|
|
223
|
+
expect(card.checklist.length).toBe(2);
|
|
224
|
+
expect(card.checklist[0]).toEqual({ id: "a", text: "写接口", done: false });
|
|
225
|
+
expect(card.checklist[1].done).toBe(true);
|
|
226
|
+
expect(card.checklist[1].id).toMatch(/^ck_[0-9a-f]{16}$/);
|
|
227
|
+
const plain = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "什么都没有" }) });
|
|
228
|
+
expect(plain.body.requirement.description).toBe("");
|
|
229
|
+
expect(plain.body.requirement.checklist).toEqual([]);
|
|
230
|
+
|
|
231
|
+
const path = `/api/requirements/${card.id}`;
|
|
232
|
+
const b = card.checklist[1].id;
|
|
233
|
+
// 单项勾选:只动那一项,显式 done,重复请求结果一样
|
|
234
|
+
const done = await api(ownerToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: true }) });
|
|
235
|
+
expect(done.status).toBe(200);
|
|
236
|
+
expect(done.body.requirement.checklist.map((i: any) => i.done)).toEqual([true, true]);
|
|
237
|
+
const again = await api(ownerToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: true }) });
|
|
238
|
+
expect(again.body.requirement.checklist.map((i: any) => i.done)).toEqual([true, true]);
|
|
239
|
+
expect(again.body.requirement.name).toBe("带描述和子任务");
|
|
240
|
+
expect((await api(ownerToken, `${path}/checklist/${b}`, { method: "PATCH", body: JSON.stringify({ done: false }) })).body.requirement.checklist[1].done).toBe(false);
|
|
241
|
+
expect((await api(ownerToken, `${path}/checklist/missing`, { method: "PATCH", body: JSON.stringify({ done: true }) })).body.error).toBe("checklist_item_not_found");
|
|
242
|
+
expect((await api(ownerToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: "yes" }) })).status).toBe(400);
|
|
243
|
+
expect((await api(ownerToken, `${path}/checklist/a`, { method: "POST", body: JSON.stringify({ done: true }) })).status).toBe(404);
|
|
244
|
+
expect((await api(viewerToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: false }) })).status).toBe(403);
|
|
245
|
+
// Agent(节点令牌)能勾自己网络里的子任务(PR B 放开)
|
|
246
|
+
const agentTick = await api(nodeToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: true }) });
|
|
247
|
+
expect(agentTick.status).toBe(200);
|
|
248
|
+
expect(agentTick.body.requirement.updated_by.kind).toBe("node");
|
|
249
|
+
expect((await api(otherToken, `${path}/checklist/a`, { method: "PATCH", body: JSON.stringify({ done: false }) })).status).toBe(404);
|
|
250
|
+
|
|
251
|
+
// 整个替换:排序 / 增删;只改描述时清单不动;只改清单是有效 PATCH
|
|
252
|
+
const reordered = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ checklist: [{ id: b, text: "写测试", done: false }, { id: "a", text: "写接口", done: true }, { id: "c", text: "发版" }] }) });
|
|
253
|
+
expect(reordered.status).toBe(200);
|
|
254
|
+
expect(reordered.body.requirement.checklist.map((i: any) => i.id)).toEqual([b, "a", "c"]);
|
|
255
|
+
const described = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ description: "改过的描述" }) });
|
|
256
|
+
expect(described.body.requirement.description).toBe("改过的描述");
|
|
257
|
+
expect(described.body.requirement.checklist.length).toBe(3);
|
|
258
|
+
const cleared = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ description: "", checklist: [] }) });
|
|
259
|
+
expect(cleared.body.requirement.description).toBe("");
|
|
260
|
+
expect(cleared.body.requirement.checklist).toEqual([]);
|
|
261
|
+
|
|
262
|
+
// 上限与坏输入整体拒绝,原值不变
|
|
263
|
+
const tooLong = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ description: "x".repeat(20_001) }) });
|
|
264
|
+
expect(tooLong.body.error).toBe("invalid_description");
|
|
265
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ description: "x".repeat(20_000) }) })).status).toBe(200);
|
|
266
|
+
const many = Array.from({ length: 101 }, (_, i) => ({ id: `i${i}`, text: `第 ${i} 项` }));
|
|
267
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ checklist: many }) })).body.error).toBe("invalid_checklist");
|
|
268
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ checklist: many.slice(0, 100) }) })).status).toBe(200);
|
|
269
|
+
for (const bad of [[{ id: "a", text: "x" }, { id: "a", text: "y" }], [{ text: " " }], [{ id: "有空格 的id", text: "x" }], [{ text: "x", done: 1 }], ["字符串"]]) {
|
|
270
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ checklist: bad }) })).body.error).toBe("invalid_checklist");
|
|
271
|
+
}
|
|
272
|
+
const listed = await api(ownerToken, "/api/requirements");
|
|
273
|
+
const row = listed.body.requirements.find((r: any) => r.id === card.id);
|
|
274
|
+
expect(row.checklist.length).toBe(100);
|
|
275
|
+
expect(row.description.length).toBe(20_000);
|
|
276
|
+
expect((await api(viewerToken, path, { method: "PATCH", body: JSON.stringify({ description: "越权" }) })).status).toBe(403);
|
|
277
|
+
expect((await api(nodeToken, path, { method: "PATCH", body: JSON.stringify({ description: "Agent 写的描述" }) })).body.requirement.description).toBe("Agent 写的描述");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("projects: CRUD per network, project_id on cards, delete nulls references, archive keeps them", async () => {
|
|
281
|
+
const q = `?network_id=${ownerNetwork}`;
|
|
282
|
+
const empty = await api(ownerToken, `/api/requirements/projects${q}`);
|
|
283
|
+
expect(empty.status).toBe(200);
|
|
284
|
+
expect(empty.body.projects).toEqual([]); // 不预置任何项目
|
|
285
|
+
const legion = await api(ownerToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "军团项目" }) });
|
|
286
|
+
expect(legion.status).toBe(201);
|
|
287
|
+
expect(legion.body.project.name).toBe("军团项目");
|
|
288
|
+
expect(legion.body.project.color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
|
289
|
+
expect(legion.body.project.archived).toBe(false);
|
|
290
|
+
const tmai = await api(ownerToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "TMAI", color: "#7c3aed" }) });
|
|
291
|
+
expect(tmai.body.project.color).toBe("#7c3aed");
|
|
292
|
+
expect((await api(ownerToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "TMAI" }) })).status).toBe(409);
|
|
293
|
+
for (const bad of [{ name: " " }, { name: "x".repeat(41) }, { name: "色", color: "red" }, { name: "序", sort: 1.5 }]) {
|
|
294
|
+
expect((await api(ownerToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify(bad) })).status).toBe(400);
|
|
295
|
+
}
|
|
296
|
+
const listed = await api(ownerToken, `/api/requirements/projects${q}`);
|
|
297
|
+
expect(listed.body.projects.map((p: any) => p.name)).toEqual(["军团项目", "TMAI"]);
|
|
298
|
+
// 权限:viewer 能读不能写;节点令牌一律拒绝;别的网络看不见、改不了
|
|
299
|
+
expect((await api(viewerToken, `/api/requirements/projects${q}`)).body.projects.length).toBe(2);
|
|
300
|
+
expect((await api(viewerToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "越权" }) })).status).toBe(403);
|
|
301
|
+
// Agent 能读项目(挂卡片要用),不能建 / 改 / 删项目
|
|
302
|
+
expect((await api(nodeToken, `/api/requirements/projects${q}`)).body.projects.length).toBe(2);
|
|
303
|
+
expect((await api(nodeToken, `/api/requirements/projects${q}`, { method: "POST", body: JSON.stringify({ name: "Agent 建的" }) })).body.error).toBe("user_token_required");
|
|
304
|
+
expect((await api(otherToken, "/api/requirements/projects")).body.projects).toEqual([]);
|
|
305
|
+
expect((await api(otherToken, `/api/requirements/projects/${tmai.body.project.id}`, { method: "PATCH", body: JSON.stringify({ name: "抢" }) })).status).toBe(404);
|
|
306
|
+
const foreign = await api(otherToken, "/api/requirements/projects", { method: "POST", body: JSON.stringify({ name: "别人的项目" }) });
|
|
307
|
+
expect(foreign.status).toBe(201);
|
|
308
|
+
|
|
309
|
+
// 卡片上的 project_id:POST / PATCH / GET;别的网络的项目被拒
|
|
310
|
+
const card = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "有项目的卡", project_id: legion.body.project.id }) });
|
|
311
|
+
expect(card.status).toBe(201);
|
|
312
|
+
expect(card.body.requirement.project_id).toBe(legion.body.project.id);
|
|
313
|
+
const bare = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "没项目的卡" }) });
|
|
314
|
+
expect(bare.body.requirement.project_id).toBeNull();
|
|
315
|
+
expect((await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "外网项目", project_id: foreign.body.project.id }) })).body.error).toBe("project_not_in_network");
|
|
316
|
+
const path = `/api/requirements/${card.body.requirement.id}`;
|
|
317
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ project_id: foreign.body.project.id }) })).body.error).toBe("project_not_in_network");
|
|
318
|
+
const moved = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ project_id: tmai.body.project.id }) });
|
|
319
|
+
expect(moved.status).toBe(200);
|
|
320
|
+
expect(moved.body.requirement.project_id).toBe(tmai.body.project.id);
|
|
321
|
+
expect(moved.body.requirement.name).toBe("有项目的卡");
|
|
322
|
+
|
|
323
|
+
// 改名 / 改色 / 排序
|
|
324
|
+
const renamed = await api(ownerToken, `/api/requirements/projects/${tmai.body.project.id}${q}`, { method: "PATCH", body: JSON.stringify({ name: "TMAI 平台", color: "#0891b2", sort: -1 }) });
|
|
325
|
+
expect(renamed.body.project).toMatchObject({ name: "TMAI 平台", color: "#0891b2", sort: -1 });
|
|
326
|
+
expect((await api(ownerToken, `/api/requirements/projects/${tmai.body.project.id}${q}`, { method: "PATCH", body: JSON.stringify({ name: "军团项目" }) })).status).toBe(409);
|
|
327
|
+
expect((await api(ownerToken, `/api/requirements/projects${q}`)).body.projects[0].name).toBe("TMAI 平台");
|
|
328
|
+
|
|
329
|
+
// 归档:引用保留,但不能再被选
|
|
330
|
+
const archived = await api(ownerToken, `/api/requirements/projects/${tmai.body.project.id}${q}`, { method: "PATCH", body: JSON.stringify({ archived: true }) });
|
|
331
|
+
expect(archived.body.project.archived).toBe(true);
|
|
332
|
+
const kept = (await api(ownerToken, "/api/requirements")).body.requirements.find((r: any) => r.id === card.body.requirement.id);
|
|
333
|
+
expect(kept.project_id).toBe(tmai.body.project.id);
|
|
334
|
+
expect((await api(ownerToken, `/api/requirements/${bare.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ project_id: tmai.body.project.id }) })).body.error).toBe("project_archived");
|
|
335
|
+
|
|
336
|
+
// 删除:卡片一张不少,引用置空
|
|
337
|
+
await api(ownerToken, `/api/requirements/${bare.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ project_id: legion.body.project.id }) });
|
|
338
|
+
const before = (await api(ownerToken, "/api/requirements")).body.requirements.length;
|
|
339
|
+
expect((await api(viewerToken, `/api/requirements/projects/${legion.body.project.id}${q}`, { method: "DELETE" })).status).toBe(403);
|
|
340
|
+
const del = await api(ownerToken, `/api/requirements/projects/${legion.body.project.id}${q}`, { method: "DELETE" });
|
|
341
|
+
expect(del.status).toBe(200);
|
|
342
|
+
const after = (await api(ownerToken, "/api/requirements")).body.requirements;
|
|
343
|
+
expect(after.length).toBe(before);
|
|
344
|
+
expect(after.find((r: any) => r.id === bare.body.requirement.id).project_id).toBeNull();
|
|
345
|
+
expect((await api(ownerToken, `/api/requirements/projects${q}`)).body.projects.map((p: any) => p.name)).toEqual(["TMAI 平台"]);
|
|
346
|
+
// 清空卡片项目
|
|
347
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ project_id: null }) })).body.requirement.project_id).toBeNull();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("due accepts date-only (all day) and an ISO datetime with offset, stored as UTC to the second", async () => {
|
|
351
|
+
const make = (due: unknown) => api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "期限", due }) });
|
|
352
|
+
const shanghai = await make("2026-10-01T18:30:45+08:00");
|
|
353
|
+
expect(shanghai.status).toBe(201);
|
|
354
|
+
expect(shanghai.body.requirement.due).toBe("2026-10-01T10:30:45Z");
|
|
355
|
+
expect((await make("2026-10-01T10:30:45Z")).body.requirement.due).toBe("2026-10-01T10:30:45Z");
|
|
356
|
+
expect((await make("2026-10-01T10:30Z")).body.requirement.due).toBe("2026-10-01T10:30:00Z");
|
|
357
|
+
expect((await make("2026-10-01T10:30:45.987-04:00")).body.requirement.due).toBe("2026-10-01T14:30:45Z");
|
|
358
|
+
// 跨日:东八区的 00:30 是 UTC 前一天
|
|
359
|
+
expect((await make("2026-10-01T00:30:00+08:00")).body.requirement.due).toBe("2026-09-30T16:30:00Z");
|
|
360
|
+
// 旧的全天值原样
|
|
361
|
+
const legacy = await make("2026-10-01");
|
|
362
|
+
expect(legacy.body.requirement.due).toBe("2026-10-01");
|
|
363
|
+
for (const bad of ["2026-10-01T18:30:45", "2026-10-01 18:30:45+08:00", "2026-02-30T10:00:00Z", "2026-10-01T24:00:00Z", "2026-10-01T10:60:00Z", "2026-10-01T10:00:61Z", "2026-10-01T10:00:00+15:00", "明天", "2026-10-1"]) {
|
|
364
|
+
const r = await make(bad);
|
|
365
|
+
expect(r.status).toBe(400);
|
|
366
|
+
expect(r.body.error).toBe("invalid_due");
|
|
367
|
+
}
|
|
368
|
+
const path = `/api/requirements/${legacy.body.requirement.id}`;
|
|
369
|
+
const patched = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "2026-12-31T23:59:59-08:00" }) });
|
|
370
|
+
expect(patched.body.requirement.due).toBe("2027-01-01T07:59:59Z");
|
|
371
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "2026-12-31" }) })).body.requirement.due).toBe("2026-12-31");
|
|
372
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "T" }) })).status).toBe(400);
|
|
373
|
+
expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
|
|
374
|
+
const listed = await api(ownerToken, "/api/requirements");
|
|
375
|
+
expect(listed.body.capabilities).toContain("due_datetime");
|
|
376
|
+
expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
|
|
380
|
+
const mk = async (name: string, extra: Record<string, unknown> = {}) => (await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name, network_id: ownerNetwork, ...extra }) })).body.requirement;
|
|
381
|
+
const root = await mk("父需求");
|
|
382
|
+
const a = await mk("子 A", { parent_id: root.id });
|
|
383
|
+
const b = await mk("子 B", { parent_id: root.id, column: "done" });
|
|
384
|
+
expect(a.parent_id).toBe(root.id);
|
|
385
|
+
const parent = (await api(ownerToken, `/api/requirements/${root.id}`)).body.requirement;
|
|
386
|
+
expect(parent.children).toEqual({ total: 2, done: 1 });
|
|
387
|
+
expect(parent.parent_id).toBeNull();
|
|
388
|
+
// 过滤
|
|
389
|
+
const kids = await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}&parent_id=${root.id}`);
|
|
390
|
+
expect(kids.body.requirements.map((r: any) => r.id).sort()).toEqual([a.id, b.id].sort());
|
|
391
|
+
const top = await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}&top_level=1`);
|
|
392
|
+
expect(top.body.requirements.some((r: any) => r.id === root.id)).toBe(true);
|
|
393
|
+
expect(top.body.requirements.some((r: any) => r.id === a.id)).toBe(false);
|
|
394
|
+
// 成环:把父卡挂到自己的子卡下 / 挂到自己
|
|
395
|
+
expect((await api(ownerToken, `/api/requirements/${root.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: a.id }) })).body.error).toBe("parent_cycle");
|
|
396
|
+
expect((await api(ownerToken, `/api/requirements/${a.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: a.id }) })).body.error).toBe("parent_cycle");
|
|
397
|
+
// 深度:第 5 层可以,第 6 层不行;把一棵 2 层的子树挂到第 4 层下面也不行
|
|
398
|
+
let chain = root;
|
|
399
|
+
const levels = [root];
|
|
400
|
+
for (let i = 2; i <= 5; i++) { chain = await mk(`第 ${i} 层`, { parent_id: chain.id }); levels.push(chain); }
|
|
401
|
+
expect(chain.parent_id).toBe(levels[3].id);
|
|
402
|
+
expect((await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "第 6 层", network_id: ownerNetwork, parent_id: chain.id }) })).body.error).toBe("parent_too_deep");
|
|
403
|
+
const subtree = await mk("子树根");
|
|
404
|
+
await mk("子树叶", { parent_id: subtree.id });
|
|
405
|
+
expect((await api(ownerToken, `/api/requirements/${subtree.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: levels[3].id }) })).body.error).toBe("parent_too_deep");
|
|
406
|
+
expect((await api(ownerToken, `/api/requirements/${subtree.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: levels[2].id }) })).status).toBe(200);
|
|
407
|
+
// 别的网络的卡当父卡:拒绝;坏 id:拒绝
|
|
408
|
+
const foreign = (await api(otherToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "别的网络的" }) })).body.requirement;
|
|
409
|
+
expect((await api(ownerToken, `/api/requirements/${a.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: foreign.id }) })).body.error).toBe("parent_not_found");
|
|
410
|
+
expect((await api(otherToken, `/api/requirements/${foreign.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: root.id }) })).body.error).toBe("parent_not_found");
|
|
411
|
+
expect((await api(ownerToken, `/api/requirements/${a.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: "bad id!" }) })).body.error).toBe("invalid_parent_id");
|
|
412
|
+
// 解挂
|
|
413
|
+
expect((await api(ownerToken, `/api/requirements/${b.id}`, { method: "PATCH", body: JSON.stringify({ parent_id: null }) })).body.requirement.parent_id).toBeNull();
|
|
414
|
+
// 删父卡:子卡保留、变顶层
|
|
415
|
+
const before = (await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}`)).body.requirements.length;
|
|
416
|
+
expect((await api(ownerToken, `/api/requirements/${root.id}`, { method: "DELETE" })).status).toBe(200);
|
|
417
|
+
const after = (await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}`)).body.requirements;
|
|
418
|
+
expect(after.length).toBe(before - 1);
|
|
419
|
+
expect(after.find((r: any) => r.id === a.id).parent_id).toBeNull();
|
|
420
|
+
expect(after.find((r: any) => r.id === levels[1].id).parent_id).toBeNull();
|
|
421
|
+
expect(after.find((r: any) => r.id === levels[2].id).parent_id).toBe(levels[1].id);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("old-app compat: owner {kind:'node'} without agent_owner becomes agent_owner (200 + flag); new clients stay strict", async () => {
|
|
425
|
+
const { db } = await import('./db.js');
|
|
426
|
+
const ownerId = db.get<{ owner_id: string }>('SELECT owner_id FROM networks WHERE network_id=?1', ownerNetwork)!.owner_id;
|
|
427
|
+
db.run('INSERT OR IGNORE INTO nodes(node_id,node_name,alias,network_id) VALUES (?1,?2,?2,?3)', ['compat-node', 'compat-node', ownerNetwork]);
|
|
428
|
+
const node = { kind: 'node', id: 'compat-node' };
|
|
429
|
+
const human = { kind: 'user', id: ownerId };
|
|
430
|
+
const card = (await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '旧 App 的卡', owner: human }) })).body.requirement;
|
|
431
|
+
const path = `/api/requirements/${card.id}`;
|
|
432
|
+
// 0.2.142 的详情保存:{name?, owner:{node}} —— 不带 agent_owner
|
|
433
|
+
const old = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: node }) });
|
|
434
|
+
expect(old.status).toBe(200);
|
|
435
|
+
expect(old.body.owner_coerced_to_agent_owner).toBe(true);
|
|
436
|
+
// 回显给旧客户端的 owner 是它设的那个节点(它按这个核对「保存生效」);agent_owner 也在
|
|
437
|
+
expect(old.body.requirement.owner).toEqual(node);
|
|
438
|
+
expect(old.body.requirement.agent_owner).toEqual(node);
|
|
439
|
+
// 实际存储:负责人清空,负责 Agent = 节点;GET 看到的是真实值
|
|
440
|
+
const stored = (await api(ownerToken, path)).body.requirement;
|
|
441
|
+
expect(stored.owner).toBeNull();
|
|
442
|
+
expect(stored.agent_owner).toEqual(node);
|
|
443
|
+
const row = db.get<{ owner_json: string | null; agent_owner_json: string | null }>('SELECT owner_json, agent_owner_json FROM requirements WHERE requirement_id=?1', card.id)!;
|
|
444
|
+
expect(row.owner_json).toBeNull();
|
|
445
|
+
expect(JSON.parse(row.agent_owner_json!)).toEqual(node);
|
|
446
|
+
// 旧 App 新建时选了节点当负责人:同样改写
|
|
447
|
+
const created = await api(ownerToken, '/api/requirements', { method: 'POST', body: JSON.stringify({ name: '旧 App 新建', owner: node, assignee: '' }) });
|
|
448
|
+
expect(created.status).toBe(201);
|
|
449
|
+
expect(created.body.owner_coerced_to_agent_owner).toBe(true);
|
|
450
|
+
expect((await api(ownerToken, `/api/requirements/${created.body.requirement.id}`)).body.requirement.agent_owner).toEqual(node);
|
|
451
|
+
// 旧 App 设人类负责人:照常,不带标志
|
|
452
|
+
const normal = await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: human }) });
|
|
453
|
+
expect(normal.body.owner_coerced_to_agent_owner).toBeUndefined();
|
|
454
|
+
expect(normal.body.requirement.owner).toEqual(human);
|
|
455
|
+
expect(normal.body.requirement.agent_owner).toEqual(node);
|
|
456
|
+
// 新客户端带 agent_owner:严格
|
|
457
|
+
expect((await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: node, agent_owner: node }) })).body.error).toBe('owner_must_be_human');
|
|
458
|
+
// 节点不在网络里:仍然 400(不因为兼容放宽成员校验)
|
|
459
|
+
expect((await api(ownerToken, path, { method: 'PATCH', body: JSON.stringify({ owner: { kind: 'node', id: 'foreign-person-node' } }) })).status).toBe(400);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("owner creates a card in the pool", async () => {
|
|
463
|
+
const created = await api(ownerToken, "/api/requirements", {
|
|
464
|
+
method: "POST",
|
|
465
|
+
body: JSON.stringify({ name: "多端同步", priority: "high", assignee: "node-a", due: "2026-10-01" }),
|
|
466
|
+
});
|
|
467
|
+
expect(created.status).toBe(201);
|
|
468
|
+
expect(created.body.requirement.column).toBe("pool");
|
|
469
|
+
expect(created.body.requirement.name).toBe("多端同步");
|
|
470
|
+
expect(created.body.requirement.assignee).toBe("node-a");
|
|
471
|
+
expect(created.body.requirement.due).toBe("2026-10-01");
|
|
472
|
+
id = created.body.requirement.id;
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("the same account reads it back", async () => {
|
|
476
|
+
const listed = await api(ownerToken, "/api/requirements");
|
|
477
|
+
expect(listed.status).toBe(200);
|
|
478
|
+
expect(listed.body.requirements.some((row: { id: string }) => row.id === id)).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("moving a card is stored on the hub", async () => {
|
|
482
|
+
const moved = await api(ownerToken, `/api/requirements/${id}`, {
|
|
483
|
+
method: "PATCH",
|
|
484
|
+
body: JSON.stringify({ column: "doing" }),
|
|
485
|
+
});
|
|
486
|
+
expect(moved.status).toBe(200);
|
|
487
|
+
expect(moved.body.requirement.column).toBe("doing");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("another network cannot see it", async () => {
|
|
491
|
+
const listed = await api(otherToken, "/api/requirements");
|
|
492
|
+
expect(listed.status).toBe(200);
|
|
493
|
+
expect(listed.body.requirements.some((row: { id: string }) => row.id === id)).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("a viewer cannot add or move", async () => {
|
|
497
|
+
const denied = await api(viewerToken, "/api/requirements", {
|
|
498
|
+
method: "POST",
|
|
499
|
+
body: JSON.stringify({ name: "不行", network_id: ownerNetwork }),
|
|
500
|
+
});
|
|
501
|
+
expect(denied.status).toBe(403);
|
|
502
|
+
const move = await api(viewerToken, `/api/requirements/${id}`, {
|
|
503
|
+
method: "PATCH",
|
|
504
|
+
body: JSON.stringify({ column: "done" }),
|
|
505
|
+
});
|
|
506
|
+
expect(move.status).toBe(403);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("agent API: a node token reads / creates / patches in its own network only; cannot delete; who did what is recorded", async () => {
|
|
510
|
+
const { db } = await import("./db.js");
|
|
511
|
+
const { createNetworkTokenForNode, register } = await import("./auth.js");
|
|
512
|
+
const ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id=?1", ownerNetwork)!.owner_id;
|
|
513
|
+
const bound = createNetworkTokenForNode(ownerId, ownerNetwork, "sync-bot", "node_sync_bot");
|
|
514
|
+
expect(bound.ok).toBe(true);
|
|
515
|
+
const bot = bound.token!;
|
|
516
|
+
// 读
|
|
517
|
+
const listed = await api(bot, "/api/requirements");
|
|
518
|
+
expect(listed.status).toBe(200);
|
|
519
|
+
expect(listed.body.requirements.length).toBeGreaterThan(0);
|
|
520
|
+
// 建:记下是哪个节点建的
|
|
521
|
+
const made = await api(bot, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "Agent 建的任务", column: "doing", checklist: [{ id: "x", text: "第一步" }] }) });
|
|
522
|
+
expect(made.status).toBe(201);
|
|
523
|
+
expect(made.body.requirement.created_by).toEqual({ kind: "node", id: "node_sync_bot" });
|
|
524
|
+
expect(made.body.requirement.updated_by).toEqual({ kind: "node", id: "node_sync_bot" });
|
|
525
|
+
const path = `/api/requirements/${made.body.requirement.id}`;
|
|
526
|
+
// 按 id 读、改、勾、归档
|
|
527
|
+
expect((await api(bot, path)).body.requirement.name).toBe("Agent 建的任务");
|
|
528
|
+
expect((await api(bot, path, { method: "PATCH", body: JSON.stringify({ column: "done" }) })).body.requirement.column).toBe("done");
|
|
529
|
+
expect((await api(bot, `${path}/checklist/x`, { method: "PATCH", body: JSON.stringify({ done: true }) })).body.requirement.checklist[0].done).toBe(true);
|
|
530
|
+
// 人改了之后 updated_by 变成人
|
|
531
|
+
const human = await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ priority: "high" }) });
|
|
532
|
+
expect(human.body.requirement.updated_by).toEqual({ kind: "user", id: ownerId });
|
|
533
|
+
expect(human.body.requirement.created_by).toEqual({ kind: "node", id: "node_sync_bot" });
|
|
534
|
+
// 不能删,只能归档;归档的默认不在列表里
|
|
535
|
+
const del = await api(bot, path, { method: "DELETE" });
|
|
536
|
+
expect(del.status).toBe(403);
|
|
537
|
+
expect(del.body.error).toBe("user_token_required");
|
|
538
|
+
expect((await api(bot, path, { method: "PATCH", body: JSON.stringify({ archived: true }) })).body.requirement.archived).toBe(true);
|
|
539
|
+
expect((await api(ownerToken, "/api/requirements")).body.requirements.some((r: any) => r.id === made.body.requirement.id)).toBe(false);
|
|
540
|
+
expect((await api(ownerToken, "/api/requirements?include_archived=1")).body.requirements.some((r: any) => r.id === made.body.requirement.id)).toBe(true);
|
|
541
|
+
expect((await api(bot, path, { method: "PATCH", body: JSON.stringify({ archived: "yes" }) })).status).toBe(400);
|
|
542
|
+
// 别的网络的节点令牌:看不到、改不了、也不能往别的网络里建
|
|
543
|
+
const outsider = register(`req_outsider_${Date.now()}`, "RequirementsOutsider123!", undefined, "seed");
|
|
544
|
+
const foreignTok = createNetworkTokenForNode(outsider.user!.user_id, outsider.network_id!, "foreign-bot", "node_foreign_bot");
|
|
545
|
+
expect(foreignTok.ok).toBe(true);
|
|
546
|
+
const foreign = foreignTok.token!;
|
|
547
|
+
expect((await api(foreign, `/api/requirements?include_archived=1`)).body.requirements.some((r: any) => r.id === made.body.requirement.id)).toBe(false);
|
|
548
|
+
expect((await api(foreign, path)).status).toBe(404);
|
|
549
|
+
expect((await api(foreign, path, { method: "PATCH", body: JSON.stringify({ name: "越界" }) })).status).toBe(404);
|
|
550
|
+
expect((await api(foreign, `${path}/checklist/x`, { method: "PATCH", body: JSON.stringify({ done: false }) })).status).toBe(404);
|
|
551
|
+
const planted = await api(foreign, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "塞进别的网络", network_id: ownerNetwork }) });
|
|
552
|
+
expect(planted.status).toBe(201);
|
|
553
|
+
// 写进的是它自己的网络(令牌绑定的网络优先于 body 里的 network_id)。owner 在测试库里是管理员,所以按网络查。
|
|
554
|
+
expect((await api(ownerToken, `/api/requirements?network_id=${ownerNetwork}`)).body.requirements.some((r: any) => r.id === planted.body.requirement.id)).toBe(false);
|
|
555
|
+
expect((await api(foreign, `/api/requirements/${planted.body.requirement.id}`)).status).toBe(200);
|
|
556
|
+
// 人照常能删
|
|
557
|
+
const humanDel = await api(ownerToken, path, { method: "DELETE" });
|
|
558
|
+
expect(humanDel.status).toBe(200);
|
|
559
|
+
expect((await api(ownerToken, path)).status).toBe(404);
|
|
560
|
+
expect((await api(viewerToken, `/api/requirements/${planted.body.requirement.id}`, { method: "DELETE" })).status).toBe(404);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("external_ref: unique per network, 409 with the existing id, upsert is idempotent, list filters", async () => {
|
|
564
|
+
const { db } = await import("./db.js");
|
|
565
|
+
const { createNetworkTokenForNode } = await import("./auth.js");
|
|
566
|
+
const ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id=?1", ownerNetwork)!.owner_id;
|
|
567
|
+
const bot = createNetworkTokenForNode(ownerId, ownerNetwork, "gh-sync", "node_gh_sync").token!;
|
|
568
|
+
const ref = "github:acme/widgets#123";
|
|
569
|
+
const first = await api(bot, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: ref, external_url: "https://github.com/acme/widgets/issues/123", name: "Issue 标题", description: "issue 正文" }) });
|
|
570
|
+
expect(first.status).toBe(201);
|
|
571
|
+
expect(first.body.created).toBe(true);
|
|
572
|
+
expect(first.body.requirement.external_ref).toBe(ref);
|
|
573
|
+
expect(first.body.requirement.external_url).toBe("https://github.com/acme/widgets/issues/123");
|
|
574
|
+
// 同一个 issue 再同步:改,不重复建;省略的字段(状态)保留
|
|
575
|
+
await api(ownerToken, `/api/requirements/${first.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ column: "doing" }) });
|
|
576
|
+
const again = await api(bot, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: ref, name: "Issue 改过的标题" }) });
|
|
577
|
+
expect(again.status).toBe(200);
|
|
578
|
+
expect(again.body.created).toBe(false);
|
|
579
|
+
expect(again.body.requirement.id).toBe(first.body.requirement.id);
|
|
580
|
+
expect(again.body.requirement.name).toBe("Issue 改过的标题");
|
|
581
|
+
expect(again.body.requirement.column).toBe("doing");
|
|
582
|
+
const same = await api(bot, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ external_ref: ref }) });
|
|
583
|
+
expect(same.body.created).toBe(false);
|
|
584
|
+
expect((await api(ownerToken, `/api/requirements?external_ref=${encodeURIComponent(ref)}`)).body.requirements.length).toBe(1);
|
|
585
|
+
// 普通 POST 撞同一个 external_ref:409 + 已有 id
|
|
586
|
+
const dup = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "重复", external_ref: ref }) });
|
|
587
|
+
expect(dup.status).toBe(409);
|
|
588
|
+
expect(dup.body.error).toBe("external_ref_exists");
|
|
589
|
+
expect(dup.body.existing_id).toBe(first.body.requirement.id);
|
|
590
|
+
// PATCH 改成别人已有的 external_ref 也 409
|
|
591
|
+
const other = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "另一个", external_ref: "github:acme/widgets#124" }) });
|
|
592
|
+
expect((await api(ownerToken, `/api/requirements/${other.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ external_ref: ref }) })).status).toBe(409);
|
|
593
|
+
// 另一个网络可以有同样的 external_ref(唯一只在网络内)
|
|
594
|
+
const elsewhere = await api(otherToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "别的网络同一个 issue", external_ref: ref }) });
|
|
595
|
+
expect(elsewhere.status).toBe(201);
|
|
596
|
+
// 坏值
|
|
597
|
+
for (const bad of [{ external_ref: "has space" }, { external_ref: "" }, { external_url: "javascript:alert(1)" }]) {
|
|
598
|
+
expect((await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "坏", ...bad }) })).status).toBe(400);
|
|
599
|
+
}
|
|
600
|
+
expect((await api(bot, "/api/requirements/upsert", { method: "POST", body: JSON.stringify({ name: "没有 ref" }) })).body.error).toBe("external_ref_required");
|
|
601
|
+
// 列表筛选
|
|
602
|
+
const byStatus = await api(ownerToken, "/api/requirements?status=doing");
|
|
603
|
+
expect(byStatus.body.requirements.every((r: any) => r.column === "doing")).toBe(true);
|
|
604
|
+
expect((await api(ownerToken, "/api/requirements?status=nope")).status).toBe(400);
|
|
605
|
+
const since = new Date(Date.now() - 60_000).toISOString();
|
|
606
|
+
expect((await api(ownerToken, `/api/requirements?updated_since=${encodeURIComponent(since)}`)).body.requirements.some((r: any) => r.id === first.body.requirement.id)).toBe(true);
|
|
607
|
+
expect((await api(ownerToken, `/api/requirements?updated_since=${encodeURIComponent(new Date(Date.now() + 60_000).toISOString())}`)).body.requirements.length).toBe(0);
|
|
608
|
+
expect((await api(ownerToken, "/api/requirements?updated_since=nope")).status).toBe(400);
|
|
609
|
+
const human = { kind: "user", id: ownerId };
|
|
610
|
+
await api(ownerToken, `/api/requirements/${other.body.requirement.id}`, { method: "PATCH", body: JSON.stringify({ owner: human }) });
|
|
611
|
+
expect((await api(ownerToken, `/api/requirements?owner=user:${ownerId}`)).body.requirements.map((r: any) => r.id)).toContain(other.body.requirement.id);
|
|
612
|
+
expect((await api(ownerToken, "/api/requirements?owner=bad")).status).toBe(400);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("the same client_id does not create a second card", async () => {
|
|
616
|
+
const first = await api(ownerToken, "/api/requirements", {
|
|
617
|
+
method: "POST",
|
|
618
|
+
body: JSON.stringify({ name: "迁移甲", client_id: "local_card_1", column: "doing" }),
|
|
619
|
+
});
|
|
620
|
+
expect(first.status).toBe(201);
|
|
621
|
+
expect(first.body.requirement.column).toBe("doing");
|
|
622
|
+
const again = await api(ownerToken, "/api/requirements", {
|
|
623
|
+
method: "POST",
|
|
624
|
+
body: JSON.stringify({ name: "迁移甲", client_id: "local_card_1", column: "done" }),
|
|
625
|
+
});
|
|
626
|
+
expect(again.status).toBe(200);
|
|
627
|
+
expect(again.body.requirement.id).toBe(first.body.requirement.id);
|
|
628
|
+
expect(again.body.requirement.column).toBe("doing");
|
|
629
|
+
const listed = await api(ownerToken, "/api/requirements");
|
|
630
|
+
const matches = listed.body.requirements.filter((row: { id: string }) => row.id === first.body.requirement.id);
|
|
631
|
+
expect(matches.length).toBe(1);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("a card can bind a github issue and a bad link is rejected", async () => {
|
|
635
|
+
const created = await api(ownerToken, "/api/requirements", {
|
|
636
|
+
method: "POST",
|
|
637
|
+
body: JSON.stringify({
|
|
638
|
+
name: "绑 issue",
|
|
639
|
+
issues: [{ url: "https://github.com/sleep2agi/agent-network-app/issues/472", title: "picker" }],
|
|
640
|
+
}),
|
|
641
|
+
});
|
|
642
|
+
expect(created.status).toBe(201);
|
|
643
|
+
expect(created.body.requirement.issues).toEqual([
|
|
644
|
+
{ repo: "sleep2agi/agent-network-app", number: 472, title: "picker" },
|
|
645
|
+
]);
|
|
646
|
+
const moved = await api(ownerToken, `/api/requirements/${created.body.requirement.id}`, {
|
|
647
|
+
method: "PATCH",
|
|
648
|
+
body: JSON.stringify({ column: "doing" }),
|
|
649
|
+
});
|
|
650
|
+
expect(moved.body.requirement.issues[0].number).toBe(472);
|
|
651
|
+
const bad = await api(ownerToken, `/api/requirements/${created.body.requirement.id}`, {
|
|
652
|
+
method: "PATCH",
|
|
653
|
+
body: JSON.stringify({ issues: ["javascript:alert(1)"] }),
|
|
654
|
+
});
|
|
655
|
+
expect(bad.status).toBe(400);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("an existing card can change its text without dropping omitted fields", async () => {
|
|
659
|
+
const { db } = await import("./db.js");
|
|
660
|
+
const ownerId = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id=?1", ownerNetwork)!.owner_id;
|
|
661
|
+
const created = await api(ownerToken, "/api/requirements", {
|
|
662
|
+
method: "POST",
|
|
663
|
+
body: JSON.stringify({
|
|
664
|
+
name: "旧标题",
|
|
665
|
+
priority: "low",
|
|
666
|
+
assignee: "node-a",
|
|
667
|
+
due: "2026-10-01",
|
|
668
|
+
column: "doing",
|
|
669
|
+
owner: { kind: "user", id: ownerId },
|
|
670
|
+
}),
|
|
671
|
+
});
|
|
672
|
+
expect(created.status).toBe(201);
|
|
673
|
+
const id = created.body.requirement.id;
|
|
674
|
+
const renamed = await api(ownerToken, `/api/requirements/${id}`, {
|
|
675
|
+
method: "PATCH",
|
|
676
|
+
body: JSON.stringify({ name: "新标题" }),
|
|
677
|
+
});
|
|
678
|
+
expect(renamed.status).toBe(200);
|
|
679
|
+
expect(renamed.body.requirement.name).toBe("新标题");
|
|
680
|
+
expect(renamed.body.requirement.priority).toBe("low");
|
|
681
|
+
expect(renamed.body.requirement.assignee).toBe("node-a");
|
|
682
|
+
expect(renamed.body.requirement.due).toBe("2026-10-01");
|
|
683
|
+
expect(renamed.body.requirement.column).toBe("doing");
|
|
684
|
+
expect(renamed.body.requirement.owner).toEqual({ kind: "user", id: ownerId });
|
|
685
|
+
const cleared = await api(ownerToken, `/api/requirements/${id}`, {
|
|
686
|
+
method: "PATCH",
|
|
687
|
+
body: JSON.stringify({ due: "", assignee: "", priority: "high" }),
|
|
688
|
+
});
|
|
689
|
+
expect(cleared.status).toBe(200);
|
|
690
|
+
expect(cleared.body.requirement.name).toBe("新标题");
|
|
691
|
+
expect(cleared.body.requirement.due).toBe("");
|
|
692
|
+
expect(cleared.body.requirement.assignee).toBe("");
|
|
693
|
+
expect(cleared.body.requirement.priority).toBe("high");
|
|
694
|
+
expect(cleared.body.requirement.owner).toEqual({ kind: "user", id: ownerId });
|
|
695
|
+
expect((await api(ownerToken, `/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: " " }) })).status).toBe(400);
|
|
696
|
+
expect((await api(ownerToken, `/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ due: "2026-13-01" }) })).status).toBe(400);
|
|
697
|
+
expect((await api(viewerToken, `/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: "越权" }) })).status).toBe(403);
|
|
698
|
+
expect((await api(otherToken, `/api/requirements/${id}`, { method: "PATCH", body: JSON.stringify({ name: "别人的" }) })).status).toBe(404);
|
|
699
|
+
const listed = await api(ownerToken, "/api/requirements");
|
|
700
|
+
expect(listed.body.requirements.find((row: { id: string }) => row.id === id).name).toBe("新标题");
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test("empty name and a bad date are rejected", async () => {
|
|
704
|
+
const empty = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: " " }) });
|
|
705
|
+
expect(empty.status).toBe(400);
|
|
706
|
+
const date = await api(ownerToken, "/api/requirements", { method: "POST", body: JSON.stringify({ name: "日期", due: "2026-13-01" }) });
|
|
707
|
+
expect(date.status).toBe(400);
|
|
708
|
+
});
|
|
709
|
+
});
|