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: `
`, }, }; 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>, statuses: number[], ): Promise { 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; }; 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((resolve) => { signalManifestReached = resolve; }); const manifestRelease = new Promise((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(); 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(); 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; 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); }); });