Files
openmaic/OpenMAIC/tests/courseware/remote-course-publish.test.ts

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