@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.
- package/README.md +42 -19
- package/package.json +3 -2
- package/src/__tests__/activity.test.ts +12 -0
- package/src/__tests__/api.test.ts +4 -2
- package/src/__tests__/board-utils.test.ts +16 -3
- package/src/__tests__/column-roles.test.ts +34 -0
- package/src/__tests__/commands/board.test.ts +7 -0
- package/src/__tests__/conflict.test.ts +11 -1
- package/src/__tests__/db.test.ts +70 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/jira-cache.test.ts +56 -0
- package/src/__tests__/jira-client.test.ts +98 -1
- package/src/__tests__/jira-jql.test.ts +51 -0
- package/src/__tests__/jira-provider-mutations.test.ts +84 -59
- package/src/__tests__/jira-provider-read.test.ts +227 -20
- package/src/__tests__/mcp-server.test.ts +2 -2
- package/src/__tests__/metrics.test.ts +37 -1
- package/src/__tests__/postgres-jira-provider.test.ts +155 -0
- package/src/__tests__/postgres-local-provider.test.ts +90 -1
- package/src/__tests__/server.test.ts +126 -0
- package/src/__tests__/use-cases.test.ts +77 -0
- package/src/__tests__/webhooks.test.ts +75 -22
- package/src/api.ts +58 -36
- package/src/column-roles.ts +52 -0
- package/src/commands/board.ts +4 -4
- package/src/db.ts +145 -114
- package/src/errors.ts +1 -0
- package/src/index.ts +84 -33
- package/src/mcp/core.ts +8 -7
- package/src/metrics.ts +48 -23
- package/src/provider-runtime.ts +9 -2
- package/src/providers/index.ts +4 -1
- package/src/providers/jira-cache.ts +23 -6
- package/src/providers/jira-client.ts +70 -4
- package/src/providers/jira-core.ts +921 -0
- package/src/providers/jira-jql.ts +48 -0
- package/src/providers/jira.ts +111 -677
- package/src/providers/linear-cache.ts +5 -3
- package/src/providers/linear-core.ts +688 -0
- package/src/providers/linear.ts +70 -554
- package/src/providers/postgres-jira-cache.ts +597 -0
- package/src/providers/postgres-jira.ts +15 -1188
- package/src/providers/postgres-linear-cache.ts +500 -0
- package/src/providers/postgres-linear.ts +22 -1088
- package/src/providers/postgres-local.ts +181 -74
- package/src/providers/types.ts +71 -2
- package/src/server.ts +118 -32
- package/src/use-cases.ts +139 -0
- package/src/webhooks.ts +26 -0
- package/ui/dist/assets/index-65LcV0R7.js +40 -0
- package/ui/dist/index.html +1 -1
- 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
|
|
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
|
+
})
|