365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
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' },
|
|
});
|
|
});
|
|
});
|