@andypai/agent-kanban 0.5.1 → 0.6.1

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 (52) hide show
  1. package/README.md +42 -19
  2. package/package.json +3 -2
  3. package/src/__tests__/activity.test.ts +12 -0
  4. package/src/__tests__/api.test.ts +4 -2
  5. package/src/__tests__/board-utils.test.ts +16 -3
  6. package/src/__tests__/column-roles.test.ts +34 -0
  7. package/src/__tests__/commands/board.test.ts +7 -0
  8. package/src/__tests__/conflict.test.ts +11 -1
  9. package/src/__tests__/db.test.ts +70 -0
  10. package/src/__tests__/index.test.ts +55 -0
  11. package/src/__tests__/jira-cache.test.ts +56 -0
  12. package/src/__tests__/jira-client.test.ts +98 -1
  13. package/src/__tests__/jira-jql.test.ts +51 -0
  14. package/src/__tests__/jira-provider-mutations.test.ts +84 -59
  15. package/src/__tests__/jira-provider-read.test.ts +227 -20
  16. package/src/__tests__/mcp-server.test.ts +2 -2
  17. package/src/__tests__/metrics.test.ts +37 -1
  18. package/src/__tests__/postgres-jira-provider.test.ts +155 -0
  19. package/src/__tests__/postgres-local-provider.test.ts +90 -1
  20. package/src/__tests__/server.test.ts +126 -0
  21. package/src/__tests__/use-cases.test.ts +77 -0
  22. package/src/__tests__/webhooks.test.ts +75 -22
  23. package/src/api.ts +58 -36
  24. package/src/column-roles.ts +52 -0
  25. package/src/commands/board.ts +4 -4
  26. package/src/db.ts +145 -114
  27. package/src/errors.ts +1 -0
  28. package/src/index.ts +84 -33
  29. package/src/mcp/core.ts +8 -7
  30. package/src/metrics.ts +48 -23
  31. package/src/provider-runtime.ts +9 -2
  32. package/src/providers/index.ts +4 -1
  33. package/src/providers/jira-cache.ts +23 -6
  34. package/src/providers/jira-client.ts +70 -4
  35. package/src/providers/jira-core.ts +921 -0
  36. package/src/providers/jira-jql.ts +48 -0
  37. package/src/providers/jira.ts +111 -677
  38. package/src/providers/linear-cache.ts +5 -3
  39. package/src/providers/linear-core.ts +688 -0
  40. package/src/providers/linear.ts +70 -554
  41. package/src/providers/postgres-jira-cache.ts +597 -0
  42. package/src/providers/postgres-jira.ts +15 -1188
  43. package/src/providers/postgres-linear-cache.ts +500 -0
  44. package/src/providers/postgres-linear.ts +22 -1088
  45. package/src/providers/postgres-local.ts +181 -74
  46. package/src/providers/types.ts +71 -2
  47. package/src/server.ts +118 -32
  48. package/src/use-cases.ts +139 -0
  49. package/src/webhooks.ts +26 -0
  50. package/ui/dist/assets/index-65LcV0R7.js +40 -0
  51. package/ui/dist/index.html +1 -1
  52. package/ui/dist/assets/index-CFhtfqCn.js +0 -40
@@ -145,6 +145,16 @@ function standardRoutes(opts: { boardCfg?: unknown } = {}): StubRoute[] {
145
145
  )
146
146
  },
147
147
  },
148
+ {
149
+ // GET /rest/api/3/issue/{key} backs hydrateIssueByKey's read-after-write.
150
+ match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+$/.test(new URL(url).pathname),
151
+ handler: (url) => {
152
+ const issueKey = new URL(url).pathname.match(/\/issue\/(ENG-\d+)$/)![1]!
153
+ const issue = issues.find((candidate) => String(candidate.key) === issueKey)
154
+ if (!issue) return jsonResponse({ errorMessages: ['missing'] }, 404)
155
+ return jsonResponse(issue)
156
+ },
157
+ },
148
158
  {
149
159
  match: (url) => /\/rest\/api\/3\/issue\/ENG-\d+\/comment$/.test(new URL(url).pathname),
150
160
  handler: async (url, init) => {
@@ -401,4 +411,149 @@ describe('postgres jira provider', () => {
401
411
  column_id: '20',
402
412
  })
403
413
  })
