@desplega.ai/agent-swarm 1.80.2 → 1.81.0

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.
Files changed (40) hide show
  1. package/README.md +3 -0
  2. package/openapi.json +486 -29
  3. package/package.json +3 -3
  4. package/plugin/commands/user-management.md +85 -46
  5. package/plugin/pi-skills/user-management/SKILL.md +85 -46
  6. package/src/agentmail/handlers.ts +25 -3
  7. package/src/agentmail/types.ts +1 -0
  8. package/src/be/db.ts +33 -109
  9. package/src/be/migrations/067_users_first_class.sql +185 -0
  10. package/src/be/migrations/068_profile_changed_event_type.sql +56 -0
  11. package/src/be/unmapped-identities.ts +98 -0
  12. package/src/be/users.ts +531 -0
  13. package/src/github/handlers.ts +67 -7
  14. package/src/gitlab/handlers.ts +73 -5
  15. package/src/http/operator-actor.ts +59 -0
  16. package/src/http/users.ts +611 -21
  17. package/src/http/webhooks.ts +9 -0
  18. package/src/http/workflows.ts +2 -15
  19. package/src/linear/oauth.ts +61 -1
  20. package/src/linear/sync.ts +134 -21
  21. package/src/slack/actions.ts +8 -2
  22. package/src/slack/assistant.ts +12 -9
  23. package/src/slack/enrich.ts +162 -0
  24. package/src/slack/handlers.ts +11 -19
  25. package/src/tests/agentmail-handlers.test.ts +166 -0
  26. package/src/tests/github-handlers.test.ts +290 -0
  27. package/src/tests/gitlab-handlers.test.ts +293 -1
  28. package/src/tests/http-api-integration.test.ts +8 -4
  29. package/src/tests/http-users.test.ts +605 -0
  30. package/src/tests/linear-sync-identity.test.ts +427 -0
  31. package/src/tests/mcp-tools-user.test.ts +292 -0
  32. package/src/tests/slack-identity-resolution.test.ts +349 -0
  33. package/src/tests/user-identity.test.ts +351 -81
  34. package/src/tests/workflow-triggers-v2.test.ts +261 -20
  35. package/src/tools/manage-user.ts +119 -24
  36. package/src/tools/resolve-user.ts +43 -29
  37. package/src/types.ts +26 -4
  38. package/src/utils/secret-scrubber.ts +5 -0
  39. package/src/workflows/input.ts +7 -2
  40. package/src/workflows/triggers.ts +89 -9
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Integration tests for the operator-facing /api/users HTTP surface.
3
+ *
4
+ * Mirrors the kv-http.test.ts harness: spins up a real Bun.serve-compatible
5
+ * Node http.Server with the same `handleCore → handleUsers` pipeline as the
6
+ * production stack so we exercise:
7
+ * - the bearer-key gate (401 on missing/wrong key)
8
+ * - operator-actor fingerprinting (events tagged op:<16hex>)
9
+ * - route() factory matching for every endpoint
10
+ *
11
+ * Each test uses a fresh isolated SQLite file so identity-event tallies are
12
+ * deterministic.
13
+ */
14
+
15
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
16
+ import { unlink } from "node:fs/promises";
17
+ import {
18
+ createServer as createHttpServer,
19
+ type IncomingMessage,
20
+ type Server,
21
+ type ServerResponse,
22
+ } from "node:http";
23
+ import { closeDb, createUser, getDb, initDb, upsertKv } from "../be/db";
24
+ import { fingerprintApiKey, linkIdentity } from "../be/users";
25
+ import { handleCore } from "../http/core";
26
+ import { handleUsers } from "../http/users";
27
+ import { getPathSegments, parseQueryParams } from "../http/utils";
28
+
29
+ const TEST_DB_PATH = "./test-http-users.sqlite";
30
+ const API_KEY = "test-users-key";
31
+
32
+ async function removeDbFiles(path: string): Promise<void> {
33
+ for (const suffix of ["", "-wal", "-shm"]) {
34
+ try {
35
+ await unlink(path + suffix);
36
+ } catch (error) {
37
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
38
+ }
39
+ }
40
+ }
41
+
42
+ async function listen(server: Server): Promise<number> {
43
+ await new Promise<void>((resolve) => server.listen(0, resolve));
44
+ const addr = server.address();
45
+ if (!addr || typeof addr === "string") throw new Error("no port");
46
+ return addr.port;
47
+ }
48
+
49
+ function createTestServer(apiKey: string): Server {
50
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
51
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
52
+ const handled = await handleCore(req, res, myAgentId, apiKey);
53
+ if (handled) return;
54
+ const pathSegments = getPathSegments(req.url || "");
55
+ const queryParams = parseQueryParams(req.url || "");
56
+ const ok = await handleUsers(req, res, pathSegments, queryParams);
57
+ if (!ok) {
58
+ res.writeHead(404);
59
+ res.end("Not Found");
60
+ }
61
+ });
62
+ }
63
+
64
+ let server: Server;
65
+ let port: number;
66
+ const ORIGINAL_API_KEY = process.env.AGENT_SWARM_API_KEY;
67
+
68
+ beforeAll(async () => {
69
+ await removeDbFiles(TEST_DB_PATH);
70
+ initDb(TEST_DB_PATH);
71
+ // operator-actor reads getApiKey() which uses AGENT_SWARM_API_KEY env.
72
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
73
+ server = createTestServer(API_KEY);
74
+ port = await listen(server);
75
+ });
76
+
77
+ afterAll(async () => {
78
+ await new Promise<void>((resolve) => server.close(() => resolve()));
79
+ closeDb();
80
+ await removeDbFiles(TEST_DB_PATH);
81
+ if (ORIGINAL_API_KEY === undefined) {
82
+ delete process.env.AGENT_SWARM_API_KEY;
83
+ } else {
84
+ process.env.AGENT_SWARM_API_KEY = ORIGINAL_API_KEY;
85
+ }
86
+ });
87
+
88
+ beforeEach(() => {
89
+ // Clean slate between tests for deterministic event counts.
90
+ const db = getDb();
91
+ db.run("DELETE FROM user_identity_events");
92
+ db.run("DELETE FROM user_external_ids");
93
+ db.run("DELETE FROM user_tokens");
94
+ db.run("DELETE FROM users");
95
+ db.run("DELETE FROM kv_entries");
96
+ });
97
+
98
+ function url(path: string): string {
99
+ return `http://localhost:${port}${path}`;
100
+ }
101
+
102
+ function authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
103
+ const headers: Record<string, string> = {
104
+ Authorization: `Bearer ${API_KEY}`,
105
+ "Content-Type": "application/json",
106
+ ...((init.headers as Record<string, string>) ?? {}),
107
+ };
108
+ return fetch(url(path), { ...init, headers });
109
+ }
110
+
111
+ const OPERATOR_FP = fingerprintApiKey(API_KEY); // "op:<16hex>"
112
+
113
+ describe("auth", () => {
114
+ test("GET /api/users without Authorization → 401", async () => {
115
+ const r = await fetch(url("/api/users"));
116
+ expect(r.status).toBe(401);
117
+ });
118
+
119
+ test("GET /api/users with wrong key → 401", async () => {
120
+ const r = await fetch(url("/api/users"), {
121
+ headers: { Authorization: "Bearer not-the-key" },
122
+ });
123
+ expect(r.status).toBe(401);
124
+ });
125
+
126
+ test("GET /api/users with valid key → 200", async () => {
127
+ const r = await authedFetch("/api/users");
128
+ expect(r.status).toBe(200);
129
+ });
130
+ });
131
+
132
+ describe("GET /api/users", () => {
133
+ test("returns users composed with identities, tokens, recentEvents", async () => {
134
+ const u = createUser({ name: "Composed", email: "c@x.com" });
135
+ linkIdentity(u.id, "slack", "U_COMP", { kind: "operator", id: OPERATOR_FP });
136
+
137
+ const r = await authedFetch("/api/users");
138
+ expect(r.status).toBe(200);
139
+ const body = (await r.json()) as { users: Array<Record<string, unknown>> };
140
+ expect(body.users.length).toBe(1);
141
+ const row = body.users[0]!;
142
+ expect(row.id).toBe(u.id);
143
+ expect(row.identities).toEqual([{ kind: "slack", externalId: "U_COMP" }]);
144
+ expect(row.tokens).toEqual([]);
145
+ const events = row.recentEvents as Array<{ eventType: string }>;
146
+ expect(events.map((e) => e.eventType)).toContain("identity_added");
147
+ });
148
+ });
149
+
150
+ describe("POST /api/users", () => {
151
+ test("creates user + links identities + budget event, all tagged op:<fp>", async () => {
152
+ const r = await authedFetch("/api/users", {
153
+ method: "POST",
154
+ body: JSON.stringify({
155
+ name: "Tester",
156
+ email: "tester@dev",
157
+ dailyBudgetUsd: 5,
158
+ identities: [
159
+ { kind: "slack", externalId: "U_QA1" },
160
+ { kind: "github", externalId: "qa-tester" },
161
+ ],
162
+ }),
163
+ });
164
+ expect(r.status).toBe(200);
165
+ const { user } = (await r.json()) as {
166
+ user: {
167
+ id: string;
168
+ identities: Array<{ kind: string; externalId: string }>;
169
+ recentEvents: Array<{ eventType: string; actor: string }>;
170
+ };
171
+ };
172
+ expect(user.identities).toEqual([
173
+ { kind: "github", externalId: "qa-tester" },
174
+ { kind: "slack", externalId: "U_QA1" },
175
+ ]);
176
+ // All operator-driven events MUST be tagged operator:<fingerprint>.
177
+ for (const ev of user.recentEvents) {
178
+ expect(ev.actor).toBe(`operator:${OPERATOR_FP}`);
179
+ }
180
+ expect(user.recentEvents.map((e) => e.eventType)).toContain("budget_changed");
181
+ expect(
182
+ user.recentEvents.map((e) => e.eventType).filter((t) => t === "identity_added").length,
183
+ ).toBe(2);
184
+ });
185
+ });
186
+
187
+ describe("PATCH /api/users/:id", () => {
188
+ test("budget / status / emailAliases diffs each emit the right event types", async () => {
189
+ const u = createUser({
190
+ name: "Patcher",
191
+ email: "p@x.com",
192
+ emailAliases: ["a1@x.com"],
193
+ dailyBudgetUsd: 10,
194
+ status: "active",
195
+ });
196
+
197
+ const r = await authedFetch(`/api/users/${u.id}`, {
198
+ method: "PATCH",
199
+ body: JSON.stringify({
200
+ dailyBudgetUsd: 20,
201
+ status: "suspended",
202
+ emailAliases: ["a2@x.com"], // a1 removed, a2 added
203
+ }),
204
+ });
205
+ expect(r.status).toBe(200);
206
+
207
+ const events = getDb()
208
+ .prepare<{ eventType: string }, string>(
209
+ "SELECT eventType FROM user_identity_events WHERE userId = ? ORDER BY rowid",
210
+ )
211
+ .all(u.id);
212
+ const types = events.map((e) => e.eventType);
213
+ expect(types).toContain("budget_changed");
214
+ expect(types).toContain("status_changed");
215
+ expect(types).toContain("email_added");
216
+ expect(types).toContain("email_removed");
217
+ });
218
+
219
+ test("identities complete-list diff adds + removes", async () => {
220
+ const u = createUser({ name: "IdDiff" });
221
+ linkIdentity(u.id, "slack", "U_OLD", { kind: "operator", id: OPERATOR_FP });
222
+
223
+ const r = await authedFetch(`/api/users/${u.id}`, {
224
+ method: "PATCH",
225
+ body: JSON.stringify({
226
+ identities: [{ kind: "github", externalId: "newone" }],
227
+ }),
228
+ });
229
+ expect(r.status).toBe(200);
230
+ const { user } = (await r.json()) as {
231
+ user: { identities: Array<{ kind: string; externalId: string }> };
232
+ };
233
+ expect(user.identities).toEqual([{ kind: "github", externalId: "newone" }]);
234
+ });
235
+
236
+ test("404 for non-existent user", async () => {
237
+ const r = await authedFetch("/api/users/nope", {
238
+ method: "PATCH",
239
+ body: JSON.stringify({ name: "X" }),
240
+ });
241
+ expect(r.status).toBe(404);
242
+ });
243
+
244
+ test("profile_changed events fire for name / role / notes / timezone / preferredChannel edits", async () => {
245
+ const u = createUser({
246
+ name: "Old",
247
+ email: "old@x.com",
248
+ role: "viewer",
249
+ notes: "before",
250
+ preferredChannel: "slack",
251
+ timezone: "UTC",
252
+ });
253
+
254
+ const r = await authedFetch(`/api/users/${u.id}`, {
255
+ method: "PATCH",
256
+ body: JSON.stringify({
257
+ name: "New",
258
+ role: "admin",
259
+ notes: "after",
260
+ timezone: "America/New_York",
261
+ preferredChannel: "email",
262
+ }),
263
+ });
264
+ expect(r.status).toBe(200);
265
+
266
+ const events = getDb()
267
+ .prepare<{ eventType: string; afterJson: string | null }, string>(
268
+ "SELECT eventType, afterJson FROM user_identity_events WHERE userId = ? AND eventType = 'profile_changed' ORDER BY rowid",
269
+ )
270
+ .all(u.id);
271
+ const fields = events
272
+ .map((e) => (e.afterJson ? Object.keys(JSON.parse(e.afterJson))[0] : null))
273
+ .filter((f): f is string => !!f);
274
+ expect(fields).toContain("name");
275
+ expect(fields).toContain("role");
276
+ expect(fields).toContain("notes");
277
+ expect(fields).toContain("timezone");
278
+ expect(fields).toContain("preferredChannel");
279
+ });
280
+
281
+ test("profile_changed does NOT fire when value is unchanged", async () => {
282
+ const u = createUser({ name: "Same", role: "admin" });
283
+ const r = await authedFetch(`/api/users/${u.id}`, {
284
+ method: "PATCH",
285
+ // role unchanged, only emit no events for it; status doesn't change here either
286
+ body: JSON.stringify({ role: "admin", name: "Renamed" }),
287
+ });
288
+ expect(r.status).toBe(200);
289
+ const events = getDb()
290
+ .prepare<{ afterJson: string | null }, string>(
291
+ "SELECT afterJson FROM user_identity_events WHERE userId = ? AND eventType = 'profile_changed'",
292
+ )
293
+ .all(u.id);
294
+ const fields = events
295
+ .map((e) => (e.afterJson ? Object.keys(JSON.parse(e.afterJson))[0] : null))
296
+ .filter((f): f is string => !!f);
297
+ expect(fields).toContain("name");
298
+ expect(fields).not.toContain("role");
299
+ });
300
+ });
301
+
302
+ describe("identity link/unlink", () => {
303
+ test("POST then DELETE round-trips", async () => {
304
+ const u = createUser({ name: "RoundTrip" });
305
+
306
+ const add = await authedFetch(`/api/users/${u.id}/identities`, {
307
+ method: "POST",
308
+ body: JSON.stringify({ kind: "github", externalId: "rt-gh" }),
309
+ });
310
+ expect(add.status).toBe(200);
311
+ const addBody = (await add.json()) as {
312
+ identities: Array<{ kind: string; externalId: string }>;
313
+ };
314
+ expect(addBody.identities).toContainEqual({ kind: "github", externalId: "rt-gh" });
315
+
316
+ const del = await authedFetch(`/api/users/${u.id}/identities/github/rt-gh`, {
317
+ method: "DELETE",
318
+ });
319
+ expect(del.status).toBe(200);
320
+ const delBody = (await del.json()) as {
321
+ identities: Array<{ kind: string; externalId: string }>;
322
+ };
323
+ expect(delBody.identities).toEqual([]);
324
+ });
325
+
326
+ test("DELETE with URL-encoded externalId removes the literal identity", async () => {
327
+ // Webhook auto-link can store an externalId containing `@` (AgentMail
328
+ // email-as-id, Linear `@handle`). The UI sends the path URL-encoded; the
329
+ // handler must decode before SELECT/DELETE or the row sticks around.
330
+ const u = createUser({ name: "DecodeDelete" });
331
+ const literal = "@deletable";
332
+
333
+ const add = await authedFetch(`/api/users/${u.id}/identities`, {
334
+ method: "POST",
335
+ body: JSON.stringify({ kind: "slack", externalId: literal }),
336
+ });
337
+ expect(add.status).toBe(200);
338
+
339
+ const del = await authedFetch(
340
+ `/api/users/${u.id}/identities/slack/${encodeURIComponent(literal)}`,
341
+ { method: "DELETE" },
342
+ );
343
+ expect(del.status).toBe(200);
344
+ const delBody = (await del.json()) as {
345
+ identities: Array<{ kind: string; externalId: string }>;
346
+ };
347
+ // Before the fix this still passed but the underlying row was never
348
+ // touched — the SELECT for `%40deletable` found nothing. Verify the
349
+ // literal-keyed row is actually gone by re-reading it.
350
+ expect(delBody.identities.some((i) => i.externalId === literal)).toBe(false);
351
+ const reread = await authedFetch(`/api/users/${u.id}`);
352
+ const rb = (await reread.json()) as {
353
+ user: { identities: Array<{ kind: string; externalId: string }> };
354
+ };
355
+ expect(rb.user.identities.some((i) => i.externalId === literal)).toBe(false);
356
+ });
357
+ });
358
+
359
+ describe("GET /api/users/:id/events", () => {
360
+ test("returns events DESC and respects limit + before cursor", async () => {
361
+ const u = createUser({ name: "EventList" });
362
+ const actor = { kind: "operator" as const, id: OPERATOR_FP };
363
+ // Emit a sequence of events with monotonically-increasing createdAt.
364
+ linkIdentity(u.id, "slack", "E1", actor);
365
+ linkIdentity(u.id, "slack", "E2", actor);
366
+ linkIdentity(u.id, "slack", "E3", actor);
367
+
368
+ const r = await authedFetch(`/api/users/${u.id}/events?limit=2`);
369
+ expect(r.status).toBe(200);
370
+ const body = (await r.json()) as {
371
+ events: Array<{ id: string; createdAt: string; eventType: string }>;
372
+ };
373
+ expect(body.events.length).toBe(2);
374
+ // DESC: first event's createdAt >= second's.
375
+ expect(body.events[0]!.createdAt >= body.events[1]!.createdAt).toBe(true);
376
+ expect(body.events.every((e) => e.eventType === "identity_added")).toBe(true);
377
+ });
378
+ });
379
+
380
+ describe("GET /api/users/unmapped", () => {
381
+ test("groups :meta + :count entries and sorts by count DESC", async () => {
382
+ // Seed two unmapped identities with different counts.
383
+ const ns = "integration:unmapped:slack";
384
+ upsertKv({
385
+ namespace: ns,
386
+ key: "U_LOW:meta",
387
+ value: { lastSeenAt: "2026-05-01T00:00:00Z", sampleEventType: "message" },
388
+ valueType: "json",
389
+ });
390
+ upsertKv({ namespace: ns, key: "U_LOW:count", value: 1, valueType: "integer" });
391
+ upsertKv({
392
+ namespace: ns,
393
+ key: "U_HIGH:meta",
394
+ value: { lastSeenAt: "2026-05-15T00:00:00Z", sampleEventType: "message" },
395
+ valueType: "json",
396
+ });
397
+ upsertKv({ namespace: ns, key: "U_HIGH:count", value: 5, valueType: "integer" });
398
+
399
+ const r = await authedFetch("/api/users/unmapped?kind=slack");
400
+ expect(r.status).toBe(200);
401
+ const body = (await r.json()) as {
402
+ unmapped: Array<{
403
+ kind: string;
404
+ externalId: string;
405
+ count: number;
406
+ lastSeenAt: string | null;
407
+ }>;
408
+ };
409
+ expect(body.unmapped.length).toBe(2);
410
+ expect(body.unmapped[0]!.externalId).toBe("U_HIGH");
411
+ expect(body.unmapped[0]!.count).toBe(5);
412
+ expect(body.unmapped[1]!.externalId).toBe("U_LOW");
413
+ });
414
+ });
415
+
416
+ describe("POST /api/users/unmapped/:kind/:externalId/resolve", () => {
417
+ test("link-to-existing branch links + clears kv rows", async () => {
418
+ const existing = createUser({ name: "ExistingTarget" });
419
+ const ns = "integration:unmapped:slack";
420
+ upsertKv({ namespace: ns, key: "U_QA9:meta", value: { lastSeenAt: "x" }, valueType: "json" });
421
+ upsertKv({ namespace: ns, key: "U_QA9:count", value: 3, valueType: "integer" });
422
+
423
+ const r = await authedFetch("/api/users/unmapped/slack/U_QA9/resolve", {
424
+ method: "POST",
425
+ body: JSON.stringify({ userId: existing.id }),
426
+ });
427
+ expect(r.status).toBe(200);
428
+ const { user } = (await r.json()) as {
429
+ user: { id: string; identities: Array<{ kind: string; externalId: string }> };
430
+ };
431
+ expect(user.id).toBe(existing.id);
432
+ expect(user.identities).toContainEqual({ kind: "slack", externalId: "U_QA9" });
433
+
434
+ // kv rows gone.
435
+ const listR = await authedFetch("/api/users/unmapped?kind=slack");
436
+ const listBody = (await listR.json()) as { unmapped: unknown[] };
437
+ expect(listBody.unmapped.length).toBe(0);
438
+ });
439
+
440
+ test("create-new branch creates the user + links + clears kv rows", async () => {
441
+ const ns = "integration:unmapped:github";
442
+ upsertKv({ namespace: ns, key: "ghuser:meta", value: { lastSeenAt: "x" }, valueType: "json" });
443
+ upsertKv({ namespace: ns, key: "ghuser:count", value: 1, valueType: "integer" });
444
+
445
+ const r = await authedFetch("/api/users/unmapped/github/ghuser/resolve", {
446
+ method: "POST",
447
+ body: JSON.stringify({ name: "GH User", email: "gh@example.com" }),
448
+ });
449
+ expect(r.status).toBe(200);
450
+ const { user } = (await r.json()) as {
451
+ user: { id: string; name: string; identities: Array<{ kind: string; externalId: string }> };
452
+ };
453
+ expect(user.name).toBe("GH User");
454
+ expect(user.identities).toContainEqual({ kind: "github", externalId: "ghuser" });
455
+ });
456
+
457
+ test("URL-encoded externalId is decoded — kv rows clear, identity stored decoded", async () => {
458
+ // Mimics an AgentMail/Linear @handle entry that contains `@` (or any
459
+ // URL-reserved char). Kv keys are written by the webhook with the literal
460
+ // externalId; the path param arrives URL-encoded; the handler must decode
461
+ // before linking AND before deleting the two kv rows.
462
+ const ns = "integration:unmapped:slack";
463
+ const literal = "@alexdev";
464
+ upsertKv({
465
+ namespace: ns,
466
+ key: `${literal}:meta`,
467
+ value: { lastSeenAt: "2026-05-19T00:00:00Z", sampleEventType: "message" },
468
+ valueType: "json",
469
+ });
470
+ upsertKv({ namespace: ns, key: `${literal}:count`, value: 2, valueType: "integer" });
471
+
472
+ const r = await authedFetch(
473
+ `/api/users/unmapped/slack/${encodeURIComponent(literal)}/resolve`,
474
+ {
475
+ method: "POST",
476
+ body: JSON.stringify({ name: "Alex Dev", email: "alexdev@example.com" }),
477
+ },
478
+ );
479
+ expect(r.status).toBe(200);
480
+ const { user } = (await r.json()) as {
481
+ user: { identities: Array<{ kind: string; externalId: string }> };
482
+ };
483
+ // Identity stored DECODED.
484
+ expect(user.identities).toContainEqual({ kind: "slack", externalId: literal });
485
+
486
+ // Kv rows for the literal key are gone (the bug was: handler deleted
487
+ // `%40alexdev:meta`/`%40alexdev:count` instead of the literal `@…` keys).
488
+ const listR = await authedFetch("/api/users/unmapped?kind=slack");
489
+ const listBody = (await listR.json()) as {
490
+ unmapped: Array<{ externalId: string }>;
491
+ };
492
+ expect(listBody.unmapped.some((u) => u.externalId === literal)).toBe(false);
493
+ });
494
+
495
+ test("URL-encoded kind is decoded — identity stored decoded, kv rows clear", async () => {
496
+ // A custom integration kind containing a URL-reserved char (`;`). The
497
+ // webhook writes the kv namespace with the literal kind; the path param
498
+ // arrives URL-encoded. The handler must decode `kind` before linking AND
499
+ // before computing the kv namespace for the two deletes.
500
+ const literalKind = "custom;crm";
501
+ const ns = `integration:unmapped:${literalKind}`;
502
+ const externalId = "CRM_42";
503
+ upsertKv({
504
+ namespace: ns,
505
+ key: `${externalId}:meta`,
506
+ value: { lastSeenAt: "2026-05-19T00:00:00Z", sampleEventType: "lead" },
507
+ valueType: "json",
508
+ });
509
+ upsertKv({ namespace: ns, key: `${externalId}:count`, value: 4, valueType: "integer" });
510
+
511
+ const r = await authedFetch(
512
+ `/api/users/unmapped/${encodeURIComponent(literalKind)}/${externalId}/resolve`,
513
+ {
514
+ method: "POST",
515
+ body: JSON.stringify({ name: "CRM User", email: "crm@example.com" }),
516
+ },
517
+ );
518
+ expect(r.status).toBe(200);
519
+ const { user } = (await r.json()) as {
520
+ user: { identities: Array<{ kind: string; externalId: string }> };
521
+ };
522
+ // Identity stored with the DECODED kind (bug stored `custom%3Bcrm`).
523
+ expect(user.identities).toContainEqual({ kind: literalKind, externalId });
524
+
525
+ // Kv rows cleared — the bug computed `integration:unmapped:custom%3Bcrm`
526
+ // so the literal-namespace rows were never deleted.
527
+ const listR = await authedFetch(`/api/users/unmapped?kind=${encodeURIComponent(literalKind)}`);
528
+ const listBody = (await listR.json()) as {
529
+ unmapped: Array<{ externalId: string }>;
530
+ };
531
+ expect(listBody.unmapped.some((u) => u.externalId === externalId)).toBe(false);
532
+ });
533
+ });
534
+
535
+ describe("POST /api/users/:id/merge", () => {
536
+ test("moves identities, removes source, leaves manual_merge event", async () => {
537
+ const target = createUser({ name: "Target", email: "t@x.com" });
538
+ const source = createUser({ name: "Source", email: "s@x.com", emailAliases: ["alt@x.com"] });
539
+ const actor = { kind: "operator" as const, id: OPERATOR_FP };
540
+ linkIdentity(source.id, "slack", "U_SRC", actor);
541
+ linkIdentity(source.id, "github", "src-gh", actor);
542
+
543
+ const r = await authedFetch(`/api/users/${target.id}/merge`, {
544
+ method: "POST",
545
+ body: JSON.stringify({ sourceUserId: source.id }),
546
+ });
547
+ expect(r.status).toBe(200);
548
+ const { user } = (await r.json()) as {
549
+ user: {
550
+ id: string;
551
+ identities: Array<{ kind: string; externalId: string }>;
552
+ emailAliases?: string[];
553
+ recentEvents: Array<{ eventType: string }>;
554
+ };
555
+ };
556
+ expect(user.id).toBe(target.id);
557
+ // Source identities migrated.
558
+ expect(user.identities).toContainEqual({ kind: "slack", externalId: "U_SRC" });
559
+ expect(user.identities).toContainEqual({ kind: "github", externalId: "src-gh" });
560
+ // Source email + aliases appended.
561
+ expect(user.emailAliases ?? []).toContain("s@x.com");
562
+ expect(user.emailAliases ?? []).toContain("alt@x.com");
563
+ // manual_merge event present.
564
+ expect(user.recentEvents.map((e) => e.eventType)).toContain("manual_merge");
565
+
566
+ // Source user is gone.
567
+ const sourceR = await authedFetch(`/api/users/${source.id}`);
568
+ expect(sourceR.status).toBe(404);
569
+ });
570
+
571
+ test("manual_merge event payload carries the source user's id/name", async () => {
572
+ const target = createUser({ name: "MergeTarget", email: "mt@x.com" });
573
+ const source = createUser({ name: "MergeSource", email: "ms@x.com" });
574
+
575
+ const r = await authedFetch(`/api/users/${target.id}/merge`, {
576
+ method: "POST",
577
+ body: JSON.stringify({ sourceUserId: source.id }),
578
+ });
579
+ expect(r.status).toBe(200);
580
+ const { user } = (await r.json()) as {
581
+ user: {
582
+ recentEvents: Array<{
583
+ eventType: string;
584
+ after: { source?: { id?: string; name?: string; email?: string } } | null;
585
+ }>;
586
+ };
587
+ };
588
+ const mergeEvent = user.recentEvents.find((e) => e.eventType === "manual_merge");
589
+ expect(mergeEvent).toBeDefined();
590
+ // The deleted source user is snapshotted under `after.source` so the UI
591
+ // can render "Merged manually from <source> → <target>".
592
+ expect(mergeEvent!.after?.source?.id).toBe(source.id);
593
+ expect(mergeEvent!.after?.source?.name).toBe("MergeSource");
594
+ expect(mergeEvent!.after?.source?.email).toBe("ms@x.com");
595
+ });
596
+
597
+ test("400 when target == source", async () => {
598
+ const u = createUser({ name: "Self" });
599
+ const r = await authedFetch(`/api/users/${u.id}/merge`, {
600
+ method: "POST",
601
+ body: JSON.stringify({ sourceUserId: u.id }),
602
+ });
603
+ expect(r.status).toBe(400);
604
+ });
605
+ });