From 2d410ad5442ad938fe8d3cafab20892cc6234675 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Wed, 25 Feb 2026 12:56:47 +0100 Subject: [PATCH 1/3] feat: send tracking service --- src/config/configuration.ts | 1 + src/externals/newsletter/index.ts | 10 +++++++--- src/modules/send/send.module.ts | 10 +++++++++- src/modules/send/send.usecase.spec.ts | 7 ++++++- src/modules/send/send.usecase.ts | 15 +++++++++++++++ 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 97d301414..fb8dd83d4 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -130,6 +130,7 @@ export default () => ({ klaviyo: { apiKey: process.env.KLAVIYO_MAILER_API_KEY, baseUrl: process.env.KLAVIYO_URL, + sendListId: process.env.KLAVIYO_SEND_LIST_ID, }, sentry: { dsn: process.env.SENTRY_DSN, diff --git a/src/externals/newsletter/index.ts b/src/externals/newsletter/index.ts index 92b6a0914..61ad228aa 100644 --- a/src/externals/newsletter/index.ts +++ b/src/externals/newsletter/index.ts @@ -11,8 +11,12 @@ export class NewsletterService { private readonly httpClient: HttpClient, ) {} - async subscribe(email: UserAttributes['email']): Promise { - const listId: string = this.configService.get('newsletter.listId'); + async subscribe( + email: UserAttributes['email'], + listId?: string, + ): Promise { + const resolvedListId = + listId ?? this.configService.get('newsletter.listId'); const apiKey: string = this.configService.get('newsletter.apiKey'); const baseUrl: string = this.configService.get('klaviyo.baseUrl'); @@ -37,7 +41,7 @@ export class NewsletterService { const profileId = profileResponse.data.data.id; await this.httpClient.post( - `${baseUrl}lists/${listId}/relationships/profiles/`, + `${baseUrl}lists/${resolvedListId}/relationships/profiles/`, { data: [{ type: 'profile', id: profileId }] }, { headers: { diff --git a/src/modules/send/send.module.ts b/src/modules/send/send.module.ts index d36248552..18355c8de 100644 --- a/src/modules/send/send.module.ts +++ b/src/modules/send/send.module.ts @@ -2,6 +2,8 @@ import { forwardRef, Module } from '@nestjs/common'; import { SequelizeModule } from '@nestjs/sequelize'; import { CryptoModule } from '../../externals/crypto/crypto.module'; import { NotificationModule } from '../../externals/notifications/notifications.module'; +import { HttpClientModule } from '../../externals/http/http.module'; +import { NewsletterService } from '../../externals/newsletter'; import { FileModule } from '../file/file.module'; import { FolderModule } from '../folder/folder.module'; import { FolderModel } from '../folder/folder.model'; @@ -31,8 +33,14 @@ import { CaptchaService } from '../../externals/captcha/captcha.service'; FolderModule, NotificationModule, CryptoModule, + HttpClientModule, ], controllers: [SendController], - providers: [SequelizeSendRepository, SendUseCases, CaptchaService], + providers: [ + SequelizeSendRepository, + SendUseCases, + CaptchaService, + NewsletterService, + ], }) export class SendModule {} diff --git a/src/modules/send/send.usecase.spec.ts b/src/modules/send/send.usecase.spec.ts index 48597d44b..39012aeb5 100644 --- a/src/modules/send/send.usecase.spec.ts +++ b/src/modules/send/send.usecase.spec.ts @@ -5,6 +5,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { Sequelize } from 'sequelize-typescript'; import { CryptoModule } from '../../externals/crypto/crypto.module'; import { NotificationService } from '../../externals/notifications/notification.service'; +import { NewsletterService } from '../../externals/newsletter'; import { FolderModel } from '../folder/folder.model'; import { User } from '../user/user.domain'; import { UserModel } from '../user/user.repository'; @@ -19,7 +20,10 @@ import { SendUseCases } from './send.usecase'; import { createMock } from '@golevelup/ts-jest'; describe('Send Use Cases', () => { - let service: SendUseCases, notificationService, sendRepository; + let service: SendUseCases, + notificationService, + sendRepository, + newsletterService; const userMock = User.build({ id: 2, userId: 'userId', @@ -88,6 +92,7 @@ describe('Send Use Cases', () => { service = module.get(SendUseCases); notificationService = module.get(NotificationService); sendRepository = module.get(SequelizeSendRepository); + newsletterService = module.get(NewsletterService); }); it('should be defined', () => { diff --git a/src/modules/send/send.usecase.ts b/src/modules/send/send.usecase.ts index bea81de6b..40de75b1f 100644 --- a/src/modules/send/send.usecase.ts +++ b/src/modules/send/send.usecase.ts @@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { type User } from '../user/user.domain'; @@ -13,13 +14,19 @@ import { NotificationService } from '../../externals/notifications/notification. import { SendLinkCreatedEvent } from '../../externals/notifications/events/send-link-created.event'; import { CryptoService } from '../../externals/crypto/crypto.service'; import { type SendLinkItemDto } from './dto/create-send-link.dto'; +import { NewsletterService } from '../../externals/newsletter'; +import { ConfigService } from '@nestjs/config'; @Injectable() export class SendUseCases { + private readonly logger = new Logger(SendUseCases.name); + constructor( private readonly sendRepository: SequelizeSendRepository, private readonly notificationService: NotificationService, private readonly cryptoService: CryptoService, + private readonly newsletterService: NewsletterService, + private readonly configService: ConfigService, ) {} async getById(id: SendLinkAttributes['id']) { @@ -119,6 +126,14 @@ export class SendUseCases { this.notificationService.add(sendLinkCreatedEvent); } + const sendListId = this.configService.get('klaviyo.sendListId'); + + this.newsletterService.subscribe(sender, sendListId).catch((err) => { + this.logger.error( + `Failed to subscribe ${sender} to Klaviyo list: ${err.message}`, + ); + }); + return sendLink; } From 1e2b1cbe5b8eec1ef74be8f67828f42f767dbfe4 Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Wed, 25 Feb 2026 13:12:53 +0100 Subject: [PATCH 2/3] feat: add coverage for newsletter --- src/externals/newsletter/index.spec.ts | 104 +++++++++++++++++++++++++ src/modules/send/send.usecase.spec.ts | 59 ++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/externals/newsletter/index.spec.ts diff --git a/src/externals/newsletter/index.spec.ts b/src/externals/newsletter/index.spec.ts new file mode 100644 index 000000000..9ed22cf80 --- /dev/null +++ b/src/externals/newsletter/index.spec.ts @@ -0,0 +1,104 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { NewsletterService } from './index'; +import { ConfigService } from '@nestjs/config'; +import { HttpClient } from '../http/http.service'; +import { createMock } from '@golevelup/ts-jest'; + +describe('NewsletterService', () => { + let service: NewsletterService; + let configService: ConfigService; + let httpClient: HttpClient; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [NewsletterService], + }) + .useMocker(createMock) + .compile(); + + service = module.get(NewsletterService); + configService = module.get(ConfigService); + httpClient = module.get(HttpClient); + + jest.spyOn(configService, 'get').mockImplementation((key: string) => { + if (key === 'newsletter.listId') return 'defaultListId'; + if (key === 'newsletter.apiKey') return 'testApiKey'; + if (key === 'klaviyo.baseUrl') return 'https://a.klaviyo.com/api/'; + return null; + }); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('subscribe', () => { + it('should subscribe user using default listId if not provided', async () => { + const email = 'test@example.com'; + jest.spyOn(httpClient, 'post').mockResolvedValueOnce({ + data: { data: { id: 'prof_123' } }, + } as any); + jest.spyOn(httpClient, 'post').mockResolvedValueOnce({} as any); + + await service.subscribe(email); + + expect(httpClient.post).toHaveBeenNthCalledWith( + 1, + 'https://a.klaviyo.com/api/profiles/', + { + data: { + type: 'profile', + attributes: { email }, + }, + }, + { + headers: { + Accept: 'application/json', + Authorization: 'Klaviyo-API-Key testApiKey', + 'Content-Type': 'application/json', + revision: '2024-10-15', + }, + }, + ); + + expect(httpClient.post).toHaveBeenNthCalledWith( + 2, + 'https://a.klaviyo.com/api/lists/defaultListId/relationships/profiles/', + { data: [{ type: 'profile', id: 'prof_123' }] }, + { + headers: { + Accept: 'application/json', + Authorization: 'Klaviyo-API-Key testApiKey', + 'Content-Type': 'application/json', + revision: '2024-10-15', + }, + }, + ); + }); + + it('should subscribe user using provided listId', async () => { + const email = 'test@example.com'; + const providedListId = 'providedListId'; + jest.spyOn(httpClient, 'post').mockResolvedValueOnce({ + data: { data: { id: 'prof_123' } }, + } as any); + jest.spyOn(httpClient, 'post').mockResolvedValueOnce({} as any); + + await service.subscribe(email, providedListId); + + expect(httpClient.post).toHaveBeenNthCalledWith( + 1, + 'https://a.klaviyo.com/api/profiles/', + expect.any(Object), + expect.any(Object), + ); + + expect(httpClient.post).toHaveBeenNthCalledWith( + 2, + 'https://a.klaviyo.com/api/lists/providedListId/relationships/profiles/', + { data: [{ type: 'profile', id: 'prof_123' }] }, + expect.any(Object), + ); + }); + }); +}); diff --git a/src/modules/send/send.usecase.spec.ts b/src/modules/send/send.usecase.spec.ts index 39012aeb5..d53f5f7b2 100644 --- a/src/modules/send/send.usecase.spec.ts +++ b/src/modules/send/send.usecase.spec.ts @@ -93,6 +93,7 @@ describe('Send Use Cases', () => { notificationService = module.get(NotificationService); sendRepository = module.get(SequelizeSendRepository); newsletterService = module.get(NewsletterService); + jest.spyOn(newsletterService, 'subscribe').mockResolvedValue(undefined); }); it('should be defined', () => { @@ -101,6 +102,12 @@ describe('Send Use Cases', () => { describe('get By Id use case', () => { const sendLinkMockId = '53cf59ce-599d-4bc3-8497-09b72301d2a4'; + it('throw bad request when id is not valid uuid format', async () => { + await expect(service.getById('invalid-uuid')).rejects.toThrow( + BadRequestException, + ); + }); + it('throw not found when id invalid', async () => { jest.spyOn(sendRepository, 'findById').mockResolvedValue(null); @@ -197,6 +204,10 @@ describe('Send Use Cases', () => { receivers: ['receiver@gmail.com'], items: [], }); + expect(newsletterService.subscribe).toHaveBeenCalledWith( + 'sender@gmail.com', + undefined, + ); }); it('create send links with user', async () => { @@ -227,6 +238,10 @@ describe('Send Use Cases', () => { receivers: ['receiver@gmail.com'], items: [], }); + expect(newsletterService.subscribe).toHaveBeenCalledWith( + 'sender@gmail.com', + undefined, + ); }); it('should create a sendLink protected by password', async () => { @@ -249,6 +264,50 @@ describe('Send Use Cases', () => { ); expect(sendLink.isProtected()).toBe(true); + expect(newsletterService.subscribe).toHaveBeenCalledWith( + 'sender@gmail.com', + undefined, + ); + }); + + it('create send links should handle newsletter subscription error', async () => { + jest.spyOn(notificationService, 'add').mockResolvedValue(true); + jest + .spyOn(sendRepository, 'createSendLinkWithItems') + .mockResolvedValue(undefined); + jest.spyOn(sendRepository, 'findById').mockResolvedValue(undefined); + jest.spyOn(sendRepository, 'countBySendersToday').mockResolvedValue(2); + + const loggerSpy = jest + .spyOn((service as any).logger, 'error') + .mockImplementation(() => {}); + jest + .spyOn(newsletterService, 'subscribe') + .mockRejectedValue(new Error('Klaviyo error')); + + const sendLink = await service.createSendLinks( + userMock, + [], + 'code', + ['receiver@gmail.com'], + 'sender@gmail.com', + 'title', + 'subject', + 'plainCode', + null, + ); + expect(sendRepository.createSendLinkWithItems).toHaveBeenCalledTimes(1); + expect(newsletterService.subscribe).toHaveBeenCalledWith( + 'sender@gmail.com', + undefined, + ); + + // Wait for the fire-and-forget promise to catch and log + await new Promise(setImmediate); + + expect(loggerSpy).toHaveBeenCalledWith( + 'Failed to subscribe sender@gmail.com to Klaviyo list: Klaviyo error', + ); }); describe('Unlock Link', () => { From 57ddfbe8009af3648c227568b1afa7b639803ede Mon Sep 17 00:00:00 2001 From: jaaaaavier Date: Wed, 25 Feb 2026 13:15:51 +0100 Subject: [PATCH 3/3] Update send.usecase.spec.ts --- src/modules/send/send.usecase.spec.ts | 73 ++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/modules/send/send.usecase.spec.ts b/src/modules/send/send.usecase.spec.ts index d53f5f7b2..744bd4852 100644 --- a/src/modules/send/send.usecase.spec.ts +++ b/src/modules/send/send.usecase.spec.ts @@ -1,4 +1,4 @@ -import { ForbiddenException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { getModelToken } from '@nestjs/sequelize'; import { Test, type TestingModule } from '@nestjs/testing'; @@ -244,6 +244,50 @@ describe('Send Use Cases', () => { ); }); + it('create send links with items', async () => { + jest.spyOn(notificationService, 'add').mockResolvedValue(true); + jest + .spyOn(sendRepository, 'createSendLinkWithItems') + .mockResolvedValue(undefined); + jest.spyOn(sendRepository, 'findById').mockResolvedValue(undefined); + jest.spyOn(sendRepository, 'countBySendersToday').mockResolvedValue(2); + + const items = [ + { + id: '965306cd-0a88-4447-aa7c-d6b354381ae2', + name: 'test.txt', + type: 'file', + networkId: 'network123', + encryptionKey: 'key', + size: 1024, + }, + { + id: 'a0ece540-3945-42cf-96d5-a7751f98d5c4', + name: 'folder', + type: 'folder', + networkId: 'network124', + encryptionKey: 'key', + size: 0, + parent_folder: 'parent_id', + }, + ] as any; + + const sendLink = await service.createSendLinks( + userMock, + items, + 'code', + ['receiver@gmail.com'], + 'sender@gmail.com', + 'title', + 'subject', + 'plainCode', + null, + ); + expect(sendRepository.createSendLinkWithItems).toHaveBeenCalledTimes(1); + expect(notificationService.add).toHaveBeenCalledTimes(1); + expect(sendLink.items).toHaveLength(2); + }); + it('should create a sendLink protected by password', async () => { jest.spyOn(notificationService, 'add').mockResolvedValue(true); jest @@ -378,5 +422,32 @@ describe('Send Use Cases', () => { expect(err).toBeInstanceOf(ForbiddenException); } }); + + it('unlock protected send link with valid password succeeds', () => { + const cryptoService = (service as any).cryptoService; + jest + .spyOn(cryptoService, 'deterministicEncryption') + .mockReturnValue('hashed'); + + const protectedSendLink = SendLink.build({ + id: '46716608-c5e4-5404-a2b9-2a38d737d87d', + views: 0, + user: userMock, + items: [], + createdAt: new Date(), + updatedAt: new Date(), + sender: 'sender@gmail.com', + receivers: ['receiver@gmail.com'], + code: 'code', + title: 'title', + subject: 'subject', + expirationAt: new Date(), + hashedPassword: 'hashed', + }); + + expect(() => + service.unlockLink(protectedSendLink, 'valid-password'), + ).not.toThrow(); + }); }); });