// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useJobsStore } from '@/stores/jobs-store' import useJobsRealtime from '@/hooks/use-jobs-realtime' type Listener = (event: { data: string }) => void class MockEventSource { static instance: MockEventSource | null = null private listeners: Record = {} onerror: (() => void) | null = null constructor(_url: string) { MockEventSource.instance = this } addEventListener(type: string, listener: Listener) { if (!this.listeners[type]) this.listeners[type] = [] this.listeners[type].push(listener) } dispatch(type: string, data: unknown) { for (const l of this.listeners[type] ?? []) { l({ data: JSON.stringify(data) }) } } close() {} } const fullJob = { id: 'job-unknown-1', kind: 'TASK_IMPLEMENTATION', status: 'RUNNING', taskCode: 'T-1', taskTitle: 'Test', ideaCode: null, ideaTitle: null, sprintGoal: null, sprintCode: null, productName: 'Scrum4Me', productCode: null, storyCode: null, pbiCode: null, modelId: null, inputTokens: null, outputTokens: null, cacheReadTokens: null, cacheWriteTokens: null, costUsd: null, branch: null, prUrl: null, error: null, summary: null, description: null, verifyResult: null, startedAt: null, finishedAt: null, createdAt: new Date('2026-01-01'), sprintRunId: null, } beforeEach(() => { vi.stubGlobal('EventSource', MockEventSource) MockEventSource.instance = null // Lege store useJobsStore.setState({ activeJobs: [], doneJobs: [], selectedJobId: null }) // fetch resolveert naar de volledige job vi.stubGlobal( 'fetch', vi.fn().mockImplementation(async () => ({ ok: true, json: async () => fullJob, })) ) }) afterEach(() => { vi.unstubAllGlobals() vi.restoreAllMocks() }) describe('useJobsRealtime: fetch-on-unknown', () => { it('haalt onbekende job op via REST bij message-event', async () => { renderHook(() => useJobsRealtime()) const es = MockEventSource.instance! // Dispatch twee events met hetzelfde onbekende job_id gelijktijdig act(() => { es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' }) es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' }) }) // Wacht op alle microtasks / fetch-promises await act(async () => { await Promise.resolve() }) expect(fetch).toHaveBeenCalledTimes(1) expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1') const { activeJobs } = useJobsStore.getState() expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true) expect(activeJobs.find(j => j.id === 'job-unknown-1')?.taskTitle).toBe('Test') }) it('gebruikt partial-upsert voor bekende jobs bij message-event', async () => { // Zet een bekende job in de store useJobsStore.setState({ activeJobs: [{ ...fullJob, id: 'job-known-1', status: 'QUEUED' } as never], doneJobs: [], selectedJobId: null, }) renderHook(() => useJobsRealtime()) const es = MockEventSource.instance! act(() => { es.dispatch('message', { job_id: 'job-known-1', status: 'RUNNING', branch: 'feat/x' }) }) await act(async () => { await Promise.resolve() }) expect(fetch).not.toHaveBeenCalled() const { activeJobs } = useJobsStore.getState() expect(activeJobs.find(j => j.id === 'job-known-1')?.status).toBe('RUNNING') }) it('haalt onbekende job op via REST bij jobs_initial-event', async () => { renderHook(() => useJobsRealtime()) const es = MockEventSource.instance! act(() => { es.dispatch('jobs_initial', [{ job_id: 'job-unknown-1', status: 'RUNNING' }]) }) await act(async () => { await Promise.resolve() }) expect(fetch).toHaveBeenCalledTimes(1) expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1') const { activeJobs } = useJobsStore.getState() expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true) }) })