From 0ef7ecb02191f9b9c43e244dfb62a3eca3d466c2 Mon Sep 17 00:00:00 2001 From: AbuJulaybeeb Date: Mon, 29 Jun 2026 23:59:26 +0100 Subject: [PATCH 1/2] feat: add content CDN cache invalidation on course update and deletion --- src/cdn/cdn-event.listener.ts | 26 +++++++++++ src/cdn/cdn.module.ts | 3 +- src/cdn/cdn.service.spec.ts | 84 +++++++++++++++++++++++++++++++++++ src/cdn/cdn.service.ts | 41 +++++++++++++---- 4 files changed, 144 insertions(+), 10 deletions(-) create mode 100644 src/cdn/cdn-event.listener.ts create mode 100644 src/cdn/cdn.service.spec.ts diff --git a/src/cdn/cdn-event.listener.ts b/src/cdn/cdn-event.listener.ts new file mode 100644 index 00000000..db8f60b9 --- /dev/null +++ b/src/cdn/cdn-event.listener.ts @@ -0,0 +1,26 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { CdnService } from './cdn.service'; + +@Injectable() +export class CdnEventListener { + private readonly logger = new Logger(CdnEventListener.name); + + constructor(private readonly cdnService: CdnService) {} + + @OnEvent('course.updated') + async handleCourseUpdatedEvent(payload: { courseId: string; paths: string[] }) { + this.logger.log(`Handling course.updated event for course ${payload.courseId}`); + if (payload.paths && payload.paths.length > 0) { + await this.cdnService.invalidate(payload.paths); + } + } + + @OnEvent('course.deleted') + async handleCourseDeletedEvent(payload: { courseId: string; paths: string[] }) { + this.logger.log(`Handling course.deleted event for course ${payload.courseId}`); + if (payload.paths && payload.paths.length > 0) { + await this.cdnService.invalidate(payload.paths); + } + } +} diff --git a/src/cdn/cdn.module.ts b/src/cdn/cdn.module.ts index da294e3f..0d9cd746 100644 --- a/src/cdn/cdn.module.ts +++ b/src/cdn/cdn.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { CdnService } from './cdn.service'; +import { CdnEventListener } from './cdn-event.listener'; @Module({ - providers: [CdnService], + providers: [CdnService, CdnEventListener], exports: [CdnService], }) export class CdnModule {} \ No newline at end of file diff --git a/src/cdn/cdn.service.spec.ts b/src/cdn/cdn.service.spec.ts new file mode 100644 index 00000000..95529fd3 --- /dev/null +++ b/src/cdn/cdn.service.spec.ts @@ -0,0 +1,84 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CdnService } from './cdn.service'; +import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront'; + +jest.mock('@aws-sdk/client-cloudfront'); + +describe('CdnService', () => { + let service: CdnService; + let cfClientSendMock: jest.Mock; + + beforeEach(async () => { + // Reset all mocks + jest.clearAllMocks(); + + cfClientSendMock = jest.fn().mockResolvedValue({}); + (CloudFrontClient as jest.Mock).mockImplementation(() => ({ + send: cfClientSendMock, + })); + + // Mock resolveCdnConfig via process.env + process.env.CDN_ENABLED = 'true'; + process.env.CLOUDFRONT_DISTRIBUTION_ID = 'TEST_DIST_ID'; + + const module: TestingModule = await Test.createTestingModule({ + providers: [CdnService], + }).compile(); + + service = module.get(CdnService); + }); + + afterEach(() => { + delete process.env.CDN_ENABLED; + delete process.env.CLOUDFRONT_DISTRIBUTION_ID; + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('invalidate', () => { + it('should call CloudFront CreateInvalidationCommand with correct paths', async () => { + const paths = ['/course/123/video.mp4', '/course/123/notes.pdf']; + + const result = await service.invalidate(paths); + + expect(result.success).toBe(true); + expect(cfClientSendMock).toHaveBeenCalledTimes(1); + + // Verify the correct command was passed to the send method + const commandCalled = cfClientSendMock.mock.calls[0][0]; + expect(commandCalled).toBeInstanceOf(CreateInvalidationCommand); + expect(commandCalled.input.DistributionId).toBe('TEST_DIST_ID'); + expect(commandCalled.input.InvalidationBatch.Paths.Items).toEqual(paths); + expect(commandCalled.input.InvalidationBatch.Paths.Quantity).toBe(paths.length); + }); + + it('should handle CDN disabled gracefully', async () => { + process.env.CDN_ENABLED = 'false'; + + // Need to re-instantiate service to pick up new env var if cdn config is resolved on init + const module: TestingModule = await Test.createTestingModule({ + providers: [CdnService], + }).compile(); + const disabledService = module.get(CdnService); + + const paths = ['/test/path']; + const result = await disabledService.invalidate(paths); + + expect(result.success).toBe(false); + expect(result.message).toBe('CDN not configured'); + expect(cfClientSendMock).not.toHaveBeenCalled(); + }); + + it('should open circuit breaker or handle error when CloudFront fails', async () => { + cfClientSendMock.mockRejectedValue(new Error('CloudFront error')); + + const paths = ['/test/path']; + const result = await service.invalidate(paths); + + expect(result.success).toBe(false); + expect(result.message).toBe('CDN invalidation failed'); + }); + }); +}); diff --git a/src/cdn/cdn.service.ts b/src/cdn/cdn.service.ts index f68ff71f..89100012 100644 --- a/src/cdn/cdn.service.ts +++ b/src/cdn/cdn.service.ts @@ -1,5 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { resolveCdnConfig, resolveCacheHeaderConfig } from './cdn.config'; +import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront'; +import CircuitBreaker from 'opossum'; export interface CacheHeaders { 'Cache-Control': string; @@ -17,6 +19,28 @@ export class CdnService { private readonly logger = new Logger(CdnService.name); private readonly cdn = resolveCdnConfig(); private readonly cacheHeaders = resolveCacheHeaderConfig(); + private readonly cfClient = new CloudFrontClient({}); + private readonly invalidationBreaker: CircuitBreaker<[string[]], any>; + + constructor() { + this.invalidationBreaker = new CircuitBreaker( + async (paths: string[]) => { + const command = new CreateInvalidationCommand({ + DistributionId: this.cdn.distributionId, + InvalidationBatch: { + Paths: { Quantity: paths.length, Items: paths }, + CallerReference: Date.now().toString(), + }, + }); + return this.cfClient.send(command); + }, + { + timeout: 5000, + errorThresholdPercentage: 50, + resetTimeout: 30000, + } + ); + } /** * Returns optimised Cache-Control headers for a given asset path. @@ -52,15 +76,14 @@ export class CdnService { this.logger.log(`Invalidating ${paths.length} path(s) on distribution ${this.cdn.distributionId}: ${paths.join(', ')}`); - // Placeholder: wire up AWS SDK CloudFront.createInvalidation here when credentials are available. - // Example: - // const cf = new CloudFrontClient({}); - // await cf.send(new CreateInvalidationCommand({ - // DistributionId: this.cdn.distributionId, - // InvalidationBatch: { Paths: { Quantity: paths.length, Items: paths }, CallerReference: Date.now().toString() }, - // })); - - return { success: true, paths, message: `Invalidation queued for distribution ${this.cdn.distributionId}` }; + try { + await this.invalidationBreaker.fire(paths); + return { success: true, paths, message: `Invalidation queued for distribution ${this.cdn.distributionId}` }; + } catch (error) { + const errMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error(`Failed to invalidate CDN cache: ${errMessage}`); + return { success: false, paths, message: 'CDN invalidation failed' }; + } } /** Returns the CDN URL for a given asset path. */ From 120265d9d43a82ef91b95dd5debfc820a65fa696 Mon Sep 17 00:00:00 2001 From: AbuJulaybeeb Date: Tue, 30 Jun 2026 00:48:30 +0100 Subject: [PATCH 2/2] fix: resolve Prettier and ESLint errors in cdn service files --- src/cdn/cdn.service.spec.ts | 9 ++------- src/cdn/cdn.service.ts | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/cdn/cdn.service.spec.ts b/src/cdn/cdn.service.spec.ts index 95529fd3..720663d1 100644 --- a/src/cdn/cdn.service.spec.ts +++ b/src/cdn/cdn.service.spec.ts @@ -40,12 +40,10 @@ describe('CdnService', () => { describe('invalidate', () => { it('should call CloudFront CreateInvalidationCommand with correct paths', async () => { const paths = ['/course/123/video.mp4', '/course/123/notes.pdf']; - const result = await service.invalidate(paths); - expect(result.success).toBe(true); expect(cfClientSendMock).toHaveBeenCalledTimes(1); - + // Verify the correct command was passed to the send method const commandCalled = cfClientSendMock.mock.calls[0][0]; expect(commandCalled).toBeInstanceOf(CreateInvalidationCommand); @@ -56,7 +54,7 @@ describe('CdnService', () => { it('should handle CDN disabled gracefully', async () => { process.env.CDN_ENABLED = 'false'; - + // Need to re-instantiate service to pick up new env var if cdn config is resolved on init const module: TestingModule = await Test.createTestingModule({ providers: [CdnService], @@ -65,7 +63,6 @@ describe('CdnService', () => { const paths = ['/test/path']; const result = await disabledService.invalidate(paths); - expect(result.success).toBe(false); expect(result.message).toBe('CDN not configured'); expect(cfClientSendMock).not.toHaveBeenCalled(); @@ -73,10 +70,8 @@ describe('CdnService', () => { it('should open circuit breaker or handle error when CloudFront fails', async () => { cfClientSendMock.mockRejectedValue(new Error('CloudFront error')); - const paths = ['/test/path']; const result = await service.invalidate(paths); - expect(result.success).toBe(false); expect(result.message).toBe('CDN invalidation failed'); }); diff --git a/src/cdn/cdn.service.ts b/src/cdn/cdn.service.ts index 89100012..ad2244fc 100644 --- a/src/cdn/cdn.service.ts +++ b/src/cdn/cdn.service.ts @@ -38,7 +38,7 @@ export class CdnService { timeout: 5000, errorThresholdPercentage: 50, resetTimeout: 30000, - } + }, ); }