Files
openmaic/OpenMAIC/tests/server/classroom-job.test.ts

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' },
});
});
});