Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/cdn/cdn-event.listener.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
3 changes: 2 additions & 1 deletion src/cdn/cdn.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
79 changes: 79 additions & 0 deletions src/cdn/cdn.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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>(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');
});
});
});
24 changes: 24 additions & 0 deletions src/cdn/cdn.service.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
Expand Down
Loading