229 lines
7.5 KiB
TypeScript
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);
|
|
});
|
|
});
|