Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
104 changes: 104 additions & 0 deletions src/externals/newsletter/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(NewsletterService);
configService = module.get<ConfigService>(ConfigService);
httpClient = module.get<HttpClient>(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('When instantiated, then it should be defined', () => {
expect(service).toBeDefined();
});

describe('subscribe', () => {
it('When a listId is not provided, then it should subscribe the user using the default listId', 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('When a listId is provided, then it should subscribe the user using that 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),
);
});
});
});
10 changes: 7 additions & 3 deletions src/externals/newsletter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ export class NewsletterService {
private readonly httpClient: HttpClient,
) {}

async subscribe(email: UserAttributes['email']): Promise<void> {
const listId: string = this.configService.get('newsletter.listId');
async subscribe(
email: UserAttributes['email'],
listId?: string,
): Promise<void> {
const resolvedListId =
listId ?? this.configService.get('newsletter.listId');
const apiKey: string = this.configService.get('newsletter.apiKey');
const baseUrl: string = this.configService.get('klaviyo.baseUrl');

Expand All @@ -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: {
Expand Down
10 changes: 9 additions & 1 deletion src/modules/send/send.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {}
139 changes: 137 additions & 2 deletions src/modules/send/send.usecase.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
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';
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';
Expand All @@ -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',
Expand Down Expand Up @@ -88,6 +92,8 @@ describe('Send Use Cases', () => {
service = module.get<SendUseCases>(SendUseCases);
notificationService = module.get<NotificationService>(NotificationService);
sendRepository = module.get<SendRepository>(SequelizeSendRepository);
newsletterService = module.get<NewsletterService>(NewsletterService);
jest.spyOn(newsletterService, 'subscribe').mockResolvedValue(undefined);
});

it('should be defined', () => {
Expand All @@ -96,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);

Expand Down Expand Up @@ -192,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 () => {
Expand Down Expand Up @@ -222,6 +238,54 @@ describe('Send Use Cases', () => {
receivers: ['receiver@gmail.com'],
items: [],
});
expect(newsletterService.subscribe).toHaveBeenCalledWith(
'sender@gmail.com',
undefined,
);
});

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 () => {
Expand All @@ -244,6 +308,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'));

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', () => {
Expand Down Expand Up @@ -314,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',
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
});

expect(() =>
service.unlockLink(protectedSendLink, 'valid-password'),
).not.toThrow();
});
});
});
Loading
Loading