414
+
415
+ pgTest(
416
+ 'delta sync adds new catalog rows but only a full reconcile prunes obsolete ones',
417
+ async () => {
418
+ if (!sql) throw new Error('expected postgres test connection')
419
+ const prioritiesState = {
420
+ list: [
421
+ { id: '2', name: 'High' },
422
+ { id: '3', name: 'Medium' },
423
+ ] as Array<{ id: string; name: string }>,
424
+ }
425
+ const routes = standardRoutes()
426
+ const pIdx = routes.findIndex((route) =>
427
+ route.match('https://example.atlassian.net/rest/api/3/priority'),
428
+ )
429
+ routes[pIdx] = {
430
+ match: (url) => url.includes('/rest/api/3/priority'),
431
+ handler: () => jsonResponse(prioritiesState.list),
432
+ }
433
+ globalThis.fetch = jiraFetchStub(routes).fn
434
+ // pollingSyncIntervalMs: 0 lets every getBoard run; the first is a full
435
+ // reconcile (no lastFullSyncAt yet), later ones are delta syncs because the
436
+ // full-reconcile interval has not elapsed.
437
+ const provider = new PostgresJiraProvider(sql, {
438
+ baseUrl: 'https://example.atlassian.net',
439
+ email: 'user@example.com',
440
+ apiToken: 'token',
441
+ projectKey: 'ENG',
442
+ boardId: 1006,
443
+ pollingSyncIntervalMs: 0,
444
+ })
445
+ const priorityNames = async () =>
446
+ (await sql!<{ name: string }[]>`SELECT name FROM jira_priorities ORDER BY name`).map(
447
+ (row) => row.name,
448
+ )
449
+
450
+ await provider.getBoard()
451
+ expect(await priorityNames()).toEqual(['High', 'Medium'])
452
+
453
+ // Drop Medium, add Low upstream. A delta sync must pick up the new row
454
+ // (Low) via the every-sync UPSERT but must NOT prune the now-obsolete row
455
+ // (Medium): pruning on a possibly-stale delta snapshot could delete a row
456
+ // another replica just added. Pruning is confined to the full reconcile.
457
+ prioritiesState.list = [
458
+ { id: '2', name: 'High' },
459
+ { id: '4', name: 'Low' },
460
+ ]
461
+ await provider.getBoard()
462
+ expect(await priorityNames()).toEqual(['High', 'Low', 'Medium'])
463
+
464
+ // Force the next sync to be a full reconcile; only then is the obsolete
465
+ // Medium pruned.
466
+ await sql`DELETE FROM jira_sync_meta WHERE key = 'lastFullSyncAt'`
467
+ await provider.getBoard()
468
+ expect(await priorityNames()).toEqual(['High', 'Low'])
469
+ },
470
+ )
471
+
472
+ pgTest('concurrent catalog refreshes do not collide on a primary key', async () => {
473
+ if (!sql) throw new Error('expected postgres test connection')
474
+ globalThis.fetch = jiraFetchStub(standardRoutes()).fn
475
+ const sql2 = postgres(databaseUrl as string, { max: 1, onnotice: () => {} })
476
+ try {
477
+ const config = {
478
+ baseUrl: 'https://example.atlassian.net',
479
+ email: 'user@example.com',
480
+ apiToken: 'token',
481
+ projectKey: 'ENG',
482
+ boardId: 1006,
483
+ pollingSyncIntervalMs: 0,
484
+ }
485
+ // Initialize schema sequentially first; concurrent CREATE TABLE IF NOT
486
+ // EXISTS races at the DDL level (a pre-existing Postgres quirk unrelated to
487
+ // catalog refresh). The race under test is the catalog DML below.
488
+ const p1 = new PostgresJiraProvider(sql, config)
489
+ await p1.initialize()
490
+ const p2 = new PostgresJiraProvider(sql2, config)
491
+ await p2.initialize()
492
+ // Two replicas on separate connections refreshing the same shared cache
493
+ // concurrently must not trip a primary-key collision. Both first syncs are
494
+ // full reconciles (no lastFullSyncAt yet), so this also exercises the
495
+ // obsolete-row DELETE running concurrently: UPSERT + conditional DELETE is
496
+ // idempotent and order-independent.
497
+ await Promise.all([p1.getBoard(), p2.getBoard()])
498
+ const rows = await sql<
499
+ { count: number }[]
500
+ >`SELECT COUNT(*)::int AS count FROM jira_priorities`
501
+ expect(rows[0]?.count ?? 0).toBeGreaterThan(0)
502
+ } finally {
503
+ await sql2.end({ timeout: 1 })
504
+ }
505
+ })
506
+
507
+ pgTest('full reconcile with a stalled cursor does not prune unfetched-page issues', async () => {
508
+ if (!sql) throw new Error('expected postgres test connection')
509
+ // Mirror of the SQLite prune-safety coverage: if the cursor stalls (a
510
+ // repeated non-last token) mid-scan, seenIssueIds is only partial, so the
511
+ // incomplete scan must NOT prune issues that exist upstream on pages we never
512
+ // fetched.
513
+ let stalled = false
514
+ const routes = standardRoutes()
515
+ const sIdx = routes.findIndex((route) => route.match('https://x/rest/api/3/search/jql'))
516
+ routes[sIdx] = {
517
+ match: (url) => url.includes('/rest/api/3/search/jql'),
518
+ handler: () =>
519
+ stalled
520
+ ? // Later full reconcile: the cursor stalls after returning only ENG-1,
521
+ // so ENG-2's page is never reached. isLast=false + a repeated token =>
522
+ // incomplete.
523
+ jsonResponse({
524
+ nextPageToken: 'stuck',
525
+ isLast: false,
526
+ issues: [makeIssue({ id: '1', key: 'ENG-1' })],
527
+ })
528
+ : // First full reconcile: a clean single terminal page with both issues.
529
+ jsonResponse({
530
+ isLast: true,
531
+ issues: [makeIssue({ id: '1', key: 'ENG-1' }), makeIssue({ id: '2', key: 'ENG-2' })],
532
+ }),
533
+ }
534
+ globalThis.fetch = jiraFetchStub(routes).fn
535
+ const provider = new PostgresJiraProvider(sql, {
536
+ baseUrl: 'https://example.atlassian.net',
537
+ email: 'user@example.com',
538
+ apiToken: 'token',
539
+ projectKey: 'ENG',
540
+ boardId: 1006,
541
+ pollingSyncIntervalMs: 0,
542
+ })
543
+ const issueKeys = async () =>
544
+ (await sql!<{ key: string }[]>`SELECT key FROM jira_issues ORDER BY key`).map(
545
+ (row) => row.key,
546
+ )
547
+
548
+ await provider.getBoard()
549
+ expect(await issueKeys()).toEqual(['ENG-1', 'ENG-2'])
550
+
551
+ // Force the next sync to be a full reconcile and hit the stalled cursor.
552
+ stalled = true
553
+ await sql`DELETE FROM jira_sync_meta WHERE key = 'lastFullSyncAt'`
554
+ await provider.getBoard()
555
+
556
+ // ENG-2 must survive: the incomplete scan is not authoritative for pruning.
557
+ expect(await issueKeys()).toEqual(['ENG-1', 'ENG-2'])
558
+ })
404
559
  })
