diff --git a/BackendAcademy/src/courses/course.service.spec.ts b/BackendAcademy/src/courses/course.service.spec.ts index 960e42a5fd..ef54fd1a68 100644 --- a/BackendAcademy/src/courses/course.service.spec.ts +++ b/BackendAcademy/src/courses/course.service.spec.ts @@ -276,6 +276,40 @@ describe('CourseService', () => { ]); }); + it('freezes historical snapshots at the persistence boundary when source arrays are mutated', async () => { + const sourcePrerequisites = ['rust-basics']; + const sourceSkills = ['ownership']; + + const course = await service.create({ + title: 'Immutable Snapshot Test', + description: 'Desc', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + prerequisites: sourcePrerequisites, + skills: sourceSkills, + }); + + await service.update(course.id, { + prerequisites: ['rust-basics', 'ownership'], + skills: ['borrowing'], + }); + + sourcePrerequisites.push('mutated-source'); + sourceSkills.push('mutated-source-skill'); + course.prerequisites.push('mutated-course'); + course.skills.push('mutated-course-skill'); + + const revisions = await service.getRevisions(course.id); + const snapshot = revisions[0].snapshot; + + expect(snapshot.prerequisites).toEqual(['rust-basics']); + expect(snapshot.skills).toEqual(['ownership']); + expect(() => snapshot.prerequisites.push('mutated-history')).toThrow(TypeError); + expect(() => snapshot.skills.push('mutated-history')).toThrow(TypeError); + }); + // --------------------------------------------------------------------------- // Revision lookup // --------------------------------------------------------------------------- diff --git a/BackendAcademy/src/courses/course.service.ts b/BackendAcademy/src/courses/course.service.ts index 291640e86d..6c6736c804 100644 --- a/BackendAcademy/src/courses/course.service.ts +++ b/BackendAcademy/src/courses/course.service.ts @@ -379,25 +379,12 @@ export class CourseService { referenceRevisionId?: string; } = {}, ): Promise { + const snapshot = this.buildImmutableSnapshot(course); const revision = this.revisionRepo.create({ id: crypto.randomUUID(), courseId: course.id, version: course.version, - snapshot: { - title: course.title, - description: course.description, - level: course.level, - order: course.order, - learningPathId: course.learningPathId, - duration: course.duration, - category: course.category, - categories: [...(course.categories ?? [])], - tags: [...(course.tags ?? [])], - prerequisites: [...(course.prerequisites ?? [])], - skills: [...(course.skills ?? [])], - xpReward: course.xpReward, - isActive: course.isActive, - }, + snapshot, changeNote: options.changeNote, revisionAuthor: options.revisionAuthor, reason, @@ -412,6 +399,43 @@ export class CourseService { return savedRevision; } + private buildImmutableSnapshot( + course: CourseEntity, + ): CourseRevisionEntity['snapshot'] { + const snapshot: CourseRevisionEntity['snapshot'] = { + title: course.title, + description: course.description, + level: course.level, + order: course.order, + learningPathId: course.learningPathId, + duration: course.duration, + category: course.category, + categories: [...(course.categories ?? [])], + tags: [...(course.tags ?? [])], + prerequisites: [...(course.prerequisites ?? [])], + skills: [...(course.skills ?? [])], + xpReward: course.xpReward, + isActive: course.isActive, + }; + + return this.deepFreeze(snapshot); + } + + private deepFreeze(value: T): T { + if (value === null || typeof value !== 'object') { + return value; + } + + Object.getOwnPropertyNames(value as object).forEach((key) => { + const nested = (value as Record)[key]; + if (nested && typeof nested === 'object' && !Object.isFrozen(nested)) { + this.deepFreeze(nested); + } + }); + + return Object.freeze(value); + } + private syncCourseTaxonomy( course: CourseEntity, dto: Pick,