import { beforeEach, describe, expect, test, vi } from 'vitest'; import { NextRequest } from 'next/server'; const mocks = vi.hoisted(() => ({ cancelJob: vi.fn(), deleteClassroom: vi.fn(), deleteJob: vi.fn(), isPublishedSource: vi.fn(), markCancelled: vi.fn(), authorizeAccess: vi.fn(), accessError: vi.fn(), readJob: vi.fn(), resumeJob: vi.fn(), })); vi.mock('@/lib/server/classroom-job-store', () => ({ deleteClassroomGenerationJob: mocks.deleteJob, isValidClassroomJobId: (value: string) => /^[a-zA-Z0-9_-]+$/.test(value), markClassroomGenerationJobCancelled: mocks.markCancelled, readClassroomGenerationJob: mocks.readJob, })); vi.mock('@/lib/server/classroom-job-runner', () => ({ cancelClassroomGenerationJob: mocks.cancelJob, resumeClassroomGenerationJob: mocks.resumeJob, })); vi.mock('@/lib/server/classroom-storage', () => ({ buildRequestOrigin: () => 'https://server.example', deleteClassroom: mocks.deleteClassroom, })); vi.mock('@/lib/server/published-classroom-access', () => ({ isPublishedClassroomSource: mocks.isPublishedSource, })); vi.mock('@/lib/server/authz/classroom-job-access', () => ({ authorizeClassroomJobRequest: mocks.authorizeAccess, classroomJobAccessError: mocks.accessError, })); function storedJob(status: 'queued' | 'failed' = 'failed') { return { id: 'job-1', status, step: status, progress: status === 'failed' ? 100 : 0, message: status, createdAt: '2026-08-15T00:00:00.000Z', updatedAt: '2026-08-15T00:00:00.000Z', inputSummary: { requirementPreview: 'topic', hasPdf: false, pdfTextLength: 0, pdfImageCount: 0, }, input: { requirement: 'topic' }, scenesGenerated: 0, }; } describe('classroom job observe-only route seam', () => { beforeEach(() => { vi.clearAllMocks(); mocks.authorizeAccess.mockResolvedValue({ allowed: true }); mocks.accessError.mockImplementation( (denial: { status: number; code: string; message: string }) => Response.json( { success: false, errorCode: denial.code, error: denial.message }, { status: denial.status }, ), ); mocks.deleteJob.mockResolvedValue(true); mocks.resumeJob.mockResolvedValue(true); mocks.isPublishedSource.mockResolvedValue(false); }); test('observes detail without changing its response', async () => { const job = storedJob(); mocks.readJob.mockResolvedValue(job); const { GET } = await import('@/app/api/generate-classroom/[jobId]/route'); const request = new NextRequest('https://server.example/api/generate-classroom/job-1'); const response = await GET(request, { params: Promise.resolve({ jobId: 'job-1' }) }); expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ success: true, jobId: 'job-1', status: 'failed', done: true, }); expect(mocks.authorizeAccess).toHaveBeenCalledWith(request, job, 'read'); }); test('observes cancel without changing its response', async () => { const job = storedJob('queued'); mocks.readJob.mockResolvedValue(job); const { POST } = await import('@/app/api/generate-classroom/[jobId]/route'); const request = new NextRequest('https://server.example/api/generate-classroom/job-1', { method: 'POST', }); const response = await POST(request, { params: Promise.resolve({ jobId: 'job-1' }) }); expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ success: true, jobId: 'job-1', status: 'cancelling', }); expect(mocks.authorizeAccess).toHaveBeenCalledWith(request, job, 'cancel'); expect(mocks.markCancelled).toHaveBeenCalled(); }); test('observes delete without changing its response', async () => { const job = storedJob(); mocks.readJob.mockResolvedValue(job); const { DELETE } = await import('@/app/api/generate-classroom/[jobId]/route'); const request = new NextRequest('https://server.example/api/generate-classroom/job-1', { method: 'DELETE', }); const response = await DELETE(request, { params: Promise.resolve({ jobId: 'job-1' }) }); expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ success: true, jobId: 'job-1', deleted: true, }); expect(mocks.authorizeAccess).toHaveBeenCalledWith(request, job, 'delete'); expect(mocks.deleteJob).toHaveBeenCalledWith('job-1'); }); test('observes resume without changing its response', async () => { const job = storedJob(); mocks.readJob.mockResolvedValue(job); const { POST } = await import('@/app/api/generate-classroom/[jobId]/resume/route'); const request = new NextRequest('https://server.example/api/generate-classroom/job-1/resume', { method: 'POST', }); const response = await POST(request, { params: Promise.resolve({ jobId: 'job-1' }) }); expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ success: true, jobId: 'job-1', status: 'queued', }); expect(mocks.authorizeAccess).toHaveBeenCalledWith(request, job, 'resume'); }); test('returns the authorization denial before cancelling or mutating a job', async () => { const job = storedJob('queued'); mocks.readJob.mockResolvedValue(job); mocks.authorizeAccess.mockResolvedValue({ allowed: false, status: 403, code: 'FORBIDDEN', message: 'You do not have access to this resource.', }); const { POST } = await import('@/app/api/generate-classroom/[jobId]/route'); const request = new NextRequest('https://server.example/api/generate-classroom/job-1', { method: 'POST', }); const response = await POST(request, { params: Promise.resolve({ jobId: 'job-1' }) }); expect(response.status).toBe(403); expect(mocks.cancelJob).not.toHaveBeenCalled(); expect(mocks.markCancelled).not.toHaveBeenCalled(); }); });