Files
openmaic/OpenMAIC/tests/course-framework/manifest.test.ts
2026-08-16 14:58:47 +08:00

229 lines
7.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import type { CourseManifestRecord } from '@/lib/course-manifest-repo/types';
import type { CoursewareRecord } from '@/lib/courseware-repo/types';
const HASH_V1 = 'ab'.repeat(32);
const HASH_V2 = 'cd'.repeat(32);
let manifestsDir: string;
let recordsDir: string;
beforeEach(async () => {
manifestsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'course-manifests-'));
recordsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'courseware-records-'));
vi.resetModules();
vi.stubEnv('COURSE_MANIFEST_DIR', manifestsDir);
vi.stubEnv('COURSEWARE_DATA_DIR', recordsDir);
vi.stubEnv('COURSEWARE_PUBLISH_TOKEN', 'secret-token');
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(manifestsDir, { recursive: true, force: true });
await fs.rm(recordsDir, { recursive: true, force: true });
});
function coursewareRecord(
coursewareId: string,
options: { version?: number; contentHash?: string } = {},
): CoursewareRecord {
const version = options.version ?? 1;
return {
coursewareId,
version,
title: coursewareId,
status: 'published',
publishedAt: '2026-08-15T00:00:00.000Z',
contentHash: options.contentHash ?? HASH_V1,
byteSize: 100,
entryCount: 5,
sceneCount: 3,
quizSceneCount: 1,
knowledgeVersion: 1,
complete: true,
bundleUrl: `http://localhost/api/coursewares/${coursewareId}/bundles/${version}/download`,
storageKey: `${coursewareId}/v${version}.zip`,
};
}
async function seedPublishedCourseware(
coursewareId: string,
options: { version?: number; contentHash?: string } = {},
) {
const { createFileCoursewareRepo } = await import('@/lib/courseware-repo/store');
await createFileCoursewareRepo(recordsDir).saveRecord(coursewareRecord(coursewareId, options));
}
const MODULES = [
{ index: 1, title: '模块一', description: '基础', coursewareId: 'cw-a' },
{ index: 2, title: '模块二', description: '进阶', coursewareId: 'cw-b' },
];
describe('course manifest repo + publish gate', () => {
test('rejects a missing or invalid token', async () => {
const { publishCourse } = await import('@/lib/course-manifest-repo');
const bad = await publishCourse({
courseId: 'course-1',
token: 'wrong',
title: 't',
summary: 's',
modules: MODULES,
});
expect(bad.ok).toBe(false);
if (!bad.ok) expect(bad.errorCode).toBe('UNAUTHORIZED');
const none = await publishCourse({
courseId: 'course-1',
token: null,
title: 't',
summary: 's',
modules: MODULES,
});
expect(none.ok).toBe(false);
});
test('rejects a course with unpublished module coursewares', async () => {
await seedPublishedCourseware('cw-a'); // cw-b missing
const { publishCourse } = await import('@/lib/course-manifest-repo');
const result = await publishCourse({
courseId: 'course-1',
token: 'secret-token',
title: 't',
summary: 's',
modules: MODULES,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errorCode).toBe('MODULE_NOT_PUBLISHED');
expect(result.error).toContain('cw-b');
}
});
test('publishes schema v2 pins, preserves manifest v1, and selects a later module version only on re-publish', async () => {
await seedPublishedCourseware('cw-a');
await seedPublishedCourseware('cw-b');
const { publishCourse } = await import('@/lib/course-manifest-repo');
const { courseManifestRepo } = await import('@/lib/course-manifest-repo/store');
const first = await publishCourse({
courseId: 'course-1',
token: 'secret-token',
title: '机器学习入门',
summary: '导论',
modules: MODULES,
});
expect(first.ok).toBe(true);
if (first.ok) {
expect(first.record.schemaVersion).toBe(2);
expect(first.record.version).toBe(1);
expect(first.record.modules).toHaveLength(2);
expect(first.record.modules[0]).toMatchObject({
index: 1,
coursewareId: 'cw-a',
coursewareVersion: 1,
contentHash: HASH_V1,
});
}
// A newly published module version must not mutate the already committed
// course manifest. It can only be selected by publishing a new manifest.
await seedPublishedCourseware('cw-a', { version: 2, contentHash: HASH_V2 });
const frozenV1BeforeRepublish = await courseManifestRepo.getRecord('course-1', 1);
expect(frozenV1BeforeRepublish?.modules[0]).toMatchObject({
coursewareVersion: 1,
contentHash: HASH_V1,
});
const second = await publishCourse({
courseId: 'course-1',
token: 'secret-token',
title: '机器学习入门 v2',
summary: '导论',
modules: MODULES,
});
expect(second.ok).toBe(true);
if (second.ok) {
expect(second.record.version).toBe(2);
expect(second.record.modules[0]).toMatchObject({
coursewareVersion: 2,
contentHash: HASH_V2,
});
}
const latest = await courseManifestRepo.getLatestRecord('course-1');
expect(latest?.version).toBe(2);
expect(latest?.title).toBe('机器学习入门 v2');
const frozenV1AfterRepublish = await courseManifestRepo.getRecord('course-1', 1);
expect(frozenV1AfterRepublish).toEqual(frozenV1BeforeRepublish);
expect(frozenV1AfterRepublish?.title).toBe('机器学习入门');
});
test('reads a legacy singleton and upgrades it to history without losing the old version', async () => {
const legacy: CourseManifestRecord = {
courseId: 'legacy-course',
version: 4,
title: '旧版连续课程',
summary: '尚未锁定模块版本',
modules: [
{
index: 1,
title: '旧模块',
description: '旧版说明',
coursewareId: 'cw-a',
},
],
publishedAt: '2026-08-14T00:00:00.000Z',
};
await fs.writeFile(
path.join(manifestsDir, 'legacy-course.json'),
JSON.stringify(legacy, null, 2),
'utf-8',
);
await seedPublishedCourseware('cw-a');
const { publishCourse } = await import('@/lib/course-manifest-repo');
const { courseManifestRepo } = await import('@/lib/course-manifest-repo/store');
expect(await courseManifestRepo.getLatestRecord('legacy-course')).toEqual(legacy);
const upgraded = await publishCourse({
courseId: 'legacy-course',
token: 'secret-token',
title: '新版连续课程',
summary: '已锁定模块版本',
modules: [MODULES[0]],
});
expect(upgraded.ok).toBe(true);
if (upgraded.ok) {
expect(upgraded.record).toMatchObject({ version: 5, schemaVersion: 2 });
expect(upgraded.record.modules[0]).toMatchObject({
coursewareVersion: 1,
contentHash: HASH_V1,
});
}
expect(await courseManifestRepo.getRecord('legacy-course', 4)).toEqual(legacy);
const stored = JSON.parse(
await fs.readFile(path.join(manifestsDir, 'legacy-course.json'), 'utf-8'),
) as { courseId: string; versions: CourseManifestRecord[] };
expect(stored.courseId).toBe('legacy-course');
expect(stored.versions.map((record) => record.version)).toEqual([4, 5]);
expect(stored.versions[0]).toEqual(legacy);
});
test('refuses an empty module list', async () => {
const { publishCourse } = await import('@/lib/course-manifest-repo');
const result = await publishCourse({
courseId: 'course-1',
token: 'secret-token',
title: 't',
summary: 's',
modules: [],
});
expect(result.ok).toBe(false);
});
});