120 lines
6.6 KiB
TypeScript
120 lines
6.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const mockExecFileAsync = vi.hoisted(() => vi.fn())
|
|
const mockLoggerError = vi.hoisted(() => vi.fn())
|
|
|
|
vi.mock('util', () => ({
|
|
promisify: () => mockExecFileAsync,
|
|
}))
|
|
|
|
vi.mock('../../packages/server/src/services/logger', () => ({
|
|
logger: {
|
|
error: mockLoggerError,
|
|
},
|
|
}))
|
|
|
|
import * as service from '../../packages/server/src/services/hermes/hermes-kanban'
|
|
|
|
describe('hermes kanban service', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('lists boards without mutating or depending on CLI current', async () => {
|
|
mockExecFileAsync.mockResolvedValueOnce({ stdout: JSON.stringify([{ slug: 'default' }]) })
|
|
|
|
await expect(service.listBoards({ includeArchived: true })).resolves.toEqual([{ slug: 'default' }])
|
|
|
|
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', 'boards', 'list', '--json', '--all'])
|
|
})
|
|
|
|
it('creates and archives boards through canonical CLI board commands', async () => {
|
|
mockExecFileAsync
|
|
.mockResolvedValueOnce({ stdout: '' })
|
|
.mockResolvedValueOnce({ stdout: JSON.stringify([{ slug: 'project-a', name: 'Project A' }]) })
|
|
.mockResolvedValueOnce({ stdout: '' })
|
|
|
|
await expect(service.createBoard({ slug: 'project-a', name: 'Project A', description: 'desc', icon: '📌', color: '#8b5cf6', switchCurrent: true })).resolves.toEqual({ slug: 'project-a', name: 'Project A' })
|
|
await expect(service.archiveBoard('project-a')).resolves.toBeUndefined()
|
|
|
|
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', 'boards', 'create', 'project-a', '--name', 'Project A', '--description', 'desc', '--icon', '📌', '--color', '#8b5cf6', '--switch'])
|
|
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', 'boards', 'list', '--json', '--all'])
|
|
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', 'boards', 'rm', 'project-a'])
|
|
})
|
|
|
|
it('exposes capability metadata for WUI/canonical parity gaps', async () => {
|
|
await expect(service.getCapabilities()).resolves.toMatchObject({
|
|
source: 'hermes-cli',
|
|
supports: { boardsList: true, boardCreate: true, commentsWrite: false, dispatch: false },
|
|
missing: expect.arrayContaining(['commentsWrite', 'dispatch']),
|
|
})
|
|
})
|
|
|
|
it('builds list/create/stats CLI calls with global --board before the action', async () => {
|
|
mockExecFileAsync
|
|
.mockResolvedValueOnce({ stdout: JSON.stringify([{ id: 'task-1' }]) })
|
|
.mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'task-2' }) })
|
|
.mockResolvedValueOnce({ stdout: JSON.stringify({ total: 1, by_status: {}, by_assignee: {} }) })
|
|
.mockResolvedValueOnce({ stdout: JSON.stringify([{ id: 'archived-1', status: 'archived' }, { id: 'archived-2', status: 'archived' }]) })
|
|
|
|
await expect(service.listTasks({ board: 'project-a', status: 'todo', assignee: 'alice', tenant: 'ops', includeArchived: true })).resolves.toEqual([{ id: 'task-1' }])
|
|
await expect(service.createTask('Ship', { board: 'project-a', body: 'write', assignee: 'alice', priority: 3, tenant: 'ops' })).resolves.toEqual({ id: 'task-2' })
|
|
await expect(service.getStats({ board: 'project-a' })).resolves.toEqual({ total: 3, by_status: { archived: 2 }, by_assignee: {} })
|
|
|
|
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'project-a', 'list', '--json', '--archived', '--status', 'todo', '--assignee', 'alice', '--tenant', 'ops'])
|
|
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'project-a', 'create', 'Ship', '--json', '--body', 'write', '--assignee', 'alice', '--priority', '3', '--tenant', 'ops'])
|
|
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'project-a', 'stats', '--json'])
|
|
expect(mockExecFileAsync.mock.calls[3][1]).toEqual(['kanban', '--board', 'project-a', 'list', '--json', '--archived', '--status', 'archived'])
|
|
})
|
|
|
|
it('normalizes omitted board to default instead of falling through to CLI current', async () => {
|
|
mockExecFileAsync
|
|
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
|
.mockResolvedValueOnce({ stdout: JSON.stringify({ total: 0, by_status: {}, by_assignee: {} }) })
|
|
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
|
|
|
await service.listTasks()
|
|
await service.getStats()
|
|
|
|
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'default', 'list', '--json'])
|
|
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'default', 'stats', '--json'])
|
|
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'default', 'list', '--json', '--archived', '--status', 'archived'])
|
|
})
|
|
|
|
it('builds action CLI calls and maps not-found show to null', async () => {
|
|
mockExecFileAsync
|
|
.mockRejectedValueOnce({ code: 1 })
|
|
.mockResolvedValueOnce({})
|
|
.mockResolvedValueOnce({})
|
|
.mockResolvedValueOnce({})
|
|
.mockResolvedValueOnce({})
|
|
.mockResolvedValueOnce({ stdout: JSON.stringify([{ name: 'alice' }]) })
|
|
|
|
await expect(service.getTask('missing', { board: 'default' })).resolves.toBeNull()
|
|
await service.completeTasks(['task-1'], 'done', { board: 'default' })
|
|
await service.blockTask('task-1', 'wait', { board: 'default' })
|
|
await service.unblockTasks(['task-1'], { board: 'default' })
|
|
await service.assignTask('task-1', 'alice', { board: 'default' })
|
|
await expect(service.getAssignees({ board: 'default' })).resolves.toEqual([{ name: 'alice' }])
|
|
|
|
expect(mockExecFileAsync.mock.calls[0][1]).toEqual(['kanban', '--board', 'default', 'show', 'missing', '--json'])
|
|
expect(mockExecFileAsync.mock.calls[1][1]).toEqual(['kanban', '--board', 'default', 'complete', 'task-1', '--summary', 'done'])
|
|
expect(mockExecFileAsync.mock.calls[2][1]).toEqual(['kanban', '--board', 'default', 'block', 'task-1', 'wait'])
|
|
expect(mockExecFileAsync.mock.calls[3][1]).toEqual(['kanban', '--board', 'default', 'unblock', 'task-1'])
|
|
expect(mockExecFileAsync.mock.calls[4][1]).toEqual(['kanban', '--board', 'default', 'assign', 'task-1', 'alice'])
|
|
expect(mockExecFileAsync.mock.calls[5][1]).toEqual(['kanban', '--board', 'default', 'assignees', '--json'])
|
|
})
|
|
|
|
it('rejects invalid board slugs before shelling out', async () => {
|
|
await expect(service.listTasks({ board: 'bad;slug' })).rejects.toThrow('Invalid kanban board slug')
|
|
expect(mockExecFileAsync).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('wraps CLI failures with service-specific errors', async () => {
|
|
mockExecFileAsync.mockRejectedValue(new Error('boom'))
|
|
|
|
await expect(service.listTasks()).rejects.toThrow('Failed to list kanban tasks: boom')
|
|
expect(mockLoggerError).toHaveBeenCalled()
|
|
})
|
|
})
|