@@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
2
  import postgres from 'postgres'
3
3
 
4
4
  import { run } from '../index'
5
- import type { Task, TaskComment, TaskWithColumn } from '../types'
5
+ import { openKanbanRuntime } from '../provider-runtime'
6
+ import type { BoardMetrics, Task, TaskComment, TaskWithColumn } from '../types'
6
7
 
7
8
  const databaseUrl = process.env['KANBAN_PG_TEST_URL'] ?? process.env['DATABASE_URL']
8
9
  const pgTest = databaseUrl ? test : test.skip
@@ -131,4 +132,92 @@ describe('postgres local provider', () => {
131
132
 
132
133
  expect(created.column_name).toBe('Todo')
133
134
  })
135
+
136
+ async function positionsInColumn(
137
+ columnName: string,
138
+ ): Promise<Array<{ id: string; position: number }>> {
139
+ if (!sql) throw new Error('sql not initialized')
140
+ return sql<Array<{ id: string; position: number }>>`
141
+ SELECT t.id, t.position FROM tasks t
142
+ JOIN columns c ON t.column_id = c.id
143
+ WHERE c.name = ${columnName}
144
+ ORDER BY t.position
145
+ `
146
+ }
147
+
148
+ pgTest('move renumbers the source column and records column-time tracking', async () => {
149
+ const a = expectOk<TaskWithColumn>(await run(['task', 'add', 'A', '-c', 'backlog']))
150
+ const b = expectOk<TaskWithColumn>(await run(['task', 'add', 'B', '-c', 'backlog']))
151
+ const c = expectOk<TaskWithColumn>(await run(['task', 'add', 'C', '-c', 'backlog']))
152
+ expect((await positionsInColumn('backlog')).map((r) => r.id)).toEqual([a.id, b.id, c.id])
153
+
154
+ expectOk<Task>(await run(['task', 'move', b.id, 'in-progress']))
155
+
156
+ // Source column compacted to 0..n-1 with no gap left by B.
157
+ expect(await positionsInColumn('backlog')).toEqual([
158
+ { id: a.id, position: 0 },
159
+ { id: c.id, position: 1 },
160
+ ])
161
+
162
+ // B has a closed backlog interval and an open in-progress interval.
163
+ const tracking = await sql!<Array<{ column_name: string; exited_at: string | null }>>`
164
+ SELECT c.name AS column_name, ct.exited_at
165
+ FROM column_time_tracking ct JOIN columns c ON ct.column_id = c.id
166
+ WHERE ct.task_id = ${b.id}
167
+ ORDER BY ct.entered_at
168
+ `
169
+ expect(tracking.map((r) => r.column_name)).toEqual(['backlog', 'in-progress'])
170
+ expect(tracking[0]!.exited_at).not.toBeNull()
171
+ expect(tracking[1]!.exited_at).toBeNull()
172
+ })
173
+
174
+ pgTest('move to the same column is a no-op (no extra tracking, no reorder)', async () => {
175
+ const a = expectOk<TaskWithColumn>(await run(['task', 'add', 'A', '-c', 'backlog']))
176
+ const b = expectOk<TaskWithColumn>(await run(['task', 'add', 'B', '-c', 'backlog']))
177
+
178
+ expectOk<Task>(await run(['task', 'move', a.id, 'backlog']))
179
+
180
+ // Order preserved (no re-append of A to the end).
181
+ expect((await positionsInColumn('backlog')).map((r) => r.id)).toEqual([a.id, b.id])
182
+ // Only the single creation interval exists for A.
183
+ const trackingRows = await sql!<Array<{ count: string | number }>>`
184
+ SELECT COUNT(*) AS count FROM column_time_tracking WHERE task_id = ${a.id}
185
+ `
186
+ expect(Number(trackingRows[0]?.count ?? 0)).toBe(1)
187
+ })
188
+
189
+ pgTest('delete renumbers the remaining tasks in the column', async () => {
190
+ const a = expectOk<TaskWithColumn>(await run(['task', 'add', 'A', '-c', 'backlog']))
191
+ const b = expectOk<TaskWithColumn>(await run(['task', 'add', 'B', '-c', 'backlog']))
192
+ const c = expectOk<TaskWithColumn>(await run(['task', 'add', 'C', '-c', 'backlog']))
193
+
194
+ expectOk<Task>(await run(['task', 'delete', b.id]))
195
+
196
+ expect(await positionsInColumn('backlog')).toEqual([
197
+ { id: a.id, position: 0 },
198
+ { id: c.id, position: 1 },
199
+ ])
200
+ })
201
+
202
+ pgTest('getMetrics reports completion time for tasks that reached Done', async () => {
203
+ const a = expectOk<TaskWithColumn>(await run(['task', 'add', 'A', '-c', 'backlog']))
204
+ expectOk<TaskWithColumn>(await run(['task', 'add', 'B', '-c', 'backlog']))
205
+ expectOk<Task>(await run(['task', 'move', a.id, 'done']))
206
+
207
+ const runtime = await openKanbanRuntime()
208
+ let metrics: BoardMetrics
209
+ try {
210
+ metrics = await runtime.provider.getMetrics()
211
+ } finally {
212
+ await runtime.close()
213
+ }
214
+
215
+ expect(metrics.completedTasks).toBe(1)
216
+ // Bug fix: a task resting in Done now contributes to the average (no longer
217
+ // requires exited_at), and the value is a real number, not a string.
218
+ expect(typeof metrics.avgCompletionHours).toBe('number')
219
+ expect(metrics.avgCompletionHours!).toBeGreaterThanOrEqual(0)
220
+ expect(metrics.tasksCreatedThisWeek).toBe(2)
221
+ expect(metrics.inProgressCount).toBe(0)
222
+ })
134
223
  })
