* refactor(jobs): extraheer job-mapper naar lib/jobs-mapper.ts + voeg breadcrumb-velden toe Verplaatst JobWithRelations, JOB_INCLUDE, RawJob, PriceRow, pickDescription, computeCost en mapJob naar lib/jobs-mapper.ts (zonder 'use server'). Voegt buildPriceMap helper toe en breidt de types uit met productCode, storyCode en pbiCode via task->story->pbi en product.code includes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(jobs): voeg GET /api/jobs/[id] route toe + tests * feat(jobs): useJobsRealtime fetch-on-unknown met dedup-Set Wanneer een SSE-event een onbekend job_id bevat, haalt de hook de volledige job op via GET /api/jobs/[id] en upsert die in de store. Een inFlight-Set voorkomt gelijktijdige dubbele fetches voor hetzelfde job_id. Bekende jobs blijven de bestaande partial-upsert gebruiken. Zelfde logica in jobs_initial. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(jobs): JobCard breadcrumb + datum-fallback per kind Voeg productCode/pbiCode/storyCode/startedAt/finishedAt toe aan JobCardProps; bouw breadcrumb per job-kind en toon finishedAt → startedAt → createdAt als datum. JobsColumn geeft de nieuwe velden door. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(jobs): JobCard breadcrumb + datum-fallback tests --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
147 lines
4 KiB
TypeScript
147 lines
4 KiB
TypeScript
// @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<string, Listener[]> = {}
|
|
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)
|
|
})
|
|
})
|