718 lines
28 KiB
TypeScript
718 lines
28 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 { NextRequest } from 'next/server';
|
|
import type { CourseFramework, CourseRecord } from '@/lib/course-framework/types';
|
|
import type { CourseManifestRepo } from '@/lib/course-manifest-repo/types';
|
|
import type { BundleByteStore } from '@/lib/courseware-repo/bundle-store';
|
|
import type { CoursewareRepo } from '@/lib/courseware-repo/types';
|
|
import type { RemoteCoursePublishMetadata } from '@/lib/server/course-publish-contract';
|
|
import type { PersistedClassroomData } from '@/lib/server/classroom-storage';
|
|
import type { GeneratedAgentConfig, Scene, Stage } from '@/lib/types/stage';
|
|
|
|
const COURSE_ID = 'remote-course';
|
|
const TOKEN = 'remote-course-secret';
|
|
const INTERNAL_BASE_URL = 'https://internal-publish.example';
|
|
const PUBLIC_BASE_URL = 'https://learn.example';
|
|
|
|
let tempRoot: string;
|
|
let opsClassroomsDir: string;
|
|
let opsFrameworksDir: string;
|
|
let serverCoursewaresDir: string;
|
|
let serverBundlesDir: string;
|
|
let serverManifestsDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-course-publish-'));
|
|
opsClassroomsDir = path.join(tempRoot, 'ops', 'classrooms');
|
|
opsFrameworksDir = path.join(tempRoot, 'ops', 'frameworks');
|
|
serverCoursewaresDir = path.join(tempRoot, 'server', 'coursewares');
|
|
serverBundlesDir = path.join(tempRoot, 'server', 'bundles');
|
|
serverManifestsDir = path.join(tempRoot, 'server', 'manifests');
|
|
|
|
vi.resetModules();
|
|
vi.stubEnv('CLASSROOM_DATA_DIR', opsClassroomsDir);
|
|
vi.stubEnv('COURSE_FRAMEWORK_DIR', opsFrameworksDir);
|
|
vi.stubEnv('COURSEWARE_DATA_DIR', serverCoursewaresDir);
|
|
vi.stubEnv('COURSEWARE_BUNDLE_DIR', serverBundlesDir);
|
|
vi.stubEnv('COURSE_MANIFEST_DIR', serverManifestsDir);
|
|
vi.stubEnv('COURSEWARE_PUBLISH_TOKEN', TOKEN);
|
|
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'server');
|
|
// A server must remain Bearer-authenticated even if the deployment shares
|
|
// an ops .env containing ACCESS_CODE. No cookie is added to these requests.
|
|
vi.stubEnv('ACCESS_CODE', 'shared-ops-access-code');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals();
|
|
vi.unstubAllEnvs();
|
|
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function publicModuleId(index: number): string {
|
|
return `course_${COURSE_ID}_module_${index}`;
|
|
}
|
|
|
|
function teacher(): GeneratedAgentConfig {
|
|
return {
|
|
id: 'teacher-agent',
|
|
name: '麦老师',
|
|
role: 'teacher',
|
|
persona: '耐心且清晰。',
|
|
avatar: '/avatars/teacher.png',
|
|
color: '#2563eb',
|
|
priority: 10,
|
|
};
|
|
}
|
|
|
|
function classroom(index: number, marker = 'v1'): PersistedClassroomData {
|
|
const id = `ops-source-${index}`;
|
|
const agent = teacher();
|
|
const stage: Stage = {
|
|
id,
|
|
name: `远程模块 ${index}`,
|
|
description: `互动模块 ${index}`,
|
|
languageDirective: 'zh-CN',
|
|
createdAt: 1_700_000_000_000 + index,
|
|
updatedAt: 1_700_000_000_100 + index,
|
|
interactiveMode: true,
|
|
agentIds: [agent.id],
|
|
generatedAgentConfigs: [agent],
|
|
};
|
|
const scene: Scene = {
|
|
id: `scene-${index}`,
|
|
stageId: id,
|
|
type: 'interactive',
|
|
title: `互动 ${index}`,
|
|
order: 1,
|
|
content: {
|
|
type: 'interactive',
|
|
html: `<main id="module-${index}"><button type="button">${marker}-${index}</button></main>`,
|
|
},
|
|
};
|
|
return {
|
|
id,
|
|
stage,
|
|
scenes: [scene],
|
|
createdAt: `2026-08-15T0${index}:00:00.000Z`,
|
|
};
|
|
}
|
|
|
|
async function persistClassrooms(classrooms: PersistedClassroomData[]) {
|
|
await fs.mkdir(opsClassroomsDir, { recursive: true });
|
|
await Promise.all(
|
|
classrooms.map((entry) =>
|
|
fs.writeFile(path.join(opsClassroomsDir, `${entry.id}.json`), JSON.stringify(entry), 'utf-8'),
|
|
),
|
|
);
|
|
}
|
|
|
|
async function packagePersistedModules(
|
|
classrooms: PersistedClassroomData[],
|
|
publishedAt = '2026-08-15T08:00:00.000Z',
|
|
) {
|
|
await persistClassrooms(classrooms);
|
|
const { readClassroom } = await import('@/lib/server/classroom-storage');
|
|
const { packagePersistedClassroom } = await import('@/lib/server/classroom-courseware-publish');
|
|
const archives = [];
|
|
for (let position = 0; position < classrooms.length; position += 1) {
|
|
const index = position + 1;
|
|
const persisted = await readClassroom(classrooms[position].id);
|
|
if (!persisted) throw new Error(`Missing persisted classroom ${classrooms[position].id}`);
|
|
const packaged = await packagePersistedClassroom(persisted, {
|
|
coursewareId: publicModuleId(index),
|
|
version: 1,
|
|
baseUrl: 'http://localhost:3000',
|
|
classroomsDir: opsClassroomsDir,
|
|
publishedAt,
|
|
});
|
|
archives.push({
|
|
index,
|
|
zipBytes: new Uint8Array(await packaged.zip.arrayBuffer()),
|
|
contentHash: packaged.contentHash,
|
|
});
|
|
}
|
|
return archives;
|
|
}
|
|
|
|
function metadata(classrooms: PersistedClassroomData[]): RemoteCoursePublishMetadata {
|
|
return {
|
|
protocolVersion: 1,
|
|
courseId: COURSE_ID,
|
|
title: '跨进程大课',
|
|
summary: '从基础到实践的连续课程。',
|
|
language: 'zh-CN',
|
|
modules: classrooms.map((entry, position) => ({
|
|
index: position + 1,
|
|
title: `模块 ${position + 1}`,
|
|
description: `模块 ${position + 1} 说明`,
|
|
coursewareId: publicModuleId(position + 1),
|
|
sourceClassroomId: entry.id,
|
|
})),
|
|
};
|
|
}
|
|
|
|
async function serverRepos(): Promise<{
|
|
coursewares: CoursewareRepo;
|
|
bundles: BundleByteStore;
|
|
manifests: CourseManifestRepo;
|
|
}> {
|
|
const { createFileCoursewareRepo } = await import('@/lib/courseware-repo/store');
|
|
const { createFileBundleByteStore } = await import('@/lib/courseware-repo/bundle-store');
|
|
const { createFileCourseManifestRepo } = await import('@/lib/course-manifest-repo/store');
|
|
return {
|
|
coursewares: createFileCoursewareRepo(serverCoursewaresDir),
|
|
bundles: createFileBundleByteStore(serverBundlesDir),
|
|
manifests: createFileCourseManifestRepo(serverManifestsDir),
|
|
};
|
|
}
|
|
|
|
async function routeFetch(
|
|
repos: Awaited<ReturnType<typeof serverRepos>>,
|
|
statuses: number[],
|
|
): Promise<typeof fetch> {
|
|
const { handleCoursePublishRequest } = await import('@/lib/server/course-publish-route');
|
|
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const request = new NextRequest(typeof input === 'string' ? input : input.toString(), {
|
|
method: init?.method,
|
|
headers: init?.headers,
|
|
body: init?.body,
|
|
});
|
|
const response = await handleCoursePublishRequest(request, {
|
|
...repos,
|
|
publicBaseUrl: PUBLIC_BASE_URL,
|
|
});
|
|
statuses.push(response.status);
|
|
return response;
|
|
}) as typeof fetch;
|
|
}
|
|
|
|
describe('remote transactional course publish', () => {
|
|
test('publishes exact learner pins and makes a timeout-style retry idempotent', async () => {
|
|
const classrooms = [classroom(1), classroom(2)];
|
|
const archives = await packagePersistedModules(classrooms);
|
|
const repos = await serverRepos();
|
|
const statuses: number[] = [];
|
|
const fetchImpl = await routeFetch(repos, statuses);
|
|
const { publishCourseToServer } = await import('@/lib/server/course-publish-transport');
|
|
|
|
const first = await publishCourseToServer({
|
|
baseUrl: INTERNAL_BASE_URL,
|
|
token: TOKEN,
|
|
metadata: metadata(classrooms),
|
|
archives,
|
|
fetchImpl,
|
|
});
|
|
expect(first.idempotent).toBe(false);
|
|
expect(first.record).toMatchObject({ schemaVersion: 2, courseId: COURSE_ID, version: 1 });
|
|
expect(first.record.modules.map((entry) => entry.contentHash)).toEqual(
|
|
archives.map((entry) => entry.contentHash),
|
|
);
|
|
|
|
const retryArchives = await packagePersistedModules(classrooms, '2026-08-15T09:00:00.000Z');
|
|
expect(retryArchives.map((entry) => entry.contentHash)).toEqual(
|
|
archives.map((entry) => entry.contentHash),
|
|
);
|
|
|
|
const retry = await publishCourseToServer({
|
|
baseUrl: INTERNAL_BASE_URL,
|
|
token: TOKEN,
|
|
metadata: metadata(classrooms),
|
|
archives: retryArchives,
|
|
fetchImpl,
|
|
});
|
|
expect(retry).toEqual({ record: first.record, idempotent: true });
|
|
expect(statuses).toEqual([201, 200]);
|
|
expect(await repos.coursewares.listRecords()).toHaveLength(2);
|
|
expect(await repos.manifests.listRecords()).toHaveLength(1);
|
|
|
|
for (const pin of first.record.modules) {
|
|
const registryRecord = await repos.coursewares.getRecord(
|
|
pin.coursewareId,
|
|
pin.coursewareVersion!,
|
|
);
|
|
expect(registryRecord).toMatchObject({
|
|
status: 'published',
|
|
contentHash: pin.contentHash,
|
|
});
|
|
expect(registryRecord?.bundleUrl).toContain(PUBLIC_BASE_URL);
|
|
expect(registryRecord?.bundleUrl).not.toContain('internal-publish');
|
|
}
|
|
|
|
// Exercise the real public learner routes against the isolated server repo.
|
|
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'learner');
|
|
const { GET: getCourse } = await import('@/app/api/learn/courses/[courseId]/route');
|
|
const courseResponse = await getCourse(
|
|
new NextRequest(`${PUBLIC_BASE_URL}/api/learn/courses/${COURSE_ID}?version=1`),
|
|
{ params: Promise.resolve({ courseId: COURSE_ID }) },
|
|
);
|
|
await expect(courseResponse.json()).resolves.toMatchObject({
|
|
success: true,
|
|
course: { version: 1, modules: first.record.modules },
|
|
});
|
|
|
|
const firstPin = first.record.modules[0];
|
|
const { GET: getCourseware } = await import('@/app/api/coursewares/[id]/route');
|
|
const coursewareResponse = await getCourseware(
|
|
new NextRequest(
|
|
`${PUBLIC_BASE_URL}/api/coursewares/${firstPin.coursewareId}?version=${firstPin.coursewareVersion}`,
|
|
),
|
|
{ params: Promise.resolve({ id: firstPin.coursewareId }) },
|
|
);
|
|
const coursewareBody = (await coursewareResponse.json()) as {
|
|
courseware: Record<string, unknown>;
|
|
};
|
|
expect(coursewareBody.courseware).toMatchObject({
|
|
version: firstPin.coursewareVersion,
|
|
contentHash: firstPin.contentHash,
|
|
});
|
|
expect(coursewareBody.courseware).not.toHaveProperty('sourceClassroomId');
|
|
});
|
|
|
|
test('never acknowledges an idempotent retry when exact stored bundle bytes are missing or tampered', async () => {
|
|
const classrooms = [classroom(1), classroom(2)];
|
|
const archives = await packagePersistedModules(classrooms);
|
|
const repos = await serverRepos();
|
|
const statuses: number[] = [];
|
|
const fetchImpl = await routeFetch(repos, statuses);
|
|
const { publishCourseToServer } = await import('@/lib/server/course-publish-transport');
|
|
const request = {
|
|
baseUrl: INTERNAL_BASE_URL,
|
|
token: TOKEN,
|
|
metadata: metadata(classrooms),
|
|
archives,
|
|
fetchImpl,
|
|
};
|
|
|
|
const first = await publishCourseToServer(request);
|
|
expect(first.idempotent).toBe(false);
|
|
await repos.bundles.remove(publicModuleId(1), 1);
|
|
|
|
const afterMissingBytes = await publishCourseToServer(request);
|
|
expect(afterMissingBytes.idempotent).toBe(false);
|
|
expect(afterMissingBytes.record.version).toBe(2);
|
|
expect(afterMissingBytes.record.modules.map((pin) => pin.coursewareVersion)).toEqual([2, 2]);
|
|
|
|
const storedV2 = await repos.bundles.read(publicModuleId(2), 2);
|
|
expect(storedV2).not.toBeNull();
|
|
const JSZip = (await import('jszip')).default;
|
|
const tamperedZip = await JSZip.loadAsync(storedV2!);
|
|
const bundleFile = tamperedZip.file('bundle.json');
|
|
expect(bundleFile).not.toBeNull();
|
|
const bundleDocument = JSON.parse(await bundleFile!.async('string')) as {
|
|
completeness: { complete: boolean };
|
|
};
|
|
bundleDocument.completeness.complete = false;
|
|
tamperedZip.file('bundle.json', JSON.stringify(bundleDocument, null, 2));
|
|
await fs.writeFile(
|
|
path.join(serverBundlesDir, publicModuleId(2), 'v2.zip'),
|
|
await tamperedZip.generateAsync({ type: 'uint8array' }),
|
|
);
|
|
const afterCorruptBytes = await publishCourseToServer(request);
|
|
expect(afterCorruptBytes.idempotent).toBe(false);
|
|
expect(afterCorruptBytes.record.version).toBe(3);
|
|
expect(afterCorruptBytes.record.modules.map((pin) => pin.coursewareVersion)).toEqual([3, 3]);
|
|
expect(statuses).toEqual([201, 201, 201]);
|
|
expect(await repos.coursewares.listRecords()).toHaveLength(6);
|
|
await expect(
|
|
Promise.all([1, 2, 3].map((version) => repos.manifests.getRecord(COURSE_ID, version))),
|
|
).resolves.toEqual([first.record, afterMissingBytes.record, afterCorruptBytes.record]);
|
|
expect(await repos.manifests.getRecord(COURSE_ID, 4)).toBeNull();
|
|
const latestBytes = await repos.bundles.read(publicModuleId(2), 3);
|
|
const { readFrozenBundleDocuments } = await import('@/lib/bundle/packager');
|
|
await expect(readFrozenBundleDocuments(latestBytes!)).resolves.toMatchObject({
|
|
meta: { coursewareId: publicModuleId(2), version: 3 },
|
|
completeness: { complete: true },
|
|
});
|
|
});
|
|
|
|
test('rolls module one back to unpublished when module two storage fails', async () => {
|
|
const classrooms = [classroom(1), classroom(2)];
|
|
const archives = await packagePersistedModules(classrooms);
|
|
const repos = await serverRepos();
|
|
const failingBundles: BundleByteStore = {
|
|
...repos.bundles,
|
|
async save(coursewareId, version, bytes) {
|
|
if (coursewareId === publicModuleId(2)) throw new Error('simulated module two failure');
|
|
return repos.bundles.save(coursewareId, version, bytes);
|
|
},
|
|
};
|
|
const fetchImpl = await routeFetch({ ...repos, bundles: failingBundles }, []);
|
|
const { publishCourseToServer } = await import('@/lib/server/course-publish-transport');
|
|
|
|
await expect(
|
|
publishCourseToServer({
|
|
baseUrl: INTERNAL_BASE_URL,
|
|
token: TOKEN,
|
|
metadata: metadata(classrooms),
|
|
archives,
|
|
fetchImpl,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
errorCode: 'MODULE_STAGE_FAILED',
|
|
phase: 'staging',
|
|
moduleIndex: 2,
|
|
});
|
|
expect(await repos.coursewares.getRecord(publicModuleId(1), 1)).toMatchObject({
|
|
status: 'unpublished',
|
|
});
|
|
expect(await repos.coursewares.getRecord(publicModuleId(2), 1)).toBeNull();
|
|
expect(await repos.coursewares.listRecords({ status: 'published' })).toEqual([]);
|
|
expect(await repos.manifests.getLatestRecord(COURSE_ID)).toBeNull();
|
|
});
|
|
|
|
test('hides every promoted module when the manifest commit fails', async () => {
|
|
const classrooms = [classroom(1), classroom(2)];
|
|
const archives = await packagePersistedModules(classrooms);
|
|
const repos = await serverRepos();
|
|
const failingManifests: CourseManifestRepo = {
|
|
...repos.manifests,
|
|
async saveNextRecord() {
|
|
throw new Error('simulated manifest failure');
|
|
},
|
|
};
|
|
const fetchImpl = await routeFetch({ ...repos, manifests: failingManifests }, []);
|
|
const { publishCourseToServer } = await import('@/lib/server/course-publish-transport');
|
|
|
|
await expect(
|
|
publishCourseToServer({
|
|
baseUrl: INTERNAL_BASE_URL,
|
|
token: TOKEN,
|
|
metadata: metadata(classrooms),
|
|
archives,
|
|
fetchImpl,
|
|
}),
|
|
).rejects.toMatchObject({ errorCode: 'MANIFEST_COMMIT_FAILED', phase: 'commit' });
|
|
expect(await repos.coursewares.listRecords({ status: 'published' })).toEqual([]);
|
|
expect(await repos.coursewares.getRecord(publicModuleId(1), 1)).toMatchObject({
|
|
status: 'unpublished',
|
|
});
|
|
expect(await repos.coursewares.getRecord(publicModuleId(2), 1)).toMatchObject({
|
|
status: 'unpublished',
|
|
});
|
|
expect(await repos.manifests.getLatestRecord(COURSE_ID)).toBeNull();
|
|
});
|
|
|
|
test('keeps promoted large-course modules out of public APIs until their exact manifest is committed', async () => {
|
|
const classrooms = [classroom(1), classroom(2)];
|
|
const archives = await packagePersistedModules(classrooms);
|
|
const repos = await serverRepos();
|
|
let signalManifestReached!: () => void;
|
|
let releaseManifest!: () => void;
|
|
const manifestReached = new Promise<void>((resolve) => {
|
|
signalManifestReached = resolve;
|
|
});
|
|
const manifestRelease = new Promise<void>((resolve) => {
|
|
releaseManifest = resolve;
|
|
});
|
|
const blockingManifests: CourseManifestRepo = {
|
|
...repos.manifests,
|
|
async saveNextRecord(draft) {
|
|
signalManifestReached();
|
|
await manifestRelease;
|
|
return repos.manifests.saveNextRecord(draft);
|
|
},
|
|
};
|
|
const fetchImpl = await routeFetch({ ...repos, manifests: blockingManifests }, []);
|
|
const { publishCourseToServer } = await import('@/lib/server/course-publish-transport');
|
|
const publishPromise = publishCourseToServer({
|
|
baseUrl: INTERNAL_BASE_URL,
|
|
token: TOKEN,
|
|
metadata: metadata(classrooms),
|
|
archives,
|
|
fetchImpl,
|
|
});
|
|
|
|
await manifestReached;
|
|
try {
|
|
expect(await repos.coursewares.listRecords({ status: 'published' })).toHaveLength(2);
|
|
|
|
const { GET: listCoursewares } = await import('@/app/api/coursewares/route');
|
|
const listResponse = await listCoursewares(
|
|
new NextRequest(`${PUBLIC_BASE_URL}/api/coursewares`),
|
|
);
|
|
const listBody = (await listResponse.json()) as {
|
|
items: Array<{ coursewareId: string }>;
|
|
};
|
|
expect(listBody.items).toEqual([]);
|
|
|
|
const { GET: getCourseware } = await import('@/app/api/coursewares/[id]/route');
|
|
const detailResponse = await getCourseware(
|
|
new NextRequest(`${PUBLIC_BASE_URL}/api/coursewares/${publicModuleId(1)}?version=1`),
|
|
{ params: Promise.resolve({ id: publicModuleId(1) }) },
|
|
);
|
|
expect(detailResponse.status).toBe(404);
|
|
|
|
const { GET: downloadBundle } =
|
|
await import('@/app/api/coursewares/[id]/bundles/[version]/download/route');
|
|
const downloadResponse = await downloadBundle(
|
|
new NextRequest(
|
|
`${PUBLIC_BASE_URL}/api/coursewares/${publicModuleId(1)}/bundles/1/download`,
|
|
),
|
|
{ params: Promise.resolve({ id: publicModuleId(1), version: '1' }) },
|
|
);
|
|
expect(downloadResponse.status).toBe(404);
|
|
} finally {
|
|
releaseManifest();
|
|
}
|
|
|
|
const committed = await publishPromise;
|
|
expect(committed.record.version).toBe(1);
|
|
|
|
const { GET: getCommittedCourseware } = await import('@/app/api/coursewares/[id]/route');
|
|
const committedDetail = await getCommittedCourseware(
|
|
new NextRequest(`${PUBLIC_BASE_URL}/api/coursewares/${publicModuleId(1)}?version=1`),
|
|
{ params: Promise.resolve({ id: publicModuleId(1) }) },
|
|
);
|
|
expect(committedDetail.status).toBe(200);
|
|
const { GET: downloadCommittedBundle } =
|
|
await import('@/app/api/coursewares/[id]/bundles/[version]/download/route');
|
|
const committedDownload = await downloadCommittedBundle(
|
|
new NextRequest(`${PUBLIC_BASE_URL}/api/coursewares/${publicModuleId(1)}/bundles/1/download`),
|
|
{ params: Promise.resolve({ id: publicModuleId(1), version: '1' }) },
|
|
);
|
|
expect(committedDownload.status).toBe(200);
|
|
});
|
|
|
|
test('rejects a public module identity drift before sending any bytes', async () => {
|
|
const classrooms = [classroom(1), classroom(2)];
|
|
const archives = await packagePersistedModules(classrooms);
|
|
const drifted = metadata(classrooms);
|
|
drifted.modules[1] = {
|
|
...drifted.modules[1],
|
|
coursewareId: classrooms[1].id,
|
|
};
|
|
const fetchImpl = vi.fn<typeof fetch>();
|
|
const { publishCourseToServer } = await import('@/lib/server/course-publish-transport');
|
|
|
|
await expect(
|
|
publishCourseToServer({
|
|
baseUrl: INTERNAL_BASE_URL,
|
|
token: TOKEN,
|
|
metadata: drifted,
|
|
archives,
|
|
fetchImpl,
|
|
}),
|
|
).rejects.toMatchObject({
|
|
errorCode: 'MODULE_IDENTITY_MISMATCH',
|
|
phase: 'request',
|
|
moduleIndex: 2,
|
|
});
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects an unsafe destination and an oversized whole-course upload', async () => {
|
|
const classrooms = [classroom(1)];
|
|
const archives = await packagePersistedModules(classrooms);
|
|
const fetchImpl = vi.fn<typeof fetch>();
|
|
const { publishCourseToServer } = await import('@/lib/server/course-publish-transport');
|
|
|
|
await expect(
|
|
publishCourseToServer({
|
|
baseUrl: 'http://10.0.0.5',
|
|
token: TOKEN,
|
|
metadata: metadata(classrooms),
|
|
archives,
|
|
fetchImpl,
|
|
}),
|
|
).rejects.toMatchObject({ errorCode: 'INSECURE_SERVER_URL' });
|
|
|
|
vi.stubEnv('COURSE_PUBLISH_MAX_UPLOAD_BYTES', '128');
|
|
await expect(
|
|
publishCourseToServer({
|
|
baseUrl: INTERNAL_BASE_URL,
|
|
token: TOKEN,
|
|
metadata: metadata(classrooms),
|
|
archives,
|
|
fetchImpl,
|
|
}),
|
|
).rejects.toMatchObject({ errorCode: 'COURSE_UPLOAD_TOO_LARGE', status: 413 });
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('does not let a shared ACCESS_CODE replace the internal Bearer token', async () => {
|
|
const classrooms = [classroom(1)];
|
|
const archives = await packagePersistedModules(classrooms);
|
|
const repos = await serverRepos();
|
|
const fetchImpl = await routeFetch(repos, []);
|
|
const { publishCourseToServer } = await import('@/lib/server/course-publish-transport');
|
|
|
|
await expect(
|
|
publishCourseToServer({
|
|
baseUrl: INTERNAL_BASE_URL,
|
|
token: 'wrong-bearer',
|
|
metadata: metadata(classrooms),
|
|
archives,
|
|
fetchImpl,
|
|
}),
|
|
).rejects.toMatchObject({ errorCode: 'UNAUTHORIZED', status: 401 });
|
|
expect(await repos.coursewares.listRecords()).toEqual([]);
|
|
expect(await repos.manifests.getLatestRecord(COURSE_ID)).toBeNull();
|
|
});
|
|
|
|
test('writes an authoritative ops receipt and invalidates it before regeneration', async () => {
|
|
vi.stubEnv('OPENMAIC_DEPLOYMENT_ROLE', 'ops');
|
|
vi.stubEnv('ACCESS_CODE', '');
|
|
vi.stubEnv('COURSE_PUBLISH_SERVER_BASE_URL', INTERNAL_BASE_URL);
|
|
// The ops-side compatibility repos are deliberately empty and isolated
|
|
// from the server deps used by the mocked network hop.
|
|
vi.stubEnv('COURSEWARE_DATA_DIR', path.join(tempRoot, 'ops', 'coursewares'));
|
|
vi.stubEnv('COURSEWARE_BUNDLE_DIR', path.join(tempRoot, 'ops', 'bundles'));
|
|
vi.stubEnv('COURSE_MANIFEST_DIR', path.join(tempRoot, 'ops', 'manifests'));
|
|
vi.resetModules();
|
|
|
|
const classrooms = [classroom(1), classroom(2)];
|
|
await persistClassrooms(classrooms);
|
|
const { buildCourseModuleOutputDigest } = await import('@/lib/course-framework/module-digest');
|
|
const digests = classrooms.map((entry) => buildCourseModuleOutputDigest(entry));
|
|
const framework: CourseFramework = {
|
|
courseTitle: '跨进程大课',
|
|
languageDirective: 'zh-CN',
|
|
targetAudience: '零基础学习者',
|
|
summary: '从基础到实践的连续课程。',
|
|
courseGoals: ['完成学习'],
|
|
continuityContract: {
|
|
terminology: ['统一术语'],
|
|
teachingStyle: '先讲解再互动',
|
|
difficultyProgression: '逐步提升',
|
|
assessmentStrategy: '每模块检查',
|
|
},
|
|
modules: classrooms.map((_entry, position) => ({
|
|
index: position + 1,
|
|
title: `模块 ${position + 1}`,
|
|
description: `模块 ${position + 1} 说明`,
|
|
learningObjectives: ['完成互动'],
|
|
generationPrompt: `生成模块 ${position + 1}`,
|
|
incomingKnowledge: position === 0 ? [] : ['模块 1'],
|
|
outgoingKnowledge: [`模块 ${position + 1}`],
|
|
excludedTopics: [],
|
|
})),
|
|
};
|
|
const record: CourseRecord = {
|
|
id: COURSE_ID,
|
|
status: 'completed',
|
|
requirement: '生成跨进程大课',
|
|
framework,
|
|
modules: framework.modules.map((moduleRecord, position) => ({
|
|
index: moduleRecord.index,
|
|
title: moduleRecord.title,
|
|
description: moduleRecord.description,
|
|
status: 'succeeded',
|
|
classroomId: classrooms[position].id,
|
|
outputDigest: digests[position],
|
|
continuityInputRefs: digests.slice(0, position).map((digest, prior) => ({
|
|
moduleIndex: prior + 1,
|
|
classroomId: digest.classroomId,
|
|
semanticHash: digest.semanticHash,
|
|
})),
|
|
})),
|
|
createdAt: '2026-08-15T00:00:00.000Z',
|
|
updatedAt: '2026-08-15T01:00:00.000Z',
|
|
};
|
|
await fs.mkdir(opsFrameworksDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(opsFrameworksDir, `${COURSE_ID}.json`),
|
|
JSON.stringify(record),
|
|
'utf-8',
|
|
);
|
|
|
|
const server = await serverRepos();
|
|
const { handleCoursePublishRequest } = await import('@/lib/server/course-publish-route');
|
|
// Retain the server module instance in this closure, then load the ops
|
|
// route with a fresh mutex exactly as two Node processes would.
|
|
vi.resetModules();
|
|
let discardFirstResponse = true;
|
|
vi.stubGlobal('fetch', (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const previousRole = process.env.OPENMAIC_DEPLOYMENT_ROLE;
|
|
process.env.OPENMAIC_DEPLOYMENT_ROLE = 'server';
|
|
try {
|
|
const serverResponse = await handleCoursePublishRequest(
|
|
new NextRequest(typeof input === 'string' ? input : input.toString(), {
|
|
method: init?.method,
|
|
headers: init?.headers,
|
|
body: init?.body,
|
|
}),
|
|
{ ...server, publicBaseUrl: PUBLIC_BASE_URL },
|
|
);
|
|
if (discardFirstResponse) {
|
|
discardFirstResponse = false;
|
|
throw new DOMException('simulated lost response after commit', 'TimeoutError');
|
|
}
|
|
return serverResponse;
|
|
} finally {
|
|
process.env.OPENMAIC_DEPLOYMENT_ROLE = previousRole;
|
|
}
|
|
}) as typeof fetch);
|
|
|
|
const { POST } = await import('@/app/api/courses/[courseId]/publish/route');
|
|
const ambiguousResponse = await POST(
|
|
new NextRequest('http://ops.example/api/courses/remote-course/publish'),
|
|
{ params: Promise.resolve({ courseId: COURSE_ID }) },
|
|
);
|
|
expect(ambiguousResponse.status).toBe(502);
|
|
const { readCourseRecord, invalidateCourseModulesFrom } =
|
|
await import('@/lib/course-framework/store');
|
|
expect((await readCourseRecord(COURSE_ID))?.publication).toBeUndefined();
|
|
expect(await server.coursewares.listRecords()).toHaveLength(2);
|
|
expect(await server.manifests.listRecords()).toHaveLength(1);
|
|
|
|
// Retrying the unchanged ops snapshot rebuilds byte-identical identities;
|
|
// the server returns its existing manifest instead of allocating v2.
|
|
const response = await POST(
|
|
new NextRequest('http://ops.example/api/courses/remote-course/publish'),
|
|
{ params: Promise.resolve({ courseId: COURSE_ID }) },
|
|
);
|
|
const responseBody = (await response.clone().json()) as Record<string, unknown>;
|
|
expect(response.status, JSON.stringify(responseBody)).toBe(200);
|
|
|
|
const published = await readCourseRecord(COURSE_ID);
|
|
expect(published?.publication).toMatchObject({
|
|
receiptVersion: 1,
|
|
manifestVersion: 1,
|
|
modules: [
|
|
{ index: 1, sourceClassroomId: classrooms[0].id },
|
|
{ index: 2, sourceClassroomId: classrooms[1].id },
|
|
],
|
|
});
|
|
expect(await server.coursewares.listRecords()).toHaveLength(2);
|
|
expect(await server.manifests.listRecords()).toHaveLength(1);
|
|
|
|
// Once the receipt write has advanced record.updatedAt, a repeated click
|
|
// still packages the same content identity and remains a no-op.
|
|
const repeatedResponse = await POST(
|
|
new NextRequest('http://ops.example/api/courses/remote-course/publish'),
|
|
{ params: Promise.resolve({ courseId: COURSE_ID }) },
|
|
);
|
|
expect(repeatedResponse.status).toBe(200);
|
|
await expect(repeatedResponse.json()).resolves.toMatchObject({
|
|
success: true,
|
|
record: { version: 1 },
|
|
});
|
|
expect(await server.coursewares.listRecords()).toHaveLength(2);
|
|
expect(await server.manifests.listRecords()).toHaveLength(1);
|
|
|
|
const { GET: getDetail } = await import('@/app/api/courses/[courseId]/route');
|
|
const detailResponse = await getDetail(
|
|
new NextRequest(`http://ops.example/api/courses/${COURSE_ID}`),
|
|
{ params: Promise.resolve({ courseId: COURSE_ID }) },
|
|
);
|
|
const detail = (await detailResponse.json()) as {
|
|
course: { counts: { published: number }; modules: Array<{ published: boolean }> };
|
|
};
|
|
expect(detail.course.counts.published).toBe(2);
|
|
expect(detail.course.modules.every((moduleRecord) => moduleRecord.published)).toBe(true);
|
|
|
|
await invalidateCourseModulesFrom(COURSE_ID, 1);
|
|
expect((await readCourseRecord(COURSE_ID))?.publication).toBeUndefined();
|
|
const invalidatedResponse = await getDetail(
|
|
new NextRequest(`http://ops.example/api/courses/${COURSE_ID}`),
|
|
{ params: Promise.resolve({ courseId: COURSE_ID }) },
|
|
);
|
|
const invalidated = (await invalidatedResponse.json()) as {
|
|
course: { counts: { published: number } };
|
|
};
|
|
expect(invalidated.course.counts.published).toBe(0);
|
|
});
|
|
});
|