@@ -289,3 +289,129 @@ describe('startServer', () => {
289
289
  expect(syncCalls).toBeGreaterThanOrEqual(2)
290
290
  })
291
291
  })
292
+
293
+ describe('startServer auth + CORS', () => {
294
+ const TOKEN = 'secret-token'
295
+
296
+ test('without a token, the API stays open (localhost default)', async () => {
297
+ const runtime = startServer(makeProvider(), 0)
298
+ runtimes.push(runtime)
299
+ const res = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap`)
300
+ expect(res.status).toBe(200)
301
+ })
302
+
303
+ test('with a token, protected routes require Bearer auth', async () => {
304
+ const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
305
+ runtimes.push(runtime)
306
+
307
+ const noAuth = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap`)
308
+ expect(noAuth.status).toBe(401)
309
+ const noAuthBody = (await noAuth.json()) as { ok: boolean; error: { code: string } }
310
+ expect(noAuthBody.ok).toBe(false)
311
+ expect(noAuthBody.error.code).toBe('UNAUTHORIZED')
312
+
313
+ const wrong = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap`, {
314
+ headers: { Authorization: 'Bearer nope' },
315
+ })
316
+ expect(wrong.status).toBe(401)
317
+
318
+ const ok = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap`, {
319
+ headers: { Authorization: `Bearer ${TOKEN}` },
320
+ })
321
+ expect(ok.status).toBe(200)
322
+ })
323
+
324
+ test('?token= does NOT authorize HTTP API routes (header only)', async () => {
325
+ const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
326
+ runtimes.push(runtime)
327
+ const res = await fetch(`http://127.0.0.1:${runtime.port}/api/bootstrap?token=${TOKEN}`)
328
+ expect(res.status).toBe(401)
329
+ })
330
+
331
+ test('/ws requires the token and accepts it as a query param', async () => {
332
+ const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
333
+ runtimes.push(runtime)
334
+
335
+ // Plain GET (no upgrade) exercises the auth gate before the upgrade attempt.
336
+ const noToken = await fetch(`http://127.0.0.1:${runtime.port}/ws`)
337
+ expect(noToken.status).toBe(401)
338
+
339
+ // Correct query token passes the gate; the non-WebSocket request then fails
340
+ // the upgrade (400) rather than auth (401).
341
+ const withToken = await fetch(`http://127.0.0.1:${runtime.port}/ws?token=${TOKEN}`)
342
+ expect(withToken.status).not.toBe(401)
343
+ })
344
+
345
+ test('with a token, /api/health stays public', async () => {
346
+ const runtime = startServer(makeProvider(), 0, { authToken: TOKEN })
347
+ runtimes.push(runtime)
348
+ const res = await fetch(`http://127.0.0.1:${runtime.port}/api/health`)
349
+ expect(res.status).toBe(200)
350
+ })
351
+
352
+ test('with a token, webhook routes are exempt (provider secret guards them)', async () => {
353
+ const runtime = startServer(
354
+ makeProvider({
355
+ type: 'local',
356
+ async handleWebhook() {
357
+ return { handled: true }
358
+ },
359
+ }),
360
+ 0,
361
+ { authToken: TOKEN },
362
+ )
363
+ runtimes.push(runtime)
364
+ const res = await fetch(`http://127.0.0.1:${runtime.port}/api/webhooks/local`, {
365
+ method: 'POST',
366
+ headers: { 'Content-Type': 'application/json' },
367
+ body: '{}',
368
+ })
369
+ // Reaches the provider handler instead of being rejected by bearer auth.
370
+ expect(res.status).not.toBe(401)
371
+ })
372
+
373
+ test('wsClients are tracked per server instance, not shared globally', async () => {
374
+ const a = startServer(makeProvider(), 0)
375
+ runtimes.push(a)
376
+ const b = startServer(makeProvider(), 0)
377
+ runtimes.push(b)
378
+
379
+ const ws = new WebSocket(`ws://127.0.0.1:${a.port}/ws`)
380
+ await new Promise<void>((resolve, reject) => {
381
+ ws.onopen = () => resolve()
382
+ ws.onerror = () => reject(new Error('ws connection failed'))
383
+ })
384
+ // Let server A's open handler register the socket.
385
+ await sleep(25)
386
+
387
+ const healthA = (await (await fetch(`http://127.0.0.1:${a.port}/api/health`)).json()) as {
388
+ data: { wsClients: number }
389
+ }
390
+ const healthB = (await (await fetch(`http://127.0.0.1:${b.port}/api/health`)).json()) as {
391
+ data: { wsClients: number }
392
+ }
393
+ expect(healthA.data.wsClients).toBe(1)
394
+ // Before per-instance scoping, B shared A's global set and also reported 1.
395
+ expect(healthB.data.wsClients).toBe(0)
396
+ ws.close()
397
+ })
398
+
399
+ test('CORS headers are emitted only when an allowed origin is configured', async () => {
400
+ const withOrigin = startServer(makeProvider(), 0, {
401
+ allowedOrigin: 'https://kanban.example',
402
+ })
403
+ runtimes.push(withOrigin)
404
+ const res = await fetch(`http://127.0.0.1:${withOrigin.port}/api/health`)
405
+ expect(res.headers.get('access-control-allow-origin')).toBe('https://kanban.example')
406
+
407
+ const preflight = await fetch(`http://127.0.0.1:${withOrigin.port}/api/bootstrap`, {
408
+ method: 'OPTIONS',
409
+ })
410
+ expect(preflight.headers.get('access-control-allow-origin')).toBe('https://kanban.example')
411
+
412
+ const noOrigin = startServer(makeProvider(), 0)
413
+ runtimes.push(noOrigin)
414
+ const bare = await fetch(`http://127.0.0.1:${noOrigin.port}/api/health`)
415
+ expect(bare.headers.get('access-control-allow-origin')).toBeNull()
416
+ })
417
+ })
@@ -0,0 +1,77 @@
1
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
+ import { Database } from 'bun:sqlite'
3
+ import { initSchema, seedDefaultColumns } from '../db'
4
+ import { createProvider } from '../providers/index'
5
+ import type { KanbanProvider } from '../providers/types'
6
+ import * as useCases from '../use-cases'
7
+
8
+ let db: Database
9
+ let provider: KanbanProvider
10
+
11
+ beforeEach(() => {
12
+ db = new Database(':memory:')
13
+ db.run('PRAGMA foreign_keys = ON')
14
+ initSchema(db)
15
+ seedDefaultColumns(db)
16
+ provider = createProvider(db, { provider: 'local' }, ':memory:')
17
+ })
18
+
19
+ describe('use-cases label normalization', () => {
20
+ // The use-case layer owns label normalization so every transport feeds raw
21
+ // labels in its own shape and gets the same result.
22
+ test('normalizes CLI-style nested flag arrays', async () => {
23
+ const task = await useCases.createTask(provider, {
24
+ title: 'cli',
25
+ labels: [['bug', 'ui'], undefined],
26
+ })
27
+ expect(task.labels).toEqual(['bug', 'ui'])
28
+ })
29
+
30
+ test('normalizes HTTP-style string arrays', async () => {
31
+ const task = await useCases.createTask(provider, {
32
+ title: 'http',
33
+ labels: ['bug', 'ui'],
34
+ })
35
+ expect(task.labels).toEqual(['bug', 'ui'])
36
+ })
37
+
38
+ test('normalizes a comma-separated string and de-dupes', async () => {
39
+ const task = await useCases.createTask(provider, {
40
+ title: 'csv',
41
+ labels: 'bug, ui, bug',
42
+ })
43
+ expect(task.labels).toEqual(['bug', 'ui'])
44
+ })
45
+
46
+ test('treats omitted labels as none', async () => {
47
+ const task = await useCases.createTask(provider, { title: 'none' })
48
+ expect(task.labels).toEqual([])
49
+ })
50
+ })
51
+
52
+ describe('use-cases provider forwarding', () => {
53
+ test('createTask then getTask/listTasks round-trip', async () => {
54
+ const created = await useCases.createTask(provider, { title: 'roundtrip', column: 'backlog' })
55
+ const fetched = await useCases.getTask(provider, created.id)
56
+ expect(fetched.id).toBe(created.id)
57
+ const all = await useCases.listTasks(provider)
58
+ expect(all.map((t) => t.id)).toContain(created.id)
59
+ })
60
+
61
+ test('moveTask forwards to the target column and returns the task', async () => {
62
+ const created = await useCases.createTask(provider, { title: 'mover', column: 'backlog' })
63
+ const columns = await useCases.listColumns(provider)
64
+ const target = columns.find((c) => c.name.toLowerCase() === 'in-progress')!
65
+ const moved = await useCases.moveTask(provider, created.id, target.name)
66
+ expect(moved.column_id).toBe(target.id)
67
+ })
68
+
69
+ test('addComment / listComments / updateComment forward correctly', async () => {
70
+ const task = await useCases.createTask(provider, { title: 'commented' })
71
+ const comment = await useCases.addComment(provider, task.id, 'first')
72
+ const listed = await useCases.listComments(provider, task.id)
73
+ expect(listed.map((c) => c.body)).toContain('first')
74
+ const updated = await useCases.updateComment(provider, task.id, comment.id, 'edited')
75
+ expect(updated.body).toBe('edited')
76
+ })
77
+ })