Files
openmaic/OpenMAIC/tests/course-framework/mutation-state.test.ts
2026-08-16 14:58:47 +08:00

108 lines
3.5 KiB
TypeScript

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