feat: enqueueAllTodoJobsAction voor batch-queueing van TO_DO-taken

Nieuwe Server Action die alle TO_DO-taken van een product zonder
actieve ClaudeJob in één $transaction als QUEUED jobs aanmaakt en
voor elk een pg_notify('claude_job_enqueued') stuurt zodat de SSE-
stream de UI live bijwerkt.

- Auth + demo-blokkade + product-access via productAccessFilter
- Idempotent: tasks met status QUEUED/CLAIMED/RUNNING worden overgeslagen
- 4 nieuwe tests (happy path, count=0, demo-blokkade, geen toegang)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-01 10:54:56 +02:00
parent 1e48eed459
commit 7d76c3baee
2 changed files with 118 additions and 2 deletions

View file

@ -3,17 +3,23 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
const {
mockGetSession,
mockFindFirstTask,
mockFindManyTask,
mockFindFirstProduct,
mockFindFirstJob,
mockCreateJob,
mockUpdateJob,
mockExecuteRaw,
mockTransaction,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockFindFirstTask: vi.fn(),
mockFindManyTask: vi.fn(),
mockFindFirstProduct: vi.fn(),
mockFindFirstJob: vi.fn(),
mockCreateJob: vi.fn(),
mockUpdateJob: vi.fn(),
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
mockTransaction: vi.fn(),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
@ -24,17 +30,23 @@ vi.mock('@/lib/auth', () => ({
vi.mock('@/lib/prisma', () => ({
prisma: {
task: { findFirst: mockFindFirstTask },
task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask },
product: { findFirst: mockFindFirstProduct },
claudeJob: {
findFirst: mockFindFirstJob,
create: mockCreateJob,
update: mockUpdateJob,
},
$executeRaw: mockExecuteRaw,
$transaction: mockTransaction,
},
}))
import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs'
import {
enqueueClaudeJobAction,
enqueueAllTodoJobsAction,
cancelClaudeJobAction,
} from '@/actions/claude-jobs'
const SESSION_USER = { userId: 'user-1', isDemo: false }
@ -108,6 +120,54 @@ describe('enqueueClaudeJobAction', () => {
})
})
describe('enqueueAllTodoJobsAction', () => {
it('happy path: queues a job for every TO_DO task without active job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindManyTask.mockResolvedValue([{ id: 'task-a' }, { id: 'task-b' }])
mockTransaction.mockResolvedValue([
{ id: 'job-a', task_id: 'task-a' },
{ id: 'job-b', task_id: 'task-b' },
])
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toEqual({ success: true, count: 2 })
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
})
it('returns count=0 when no queueable tasks', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
mockFindManyTask.mockResolvedValue([])
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toEqual({ success: true, count: 0 })
expect(mockTransaction).not.toHaveBeenCalled()
expect(mockExecuteRaw).not.toHaveBeenCalled()
})
it('blocks demo user', async () => {
mockGetSession.mockResolvedValue(SESSION_DEMO)
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
expect(mockTransaction).not.toHaveBeenCalled()
})
it('returns error when product not accessible', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)
mockFindFirstProduct.mockResolvedValue(null)
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
expect(mockTransaction).not.toHaveBeenCalled()
})
})
describe('cancelClaudeJobAction', () => {
it('happy path: cancels QUEUED job', async () => {
mockGetSession.mockResolvedValue(SESSION_USER)