import { describe, expect, test } from 'vitest'; import { acquireCourseGenerationActivity, ClassroomSourceMutationInProgressError, CourseMutationInProgressError, CoursePublishInProgressError, isCoursePublishing, runClassroomSourceDeleteExclusive, runClassroomSourcesPublishExclusive, runCoursePublishExclusive, runCourseRegistryPublishExclusive, } from '@/lib/course-framework/publish-state'; function deferred() { let resolve!: () => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } describe('course source mutation gate', () => { test('atomically rejects generation while an ops publication is active', async () => { const hold = deferred(); const publishing = runCoursePublishExclusive('course-publish-first', () => hold.promise); expect(isCoursePublishing('course-publish-first')).toBe(true); expect(() => acquireCourseGenerationActivity('course-publish-first')).toThrow( CourseMutationInProgressError, ); hold.resolve(); await publishing; expect(isCoursePublishing('course-publish-first')).toBe(false); }); test('atomically rejects publication while generation is active', async () => { const release = acquireCourseGenerationActivity('course-generation-first'); await expect( runCoursePublishExclusive('course-generation-first', async () => undefined), ).rejects.toBeInstanceOf(CoursePublishInProgressError); release(); await expect( runCoursePublishExclusive('course-generation-first', async () => 'published'), ).resolves.toBe('published'); }); test('keeps remote ops source and server registry gates in separate namespaces', async () => { const registryEntered = deferred(); const registryRelease = deferred(); const sourcePublish = runCoursePublishExclusive('remote-course', async () => { const registryPublish = runCourseRegistryPublishExclusive('remote-course', async () => { registryEntered.resolve(); await registryRelease.promise; }); await registryEntered.promise; registryRelease.resolve(); await registryPublish; }); await expect(sourcePublish).resolves.toBeUndefined(); }); }); describe('classroom source publish/delete gate', () => { test('rejects source deletion throughout an active publication', async () => { const hold = deferred(); const publishing = runClassroomSourcesPublishExclusive( ['classroom-published-source'], () => hold.promise, ); await expect( runClassroomSourceDeleteExclusive('classroom-published-source', async () => undefined), ).rejects.toBeInstanceOf(ClassroomSourceMutationInProgressError); hold.resolve(); await publishing; await expect( runClassroomSourceDeleteExclusive('classroom-published-source', async () => 'deleted'), ).resolves.toBe('deleted'); }); test('claims a multi-module publication atomically', async () => { const holdDelete = deferred(); const deleting = runClassroomSourceDeleteExclusive( 'classroom-module-2', () => holdDelete.promise, ); await expect( runClassroomSourcesPublishExclusive( ['classroom-module-1', 'classroom-module-2'], async () => undefined, ), ).rejects.toBeInstanceOf(ClassroomSourceMutationInProgressError); // The failed batch must not leave its earlier source claimed. await expect( runClassroomSourceDeleteExclusive('classroom-module-1', async () => 'deleted'), ).resolves.toBe('deleted'); holdDelete.resolve(); await deleting; }); });