M13: Claude job queue — 'Voer uit'-knop + worker presence (ST-1111) (#18)
* 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>
This commit is contained in:
parent
1cb5772edd
commit
73087e9705
18 changed files with 921 additions and 27 deletions
165
__tests__/actions/claude-jobs.test.ts
Normal file
165
__tests__/actions/claude-jobs.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
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' })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue