import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; const mocks = vi.hoisted(() => ({ generateClassroom: vi.fn(), })); vi.mock('@/lib/server/classroom-generation', () => ({ generateClassroom: (...args: unknown[]) => mocks.generateClassroom(...args), })); let jobsDir: string; beforeAll(async () => { jobsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'classroom-jobs-')); }); afterAll(async () => { await fs.rm(jobsDir, { recursive: true, force: true }); vi.unstubAllEnvs(); }); beforeEach(() => { vi.resetModules(); vi.stubEnv('CLASSROOM_JOBS_DIR', jobsDir); mocks.generateClassroom.mockReset(); }); async function importStore() { return await import('@/lib/server/classroom-job-store'); } async function importRunner() { return await import('@/lib/server/classroom-job-runner'); } const INPUT = { requirement: '测试主题', }; describe('classroom job store', () => { test('full lifecycle: create → running → progress → cancelled', async () => { const store = await importStore(); const job = await store.createClassroomGenerationJob('job-lifecycle', INPUT); expect(job.status).toBe('queued'); await store.markClassroomGenerationJobRunning('job-lifecycle'); await store.updateClassroomGenerationJobProgress('job-lifecycle', { step: 'generating_scenes', progress: 40, message: 'Generating scene 2/5', scenesGenerated: 1, totalScenes: 5, }); const running = await store.readClassroomGenerationJob('job-lifecycle'); expect(running?.status).toBe('running'); expect(running?.progress).toBe(40); expect(running?.scenesGenerated).toBe(1); expect(running?.totalScenes).toBe(5); await store.markClassroomGenerationJobCancelled('job-lifecycle', 'User cancelled'); const cancelled = await store.readClassroomGenerationJob('job-lifecycle'); expect(cancelled?.status).toBe('cancelled'); expect(cancelled?.completedAt).toBeDefined(); }); test('list returns newest first with a limit', async () => { const store = await importStore(); for (const id of ['job-a', 'job-b', 'job-c']) { await store.createClassroomGenerationJob(id, INPUT); await new Promise((r) => setTimeout(r, 5)); // ensure distinct createdAt } const jobs = await store.listClassroomGenerationJobs(2); expect(jobs).toHaveLength(2); expect(jobs[0].id).toBe('job-c'); expect(jobs[1].id).toBe('job-b'); }); test('delete removes the record', async () => { const store = await importStore(); await store.createClassroomGenerationJob('job-del', INPUT); expect(await store.readClassroomGenerationJob('job-del')).not.toBeNull(); await store.deleteClassroomGenerationJob('job-del'); expect(await store.readClassroomGenerationJob('job-del')).toBeNull(); }); test('keeps legacy records ownerless and binds new account ownership atomically', async () => { const store = await importStore(); const legacy = await store.createClassroomGenerationJob('job-owner-compat', INPUT); expect(legacy).not.toHaveProperty('ownerPrincipalId'); const owned = await store.createClassroomGenerationJob('job-owner-bound', INPUT, { ownerPrincipalId: 'principal-1', }); expect(owned).toMatchObject({ id: 'job-owner-bound', ownerPrincipalId: 'principal-1', ownershipBoundAt: expect.any(String), }); }); test('rejects ownership changes through the ordinary status patch path', async () => { const store = await importStore(); await store.createClassroomGenerationJob('job-owner-immutable', INPUT, { ownerPrincipalId: 'principal-1', }); await expect( store.updateClassroomGenerationJob('job-owner-immutable', { ownerPrincipalId: 'principal-2', } as never), ).rejects.toThrow(/ownership field is immutable/); await expect(store.readClassroomGenerationJob('job-owner-immutable')).resolves.toMatchObject({ ownerPrincipalId: 'principal-1', }); }); test('migrates guest ownership with compare-and-set and never transfers an account owner', async () => { const store = await importStore(); await store.createClassroomGenerationJob('job-guest-migrate', INPUT, { guestPrincipalId: 'device-1', }); await expect( store.migrateClassroomGenerationJobOwnership('job-guest-migrate', { ownerPrincipalId: 'account-1', expectedGuestPrincipalId: 'wrong-device', }), ).rejects.toThrow(/Guest ownership changed/); const migrated = await store.migrateClassroomGenerationJobOwnership('job-guest-migrate', { ownerPrincipalId: 'account-1', expectedGuestPrincipalId: 'device-1', }); expect(migrated).toMatchObject({ ownerPrincipalId: 'account-1', ownershipBoundAt: expect.any(String), ownershipMigratedAt: expect.any(String), }); expect(migrated).not.toHaveProperty('guestPrincipalId'); await expect( store.migrateClassroomGenerationJobOwnership('job-guest-migrate', { ownerPrincipalId: 'account-2', }), ).rejects.toThrow(/different account/); await expect( store.migrateClassroomGenerationJobOwnership('job-guest-migrate', { ownerPrincipalId: 'account-1', }), ).resolves.toMatchObject({ ownerPrincipalId: 'account-1' }); }); test('requires explicit administrative opt-in to claim a legacy ownerless job', async () => { const store = await importStore(); await store.createClassroomGenerationJob('job-legacy-migrate', INPUT); await expect( store.migrateClassroomGenerationJobOwnership('job-legacy-migrate', { ownerPrincipalId: 'account-1', }), ).rejects.toThrow(/explicit administrative migration/); await expect( store.migrateClassroomGenerationJobOwnership('job-legacy-migrate', { ownerPrincipalId: 'account-1', allowLegacyUnowned: true, }), ).resolves.toMatchObject({ ownerPrincipalId: 'account-1' }); }); test('refuses to migrate a corrupt record with simultaneous owner and guest subjects', async () => { const store = await importStore(); const created = await store.createClassroomGenerationJob('job-dual-owner', INPUT, { guestPrincipalId: 'device-1', }); await fs.writeFile( path.join(jobsDir, 'job-dual-owner.json'), JSON.stringify({ ...created, ownerPrincipalId: 'account-1' }), 'utf8', ); await expect( store.migrateClassroomGenerationJobOwnership('job-dual-owner', { ownerPrincipalId: 'account-1', expectedGuestPrincipalId: 'device-1', }), ).rejects.toThrow(/conflicting ownership subjects/); }); }); describe('classroom job runner cancellation', () => { test('passes the persisted account owner to classroom persistence options', async () => { const store = await importStore(); const runner = await importRunner(); await store.createClassroomGenerationJob('job-owned-run', INPUT, { ownerPrincipalId: 'account-1', }); mocks.generateClassroom.mockResolvedValueOnce({ id: 'owned-classroom', url: 'http://localhost/classroom/owned-classroom', scenesCount: 1, }); await runner.runClassroomGenerationJob('job-owned-run', INPUT, 'http://localhost'); expect(mocks.generateClassroom).toHaveBeenCalledWith( INPUT, expect.objectContaining({ ownership: expect.objectContaining({ ownerPrincipalId: 'account-1' }), }), ); }); test('aborting the controller marks the job cancelled (cooperative)', async () => { const store = await importStore(); const runner = await importRunner(); await store.createClassroomGenerationJob('job-cancel', INPUT); // generateClassroom hangs until the signal aborts, then throws AbortError. mocks.generateClassroom.mockImplementation( (_input: unknown, options: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { const onAbort = () => { options.signal?.removeEventListener('abort', onAbort); reject(new DOMException('cancelled', 'AbortError')); }; if (options.signal?.aborted) return reject(new DOMException('cancelled', 'AbortError')); options.signal?.addEventListener('abort', onAbort, { once: true }); }), ); const running = runner.runClassroomGenerationJob('job-cancel', INPUT, 'http://localhost'); expect(runner.cancelClassroomGenerationJob('job-cancel')).toBe(true); await running; const job = await store.readClassroomGenerationJob('job-cancel'); expect(job?.status).toBe('cancelled'); }); test('cancelling an unknown job is a no-op', async () => { const runner = await importRunner(); expect(runner.cancelClassroomGenerationJob('never-existed')).toBe(false); }); }); describe('job resume', () => { test('resumes a cancelled job from its persisted input', async () => { const store = await importStore(); const runner = await importRunner(); // First run: hang until aborted → cancelled. await store.createClassroomGenerationJob('job-resume', INPUT); mocks.generateClassroom.mockImplementationOnce( (_input: unknown, options: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { if (options.signal?.aborted) { return reject(new DOMException('cancelled', 'AbortError')); } const onAbort = () => { options.signal?.removeEventListener('abort', onAbort); reject(new DOMException('cancelled', 'AbortError')); }; options.signal?.addEventListener('abort', onAbort, { once: true }); }), ); const first = runner.runClassroomGenerationJob('job-resume', INPUT, 'http://localhost'); runner.cancelClassroomGenerationJob('job-resume'); await first; expect((await store.readClassroomGenerationJob('job-resume'))?.status).toBe('cancelled'); // Resume: succeeds immediately (mock resolves), job goes queued → succeeded. mocks.generateClassroom.mockResolvedValueOnce({ id: 'resumed-classroom', url: 'http://localhost/classroom/resumed-classroom', scenesCount: 3, }); const resumed = await runner.resumeClassroomGenerationJob('job-resume', 'http://localhost'); expect(resumed).toBe(true); expect((await store.readClassroomGenerationJob('job-resume'))?.status).toBe('queued'); // Wait for the resumed run to finish. await new Promise((r) => setTimeout(r, 50)); const job = await store.readClassroomGenerationJob('job-resume'); expect(job?.status).toBe('succeeded'); expect(job?.result?.classroomId).toBe('resumed-classroom'); }); test('resume refuses active or input-less jobs', async () => { const store = await importStore(); const runner = await importRunner(); // Active job (simulated directly on the record — no runner involvement, // so nothing can leak past the suite). await store.createClassroomGenerationJob('job-active', INPUT); await store.updateClassroomGenerationJob('job-active', { status: 'running', startedAt: new Date().toISOString(), }); expect(await runner.resumeClassroomGenerationJob('job-active', 'http://localhost')).toBe(false); // Input-less job (legacy record without the persisted input). await store.createClassroomGenerationJob('job-no-input', INPUT); await store.updateClassroomGenerationJob('job-no-input', { status: 'failed', completedAt: new Date().toISOString(), input: undefined as never, }); expect(await runner.resumeClassroomGenerationJob('job-no-input', 'http://localhost')).toBe( false, ); }); test('resume refuses a succeeded job without mutating its frozen source result', async () => { const store = await importStore(); const runner = await importRunner(); await store.createClassroomGenerationJob('job-succeeded', INPUT); await store.updateClassroomGenerationJob('job-succeeded', { status: 'succeeded', step: 'completed', progress: 100, message: 'Classroom generation completed', result: { classroomId: 'frozen-classroom', url: 'http://localhost/classroom/frozen-classroom', scenesCount: 2, }, }); const before = await store.readClassroomGenerationJob('job-succeeded'); expect( await runner.resumeClassroomGenerationJob('job-succeeded', 'http://localhost'), ).toBe(false); expect(await store.readClassroomGenerationJob('job-succeeded')).toEqual(before); expect(mocks.generateClassroom).not.toHaveBeenCalled(); }); test('resumes a failed job from its persisted input', async () => { const store = await importStore(); const runner = await importRunner(); await store.createClassroomGenerationJob('job-failed-resume', INPUT); await store.markClassroomGenerationJobFailed('job-failed-resume', 'provider failed'); mocks.generateClassroom.mockResolvedValueOnce({ id: 'recovered-classroom', url: 'http://localhost/classroom/recovered-classroom', scenesCount: 1, }); expect( await runner.resumeClassroomGenerationJob('job-failed-resume', 'http://localhost'), ).toBe(true); expect(await store.readClassroomGenerationJob('job-failed-resume')).toMatchObject({ status: 'queued', progress: 0, message: 'Classroom generation job queued (resumed)', }); await new Promise((resolve) => setTimeout(resolve, 50)); expect(await store.readClassroomGenerationJob('job-failed-resume')).toMatchObject({ status: 'succeeded', result: { classroomId: 'recovered-classroom' }, }); }); });