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 29b636bd..a8749f1c 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 {} diff --git a/src/cdn/cdn.service.spec.ts b/src/cdn/cdn.service.spec.ts new file mode 100644 index 00000000..720663d1 --- /dev/null +++ b/src/cdn/cdn.service.spec.ts @@ -0,0 +1,79 @@ +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 2cd5d400..eac786e4 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.