* feat(ST-1111.1): add ClaudeJob model and state-machine enum Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.2): add ClaudeJob status API mappers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.3): add enqueue/cancel ClaudeJob server actions with idempotency + NOTIFY Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.4): forward ClaudeJob events on solo SSE stream + initial state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.6): add 'Voer uit' + cancel buttons to task detail dialog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.7): add job status pill with spinner on solo task cards Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(ST-1111.8): cover job-status mappers and enqueue/cancel actions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(ST-1111.9): document Claude job queue architecture and agent flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.10a): add ClaudeWorker presence model Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.10c): forward worker presence events on solo SSE + initial count Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1111.10d): show worker presence indicator and gate 'Voer uit' on connected workers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
165 lines
5.2 KiB
TypeScript
165 lines
5.2 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const {
|
|
mockGetSession,
|
|
mockFindFirstTask,
|
|
mockFindFirstJob,
|
|
mockCreateJob,
|
|
mockUpdateJob,
|
|
mockExecuteRaw,
|
|
} = vi.hoisted(() => ({
|
|
mockGetSession: vi.fn(),
|
|
mockFindFirstTask: vi.fn(),
|
|
mockFindFirstJob: vi.fn(),
|
|
mockCreateJob: vi.fn(),
|
|
mockUpdateJob: vi.fn(),
|
|
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
|
}))
|
|
|
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
|
|
|
vi.mock('@/lib/auth', () => ({
|
|
getSession: mockGetSession,
|
|
}))
|
|
|
|
vi.mock('@/lib/prisma', () => ({
|
|
prisma: {
|
|
task: { findFirst: mockFindFirstTask },
|
|
claudeJob: {
|
|
findFirst: mockFindFirstJob,
|
|
create: mockCreateJob,
|
|
update: mockUpdateJob,
|
|
},
|
|
$executeRaw: mockExecuteRaw,
|
|
},
|
|
}))
|
|
|
|
import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs'
|
|
|
|
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
|
|
|
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
|
const TASK_ID = 'task-cuid-1'
|
|
const JOB_ID = 'job-cuid-1'
|
|
const PRODUCT_ID = 'product-cuid-1'
|
|
|
|
const MOCK_TASK = { id: TASK_ID, story: { product_id: PRODUCT_ID } }
|
|
const MOCK_JOB_QUEUED = { id: JOB_ID, status: 'QUEUED' as const, task_id: TASK_ID, product_id: PRODUCT_ID }
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockExecuteRaw.mockResolvedValue(undefined)
|
|
})
|
|
|
|
describe('enqueueClaudeJobAction', () => {
|
|
it('happy path: creates job with QUEUED status', async () => {
|
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
|
|
mockFindFirstJob.mockResolvedValue(null)
|
|
mockCreateJob.mockResolvedValue({ id: JOB_ID })
|
|
|
|
const result = await enqueueClaudeJobAction(TASK_ID)
|
|
|
|
expect(result).toEqual({ success: true, jobId: JOB_ID })
|
|
expect(mockCreateJob).toHaveBeenCalledWith(
|
|
expect.objectContaining({ data: expect.objectContaining({ status: 'QUEUED', task_id: TASK_ID }) })
|
|
)
|
|
})
|
|
|
|
it('blocks demo user', async () => {
|
|
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
|
|
|
const result = await enqueueClaudeJobAction(TASK_ID)
|
|
|
|
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
|
expect(mockCreateJob).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns error when task not found', async () => {
|
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
mockFindFirstTask.mockResolvedValue(null)
|
|
|
|
const result = await enqueueClaudeJobAction(TASK_ID)
|
|
|
|
expect(result).toMatchObject({ error: 'Task niet gevonden' })
|
|
expect(mockCreateJob).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('idempotency: returns existing jobId when QUEUED job exists', async () => {
|
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
|
|
mockFindFirstJob.mockResolvedValue({ id: JOB_ID })
|
|
|
|
const result = await enqueueClaudeJobAction(TASK_ID)
|
|
|
|
expect(result).toMatchObject({ error: 'Er loopt al een agent voor deze task', jobId: JOB_ID })
|
|
expect(mockCreateJob).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('allows new enqueue after terminal (DONE) job', async () => {
|
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
|
|
mockFindFirstJob.mockResolvedValue(null) // no active job
|
|
mockCreateJob.mockResolvedValue({ id: 'new-job-id' })
|
|
|
|
const result = await enqueueClaudeJobAction(TASK_ID)
|
|
|
|
expect(result).toEqual({ success: true, jobId: 'new-job-id' })
|
|
})
|
|
})
|
|
|
|
describe('cancelClaudeJobAction', () => {
|
|
it('happy path: cancels QUEUED job', async () => {
|
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
mockFindFirstJob.mockResolvedValue(MOCK_JOB_QUEUED)
|
|
mockUpdateJob.mockResolvedValue({})
|
|
|
|
const result = await cancelClaudeJobAction(JOB_ID)
|
|
|
|
expect(result).toEqual({ success: true })
|
|
expect(mockUpdateJob).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { id: JOB_ID },
|
|
data: expect.objectContaining({ status: 'CANCELLED' }),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('demo user is blocked', async () => {
|
|
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
|
|
|
const result = await cancelClaudeJobAction(JOB_ID)
|
|
|
|
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
|
expect(mockUpdateJob).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns error when job not found (ownership check)', async () => {
|
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
mockFindFirstJob.mockResolvedValue(null)
|
|
|
|
const result = await cancelClaudeJobAction(JOB_ID)
|
|
|
|
expect(result).toMatchObject({ error: 'Job niet gevonden' })
|
|
expect(mockUpdateJob).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns error when cancelling terminal (DONE) job', async () => {
|
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'DONE' as const })
|
|
|
|
const result = await cancelClaudeJobAction(JOB_ID)
|
|
|
|
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
|
|
expect(mockUpdateJob).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns error when cancelling FAILED job', async () => {
|
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
|
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'FAILED' as const })
|
|
|
|
const result = await cancelClaudeJobAction(JOB_ID)
|
|
|
|
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
|
|
})
|
|
})
|