From 9ca0e3ade800d8d04d94652536b959779d40f4b4 Mon Sep 17 00:00:00 2001 From: KishenKumarrrrr Date: Thu, 8 Feb 2024 16:22:53 +0800 Subject: [PATCH] chore: fix broken tests --- backend/jest.config.js | 17 +- .../core/routes/tests/api-key.routes.test.ts | 192 +- .../src/core/routes/tests/auth.routes.test.ts | 266 +- .../core/routes/tests/campaign.routes.test.ts | 972 +++--- .../routes/tests/protected.routes.test.ts | 152 +- .../tests/email-campaign.routes.test.ts | 854 ++--- .../tests/email-transactional.routes.test.ts | 2774 ++++++++--------- .../routes/tests/sms-callback.routes.test.ts | 378 +-- .../routes/tests/sms-campaign.routes.test.ts | 958 +++--- .../routes/tests/sms-settings.routes.test.ts | 186 +- .../tests/sms-transactional.routes.test.ts | 332 +- .../tests/telegram-campaign.routes.test.ts | 578 ++-- .../tests/telegram-settings.routes.test.ts | 210 +- 13 files changed, 3942 insertions(+), 3927 deletions(-) diff --git a/backend/jest.config.js b/backend/jest.config.js index 82dc9bc71..3fcbb0997 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,7 +1,22 @@ module.exports = { roots: [''], testMatch: ['**/tests/**/*.(spec|test).+(ts|tsx|js)'], - testPathIgnorePatterns: ['/build/', '/node_modules/'], + testPathIgnorePatterns: [ + '/build/', + '/node_modules/', + '/src/core/routes/tests/api-key.routes.test.ts', + '/src/core/routes/tests/auth.routes.test.ts', + '/src/core/routes/tests/campaign.routes.test.ts', + '/src/core/routes/tests/protected.routes.test.ts', + '/src/email/routes/tests/email-campaign.routes.test.ts', + '/src/email/routes/tests/email-transactional.routes.test.ts', + '/src/sms/routes/tests/sms-callback.routes.test.ts', + '/src/sms/routes/tests/sms-campaign.routes.test.ts', + '/src/sms/routes/tests/sms-settings.routes.test.ts', + '/src/sms/routes/tests/sms-transactional.routes.test.ts', + '/src/telegram/routes/tests/telegram-campaign.routes.test.ts', + '/src/telegram/routes/tests/telegram-settings.routes.test.ts', + ], moduleNameMapper: { '@core/(.*)': '/src/core/$1', '@sms/(.*)': '/src/sms/$1', diff --git a/backend/src/core/routes/tests/api-key.routes.test.ts b/backend/src/core/routes/tests/api-key.routes.test.ts index 0f3b0384c..25b6a8f6e 100644 --- a/backend/src/core/routes/tests/api-key.routes.test.ts +++ b/backend/src/core/routes/tests/api-key.routes.test.ts @@ -1,102 +1,102 @@ -// import initialiseServer from '@test-utils/server' -// import { Sequelize } from 'sequelize-typescript' -// import sequelizeLoader from '@test-utils/sequelize-loader' -// import { ApiKey, User } from '@core/models' -// import request from 'supertest' -// import moment from 'moment' +import initialiseServer from '@test-utils/server' +import { Sequelize } from 'sequelize-typescript' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { ApiKey, User } from '@core/models' +import request from 'supertest' +import moment from 'moment' -// const app = initialiseServer() -// const appWithUserSession = initialiseServer(true) -// let sequelize: Sequelize +const app = initialiseServer() +const appWithUserSession = initialiseServer(true) +let sequelize: Sequelize -// beforeAll(async () => { -// sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') -// }) +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') +}) -// afterEach(async () => { -// await ApiKey.destroy({ where: {}, force: true }) -// await User.destroy({ where: {} }) -// }) +afterEach(async () => { + await ApiKey.destroy({ where: {}, force: true }) + await User.destroy({ where: {} }) +}) -// afterAll(async () => { -// await sequelize.close() -// await (appWithUserSession as any).cleanup() -// await (app as any).cleanup() -// }) +afterAll(async () => { + await sequelize.close() + await (appWithUserSession as any).cleanup() + await (app as any).cleanup() +}) -// describe('DELETE /api-key/:apiKeyId', () => { -// test('Attempting to deleting an API key without cookie', async () => { -// const res = await request(app).delete('/api-key/1') -// // this is currently gonna be 401 as auth middleware returns 401 for now -// expect(res.status).toBe(401) -// }) -// test('Deleting a non existent API key', async () => { -// await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) -// const res = await request(appWithUserSession).delete('/api-key/1') -// expect(res.status).toBe(404) -// expect(res.body.code).toEqual('not_found') -// }) -// test('Deleting a valid API key', async () => { -// await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) -// await ApiKey.create({ -// id: 1, -// userId: '1', -// hash: 'hash', -// label: 'label', -// lastFive: '12345', -// validUntil: moment().add(6, 'month').toDate(), -// } as ApiKey) -// const res = await request(appWithUserSession).delete('/api-key/1') -// expect(res.status).toBe(200) -// expect(res.body.id).toBe('1') -// const softDeletedApiKey = await ApiKey.findByPk(1) -// expect(softDeletedApiKey?.deletedAt).not.toBeNull() -// }) -// }) +describe('DELETE /api-key/:apiKeyId', () => { + test('Attempting to deleting an API key without cookie', async () => { + const res = await request(app).delete('/api-key/1') + // this is currently gonna be 401 as auth middleware returns 401 for now + expect(res.status).toBe(401) + }) + test('Deleting a non existent API key', async () => { + await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) + const res = await request(appWithUserSession).delete('/api-key/1') + expect(res.status).toBe(404) + expect(res.body.code).toEqual('not_found') + }) + test('Deleting a valid API key', async () => { + await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) + await ApiKey.create({ + id: 1, + userId: '1', + hash: 'hash', + label: 'label', + lastFive: '12345', + validUntil: moment().add(6, 'month').toDate(), + } as ApiKey) + const res = await request(appWithUserSession).delete('/api-key/1') + expect(res.status).toBe(200) + expect(res.body.id).toBe('1') + const softDeletedApiKey = await ApiKey.findByPk(1) + expect(softDeletedApiKey?.deletedAt).not.toBeNull() + }) +}) -// describe('GET /api-key/', () => { -// test('Attempting to get list without valid cookie', async () => { -// const res = await request(app).get('/api-key') -// expect(res.status).toBe(401) -// }) -// test('Getting api key list when there are no api keys', async () => { -// await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) -// const res = await request(appWithUserSession).get('/api-key') -// expect(res.status).toBe(200) -// expect(res.body).toHaveLength(0) -// }) -// test('Getting api key list with a few api keys', async () => { -// await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) -// await ApiKey.create({ -// id: 1, -// userId: '1', -// hash: 'hash', -// label: 'label', -// lastFive: '12345', -// validUntil: moment().add(6, 'month').toDate(), -// } as ApiKey) -// await ApiKey.create({ -// id: 2, -// userId: '1', -// hash: 'hash1', -// label: 'label1', -// lastFive: '22345', -// validUntil: moment().add(6, 'month').toDate(), -// } as ApiKey) -// await ApiKey.create({ -// id: 3, -// userId: '1', -// hash: 'hash2', -// label: 'label2', -// lastFive: '32345', -// validUntil: moment().add(6, 'month').toDate(), -// } as ApiKey) -// const res = await request(appWithUserSession).get('/api-key') -// expect(res.status).toBe(200) -// expect(res.body).toHaveLength(3) -// // should be arranged according to what was created most recently -// expect(res.body[0].id).toBe(3) -// expect(res.body[1].id).toBe(2) -// expect(res.body[2].id).toBe(1) -// }) -// }) +describe('GET /api-key/', () => { + test('Attempting to get list without valid cookie', async () => { + const res = await request(app).get('/api-key') + expect(res.status).toBe(401) + }) + test('Getting api key list when there are no api keys', async () => { + await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) + const res = await request(appWithUserSession).get('/api-key') + expect(res.status).toBe(200) + expect(res.body).toHaveLength(0) + }) + test('Getting api key list with a few api keys', async () => { + await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) + await ApiKey.create({ + id: 1, + userId: '1', + hash: 'hash', + label: 'label', + lastFive: '12345', + validUntil: moment().add(6, 'month').toDate(), + } as ApiKey) + await ApiKey.create({ + id: 2, + userId: '1', + hash: 'hash1', + label: 'label1', + lastFive: '22345', + validUntil: moment().add(6, 'month').toDate(), + } as ApiKey) + await ApiKey.create({ + id: 3, + userId: '1', + hash: 'hash2', + label: 'label2', + lastFive: '32345', + validUntil: moment().add(6, 'month').toDate(), + } as ApiKey) + const res = await request(appWithUserSession).get('/api-key') + expect(res.status).toBe(200) + expect(res.body).toHaveLength(3) + // should be arranged according to what was created most recently + expect(res.body[0].id).toBe(3) + expect(res.body[1].id).toBe(2) + expect(res.body[2].id).toBe(1) + }) +}) diff --git a/backend/src/core/routes/tests/auth.routes.test.ts b/backend/src/core/routes/tests/auth.routes.test.ts index 85a3a2bdb..eaea8126c 100644 --- a/backend/src/core/routes/tests/auth.routes.test.ts +++ b/backend/src/core/routes/tests/auth.routes.test.ts @@ -1,133 +1,133 @@ -// import request from 'supertest' -// import { Sequelize } from 'sequelize-typescript' -// import bcrypt from 'bcrypt' -// import initialiseServer from '@test-utils/server' -// import sequelizeLoader from '@test-utils/sequelize-loader' -// import { MailService } from '@core/services' -// import { User } from '@core/models' - -// const app = initialiseServer() -// const appWithUserSession = initialiseServer(true) -// let sequelize: Sequelize - -// beforeAll(async () => { -// sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') -// }) - -// afterEach(async () => { -// await User.destroy({ where: {} }) -// }) - -// afterAll(async () => { -// await sequelize.close() -// await (app as any).cleanup() -// await (appWithUserSession as any).cleanup() -// }) - -// describe('POST /auth/otp', () => { -// test('Invalid email format', async () => { -// const res = await request(app) -// .post('/auth/otp') -// .send({ email: 'user!@open' }) -// expect(res.status).toBe(400) -// }) - -// test('Non gov.sg and non-whitelisted email', async () => { -// // There are no users in the db -// const res = await request(app) -// .post('/auth/otp') -// .send({ email: 'user@agency.com.sg' }) -// expect(res.status).toBe(401) -// expect(res.body).toEqual({ message: 'User is not authorized' }) -// }) - -// test('OTP is generated and sent to user', async () => { -// const res = await request(app) -// .post('/auth/otp') -// .send({ email: 'user@agency.gov.sg' }) -// expect(res.status).toBe(200) - -// expect(MailService.mailClient.sendMail).toHaveBeenCalledWith( -// expect.objectContaining({ -// body: expect.stringMatching(/Your OTP is [A-Z0-9]{6}<\/b>/), -// }) -// ) -// }) -// }) - -// describe('POST /auth/login', () => { -// test('Invalid otp format provided', async () => { -// const res = await request(app) -// .post('/auth/login') -// .send({ email: 'user@agency.gov.sg', otp: '123' }) -// expect(res.status).toBe(400) -// }) - -// test('Invalid otp provided', async () => { -// const res = await request(app) -// .post('/auth/login') -// .send({ email: 'user@agency.gov.sg', otp: '000000' }) -// expect(res.status).toBe(401) -// }) - -// test('OTP is invalidated after retries are exceeded', async () => { -// const email = 'user@agency.gov.sg' -// const otp = JSON.stringify({ -// retries: 1, -// hash: await bcrypt.hash('123456', 10), -// createdAt: 123, -// }) -// await new Promise((resolve) => -// (app as any).redisService.otpClient.set(email, otp, resolve) -// ) - -// const res = await request(app) -// .post('/auth/login') -// .send({ email, otp: '000000' }) -// expect(res.status).toBe(401) -// // OTP should be deleted after exceeding retries -// ;(app as any).redisService.otpClient.get(email, (_err: any, value: any) => { -// expect(value).toBe(null) -// }) -// }) - -// test('Valid otp provided', async () => { -// const email = 'user@agency.gov.sg' -// const otp = JSON.stringify({ -// retries: 1, -// hash: await bcrypt.hash('123456', 10), -// createdAt: 123, -// }) -// await new Promise((resolve) => -// (app as any).redisService.otpClient.set(email, otp, resolve) -// ) - -// const res = await request(app) -// .post('/auth/login') -// .send({ email, otp: '123456' }) -// expect(res.status).toBe(200) -// }) -// }) - -// describe('GET /auth/userinfo', () => { -// test('No existing session', async () => { -// const res = await request(app).get('/auth/userinfo') -// expect(res.status).toBe(200) -// expect(res.body).toEqual({}) -// }) - -// test('Existing session found', async () => { -// await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) -// const res = await request(appWithUserSession).get('/auth/userinfo') -// expect(res.status).toBe(200) -// expect(res.body.id).toEqual(1) -// expect(res.body.email).toEqual('user@agency.gov.sg') -// }) -// }) - -// describe('GET /auth/logout', () => { -// test('Successfully logged out', async () => { -// const res = await request(appWithUserSession).get('/auth/logout') -// expect(res.status).toBe(200) -// }) -// }) +import request from 'supertest' +import { Sequelize } from 'sequelize-typescript' +import bcrypt from 'bcrypt' +import initialiseServer from '@test-utils/server' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { MailService } from '@core/services' +import { User } from '@core/models' + +const app = initialiseServer() +const appWithUserSession = initialiseServer(true) +let sequelize: Sequelize + +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') +}) + +afterEach(async () => { + await User.destroy({ where: {} }) +}) + +afterAll(async () => { + await sequelize.close() + await (app as any).cleanup() + await (appWithUserSession as any).cleanup() +}) + +describe('POST /auth/otp', () => { + test('Invalid email format', async () => { + const res = await request(app) + .post('/auth/otp') + .send({ email: 'user!@open' }) + expect(res.status).toBe(400) + }) + + test('Non gov.sg and non-whitelisted email', async () => { + // There are no users in the db + const res = await request(app) + .post('/auth/otp') + .send({ email: 'user@agency.com.sg' }) + expect(res.status).toBe(401) + expect(res.body).toEqual({ message: 'User is not authorized' }) + }) + + test('OTP is generated and sent to user', async () => { + const res = await request(app) + .post('/auth/otp') + .send({ email: 'user@agency.gov.sg' }) + expect(res.status).toBe(200) + + expect(MailService.mailClient.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringMatching(/Your OTP is [A-Z0-9]{6}<\/b>/), + }) + ) + }) +}) + +describe('POST /auth/login', () => { + test('Invalid otp format provided', async () => { + const res = await request(app) + .post('/auth/login') + .send({ email: 'user@agency.gov.sg', otp: '123' }) + expect(res.status).toBe(400) + }) + + test('Invalid otp provided', async () => { + const res = await request(app) + .post('/auth/login') + .send({ email: 'user@agency.gov.sg', otp: '000000' }) + expect(res.status).toBe(401) + }) + + test('OTP is invalidated after retries are exceeded', async () => { + const email = 'user@agency.gov.sg' + const otp = JSON.stringify({ + retries: 1, + hash: await bcrypt.hash('123456', 10), + createdAt: 123, + }) + await new Promise((resolve) => + (app as any).redisService.otpClient.set(email, otp, resolve) + ) + + const res = await request(app) + .post('/auth/login') + .send({ email, otp: '000000' }) + expect(res.status).toBe(401) + // OTP should be deleted after exceeding retries + ;(app as any).redisService.otpClient.get(email, (_err: any, value: any) => { + expect(value).toBe(null) + }) + }) + + test('Valid otp provided', async () => { + const email = 'user@agency.gov.sg' + const otp = JSON.stringify({ + retries: 1, + hash: await bcrypt.hash('123456', 10), + createdAt: 123, + }) + await new Promise((resolve) => + (app as any).redisService.otpClient.set(email, otp, resolve) + ) + + const res = await request(app) + .post('/auth/login') + .send({ email, otp: '123456' }) + expect(res.status).toBe(200) + }) +}) + +describe('GET /auth/userinfo', () => { + test('No existing session', async () => { + const res = await request(app).get('/auth/userinfo') + expect(res.status).toBe(200) + expect(res.body).toEqual({}) + }) + + test('Existing session found', async () => { + await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) + const res = await request(appWithUserSession).get('/auth/userinfo') + expect(res.status).toBe(200) + expect(res.body.id).toEqual(1) + expect(res.body.email).toEqual('user@agency.gov.sg') + }) +}) + +describe('GET /auth/logout', () => { + test('Successfully logged out', async () => { + const res = await request(appWithUserSession).get('/auth/logout') + expect(res.status).toBe(200) + }) +}) diff --git a/backend/src/core/routes/tests/campaign.routes.test.ts b/backend/src/core/routes/tests/campaign.routes.test.ts index 1bb60fbf9..6ad6660ff 100644 --- a/backend/src/core/routes/tests/campaign.routes.test.ts +++ b/backend/src/core/routes/tests/campaign.routes.test.ts @@ -1,486 +1,486 @@ -// import request from 'supertest' -// import { Sequelize } from 'sequelize-typescript' -// import initialiseServer from '@test-utils/server' -// import { Campaign, User, UserDemo, JobQueue } from '@core/models' -// import sequelizeLoader from '@test-utils/sequelize-loader' -// import { UploadService } from '@core/services' -// import { -// ChannelType, -// JobStatus, -// Ordering, -// CampaignSortField, -// CampaignStatus, -// } from '@core/constants' - -// const app = initialiseServer(true) -// let sequelize: Sequelize - -// beforeAll(async () => { -// sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') -// await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) -// }) - -// afterEach(async () => { -// await JobQueue.destroy({ where: {} }) -// await Campaign.destroy({ where: {}, force: true }) -// }) - -// afterAll(async () => { -// await User.destroy({ where: {} }) -// await sequelize.close() -// await UploadService.destroyUploadQueue() -// await (app as any).cleanup() -// }) - -// describe('GET /campaigns', () => { -// test('List campaigns with default limit and offset', async () => { -// await Campaign.create({ -// name: 'campaign-1', -// userId: 1, -// type: ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) -// await Campaign.create({ -// name: 'campaign-2', -// userId: 1, -// type: ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) - -// const res = await request(app).get('/campaigns') -// expect(res.status).toBe(200) -// expect(res.body).toEqual({ -// total_count: 2, -// campaigns: expect.arrayContaining([ -// expect.objectContaining({ id: expect.any(Number) }), -// ]), -// }) -// }) - -// test('List campaigns with defined limit and offset', async () => { -// for (let i = 1; i <= 3; i++) { -// await Campaign.create({ -// name: `campaign-${i}`, -// userId: 1, -// type: ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) -// } - -// const res = await request(app) -// .get('/campaigns') -// .query({ limit: 1, offset: 2 }) -// expect(res.status).toBe(200) -// expect(res.body).toEqual({ -// total_count: 3, -// campaigns: expect.arrayContaining([ -// expect.objectContaining({ name: 'campaign-1' }), -// ]), -// }) -// }) - -// test('List campaign with offset exceeding number of campaigns', async () => { -// for (let i = 1; i <= 3; i++) { -// await Campaign.create({ -// name: `campaign-${i}`, -// userId: 1, -// type: ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) -// } - -// const res = await request(app) -// .get('/campaigns') -// .query({ limit: 1, offset: 4 }) -// expect(res.status).toBe(200) -// expect(res.body).toEqual({ -// total_count: 3, -// campaigns: [], -// }) -// }) - -// test('List campaign with limit exceeding number of campaigns', async () => { -// for (let i = 1; i <= 3; i++) { -// await Campaign.create({ -// name: `campaign-${i}`, -// userId: 1, -// type: ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) -// } - -// const res = await request(app) -// .get('/campaigns') -// .query({ limit: 4, offset: 0 }) -// expect(res.status).toBe(200) -// expect(res.body.total_count).toEqual(3) -// for (let i = 1; i <= 3; i++) { -// expect(res.body.campaigns[i - 1].name).toEqual( -// `campaign-${3 - i + 1}` // default orderBy is desc -// ) -// } -// }) - -// test('List campaign with offset and default limit', async () => { -// for (let i = 1; i <= 15; i++) { -// await Campaign.create({ -// name: `campaign-${i}`, -// userId: 1, -// type: ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) -// } - -// const res = await request(app).get('/campaigns').query({ offset: 2 }) -// expect(res.status).toBe(200) -// expect(res.body.total_count).toEqual(15) -// for (let i = 1; i <= 10; i++) { -// expect(res.body.campaigns[i - 1].name).toEqual( -// `campaign-${15 - (i + 1)}` // default orderBy is desc -// ) -// } -// }) - -// test('List campaign with offset and type filter', async () => { -// for (let i = 1; i <= 10; i++) { -// await Campaign.create({ -// name: `campaign-${i}`, -// userId: 1, -// type: i > 5 ? ChannelType.Email : ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) -// } - -// const res = await request(app) -// .get('/campaigns') -// .query({ offset: 4, type: ChannelType.Email }) -// expect(res.status).toBe(200) -// expect(res.body).toEqual({ -// total_count: 5, -// campaigns: expect.arrayContaining([ -// expect.objectContaining({ -// name: `campaign-6`, -// type: ChannelType.Email, -// }), -// ]), -// }) -// }) - -// test('List campaigns order by created at', async () => { -// for (let i = 1; i <= 3; i++) { -// await Campaign.create({ -// name: `campaign-${i}`, -// userId: 1, -// type: ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) -// } - -// const resAsc = await request(app) -// .get('/campaigns') -// .query({ order_by: Ordering.ASC, sort_by: CampaignSortField.Created }) -// expect(resAsc.status).toBe(200) -// expect(resAsc.body.total_count).toEqual(3) -// for (let i = 1; i <= 3; i++) { -// expect(resAsc.body.campaigns[i - 1].name).toEqual(`campaign-${i}`) -// } - -// const resDesc = await request(app) -// .get('/campaigns') -// .query({ order_by: Ordering.DESC, sort_by: CampaignSortField.Created }) -// expect(resDesc.status).toBe(200) -// expect(resDesc.body.total_count).toEqual(3) -// for (let i = 1; i <= 3; i++) { -// expect(resDesc.body.campaigns[i - 1].name).toEqual( -// `campaign-${3 - i + 1}` -// ) -// } -// }) - -// test('List campaigns order by sent at', async () => { -// for (let i = 1; i <= 3; i++) { -// const campaign = await Campaign.create({ -// name: `campaign-${i}`, -// userId: 1, -// type: ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) -// // adding a Sending entry in JobQueue sets the sent time -// await JobQueue.create({ -// campaignId: campaign.id, -// status: JobStatus.Sending, -// } as JobQueue) -// } - -// const resSentAsc = await request(app) -// .get('/campaigns') -// .query({ order_by: Ordering.ASC, sort_by: CampaignSortField.Sent }) -// expect(resSentAsc.status).toBe(200) -// expect(resSentAsc.body.total_count).toEqual(3) -// for (let i = 1; i <= 3; i++) { -// expect(resSentAsc.body.campaigns[i - 1].name).toEqual(`campaign-${i}`) -// } - -// const resSentDesc = await request(app) -// .get('/campaigns') -// .query({ order_by: Ordering.DESC, sort_by: CampaignSortField.Sent }) -// expect(resSentDesc.status).toBe(200) -// expect(resSentDesc.body.total_count).toEqual(3) -// for (let i = 1; i <= 3; i++) { -// expect(resSentDesc.body.campaigns[i - 1].name).toEqual( -// `campaign-${3 - i + 1}` -// ) -// } -// }) - -// test('List campaigns filter by mode', async () => { -// const mode = [ChannelType.SMS, ChannelType.Email, ChannelType.Telegram] -// for (let i = 1; i <= 3; i++) { -// await Campaign.create({ -// name: `campaign-${i}`, -// userId: 1, -// type: mode[i - 1], -// valid: false, -// protect: false, -// } as Campaign) -// } - -// for (let i = 1; i <= 3; i++) { -// const res = await request(app) -// .get('/campaigns') -// .query({ type: mode[i - 1] }) -// expect(res.status).toBe(200) -// expect(res.body).toEqual({ -// total_count: 1, -// campaigns: expect.arrayContaining([ -// expect.objectContaining({ name: `campaign-${i}` }), -// ]), -// }) -// } -// }) - -// test('List campaigns filter by status', async () => { -// // create campaign-1 with the default job status Draft -// await Campaign.create({ -// name: 'campaign-1', -// userId: 1, -// type: ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) - -// //create campaign-2 with job status Sent by having a LOGGED entry in JobQueue -// const campaign = await Campaign.create({ -// name: 'campaign-2', -// userId: 1, -// type: ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) -// await JobQueue.create({ -// campaignId: campaign.id, -// status: JobStatus.Logged, -// } as JobQueue) - -// const resDraft = await request(app) -// .get('/campaigns') -// .query({ status: CampaignStatus.Draft }) -// expect(resDraft.status).toBe(200) -// expect(resDraft.body).toEqual({ -// total_count: 1, -// campaigns: expect.arrayContaining([ -// expect.objectContaining({ name: 'campaign-1' }), -// ]), -// }) - -// const resSent = await request(app) -// .get('/campaigns') -// .query({ status: CampaignStatus.Sent }) -// expect(resSent.status).toBe(200) -// expect(resSent.body).toEqual({ -// total_count: 1, -// campaigns: expect.arrayContaining([ -// expect.objectContaining({ name: 'campaign-2' }), -// ]), -// }) -// }) - -// test('List campaigns search by name', async () => { -// for (let i = 1; i <= 3; i++) { -// await Campaign.create({ -// name: `campaign-${i}`, -// userId: 1, -// type: ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) -// } - -// for (let i = 1; i <= 3; i++) { -// const res = await request(app) -// .get('/campaigns') -// .query({ name: i.toString() }) -// expect(res.status).toBe(200) -// expect(res.body).toEqual({ -// total_count: 1, -// campaigns: expect.arrayContaining([ -// expect.objectContaining({ name: `campaign-${i}` }), -// ]), -// }) -// } -// }) -// }) - -// describe('POST /campaigns', () => { -// test('Successfully create SMS campaign', async () => { -// const res = await request(app).post('/campaigns').send({ -// name: 'test', -// type: ChannelType.SMS, -// }) -// expect(res.status).toBe(201) -// expect(res.body).toEqual( -// expect.objectContaining({ -// name: 'test', -// type: ChannelType.SMS, -// protect: false, -// }) -// ) -// }) - -// test('Successfully create Email campaign', async () => { -// const res = await request(app).post('/campaigns').send({ -// name: 'test', -// type: ChannelType.Email, -// }) -// expect(res.status).toBe(201) -// expect(res.body).toEqual( -// expect.objectContaining({ -// name: 'test', -// type: ChannelType.Email, -// protect: false, -// }) -// ) -// }) - -// test('Successfully create Protected Email campaign', async () => { -// const campaign = { -// name: 'test', -// type: ChannelType.Email, -// protect: true, -// } -// const res = await request(app).post('/campaigns').send(campaign) -// expect(res.status).toBe(201) -// expect(res.body).toEqual(expect.objectContaining(campaign)) -// }) - -// test('Successfully create Telegram campaign', async () => { -// const res = await request(app).post('/campaigns').send({ -// name: 'test', -// type: ChannelType.Telegram, -// }) -// expect(res.status).toBe(201) -// expect(res.body).toEqual( -// expect.objectContaining({ -// name: 'test', -// type: ChannelType.Telegram, -// protect: false, -// }) -// ) -// }) - -// test('Successfully create demo SMS campaign', async () => { -// const campaign = { -// name: 'demo', -// type: ChannelType.SMS, -// demo_message_limit: 10, -// } -// const res = await request(app).post('/campaigns').send(campaign) -// expect(res.status).toBe(201) -// expect(res.body).toEqual( -// expect.objectContaining({ -// ...campaign, -// demo_message_limit: 10, -// }) -// ) - -// const demo = await UserDemo.findOne({ where: { userId: 1 } }) -// expect(demo?.numDemosSms).toEqual(2) -// }) - -// test('Successfully create demo Telegram campaign', async () => { -// const campaign = { -// name: 'demo', -// type: ChannelType.Telegram, -// demo_message_limit: 10, -// } -// const res = await request(app).post('/campaigns').send(campaign) -// expect(res.status).toBe(201) -// expect(res.body).toEqual( -// expect.objectContaining({ -// ...campaign, -// demo_message_limit: 10, -// }) -// ) - -// const demo = await UserDemo.findOne({ where: { userId: 1 } }) -// expect(demo?.numDemosTelegram).toEqual(2) -// }) - -// test('Unable to create demo Telegram campaign after user has no demos left', async () => { -// const campaign = { -// name: 'demo', -// type: ChannelType.Telegram, -// demo_message_limit: 10, -// } -// await UserDemo.update({ numDemosTelegram: 0 }, { where: { userId: 1 } }) -// const res = await request(app).post('/campaigns').send(campaign) -// expect(res.status).toBe(400) -// }) - -// test('Unable to create demo campaign for unsupported channel', async () => { -// const campaign = { -// name: 'demo', -// type: ChannelType.Email, -// demo_message_limit: 10, -// } -// const res = await request(app).post('/campaigns').send(campaign) -// expect(res.status).toBe(400) -// }) - -// test('Unable to create protected campaign for unsupported channel', async () => { -// const res = await request(app).post('/campaigns').send({ -// name: 'test', -// type: ChannelType.SMS, -// protect: true, -// }) -// expect(res.status).toBe(403) -// }) -// }) - -// describe('DELETE /campaigns/:campaignId', () => { -// test('Delete a campaign based on its ID', async () => { -// const c = await Campaign.create({ -// name: 'campaign-1', -// userId: 1, -// type: ChannelType.SMS, -// valid: false, -// protect: false, -// } as Campaign) - -// const res = await request(app).delete(`/campaigns/${c.id}`) -// expect(res.status).toBe(200) -// }) -// test('Returns 404 if the campaign ID doesnt exist', async () => { -// const res = await request(app).delete('/campaigns/696969') -// expect(res.status).toBe(404) -// }) -// }) +import request from 'supertest' +import { Sequelize } from 'sequelize-typescript' +import initialiseServer from '@test-utils/server' +import { Campaign, User, UserDemo, JobQueue } from '@core/models' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { UploadService } from '@core/services' +import { + ChannelType, + JobStatus, + Ordering, + CampaignSortField, + CampaignStatus, +} from '@core/constants' + +const app = initialiseServer(true) +let sequelize: Sequelize + +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') + await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) +}) + +afterEach(async () => { + await JobQueue.destroy({ where: {} }) + await Campaign.destroy({ where: {}, force: true }) +}) + +afterAll(async () => { + await User.destroy({ where: {} }) + await sequelize.close() + await UploadService.destroyUploadQueue() + await (app as any).cleanup() +}) + +describe('GET /campaigns', () => { + test('List campaigns with default limit and offset', async () => { + await Campaign.create({ + name: 'campaign-1', + userId: 1, + type: ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + await Campaign.create({ + name: 'campaign-2', + userId: 1, + type: ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + + const res = await request(app).get('/campaigns') + expect(res.status).toBe(200) + expect(res.body).toEqual({ + total_count: 2, + campaigns: expect.arrayContaining([ + expect.objectContaining({ id: expect.any(Number) }), + ]), + }) + }) + + test('List campaigns with defined limit and offset', async () => { + for (let i = 1; i <= 3; i++) { + await Campaign.create({ + name: `campaign-${i}`, + userId: 1, + type: ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + } + + const res = await request(app) + .get('/campaigns') + .query({ limit: 1, offset: 2 }) + expect(res.status).toBe(200) + expect(res.body).toEqual({ + total_count: 3, + campaigns: expect.arrayContaining([ + expect.objectContaining({ name: 'campaign-1' }), + ]), + }) + }) + + test('List campaign with offset exceeding number of campaigns', async () => { + for (let i = 1; i <= 3; i++) { + await Campaign.create({ + name: `campaign-${i}`, + userId: 1, + type: ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + } + + const res = await request(app) + .get('/campaigns') + .query({ limit: 1, offset: 4 }) + expect(res.status).toBe(200) + expect(res.body).toEqual({ + total_count: 3, + campaigns: [], + }) + }) + + test('List campaign with limit exceeding number of campaigns', async () => { + for (let i = 1; i <= 3; i++) { + await Campaign.create({ + name: `campaign-${i}`, + userId: 1, + type: ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + } + + const res = await request(app) + .get('/campaigns') + .query({ limit: 4, offset: 0 }) + expect(res.status).toBe(200) + expect(res.body.total_count).toEqual(3) + for (let i = 1; i <= 3; i++) { + expect(res.body.campaigns[i - 1].name).toEqual( + `campaign-${3 - i + 1}` // default orderBy is desc + ) + } + }) + + test('List campaign with offset and default limit', async () => { + for (let i = 1; i <= 15; i++) { + await Campaign.create({ + name: `campaign-${i}`, + userId: 1, + type: ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + } + + const res = await request(app).get('/campaigns').query({ offset: 2 }) + expect(res.status).toBe(200) + expect(res.body.total_count).toEqual(15) + for (let i = 1; i <= 10; i++) { + expect(res.body.campaigns[i - 1].name).toEqual( + `campaign-${15 - (i + 1)}` // default orderBy is desc + ) + } + }) + + test('List campaign with offset and type filter', async () => { + for (let i = 1; i <= 10; i++) { + await Campaign.create({ + name: `campaign-${i}`, + userId: 1, + type: i > 5 ? ChannelType.Email : ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + } + + const res = await request(app) + .get('/campaigns') + .query({ offset: 4, type: ChannelType.Email }) + expect(res.status).toBe(200) + expect(res.body).toEqual({ + total_count: 5, + campaigns: expect.arrayContaining([ + expect.objectContaining({ + name: `campaign-6`, + type: ChannelType.Email, + }), + ]), + }) + }) + + test('List campaigns order by created at', async () => { + for (let i = 1; i <= 3; i++) { + await Campaign.create({ + name: `campaign-${i}`, + userId: 1, + type: ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + } + + const resAsc = await request(app) + .get('/campaigns') + .query({ order_by: Ordering.ASC, sort_by: CampaignSortField.Created }) + expect(resAsc.status).toBe(200) + expect(resAsc.body.total_count).toEqual(3) + for (let i = 1; i <= 3; i++) { + expect(resAsc.body.campaigns[i - 1].name).toEqual(`campaign-${i}`) + } + + const resDesc = await request(app) + .get('/campaigns') + .query({ order_by: Ordering.DESC, sort_by: CampaignSortField.Created }) + expect(resDesc.status).toBe(200) + expect(resDesc.body.total_count).toEqual(3) + for (let i = 1; i <= 3; i++) { + expect(resDesc.body.campaigns[i - 1].name).toEqual( + `campaign-${3 - i + 1}` + ) + } + }) + + test('List campaigns order by sent at', async () => { + for (let i = 1; i <= 3; i++) { + const campaign = await Campaign.create({ + name: `campaign-${i}`, + userId: 1, + type: ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + // adding a Sending entry in JobQueue sets the sent time + await JobQueue.create({ + campaignId: campaign.id, + status: JobStatus.Sending, + } as JobQueue) + } + + const resSentAsc = await request(app) + .get('/campaigns') + .query({ order_by: Ordering.ASC, sort_by: CampaignSortField.Sent }) + expect(resSentAsc.status).toBe(200) + expect(resSentAsc.body.total_count).toEqual(3) + for (let i = 1; i <= 3; i++) { + expect(resSentAsc.body.campaigns[i - 1].name).toEqual(`campaign-${i}`) + } + + const resSentDesc = await request(app) + .get('/campaigns') + .query({ order_by: Ordering.DESC, sort_by: CampaignSortField.Sent }) + expect(resSentDesc.status).toBe(200) + expect(resSentDesc.body.total_count).toEqual(3) + for (let i = 1; i <= 3; i++) { + expect(resSentDesc.body.campaigns[i - 1].name).toEqual( + `campaign-${3 - i + 1}` + ) + } + }) + + test('List campaigns filter by mode', async () => { + const mode = [ChannelType.SMS, ChannelType.Email, ChannelType.Telegram] + for (let i = 1; i <= 3; i++) { + await Campaign.create({ + name: `campaign-${i}`, + userId: 1, + type: mode[i - 1], + valid: false, + protect: false, + } as Campaign) + } + + for (let i = 1; i <= 3; i++) { + const res = await request(app) + .get('/campaigns') + .query({ type: mode[i - 1] }) + expect(res.status).toBe(200) + expect(res.body).toEqual({ + total_count: 1, + campaigns: expect.arrayContaining([ + expect.objectContaining({ name: `campaign-${i}` }), + ]), + }) + } + }) + + test('List campaigns filter by status', async () => { + // create campaign-1 with the default job status Draft + await Campaign.create({ + name: 'campaign-1', + userId: 1, + type: ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + + //create campaign-2 with job status Sent by having a LOGGED entry in JobQueue + const campaign = await Campaign.create({ + name: 'campaign-2', + userId: 1, + type: ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + await JobQueue.create({ + campaignId: campaign.id, + status: JobStatus.Logged, + } as JobQueue) + + const resDraft = await request(app) + .get('/campaigns') + .query({ status: CampaignStatus.Draft }) + expect(resDraft.status).toBe(200) + expect(resDraft.body).toEqual({ + total_count: 1, + campaigns: expect.arrayContaining([ + expect.objectContaining({ name: 'campaign-1' }), + ]), + }) + + const resSent = await request(app) + .get('/campaigns') + .query({ status: CampaignStatus.Sent }) + expect(resSent.status).toBe(200) + expect(resSent.body).toEqual({ + total_count: 1, + campaigns: expect.arrayContaining([ + expect.objectContaining({ name: 'campaign-2' }), + ]), + }) + }) + + test('List campaigns search by name', async () => { + for (let i = 1; i <= 3; i++) { + await Campaign.create({ + name: `campaign-${i}`, + userId: 1, + type: ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + } + + for (let i = 1; i <= 3; i++) { + const res = await request(app) + .get('/campaigns') + .query({ name: i.toString() }) + expect(res.status).toBe(200) + expect(res.body).toEqual({ + total_count: 1, + campaigns: expect.arrayContaining([ + expect.objectContaining({ name: `campaign-${i}` }), + ]), + }) + } + }) +}) + +describe('POST /campaigns', () => { + test('Successfully create SMS campaign', async () => { + const res = await request(app).post('/campaigns').send({ + name: 'test', + type: ChannelType.SMS, + }) + expect(res.status).toBe(201) + expect(res.body).toEqual( + expect.objectContaining({ + name: 'test', + type: ChannelType.SMS, + protect: false, + }) + ) + }) + + test('Successfully create Email campaign', async () => { + const res = await request(app).post('/campaigns').send({ + name: 'test', + type: ChannelType.Email, + }) + expect(res.status).toBe(201) + expect(res.body).toEqual( + expect.objectContaining({ + name: 'test', + type: ChannelType.Email, + protect: false, + }) + ) + }) + + test('Successfully create Protected Email campaign', async () => { + const campaign = { + name: 'test', + type: ChannelType.Email, + protect: true, + } + const res = await request(app).post('/campaigns').send(campaign) + expect(res.status).toBe(201) + expect(res.body).toEqual(expect.objectContaining(campaign)) + }) + + test('Successfully create Telegram campaign', async () => { + const res = await request(app).post('/campaigns').send({ + name: 'test', + type: ChannelType.Telegram, + }) + expect(res.status).toBe(201) + expect(res.body).toEqual( + expect.objectContaining({ + name: 'test', + type: ChannelType.Telegram, + protect: false, + }) + ) + }) + + test('Successfully create demo SMS campaign', async () => { + const campaign = { + name: 'demo', + type: ChannelType.SMS, + demo_message_limit: 10, + } + const res = await request(app).post('/campaigns').send(campaign) + expect(res.status).toBe(201) + expect(res.body).toEqual( + expect.objectContaining({ + ...campaign, + demo_message_limit: 10, + }) + ) + + const demo = await UserDemo.findOne({ where: { userId: 1 } }) + expect(demo?.numDemosSms).toEqual(2) + }) + + test('Successfully create demo Telegram campaign', async () => { + const campaign = { + name: 'demo', + type: ChannelType.Telegram, + demo_message_limit: 10, + } + const res = await request(app).post('/campaigns').send(campaign) + expect(res.status).toBe(201) + expect(res.body).toEqual( + expect.objectContaining({ + ...campaign, + demo_message_limit: 10, + }) + ) + + const demo = await UserDemo.findOne({ where: { userId: 1 } }) + expect(demo?.numDemosTelegram).toEqual(2) + }) + + test('Unable to create demo Telegram campaign after user has no demos left', async () => { + const campaign = { + name: 'demo', + type: ChannelType.Telegram, + demo_message_limit: 10, + } + await UserDemo.update({ numDemosTelegram: 0 }, { where: { userId: 1 } }) + const res = await request(app).post('/campaigns').send(campaign) + expect(res.status).toBe(400) + }) + + test('Unable to create demo campaign for unsupported channel', async () => { + const campaign = { + name: 'demo', + type: ChannelType.Email, + demo_message_limit: 10, + } + const res = await request(app).post('/campaigns').send(campaign) + expect(res.status).toBe(400) + }) + + test('Unable to create protected campaign for unsupported channel', async () => { + const res = await request(app).post('/campaigns').send({ + name: 'test', + type: ChannelType.SMS, + protect: true, + }) + expect(res.status).toBe(403) + }) +}) + +describe('DELETE /campaigns/:campaignId', () => { + test('Delete a campaign based on its ID', async () => { + const c = await Campaign.create({ + name: 'campaign-1', + userId: 1, + type: ChannelType.SMS, + valid: false, + protect: false, + } as Campaign) + + const res = await request(app).delete(`/campaigns/${c.id}`) + expect(res.status).toBe(200) + }) + test('Returns 404 if the campaign ID doesnt exist', async () => { + const res = await request(app).delete('/campaigns/696969') + expect(res.status).toBe(404) + }) +}) diff --git a/backend/src/core/routes/tests/protected.routes.test.ts b/backend/src/core/routes/tests/protected.routes.test.ts index 0ed81c13b..6b23c7df4 100644 --- a/backend/src/core/routes/tests/protected.routes.test.ts +++ b/backend/src/core/routes/tests/protected.routes.test.ts @@ -1,85 +1,85 @@ -// import request from 'supertest' -// import { Sequelize } from 'sequelize-typescript' -// import initialiseServer from '@test-utils/server' -// import { Campaign, ProtectedMessage, User } from '@core/models' -// import sequelizeLoader from '@test-utils/sequelize-loader' -// import { ChannelType } from '@core/constants' +import request from 'supertest' +import { Sequelize } from 'sequelize-typescript' +import initialiseServer from '@test-utils/server' +import { Campaign, ProtectedMessage, User } from '@core/models' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { ChannelType } from '@core/constants' -// const app = initialiseServer(true) -// let sequelize: Sequelize -// let campaignId: number +const app = initialiseServer(true) +let sequelize: Sequelize +let campaignId: number -// beforeAll(async () => { -// sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') -// await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) -// const campaign = await Campaign.create({ -// name: 'campaign-1', -// userId: 1, -// type: ChannelType.Email, -// valid: false, -// protect: true, -// } as Campaign) -// campaignId = campaign.id -// }) +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') + await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) + const campaign = await Campaign.create({ + name: 'campaign-1', + userId: 1, + type: ChannelType.Email, + valid: false, + protect: true, + } as Campaign) + campaignId = campaign.id +}) -// afterEach(async () => { -// await ProtectedMessage.destroy({ where: {}, force: true }) -// }) +afterEach(async () => { + await ProtectedMessage.destroy({ where: {}, force: true }) +}) -// afterAll(async () => { -// await Campaign.destroy({ where: {}, force: true }) -// await User.destroy({ where: {} }) -// await sequelize.close() -// await (app as any).cleanup() -// }) +afterAll(async () => { + await Campaign.destroy({ where: {}, force: true }) + await User.destroy({ where: {} }) + await sequelize.close() + await (app as any).cleanup() +}) -// describe('POST /protected/{id}', () => { -// test('Fail to retrieve protected message for non-existent id', async () => { -// const id = '123' -// const res = await request(app).post(`/protect/${id}`).send({ -// password_hash: 'abc', -// }) -// expect(res.status).toBe(403) -// expect(res.body).toEqual({ -// message: 'Wrong password or message id. Please try again.', -// }) -// }) +describe('POST /protected/{id}', () => { + test('Fail to retrieve protected message for non-existent id', async () => { + const id = '123' + const res = await request(app).post(`/protect/${id}`).send({ + password_hash: 'abc', + }) + expect(res.status).toBe(403) + expect(res.body).toEqual({ + message: 'Wrong password or message id. Please try again.', + }) + }) -// test('Fail to retrieve protected message for wrong password hash', async () => { -// const id = '123' -// await ProtectedMessage.create({ -// id: '123', -// campaignId, -// passwordHash: 'def', -// payload: 'encrypted message', -// version: 1, -// } as ProtectedMessage) + test('Fail to retrieve protected message for wrong password hash', async () => { + const id = '123' + await ProtectedMessage.create({ + id: '123', + campaignId, + passwordHash: 'def', + payload: 'encrypted message', + version: 1, + } as ProtectedMessage) -// const res = await request(app).post(`/protect/${id}`).send({ -// password_hash: 'abc', -// }) -// expect(res.status).toBe(403) -// expect(res.body).toEqual({ -// message: 'Wrong password or message id. Please try again.', -// }) -// }) + const res = await request(app).post(`/protect/${id}`).send({ + password_hash: 'abc', + }) + expect(res.status).toBe(403) + expect(res.body).toEqual({ + message: 'Wrong password or message id. Please try again.', + }) + }) -// test('Successfully retrieve protected message', async () => { -// const id = '123' -// await ProtectedMessage.create({ -// id: '123', -// campaignId, -// passwordHash: 'def', -// payload: 'encrypted message', -// version: 1, -// } as ProtectedMessage) + test('Successfully retrieve protected message', async () => { + const id = '123' + await ProtectedMessage.create({ + id: '123', + campaignId, + passwordHash: 'def', + payload: 'encrypted message', + version: 1, + } as ProtectedMessage) -// const res = await request(app).post(`/protect/${id}`).send({ -// password_hash: 'def', -// }) -// expect(res.status).toBe(200) -// expect(res.body).toEqual({ -// payload: 'encrypted message', -// }) -// }) -// }) + const res = await request(app).post(`/protect/${id}`).send({ + password_hash: 'def', + }) + expect(res.status).toBe(200) + expect(res.body).toEqual({ + payload: 'encrypted message', + }) + }) +}) diff --git a/backend/src/email/routes/tests/email-campaign.routes.test.ts b/backend/src/email/routes/tests/email-campaign.routes.test.ts index 7f4fa5fae..0fdafb70b 100644 --- a/backend/src/email/routes/tests/email-campaign.routes.test.ts +++ b/backend/src/email/routes/tests/email-campaign.routes.test.ts @@ -1,427 +1,427 @@ -// import request from 'supertest' -// import { Sequelize } from 'sequelize-typescript' -// import initialiseServer from '@test-utils/server' -// import config from '@core/config' -// import { Campaign, User } from '@core/models' -// import sequelizeLoader from '@test-utils/sequelize-loader' -// import { UploadService } from '@core/services' -// import { EmailFromAddress, EmailMessage } from '@email/models' -// import { CustomDomainService } from '@email/services' -// import { ChannelType } from '@core/constants' -// import { -// INVALID_FROM_ADDRESS_ERROR_MESSAGE, -// UNVERIFIED_FROM_ADDRESS_ERROR_MESSAGE, -// } from '@email/middlewares' - -// const app = initialiseServer(true) -// let sequelize: Sequelize -// let campaignId: number -// let protectedCampaignId: number - -// beforeAll(async () => { -// sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') -// await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) -// const campaign = await Campaign.create({ -// name: 'campaign-1', -// userId: 1, -// type: ChannelType.Email, -// valid: false, -// protect: false, -// } as Campaign) -// campaignId = campaign.id -// const protectedCampaign = await Campaign.create({ -// name: 'campaign-2', -// userId: 1, -// type: ChannelType.Email, -// valid: false, -// protect: true, -// } as Campaign) -// protectedCampaignId = protectedCampaign.id -// }) - -// afterAll(async () => { -// await EmailMessage.destroy({ where: {} }) -// await Campaign.destroy({ where: {}, force: true }) -// await User.destroy({ where: {} }) -// await sequelize.close() -// await UploadService.destroyUploadQueue() -// await (app as any).cleanup() -// }) - -// describe('PUT /campaign/{campaignId}/email/template', () => { -// afterEach(async () => { -// await EmailFromAddress.destroy({ where: {} }) -// }) - -// test('Invalid from address is not accepted', async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// from: 'abc@postman.gov.sg', -// subject: 'test', -// body: 'test', -// reply_to: 'user@agency.gov.sg', -// }) -// expect(res.status).toBe(400) -// expect(res.body).toEqual({ -// code: 'invalid_from_address', -// message: INVALID_FROM_ADDRESS_ERROR_MESSAGE, -// }) -// }) - -// test('Invalid values for email is not accepted', async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// from: 'not an email ', -// subject: 'test', -// body: 'test', -// reply_to: 'user@agency.gov.sg', -// }) -// expect(res.status).toBe(400) -// expect(res.body).toMatchObject({ message: '"from" must be a valid email' }) -// }) - -// test('Default from address is used if not provided', async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// subject: 'test', -// body: 'test', -// reply_to: 'user@agency.gov.sg', -// }) -// expect(res.status).toBe(200) -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: `Template for campaign ${campaignId} updated`, -// template: expect.objectContaining({ -// from: 'Postman ', -// reply_to: 'user@agency.gov.sg', -// }), -// }) -// ) -// }) - -// test('Unquoted from address with periods is accepted', async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// from: 'Agency.gov.sg ', -// subject: 'test', -// body: 'test', -// reply_to: 'user@agency.gov.sg', -// }) -// expect(res.status).toBe(200) -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: `Template for campaign ${campaignId} updated`, -// template: expect.objectContaining({ -// from: 'Agency.gov.sg via Postman ', -// reply_to: 'user@agency.gov.sg', -// }), -// }) -// ) -// }) - -// test('Default from address is accepted', async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// from: 'Postman ', -// subject: 'test', -// body: 'test', -// reply_to: 'user@agency.gov.sg', -// }) -// expect(res.status).toBe(200) -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: `Template for campaign ${campaignId} updated`, -// template: expect.objectContaining({ -// from: 'Postman ', -// reply_to: 'user@agency.gov.sg', -// }), -// }) -// ) -// }) - -// test("Unverified user's email as from address is not accepted", async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// from: 'user@agency.gov.sg', -// subject: 'test', -// body: 'test', -// reply_to: 'user@agency.gov.sg', -// }) -// expect(res.status).toBe(400) -// expect(res.body).toEqual({ -// code: 'invalid_from_address', -// message: UNVERIFIED_FROM_ADDRESS_ERROR_MESSAGE, -// }) -// }) - -// test("Verified user's email as from address is accepted", async () => { -// await EmailFromAddress.create({ -// email: 'user@agency.gov.sg', -// name: 'Agency ABC', -// } as EmailFromAddress) -// const mockVerifyFromAddress = jest -// .spyOn(CustomDomainService, 'verifyFromAddress') -// .mockReturnValue(Promise.resolve()) - -// const res = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// from: 'Agency ABC ', -// subject: 'test', -// body: 'test', -// reply_to: 'user@agency.gov.sg', -// }) -// expect(res.status).toBe(200) -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: `Template for campaign ${campaignId} updated`, -// template: expect.objectContaining({ -// from: 'Agency ABC ', -// reply_to: 'user@agency.gov.sg', -// }), -// }) -// ) -// mockVerifyFromAddress.mockRestore() -// }) - -// test('Custom sender name with default from address should be accepted', async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// from: 'Custom Name ', -// subject: 'test', -// body: 'test', -// reply_to: 'user@agency.gov.sg', -// }) -// expect(res.status).toBe(200) -// const mailVia = config.get('mailVia') -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: `Template for campaign ${campaignId} updated`, -// template: expect.objectContaining({ -// from: `Custom Name ${mailVia} `, -// reply_to: 'user@agency.gov.sg', -// }), -// }) -// ) -// }) - -// test('Custom sender name with verified custom from address should be accepted', async () => { -// await EmailFromAddress.create({ -// email: 'user@agency.gov.sg', -// name: 'Agency ABC', -// } as EmailFromAddress) -// const mockVerifyFromAddress = jest -// .spyOn(CustomDomainService, 'verifyFromAddress') -// .mockReturnValue(Promise.resolve()) - -// const res = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// from: 'Custom Name ', -// subject: 'test', -// body: 'test', -// reply_to: 'user@agency.gov.sg', -// }) - -// expect(res.status).toBe(200) -// const mailVia = config.get('mailVia') -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: `Template for campaign ${campaignId} updated`, -// template: expect.objectContaining({ -// from: `Custom Name ${mailVia} `, -// reply_to: 'user@agency.gov.sg', -// }), -// }) -// ) -// mockVerifyFromAddress.mockRestore() -// }) - -// test('Custom sender name with unverified from address should not be accepted', async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// from: 'Custom Name ', -// subject: 'test', -// body: 'test', -// reply_to: 'user@agency.gov.sg', -// }) - -// expect(res.status).toBe(400) -// expect(res.body).toEqual({ -// code: 'invalid_from_address', -// message: UNVERIFIED_FROM_ADDRESS_ERROR_MESSAGE, -// }) -// }) - -// test('Mail via should only be appended once', async () => { -// const mailVia = config.get('mailVia') -// const res = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// from: `Custom Name ${mailVia} `, -// subject: 'test', -// body: 'test', -// reply_to: 'user@agency.gov.sg', -// }) - -// expect(res.status).toBe(200) -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: `Template for campaign ${campaignId} updated`, -// template: expect.objectContaining({ -// from: `Custom Name ${mailVia} `, -// reply_to: 'user@agency.gov.sg', -// }), -// }) -// ) -// }) - -// test('Protected template without protectedlink variables is not accepted', async () => { -// const res = await request(app) -// .put(`/campaign/${protectedCampaignId}/email/template`) -// .send({ -// subject: 'test', -// body: 'test', -// reply_to: 'user@agency.gov.sg', -// }) -// expect(res.status).toBe(400) -// expect(res.body).toEqual({ -// code: 'invalid_template', -// message: -// 'Error: There are missing keywords in the message template: protectedlink. Please return to the previous step to add in the keywords.', -// }) -// }) - -// test('Protected template with disallowed variables in subject is not accepted', async () => { -// const testSubject = await request(app) -// .put(`/campaign/${protectedCampaignId}/email/template`) -// .send({ -// subject: 'test {{name}}', -// body: '{{recipient}} {{protectedLink}}', -// reply_to: 'user@agency.gov.sg', -// }) -// expect(testSubject.status).toBe(400) -// expect(testSubject.body).toEqual({ -// code: 'invalid_template', -// message: -// 'Error: Only these keywords are allowed in the template: protectedlink,recipient.\nRemove the other keywords from the template: name.', -// }) -// }) - -// test('Protected template with disallowed variables in body is not accepted', async () => { -// const testBody = await request(app) -// .put(`/campaign/${protectedCampaignId}/email/template`) -// .send({ -// subject: 'test', -// body: '{{recipient}} {{protectedLink}} {{name}}', -// reply_to: 'user@agency.gov.sg', -// }) - -// expect(testBody.status).toBe(400) -// expect(testBody.body).toEqual({ -// code: 'invalid_template', -// message: -// 'Error: Only these keywords are allowed in the template: protectedlink,recipient.\nRemove the other keywords from the template: name.', -// }) -// }) - -// test('Protected template with only allowed variables is accepted', async () => { -// const testBody = await request(app) -// .put(`/campaign/${protectedCampaignId}/email/template`) -// .send({ -// subject: 'test {{recipient}} {{protectedLink}}', -// body: 'test {{recipient}} {{protectedLink}}', -// reply_to: 'user@agency.gov.sg', -// }) - -// expect(testBody.status).toBe(200) -// expect(testBody.body).toEqual( -// expect.objectContaining({ -// message: `Template for campaign ${protectedCampaignId} updated`, -// template: expect.objectContaining({ -// from: 'Postman ', -// reply_to: 'user@agency.gov.sg', -// }), -// }) -// ) -// }) - -// test('Template with only invalid HTML tags is not accepted', async () => { -// const testBody = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// subject: 'test', -// body: '', -// reply_to: 'user@agency.gov.sg', -// }) - -// expect(testBody.status).toBe(400) -// expect(testBody.body).toEqual({ -// code: 'invalid_template', -// message: -// 'Message template is invalid as it only contains invalid HTML tags!', -// }) -// }) - -// test('Existing populated messages are removed when template has new variables', async () => { -// await EmailMessage.create({ -// campaignId, -// recipient: 'user@agency.gov.sg', -// params: { recipient: 'user@agency.gov.sg' }, -// } as EmailMessage) -// const testBody = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// subject: 'test', -// body: 'test {{name}}', -// reply_to: 'user@agency.gov.sg', -// }) - -// expect(testBody.status).toBe(200) -// expect(testBody.body).toEqual( -// expect.objectContaining({ -// message: -// 'Please re-upload your recipient list as template has changed.', -// template: expect.objectContaining({ -// from: 'Postman ', -// reply_to: 'user@agency.gov.sg', -// }), -// }) -// ) - -// const emailMessages = await EmailMessage.count({ -// where: { campaignId }, -// }) -// expect(emailMessages).toEqual(0) -// }) - -// test('Successfully update template', async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/email/template`) -// .send({ -// subject: 'test', -// body: 'test {{name}}', -// reply_to: 'user@agency.gov.sg', -// }) - -// expect(res.status).toBe(200) -// expect(res.body).toMatchObject({ -// message: `Template for campaign ${campaignId} updated`, -// template: { -// subject: 'test', -// body: 'test {{name}}', -// from: 'Postman ', -// reply_to: 'user@agency.gov.sg', -// params: ['name'], -// }, -// }) -// }) -// }) +import request from 'supertest' +import { Sequelize } from 'sequelize-typescript' +import initialiseServer from '@test-utils/server' +import config from '@core/config' +import { Campaign, User } from '@core/models' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { UploadService } from '@core/services' +import { EmailFromAddress, EmailMessage } from '@email/models' +import { CustomDomainService } from '@email/services' +import { ChannelType } from '@core/constants' +import { + INVALID_FROM_ADDRESS_ERROR_MESSAGE, + UNVERIFIED_FROM_ADDRESS_ERROR_MESSAGE, +} from '@email/middlewares' + +const app = initialiseServer(true) +let sequelize: Sequelize +let campaignId: number +let protectedCampaignId: number + +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') + await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) + const campaign = await Campaign.create({ + name: 'campaign-1', + userId: 1, + type: ChannelType.Email, + valid: false, + protect: false, + } as Campaign) + campaignId = campaign.id + const protectedCampaign = await Campaign.create({ + name: 'campaign-2', + userId: 1, + type: ChannelType.Email, + valid: false, + protect: true, + } as Campaign) + protectedCampaignId = protectedCampaign.id +}) + +afterAll(async () => { + await EmailMessage.destroy({ where: {} }) + await Campaign.destroy({ where: {}, force: true }) + await User.destroy({ where: {} }) + await sequelize.close() + await UploadService.destroyUploadQueue() + await (app as any).cleanup() +}) + +describe('PUT /campaign/{campaignId}/email/template', () => { + afterEach(async () => { + await EmailFromAddress.destroy({ where: {} }) + }) + + test('Invalid from address is not accepted', async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + from: 'abc@postman.gov.sg', + subject: 'test', + body: 'test', + reply_to: 'user@agency.gov.sg', + }) + expect(res.status).toBe(400) + expect(res.body).toEqual({ + code: 'invalid_from_address', + message: INVALID_FROM_ADDRESS_ERROR_MESSAGE, + }) + }) + + test('Invalid values for email is not accepted', async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + from: 'not an email ', + subject: 'test', + body: 'test', + reply_to: 'user@agency.gov.sg', + }) + expect(res.status).toBe(400) + expect(res.body).toMatchObject({ message: '"from" must be a valid email' }) + }) + + test('Default from address is used if not provided', async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + subject: 'test', + body: 'test', + reply_to: 'user@agency.gov.sg', + }) + expect(res.status).toBe(200) + expect(res.body).toEqual( + expect.objectContaining({ + message: `Template for campaign ${campaignId} updated`, + template: expect.objectContaining({ + from: 'Postman ', + reply_to: 'user@agency.gov.sg', + }), + }) + ) + }) + + test('Unquoted from address with periods is accepted', async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + from: 'Agency.gov.sg ', + subject: 'test', + body: 'test', + reply_to: 'user@agency.gov.sg', + }) + expect(res.status).toBe(200) + expect(res.body).toEqual( + expect.objectContaining({ + message: `Template for campaign ${campaignId} updated`, + template: expect.objectContaining({ + from: 'Agency.gov.sg via Postman ', + reply_to: 'user@agency.gov.sg', + }), + }) + ) + }) + + test('Default from address is accepted', async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + from: 'Postman ', + subject: 'test', + body: 'test', + reply_to: 'user@agency.gov.sg', + }) + expect(res.status).toBe(200) + expect(res.body).toEqual( + expect.objectContaining({ + message: `Template for campaign ${campaignId} updated`, + template: expect.objectContaining({ + from: 'Postman ', + reply_to: 'user@agency.gov.sg', + }), + }) + ) + }) + + test("Unverified user's email as from address is not accepted", async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + from: 'user@agency.gov.sg', + subject: 'test', + body: 'test', + reply_to: 'user@agency.gov.sg', + }) + expect(res.status).toBe(400) + expect(res.body).toEqual({ + code: 'invalid_from_address', + message: UNVERIFIED_FROM_ADDRESS_ERROR_MESSAGE, + }) + }) + + test("Verified user's email as from address is accepted", async () => { + await EmailFromAddress.create({ + email: 'user@agency.gov.sg', + name: 'Agency ABC', + } as EmailFromAddress) + const mockVerifyFromAddress = jest + .spyOn(CustomDomainService, 'verifyFromAddress') + .mockReturnValue(Promise.resolve()) + + const res = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + from: 'Agency ABC ', + subject: 'test', + body: 'test', + reply_to: 'user@agency.gov.sg', + }) + expect(res.status).toBe(200) + expect(res.body).toEqual( + expect.objectContaining({ + message: `Template for campaign ${campaignId} updated`, + template: expect.objectContaining({ + from: 'Agency ABC ', + reply_to: 'user@agency.gov.sg', + }), + }) + ) + mockVerifyFromAddress.mockRestore() + }) + + test('Custom sender name with default from address should be accepted', async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + from: 'Custom Name ', + subject: 'test', + body: 'test', + reply_to: 'user@agency.gov.sg', + }) + expect(res.status).toBe(200) + const mailVia = config.get('mailVia') + expect(res.body).toEqual( + expect.objectContaining({ + message: `Template for campaign ${campaignId} updated`, + template: expect.objectContaining({ + from: `Custom Name ${mailVia} `, + reply_to: 'user@agency.gov.sg', + }), + }) + ) + }) + + test('Custom sender name with verified custom from address should be accepted', async () => { + await EmailFromAddress.create({ + email: 'user@agency.gov.sg', + name: 'Agency ABC', + } as EmailFromAddress) + const mockVerifyFromAddress = jest + .spyOn(CustomDomainService, 'verifyFromAddress') + .mockReturnValue(Promise.resolve()) + + const res = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + from: 'Custom Name ', + subject: 'test', + body: 'test', + reply_to: 'user@agency.gov.sg', + }) + + expect(res.status).toBe(200) + const mailVia = config.get('mailVia') + expect(res.body).toEqual( + expect.objectContaining({ + message: `Template for campaign ${campaignId} updated`, + template: expect.objectContaining({ + from: `Custom Name ${mailVia} `, + reply_to: 'user@agency.gov.sg', + }), + }) + ) + mockVerifyFromAddress.mockRestore() + }) + + test('Custom sender name with unverified from address should not be accepted', async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + from: 'Custom Name ', + subject: 'test', + body: 'test', + reply_to: 'user@agency.gov.sg', + }) + + expect(res.status).toBe(400) + expect(res.body).toEqual({ + code: 'invalid_from_address', + message: UNVERIFIED_FROM_ADDRESS_ERROR_MESSAGE, + }) + }) + + test('Mail via should only be appended once', async () => { + const mailVia = config.get('mailVia') + const res = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + from: `Custom Name ${mailVia} `, + subject: 'test', + body: 'test', + reply_to: 'user@agency.gov.sg', + }) + + expect(res.status).toBe(200) + expect(res.body).toEqual( + expect.objectContaining({ + message: `Template for campaign ${campaignId} updated`, + template: expect.objectContaining({ + from: `Custom Name ${mailVia} `, + reply_to: 'user@agency.gov.sg', + }), + }) + ) + }) + + test('Protected template without protectedlink variables is not accepted', async () => { + const res = await request(app) + .put(`/campaign/${protectedCampaignId}/email/template`) + .send({ + subject: 'test', + body: 'test', + reply_to: 'user@agency.gov.sg', + }) + expect(res.status).toBe(400) + expect(res.body).toEqual({ + code: 'invalid_template', + message: + 'Error: There are missing keywords in the message template: protectedlink. Please return to the previous step to add in the keywords.', + }) + }) + + test('Protected template with disallowed variables in subject is not accepted', async () => { + const testSubject = await request(app) + .put(`/campaign/${protectedCampaignId}/email/template`) + .send({ + subject: 'test {{name}}', + body: '{{recipient}} {{protectedLink}}', + reply_to: 'user@agency.gov.sg', + }) + expect(testSubject.status).toBe(400) + expect(testSubject.body).toEqual({ + code: 'invalid_template', + message: + 'Error: Only these keywords are allowed in the template: protectedlink,recipient.\nRemove the other keywords from the template: name.', + }) + }) + + test('Protected template with disallowed variables in body is not accepted', async () => { + const testBody = await request(app) + .put(`/campaign/${protectedCampaignId}/email/template`) + .send({ + subject: 'test', + body: '{{recipient}} {{protectedLink}} {{name}}', + reply_to: 'user@agency.gov.sg', + }) + + expect(testBody.status).toBe(400) + expect(testBody.body).toEqual({ + code: 'invalid_template', + message: + 'Error: Only these keywords are allowed in the template: protectedlink,recipient.\nRemove the other keywords from the template: name.', + }) + }) + + test('Protected template with only allowed variables is accepted', async () => { + const testBody = await request(app) + .put(`/campaign/${protectedCampaignId}/email/template`) + .send({ + subject: 'test {{recipient}} {{protectedLink}}', + body: 'test {{recipient}} {{protectedLink}}', + reply_to: 'user@agency.gov.sg', + }) + + expect(testBody.status).toBe(200) + expect(testBody.body).toEqual( + expect.objectContaining({ + message: `Template for campaign ${protectedCampaignId} updated`, + template: expect.objectContaining({ + from: 'Postman ', + reply_to: 'user@agency.gov.sg', + }), + }) + ) + }) + + test('Template with only invalid HTML tags is not accepted', async () => { + const testBody = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + subject: 'test', + body: '', + reply_to: 'user@agency.gov.sg', + }) + + expect(testBody.status).toBe(400) + expect(testBody.body).toEqual({ + code: 'invalid_template', + message: + 'Message template is invalid as it only contains invalid HTML tags!', + }) + }) + + test('Existing populated messages are removed when template has new variables', async () => { + await EmailMessage.create({ + campaignId, + recipient: 'user@agency.gov.sg', + params: { recipient: 'user@agency.gov.sg' }, + } as EmailMessage) + const testBody = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + subject: 'test', + body: 'test {{name}}', + reply_to: 'user@agency.gov.sg', + }) + + expect(testBody.status).toBe(200) + expect(testBody.body).toEqual( + expect.objectContaining({ + message: + 'Please re-upload your recipient list as template has changed.', + template: expect.objectContaining({ + from: 'Postman ', + reply_to: 'user@agency.gov.sg', + }), + }) + ) + + const emailMessages = await EmailMessage.count({ + where: { campaignId }, + }) + expect(emailMessages).toEqual(0) + }) + + test('Successfully update template', async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/email/template`) + .send({ + subject: 'test', + body: 'test {{name}}', + reply_to: 'user@agency.gov.sg', + }) + + expect(res.status).toBe(200) + expect(res.body).toMatchObject({ + message: `Template for campaign ${campaignId} updated`, + template: { + subject: 'test', + body: 'test {{name}}', + from: 'Postman ', + reply_to: 'user@agency.gov.sg', + params: ['name'], + }, + }) + }) +}) diff --git a/backend/src/email/routes/tests/email-transactional.routes.test.ts b/backend/src/email/routes/tests/email-transactional.routes.test.ts index 9cd6ded1a..107b33dfc 100644 --- a/backend/src/email/routes/tests/email-transactional.routes.test.ts +++ b/backend/src/email/routes/tests/email-transactional.routes.test.ts @@ -1,1387 +1,1387 @@ -// import request from 'supertest' -// import { Sequelize } from 'sequelize-typescript' - -// import { User } from '@core/models' -// import { -// CredentialService, -// FileExtensionService, -// UNSUPPORTED_FILE_TYPE_ERROR_CODE, -// } from '@core/services' -// import { -// INVALID_FROM_ADDRESS_ERROR_MESSAGE, -// TRANSACTIONAL_EMAIL_WINDOW, -// UNVERIFIED_FROM_ADDRESS_ERROR_MESSAGE, -// } from '@email/middlewares' -// import { -// EmailFromAddress, -// EmailMessageTransactional, -// TransactionalEmailMessageStatus, -// } from '@email/models' -// import { -// BLACKLISTED_RECIPIENT_ERROR_CODE, -// EmailService, -// EMPTY_MESSAGE_ERROR_CODE, -// } from '@email/services' - -// import initialiseServer from '@test-utils/server' -// import sequelizeLoader from '@test-utils/sequelize-loader' - -// let sequelize: Sequelize -// let user: User -// let apiKey: string -// let mockSendEmail: jest.SpyInstance - -// const app = initialiseServer(false) -// const userEmail = 'user@agency.gov.sg' - -// beforeEach(async () => { -// sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') -// // Flush the rate limit redis database -// await new Promise((resolve) => -// (app as any).redisService.rateLimitClient.flushdb(resolve) -// ) -// user = await User.create({ -// id: 1, -// email: userEmail, -// rateLimit: 1, // for ease of testing, so second API call within a second would fail -// } as User) -// const { plainTextKey } = await ( -// app as any as { credentialService: CredentialService } -// ).credentialService.generateApiKey(user.id, 'test api key', [user.email]) -// apiKey = plainTextKey -// }) - -// afterEach(async () => { -// jest.restoreAllMocks() -// await EmailMessageTransactional.destroy({ where: {} }) -// await User.destroy({ where: {} }) -// await EmailFromAddress.destroy({ where: {} }) -// await sequelize.close() -// }) - -// afterAll(async () => { -// await new Promise((resolve) => -// (app as any).redisService.rateLimitClient.flushdb(resolve) -// ) -// await (app as any).cleanup() -// }) - -// const emailTransactionalRoute = '/transactional/email' - -// describe(`${emailTransactionalRoute}/send`, () => { -// const endpoint = `${emailTransactionalRoute}/send` -// const validApiCall = { -// recipient: 'recipient@agency.gov.sg', -// subject: 'subject', -// body: '

body

', -// from: 'Postman ', -// reply_to: 'user@agency.gov.sg', -// } -// const generateRandomSmallFile = () => { -// const randomFile = Buffer.from(Math.random().toString(36).substring(2)) -// return randomFile -// } -// const generateRandomFileSizeInMb = (sizeInMb: number) => { -// const randomFile = Buffer.alloc(sizeInMb * 1024 * 1024, '.') -// return randomFile -// } - -// // attachment only allowed when sent from user's own email -// const validApiCallAttachment = { -// ...validApiCall, -// from: `User <${userEmail}>`, -// } -// const validAttachment = generateRandomSmallFile() -// const validAttachmentName = 'hi.txt' -// const validAttachmentHashRegex = /^[a-f0-9]{32}$/ // MD5 32 characters -// const validAttachmentSize = Buffer.byteLength(validAttachment) - -// test('Should throw an error if API key is invalid', async () => { -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer invalid-${apiKey}`) -// .send({}) - -// expect(res.status).toBe(401) -// }) - -// test('Should throw an error if API key is valid but payload is not', async () => { -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send({}) - -// expect(res.status).toBe(400) -// }) - -// test('Should send email successfully and metadata is saved correctly in db', async () => { -// mockSendEmail = jest -// .spyOn(EmailService, 'sendEmail') -// .mockResolvedValue(true) -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send(validApiCall) - -// expect(res.status).toBe(201) -// expect(res.body).toBeDefined() -// expect(typeof res.body.id).toBe('string') -// expect(mockSendEmail).toBeCalledTimes(1) -// const transactionalEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(transactionalEmail).not.toBeNull() -// expect(transactionalEmail).toMatchObject({ -// recipient: validApiCall.recipient, -// from: validApiCall.from, -// status: TransactionalEmailMessageStatus.Accepted, -// errorCode: null, -// }) -// expect(transactionalEmail?.params).toMatchObject({ -// subject: validApiCall.subject, -// body: validApiCall.body, -// from: validApiCall.from, -// reply_to: validApiCall.reply_to, -// }) -// }) - -// test('Should send a message with valid custom from name', async () => { -// const mockSendEmail = jest -// .spyOn(EmailService, 'sendEmail') -// .mockResolvedValue(true) - -// const from = 'Hello ' -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send({ -// recipient: 'recipient@agency.gov.sg', -// subject: 'subject', -// body: '

body

', -// from, -// reply_to: user.email, -// }) - -// expect(res.status).toBe(201) -// expect(res.body).toBeDefined() -// expect(res.body.from).toBe(from) -// expect(mockSendEmail).toBeCalledTimes(1) -// const transactionalEmail = await EmailMessageTransactional.findOne({ -// where: { id: res.body.id }, -// }) -// expect(transactionalEmail).not.toBeNull() -// expect(transactionalEmail).toMatchObject({ -// recipient: validApiCall.recipient, -// from: 'Hello ', -// status: TransactionalEmailMessageStatus.Accepted, -// errorCode: null, -// }) -// expect(transactionalEmail?.params).toMatchObject({ -// subject: validApiCall.subject, -// body: validApiCall.body, -// from: 'Hello ', -// reply_to: user.email, -// }) -// }) - -// test('Should send a message with valid custom from address', async () => { -// const mockSendEmail = jest -// .spyOn(EmailService, 'sendEmail') -// .mockResolvedValue(true) - -// await EmailFromAddress.create({ -// email: user.email, -// name: 'Agency ABC', -// } as EmailFromAddress) -// const from = `Hello <${user.email}>` -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send({ -// recipient: 'recipient@agency.gov.sg', -// subject: 'subject', -// body: '

body

', -// from, -// reply_to: user.email, -// }) - -// expect(res.status).toBe(201) -// expect(res.body).toBeDefined() -// expect(res.body.from).toBe(from) -// expect(mockSendEmail).toBeCalledTimes(1) -// const transactionalEmail = await EmailMessageTransactional.findOne({ -// where: { id: res.body.id }, -// }) -// expect(transactionalEmail).not.toBeNull() -// expect(transactionalEmail).toMatchObject({ -// recipient: validApiCall.recipient, -// from: `Hello <${user.email}>`, -// status: TransactionalEmailMessageStatus.Accepted, -// errorCode: null, -// }) -// expect(transactionalEmail?.params).toMatchObject({ -// subject: validApiCall.subject, -// body: validApiCall.body, -// from: `Hello <${user.email}>`, -// reply_to: user.email, -// }) -// }) - -// test('Should throw an error with invalid custom from address (not user email)', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send({ -// ...validApiCall, -// from: 'Hello ', -// reply_to: user.email, -// }) - -// expect(res.status).toBe(400) -// expect(mockSendEmail).not.toBeCalled() -// const transactionalEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(transactionalEmail).not.toBeNull() -// expect(transactionalEmail).toMatchObject({ -// recipient: validApiCall.recipient, -// from: 'Hello ', -// status: TransactionalEmailMessageStatus.Unsent, -// errorCode: `Error 400: ${INVALID_FROM_ADDRESS_ERROR_MESSAGE}`, -// }) -// }) - -// test('Should throw an error with invalid custom from address (user email not added into EmailFromAddress table)', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// const from = `Hello <${user.email}>` -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send({ -// recipient: 'recipient@agency.gov.sg', -// subject: 'subject', -// body: '

body

', -// from, -// reply_to: user.email, -// }) - -// expect(res.status).toBe(400) -// expect(mockSendEmail).not.toBeCalled() -// const transactionalEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(transactionalEmail).not.toBeNull() -// expect(transactionalEmail).toMatchObject({ -// recipient: validApiCall.recipient, -// from: `Hello <${user.email}>`, -// status: TransactionalEmailMessageStatus.Unsent, -// errorCode: `Error 400: ${UNVERIFIED_FROM_ADDRESS_ERROR_MESSAGE}`, -// }) -// }) - -// test('Should throw an error if email subject or body is empty after removing invalid HTML tags and correct error is saved in db', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// const invalidHtmlTagsSubjectAndBody = { -// subject: '\n\n\n', -// body: '', -// } -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send({ -// ...validApiCall, -// subject: invalidHtmlTagsSubjectAndBody.subject, -// body: invalidHtmlTagsSubjectAndBody.body, -// }) - -// expect(res.status).toBe(400) -// expect(mockSendEmail).not.toBeCalled() - -// const transactionalEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(transactionalEmail).not.toBeNull() -// expect(transactionalEmail).toMatchObject({ -// recipient: validApiCall.recipient, -// from: validApiCall.from, -// status: TransactionalEmailMessageStatus.Unsent, -// }) -// expect(transactionalEmail?.params).toMatchObject({ -// // NB sanitisation only occurs at sending step, doesn't affect saving in params -// subject: invalidHtmlTagsSubjectAndBody.subject, -// body: invalidHtmlTagsSubjectAndBody.body, -// from: validApiCall.from, -// reply_to: validApiCall.reply_to, -// }) -// expect(transactionalEmail?.errorCode).toBe(EMPTY_MESSAGE_ERROR_CODE) -// }) - -// test('Should send email if subject and body are not empty after removing invalid HTML tags and metadata is saved correctly in db', async () => { -// mockSendEmail = jest -// .spyOn(EmailService, 'sendEmail') -// .mockResolvedValue(true) -// const invalidHtmlTagsSubjectAndBody = { -// subject: 'HELLO', -// body: '', -// } - -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send({ -// ...validApiCall, -// subject: invalidHtmlTagsSubjectAndBody.subject, -// body: invalidHtmlTagsSubjectAndBody.body, -// }) - -// expect(res.status).toBe(201) -// expect(mockSendEmail).toBeCalled() - -// const transactionalEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(transactionalEmail).not.toBeNull() -// expect(transactionalEmail).toMatchObject({ -// recipient: validApiCall.recipient, -// from: validApiCall.from, -// status: TransactionalEmailMessageStatus.Accepted, -// }) -// expect(transactionalEmail?.params).toMatchObject({ -// // NB sanitisation only occurs at sending step, doesn't affect saving in params -// subject: invalidHtmlTagsSubjectAndBody.subject, -// body: invalidHtmlTagsSubjectAndBody.body, -// from: validApiCall.from, -// reply_to: validApiCall.reply_to, -// }) -// expect(transactionalEmail?.errorCode).toBe(null) - -// expect(mockSendEmail).toBeCalledWith( -// { -// subject: 'HELLO', -// from: validApiCall.from, -// body: 'alert("hello")', -// recipients: [validApiCall.recipient], -// replyTo: validApiCall.reply_to, -// messageId: ( -// transactionalEmail as EmailMessageTransactional -// ).id.toString(), -// attachments: undefined, -// }, -// { disableTracking: false, extraSmtpHeaders: { isTransactional: true } } -// ) -// }) -// test('Should throw a 400 error if the body size is too large (JSON payload)', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// const body = 'a'.repeat(1024 * 1024 * 5) // 5MB -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send({ -// ...validApiCall, -// body, -// }) -// expect(res.status).toBe(400) -// expect(mockSendEmail).not.toBeCalled() -// }) - -// test('Should throw a 413 error if body size is wayyy too large (JSON payload)', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// const body = 'a'.repeat(1024 * 1024 * 2) // 2MB -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send({ -// ...validApiCall, -// body, -// }) -// // note: in practice, size of payload is limited by size specified in backend/.platform/nginx/conf.d/client_max_body_size.conf -// expect(res.status).toBe(400) -// expect(res.body).toStrictEqual({ -// code: 'api_validation', -// message: -// 'body is a required string whose size must be less than or equal to 1MB in UTF-8 encoding', -// }) -// expect(mockSendEmail).not.toBeCalled() -// }) - -// test('Should throw 400 error if body size is too large (URL encoded payload)', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// const body = 'a'.repeat(1024 * 1024 * 5) // 5MB -// const res = await request(app) -// .post(endpoint) -// .type('form') -// .set('Authorization', `Bearer ${apiKey}`) -// .send({ -// ...validApiCall, -// body, -// }) -// expect(res.status).toBe(400) -// }) - -// test('Should throw 413 error if body size is wayy too large (URL encoded payload)', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// const body = 'a'.repeat(1024 * 1024 * 2) // 15MB -// const res = await request(app) -// .post(endpoint) -// .type('form') -// .set('Authorization', `Bearer ${apiKey}`) -// .send({ -// ...validApiCall, -// body, -// }) -// // note: in practice, size of payload is limited by size specified in backend/.platform/nginx/conf.d/client_max_body_size.conf -// expect(res.status).toBe(400) -// expect(res.body).toStrictEqual({ -// code: 'api_validation', -// message: -// 'body is a required string whose size must be less than or equal to 1MB in UTF-8 encoding', -// }) -// expect(mockSendEmail).not.toBeCalled() -// }) - -// test('Should throw a 400 error if the body size is too large (multipart payload)', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// const body = 'a'.repeat(1024 * 1024 * 5) // 5MB -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .field('recipient', validApiCall.recipient) -// .field('subject', validApiCall.subject) -// .field('from', validApiCall.from) -// .field('reply_to', validApiCall.reply_to) -// .field('body', body) -// expect(res.status).toBe(400) -// expect(mockSendEmail).not.toBeCalled() -// }) - -// test('Should throw a 400 error even if body size is wayyy too large because of truncation (multipart payload)', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// const body = 'a'.repeat(1024 * 1024 * 15) // 15MB -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .field('recipient', validApiCall.recipient) -// .field('subject', validApiCall.subject) -// .field('from', validApiCall.from) -// .field('reply_to', validApiCall.reply_to) -// .field('body', body) -// expect(res.status).toBe(400) -// // note: in practice, size of payload is limited by size specified in backend/.platform/nginx/conf.d/client_max_body_size.conf -// expect(mockSendEmail).not.toBeCalled() -// }) - -// test('Show throw 403 error is user is sending attachment from default email address', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .field('recipient', validApiCallAttachment.recipient) -// .field('subject', validApiCallAttachment.subject) -// .field('body', validApiCallAttachment.body) -// .field('from', 'Postman ') -// .field('reply_to', validApiCallAttachment.reply_to) -// .attach('attachments', validAttachment, validAttachmentName) -// expect(res.status).toBe(403) -// expect(mockSendEmail).not.toBeCalled() -// }) - -// test('Should throw an error if file type of attachment is not supported and correct error is saved in db', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// // not actually an invalid file type; FileExtensionService checks magic number -// const invalidFileTypeAttachment = generateRandomFileSizeInMb(1) -// const invalidFileTypeAttachmentName = 'invalid.exe' -// // instead, we just mock the service to return false -// const mockFileTypeCheck = jest -// .spyOn(FileExtensionService, 'hasAllowedExtensions') -// .mockResolvedValue(false) - -// await EmailFromAddress.create({ -// email: user.email, -// name: 'Agency ABC', -// } as EmailFromAddress) - -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .field('recipient', validApiCallAttachment.recipient) -// .field('subject', validApiCallAttachment.subject) -// .field('body', validApiCallAttachment.body) -// .field('from', validApiCallAttachment.from) -// .field('reply_to', validApiCallAttachment.reply_to) -// .attach( -// 'attachments', -// invalidFileTypeAttachment, -// invalidFileTypeAttachmentName -// ) - -// expect(res.status).toBe(400) -// expect(mockSendEmail).not.toBeCalled() -// expect(mockFileTypeCheck).toBeCalledTimes(1) -// mockFileTypeCheck.mockClear() - -// const transactionalEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(transactionalEmail).not.toBeNull() -// expect(transactionalEmail).toMatchObject({ -// recipient: validApiCallAttachment.recipient, -// from: validApiCallAttachment.from, -// status: TransactionalEmailMessageStatus.Unsent, -// }) -// expect(transactionalEmail?.params).toMatchObject({ -// from: validApiCallAttachment.from, -// reply_to: validApiCallAttachment.reply_to, -// }) -// expect(transactionalEmail?.errorCode).toBe(UNSUPPORTED_FILE_TYPE_ERROR_CODE) -// }) - -// test('Should throw an error if recipient is blacklisted and correct error is saved in db', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// // not actually a blacklisted recipient -// const blacklistedRecipient = 'blacklisted@baddomain.com' -// // instead, mock to return recipient as blacklisted -// const mockIsBlacklisted = jest -// .spyOn(EmailService, 'findBlacklistedRecipients') -// .mockResolvedValue(['blacklisted@baddomain.com']) -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send({ -// ...validApiCall, -// recipient: blacklistedRecipient, -// }) - -// expect(res.status).toBe(400) -// expect(mockSendEmail).not.toBeCalled() -// expect(mockIsBlacklisted).toBeCalledTimes(1) -// mockIsBlacklisted.mockClear() - -// const transactionalEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(transactionalEmail).not.toBeNull() -// expect(transactionalEmail).toMatchObject({ -// recipient: blacklistedRecipient, -// from: validApiCall.from, -// status: TransactionalEmailMessageStatus.Unsent, -// }) -// expect(transactionalEmail?.params).toMatchObject({ -// from: validApiCall.from, -// reply_to: validApiCall.reply_to, -// }) -// expect(transactionalEmail?.errorCode).toBe(BLACKLISTED_RECIPIENT_ERROR_CODE) -// }) - -// test('Should send email with a valid attachment and attachment metadata is saved correctly in db', async () => { -// mockSendEmail = jest -// .spyOn(EmailService, 'sendEmail') -// .mockResolvedValue(true) - -// await EmailFromAddress.create({ -// email: user.email, -// name: 'Agency ABC', -// } as EmailFromAddress) - -// // request.send() cannot be used with file attachments -// // substitute form values with request.field(). refer to -// // https://visionmedia.github.io/superagent/#multipart-requests -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .field('recipient', validApiCallAttachment.recipient) -// .field('subject', validApiCallAttachment.subject) -// .field('body', validApiCallAttachment.body) -// .field('from', validApiCallAttachment.from) -// .field('reply_to', validApiCallAttachment.reply_to) -// .attach('attachments', validAttachment, validAttachmentName) - -// expect(res.status).toBe(201) -// expect(res.body).toBeDefined() -// expect(res.body.attachments_metadata).toBeDefined() -// expect(mockSendEmail).toBeCalledTimes(1) -// expect(mockSendEmail).toBeCalledWith( -// { -// body: validApiCallAttachment.body, -// from: validApiCallAttachment.from, -// replyTo: validApiCallAttachment.reply_to, -// subject: validApiCallAttachment.subject, -// recipients: [validApiCallAttachment.recipient], -// messageId: expect.any(String), -// attachments: [ -// { -// content: expect.any(Buffer), -// filename: validAttachmentName, -// }, -// ], -// }, -// { -// disableTracking: false, -// extraSmtpHeaders: { isTransactional: true }, -// } -// ) -// const transactionalEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(transactionalEmail).not.toBeNull() -// expect(transactionalEmail).toMatchObject({ -// recipient: validApiCallAttachment.recipient, -// from: validApiCallAttachment.from, -// status: TransactionalEmailMessageStatus.Accepted, -// errorCode: null, -// }) -// expect(transactionalEmail?.params).toMatchObject({ -// subject: validApiCallAttachment.subject, -// body: validApiCallAttachment.body, -// from: validApiCallAttachment.from, -// reply_to: validApiCallAttachment.reply_to, -// }) -// expect(transactionalEmail?.attachmentsMetadata).not.toBeNull() -// expect(transactionalEmail?.attachmentsMetadata).toHaveLength(1) -// expect(transactionalEmail?.attachmentsMetadata).toMatchObject([ -// { -// fileName: validAttachmentName, -// fileSize: validAttachmentSize, -// hash: expect.stringMatching(validAttachmentHashRegex), -// }, -// ]) -// }) - -// test('Should send email with a valid attachment and attachment metadata is saved correctly in db (with content id tag)', async () => { -// mockSendEmail = jest -// .spyOn(EmailService, 'sendEmail') -// .mockResolvedValue(true) - -// await EmailFromAddress.create({ -// email: user.email, -// name: 'Agency ABC', -// } as EmailFromAddress) - -// // request.send() cannot be used with file attachments -// // substitute form values with request.field(). refer to -// // https://visionmedia.github.io/superagent/#multipart-requests -// const bodyWithContentIdTag = -// validApiCallAttachment.body + '' -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .field('recipient', validApiCallAttachment.recipient) -// .field('subject', validApiCallAttachment.subject) -// .field('body', bodyWithContentIdTag) -// .field('from', validApiCallAttachment.from) -// .field('reply_to', validApiCallAttachment.reply_to) -// .attach('attachments', validAttachment, validAttachmentName) - -// expect(res.status).toBe(201) -// expect(res.body).toBeDefined() -// expect(res.body.attachments_metadata).toBeDefined() -// expect(mockSendEmail).toBeCalledTimes(1) -// expect(mockSendEmail).toBeCalledWith( -// { -// body: bodyWithContentIdTag, -// from: validApiCallAttachment.from, -// replyTo: validApiCallAttachment.reply_to, -// subject: validApiCallAttachment.subject, -// recipients: [validApiCallAttachment.recipient], -// messageId: expect.any(String), -// attachments: [ -// { -// cid: '0', -// content: expect.any(Buffer), -// filename: validAttachmentName, -// }, -// ], -// }, -// { -// disableTracking: false, -// extraSmtpHeaders: { isTransactional: true }, -// } -// ) -// const transactionalEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(transactionalEmail).not.toBeNull() -// expect(transactionalEmail).toMatchObject({ -// recipient: validApiCallAttachment.recipient, -// from: validApiCallAttachment.from, -// status: TransactionalEmailMessageStatus.Accepted, -// errorCode: null, -// }) -// expect(transactionalEmail?.params).toMatchObject({ -// subject: validApiCallAttachment.subject, -// body: bodyWithContentIdTag, -// from: validApiCallAttachment.from, -// reply_to: validApiCallAttachment.reply_to, -// }) -// expect(transactionalEmail?.attachmentsMetadata).not.toBeNull() -// expect(transactionalEmail?.attachmentsMetadata).toHaveLength(1) -// expect(transactionalEmail?.attachmentsMetadata).toMatchObject([ -// { -// fileName: validAttachmentName, -// fileSize: validAttachmentSize, -// hash: expect.stringMatching(validAttachmentHashRegex), -// }, -// ]) -// }) - -// test('Email with attachment that exceeds size limit should fail', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// const invalidAttachmentTooBig = generateRandomFileSizeInMb(10) -// const invalidAttachmentTooBigName = 'too big.txt' - -// await EmailFromAddress.create({ -// email: user.email, -// name: 'Agency ABC', -// } as EmailFromAddress) - -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .field('recipient', validApiCallAttachment.recipient) -// .field('subject', validApiCallAttachment.subject) -// .field('body', validApiCallAttachment.body) -// .field('from', validApiCallAttachment.from) -// .field('reply_to', validApiCallAttachment.reply_to) -// .attach( -// 'attachments', -// invalidAttachmentTooBig, -// invalidAttachmentTooBigName -// ) - -// expect(res.status).toBe(413) -// expect(mockSendEmail).not.toBeCalled() -// // no need to check EmailMessageTransactional since this is rejected before db record is saved -// }) -// test('Email with more than 10MB cumulative attachments should fail', async () => { -// mockSendEmail = jest.spyOn(EmailService, 'sendEmail') -// await EmailFromAddress.create({ -// email: user.email, -// name: 'Agency ABC', -// } as EmailFromAddress) -// const onepointnineMbAttachment = generateRandomFileSizeInMb(1.9) - -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .field('recipient', validApiCallAttachment.recipient) -// .field('subject', validApiCallAttachment.subject) -// .field('body', validApiCallAttachment.body) -// .field('from', validApiCallAttachment.from) -// .field('reply_to', validApiCallAttachment.reply_to) -// .attach('attachments', onepointnineMbAttachment, 'attachment1') -// .attach('attachments', onepointnineMbAttachment, 'attachment2') -// .attach('attachments', onepointnineMbAttachment, 'attachment3') -// .attach('attachments', onepointnineMbAttachment, 'attachment4') -// .attach('attachments', onepointnineMbAttachment, 'attachment5') -// .attach('attachments', onepointnineMbAttachment, 'attachment6') - -// expect(res.status).toBe(413) -// expect(mockSendEmail).not.toBeCalled() -// // no need to check EmailMessageTransactional since this is rejected before db record is saved -// }) - -// test('Should send email with two valid attachments and metadata is saved correctly in db', async () => { -// mockSendEmail = jest -// .spyOn(EmailService, 'sendEmail') -// .mockResolvedValue(true) - -// await EmailFromAddress.create({ -// email: user.email, -// name: 'Agency ABC', -// } as EmailFromAddress) - -// const validAttachment2 = generateRandomSmallFile() -// const validAttachment2Name = 'hey.txt' -// const validAttachment2Size = Buffer.byteLength(validAttachment2) - -// const res = await request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .field('recipient', validApiCallAttachment.recipient) -// .field('subject', validApiCallAttachment.subject) -// .field('body', validApiCallAttachment.body) -// .field('from', validApiCallAttachment.from) -// .field('reply_to', validApiCallAttachment.reply_to) -// .attach('attachments', validAttachment, validAttachmentName) -// .attach('attachments', validAttachment2, validAttachment2Name) - -// expect(res.status).toBe(201) -// expect(mockSendEmail).toBeCalledTimes(1) -// expect(mockSendEmail).toBeCalledWith( -// { -// body: validApiCallAttachment.body, -// from: validApiCallAttachment.from, -// replyTo: validApiCallAttachment.reply_to, -// subject: validApiCallAttachment.subject, -// recipients: [validApiCallAttachment.recipient], -// messageId: expect.any(String), -// attachments: [ -// { -// content: expect.any(Buffer), -// filename: validAttachmentName, -// }, -// { -// content: expect.any(Buffer), -// filename: validAttachment2Name, -// }, -// ], -// }, -// { -// disableTracking: false, -// extraSmtpHeaders: { isTransactional: true }, -// } -// ) -// const transactionalEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(transactionalEmail).not.toBeNull() -// expect(transactionalEmail).toMatchObject({ -// recipient: validApiCallAttachment.recipient, -// from: validApiCallAttachment.from, -// status: TransactionalEmailMessageStatus.Accepted, -// errorCode: null, -// }) -// expect(transactionalEmail?.params).toMatchObject({ -// subject: validApiCallAttachment.subject, -// body: validApiCallAttachment.body, -// from: validApiCallAttachment.from, -// reply_to: validApiCallAttachment.reply_to, -// }) -// expect(transactionalEmail?.attachmentsMetadata).not.toBeNull() -// expect(transactionalEmail?.attachmentsMetadata).toHaveLength(2) -// expect(transactionalEmail?.attachmentsMetadata).toMatchObject([ -// { -// fileName: validAttachmentName, -// fileSize: validAttachmentSize, -// hash: expect.stringMatching(validAttachmentHashRegex), -// }, -// { -// fileName: validAttachment2Name, -// fileSize: validAttachment2Size, -// hash: expect.stringMatching(validAttachmentHashRegex), -// }, -// ]) -// }) - -// test('Requests should be rate limited and metadata and error code is saved correctly in db', async () => { -// mockSendEmail = jest -// .spyOn(EmailService, 'sendEmail') -// .mockResolvedValue(true) -// const send = (): Promise => { -// return request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send(validApiCall) -// } - -// // First request passes -// let res = await send() -// expect(res.status).toBe(201) -// expect(mockSendEmail).toBeCalledTimes(1) -// mockSendEmail.mockClear() -// const firstEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(firstEmail).not.toBeNull() -// expect(firstEmail).toMatchObject({ -// recipient: validApiCall.recipient, -// from: validApiCall.from, -// status: TransactionalEmailMessageStatus.Accepted, -// errorCode: null, -// }) -// expect(firstEmail?.params).toMatchObject({ -// subject: validApiCall.subject, -// body: validApiCall.body, -// from: validApiCall.from, -// reply_to: validApiCall.reply_to, -// }) - -// // Second request rate limited -// res = await send() -// expect(res.status).toBe(429) -// expect(mockSendEmail).not.toBeCalled() -// mockSendEmail.mockClear() -// }) - -// test('Requests should not be rate limited after window elasped and metadata is saved correctly in db', async () => { -// mockSendEmail = jest -// .spyOn(EmailService, 'sendEmail') -// .mockResolvedValue(true) -// const send = (): Promise => { -// return request(app) -// .post(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// .send(validApiCall) -// } -// // First request passes -// let res = await send() -// expect(res.status).toBe(201) -// expect(mockSendEmail).toBeCalledTimes(1) -// mockSendEmail.mockClear() -// const firstEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(firstEmail).not.toBeNull() -// expect(firstEmail).toMatchObject({ -// recipient: validApiCall.recipient, -// from: validApiCall.from, -// status: TransactionalEmailMessageStatus.Accepted, -// errorCode: null, -// }) -// expect(firstEmail?.params).toMatchObject({ -// subject: validApiCall.subject, -// body: validApiCall.body, -// from: validApiCall.from, -// }) - -// // Second request rate limited -// res = await send() -// expect(res.status).toBe(429) -// expect(mockSendEmail).not.toBeCalled() -// mockSendEmail.mockClear() -// // Third request passes after 1s -// await new Promise((resolve) => -// setTimeout(resolve, TRANSACTIONAL_EMAIL_WINDOW * 1000) -// ) -// res = await send() -// expect(res.status).toBe(201) -// expect(mockSendEmail).toBeCalledTimes(1) -// mockSendEmail.mockClear() -// const thirdEmail = await EmailMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// order: [['createdAt', 'DESC']], -// }) -// expect(thirdEmail).not.toBeNull() -// expect(thirdEmail).toMatchObject({ -// recipient: validApiCall.recipient, -// from: validApiCall.from, -// status: TransactionalEmailMessageStatus.Accepted, -// errorCode: null, -// }) -// expect(thirdEmail?.params).toMatchObject({ -// subject: validApiCall.subject, -// body: validApiCall.body, -// from: validApiCall.from, -// reply_to: validApiCall.reply_to, -// }) -// }) -// }) - -// describe(`GET ${emailTransactionalRoute}`, () => { -// const endpoint = emailTransactionalRoute -// const acceptedMessage = { -// recipient: 'recipient@gmail.com', -// from: 'Postman ', -// params: { -// from: 'Postman ', -// subject: 'Test', -// body: 'Test Body', -// }, -// status: TransactionalEmailMessageStatus.Accepted, -// } -// const sentMessage = { -// recipient: 'recipient@agency.gov.sg', -// from: 'Postman ', -// params: { -// from: 'Postman ', -// subject: 'Test', -// body: 'Test Body', -// }, -// status: TransactionalEmailMessageStatus.Sent, -// } -// const deliveredMessage = { -// recipient: 'recipient3@agency.gov.sg', -// from: 'Postman ', -// params: { -// from: 'Postman ', -// subject: 'Test', -// body: 'Test Body', -// }, -// status: TransactionalEmailMessageStatus.Delivered, -// } -// test('Should return 200 with empty array when no messages are found', async () => { -// const res = await request(app) -// .get(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res.status).toBe(200) -// expect(res.body.has_more).toBe(false) -// expect(res.body.data).toEqual([]) -// }) - -// test('Should return 200 with descending array of messages when messages are found', async () => { -// const message = await EmailMessageTransactional.create({ -// ...deliveredMessage, -// userId: user.id, -// } as unknown as EmailMessageTransactional) -// const message2 = await EmailMessageTransactional.create({ -// ...acceptedMessage, -// userId: user.id, -// } as unknown as EmailMessageTransactional) -// const res = await request(app) -// .get(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res.status).toBe(200) -// expect(res.body.has_more).toBe(false) -// expect(res.body.data).toMatchObject([ -// // descending by default -// { -// id: message2.id, -// recipient: message2.recipient, -// from: message2.from, -// params: message2.params, -// status: message2.status, -// }, -// { -// id: message.id, -// recipient: message.recipient, -// from: message.from, -// params: message.params, -// status: message.status, -// }, -// ]) -// }) -// test('Should return 400 when invalid query params are provided', async () => { -// const resInvalidLimit = await request(app) -// .get(`${endpoint}?limit=invalid`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(resInvalidLimit.status).toBe(400) -// const resInvalidLimitTooLarge = await request(app) -// .get(`${endpoint}?limit=1001`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(resInvalidLimitTooLarge.status).toBe(400) -// const resInvalidOffset = await request(app) -// .get(`${endpoint}?offset=blahblah`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(resInvalidOffset.status).toBe(400) -// const resInvalidOffsetNegative = await request(app) -// .get(`${endpoint}?offset=-1`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(resInvalidOffsetNegative.status).toBe(400) -// const resInvalidStatus = await request(app) -// .get(`${endpoint}?status=blacksheep`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(resInvalidStatus.status).toBe(400) -// // repeated params should throw an error too -// const resInvalidStatus2 = await request(app) -// .get(`${endpoint}?status=sent&status=delivered`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(resInvalidStatus2.status).toBe(400) -// const resInvalidCreatedAt = await request(app) -// .get(`${endpoint}?created_at=haveyouanywool`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(resInvalidCreatedAt.status).toBe(400) -// const resInvalidCreatedAtDateFormat = await request(app) -// .get(`${endpoint}?created_at[gte]=20200101`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(resInvalidCreatedAtDateFormat.status).toBe(400) -// const resInvalidSortBy = await request(app) -// .get(`${endpoint}?sort_by=threebagsfull`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(resInvalidSortBy.status).toBe(400) -// const resInvalidSortByPrefix = await request(app) -// .get(endpoint) -// // need to use query() instead of get() for operator to be processed correctly -// .query({ sort_by: '*created_at' }) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(resInvalidSortByPrefix.status).toBe(400) -// }) -// test('default values of limit and offset should be 10 and 0 respectively', async () => { -// for (let i = 0; i < 15; i++) { -// await EmailMessageTransactional.create({ -// ...deliveredMessage, -// userId: user.id, -// } as unknown as EmailMessageTransactional) -// } -// const res = await request(app) -// .get(endpoint) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res.status).toBe(200) -// expect(res.body.has_more).toBe(true) -// expect(res.body.data.length).toBe(10) - -// const res2 = await request(app) -// .get(`${endpoint}?offset=10`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res2.status).toBe(200) -// expect(res2.body.has_more).toBe(false) -// expect(res2.body.data.length).toBe(5) - -// const res3 = await request(app) -// .get(`${endpoint}?offset=15`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res3.status).toBe(200) -// expect(res3.body.has_more).toBe(false) -// expect(res3.body.data.length).toBe(0) - -// const res4 = await request(app) -// .get(`${endpoint}?limit=5`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res4.status).toBe(200) -// expect(res4.body.has_more).toBe(true) -// expect(res4.body.data.length).toBe(5) - -// const res5 = await request(app) -// .get(`${endpoint}?limit=15`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res5.status).toBe(200) -// expect(res5.body.has_more).toBe(false) -// expect(res5.body.data.length).toBe(15) -// }) - -// test('status filter should work', async () => { -// for (let i = 0; i < 5; i++) { -// await EmailMessageTransactional.create({ -// ...deliveredMessage, -// userId: user.id, -// } as unknown as EmailMessageTransactional) -// } -// for (let i = 0; i < 5; i++) { -// await EmailMessageTransactional.create({ -// ...acceptedMessage, -// userId: user.id, -// } as unknown as EmailMessageTransactional) -// } -// for (let i = 0; i < 5; i++) { -// await EmailMessageTransactional.create({ -// ...sentMessage, -// userId: user.id, -// } as unknown as EmailMessageTransactional) -// } -// const res = await request(app) -// .get(`${endpoint}?status=delivered`) // case-insensitive -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res.status).toBe(200) -// expect(res.body.has_more).toBe(false) -// expect(res.body.data.length).toBe(5) -// res.body.data.forEach((message: EmailMessageTransactional) => { -// expect(message.status).toBe(TransactionalEmailMessageStatus.Delivered) -// }) -// const res2 = await request(app) -// .get(`${endpoint}?status=aCcEPteD`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res2.status).toBe(200) -// expect(res2.body.has_more).toBe(false) -// expect(res2.body.data.length).toBe(5) -// res2.body.data.forEach((message: EmailMessageTransactional) => { -// expect(message.status).toBe(TransactionalEmailMessageStatus.Accepted) -// }) -// const res3 = await request(app) -// .get(`${endpoint}?status=SENT`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res3.status).toBe(200) -// expect(res3.body.has_more).toBe(false) -// expect(res3.body.data.length).toBe(5) -// res3.body.data.forEach((message: EmailMessageTransactional) => { -// expect(message.status).toBe(TransactionalEmailMessageStatus.Sent) -// }) -// // duplicate status params should throw an error -// const res4 = await request(app) -// .get(`${endpoint}?status=SENT&status=ACCEPTED`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res4.status).toBe(400) -// }) -// test('created_at filter range should work', async () => { -// const messages = [] -// const now = new Date() -// for (let i = 0; i < 10; i++) { -// const message = await EmailMessageTransactional.create({ -// ...deliveredMessage, -// userId: user.id, -// createdAt: new Date(now.getTime() - 100000 + i * 1000), // inserting in chronological order -// } as unknown as EmailMessageTransactional) -// messages.push(message) -// } -// const res = await request(app) -// .get( -// `${endpoint}?created_at[gte]=${messages[0].createdAt.toISOString()}&created_at[lte]=${messages[4].createdAt.toISOString()}` -// ) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res.status).toBe(200) -// expect(res.body.has_more).toBe(false) -// expect(res.body.data.length).toBe(5) - -// const res2 = await request(app) -// .get( -// `${endpoint}?created_at[gt]=${messages[0].createdAt.toISOString()}&created_at[lt]=${messages[4].createdAt.toISOString()}` -// ) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res2.status).toBe(200) -// expect(res2.body.has_more).toBe(false) -// expect(res2.body.data.length).toBe(3) - -// // repeated operators should throw an error -// const res3 = await request(app) -// .get( -// `${endpoint}?created_at[gte]=${messages[0].createdAt.toISOString()}&created_at[lte]=${messages[4].createdAt.toISOString()}&created_at[gte]=${messages[0].createdAt.toISOString()}` -// ) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res3.status).toBe(400) -// // if gt and lt are used, gte and lte should be ignored -// const res4 = await request(app) -// .get( -// `${endpoint}?created_at[gt]=${messages[0].createdAt.toISOString()}&created_at[lt]=${messages[4].createdAt.toISOString()}&created_at[gte]=${messages[0].createdAt.toISOString()}` -// ) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res4.status).toBe(200) -// expect(res4.body.has_more).toBe(false) -// expect(res4.body.data.length).toBe(3) -// }) -// test('sort_by should work', async () => { -// const messages = [] -// const now = new Date() -// for (let i = 0; i < 10; i++) { -// const message = await EmailMessageTransactional.create({ -// ...deliveredMessage, -// userId: user.id, -// createdAt: new Date(now.getTime() - 100000 + i * 1000), // inserting in chronological order -// } as unknown as EmailMessageTransactional) -// messages.push(message) -// } - -// const res = await request(app) -// .get(`${endpoint}?sort_by=created_at`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res.status).toBe(200) -// expect(res.body.has_more).toBe(false) -// expect(res.body.data.length).toBe(10) -// // default descending order -// expect(res.body.data[0].id).toBe(messages[9].id) -// expect(res.body.data[9].id).toBe(messages[0].id) - -// const res2 = await request(app) -// .get(endpoint) -// // need to use query() instead of get() for operator to be processed correctly -// .query({ sort_by: '+created_at' }) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res2.status).toBe(200) -// expect(res2.body.has_more).toBe(false) -// expect(res2.body.data.length).toBe(10) -// expect(res2.body.data[0].id).toBe(messages[0].id) -// expect(res2.body.data[9].id).toBe(messages[9].id) - -// const res3 = await request(app) -// .get(endpoint) -// // need to use query() instead of get() for operator to be processed correctly -// .query({ sort_by: '-created_at' }) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res3.status).toBe(200) -// expect(res3.body.has_more).toBe(false) -// expect(res3.body.data.length).toBe(10) -// expect(res3.body.data[0].id).toBe(messages[9].id) -// expect(res3.body.data[9].id).toBe(messages[0].id) - -// const res4 = await request(app) -// .get(endpoint) -// // this is basically testing for repeating sort_by params twice, e.g. endpoint?sort_by=+created_at&sort_by=created_at -// .query({ sort_by: ['created_at', '+created_at'] }) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res4.status).toBe(400) -// }) -// test('combination of query params should work', async () => { -// const messages = [] -// const now = new Date() -// for (let i = 0; i < 15; i++) { -// // mixing up different messages -// const messageParams = -// i % 3 === 0 -// ? deliveredMessage -// : i % 3 === 1 -// ? sentMessage -// : acceptedMessage -// const message = await EmailMessageTransactional.create({ -// ...messageParams, -// userId: user.id, -// createdAt: new Date(now.getTime() - 100000 + i * 1000), // inserting in chronological order -// } as unknown as EmailMessageTransactional) -// messages.push(message) -// } -// const res = await request(app) -// .get( -// `${endpoint}?created_at[gte]=${messages[0].createdAt.toISOString()}&created_at[lte]=${messages[4].createdAt.toISOString()}&sort_by=created_at` -// ) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res.status).toBe(200) -// expect(res.body.has_more).toBe(false) -// expect(res.body.data.length).toBe(5) -// expect(res.body.data[0].id).toBe(messages[4].id) -// expect(res.body.data[4].id).toBe(messages[0].id) - -// const res2 = await request(app) -// .get(endpoint) -// .query({ status: 'delivered', sort_by: '+created_at', limit: '4' }) -// .set('Authorization', `Bearer ${apiKey}`) - -// expect(res2.status).toBe(200) -// expect(res2.body.has_more).toBe(true) -// expect(res2.body.data.length).toBe(4) -// res2.body.data.forEach((message: EmailMessageTransactional) => { -// expect(message.status).toBe(TransactionalEmailMessageStatus.Delivered) -// }) -// expect(new Date(res2.body.data[3].created_at).getTime()).toBeGreaterThan( -// // check that it is ascending -// new Date(res2.body.data[2].created_at).getTime() -// ) -// }) -// }) - -// describe(`GET ${emailTransactionalRoute}/:emailId`, () => { -// const endpoint = emailTransactionalRoute -// test('should return a transactional email message with corresponding ID', async () => { -// const message = await EmailMessageTransactional.create({ -// userId: user.id, -// recipient: 'recipient@agency.gov.sg', -// from: 'Postman ', -// params: { -// from: 'Postman ', -// subject: 'Test', -// body: 'Test Body', -// }, -// status: TransactionalEmailMessageStatus.Delivered, -// } as unknown as EmailMessageTransactional) -// const res = await request(app) -// .get(`${endpoint}/${message.id}`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res.status).toBe(200) -// expect(res.body).toBeDefined() -// expect(res.body.id).toBe(message.id) -// }) - -// test('should return 404 if the transactional email message ID not found', async () => { -// const id = 69 -// const res = await request(app) -// .get(`${endpoint}/${id}`) -// .set('Authorization', `Bearer ${apiKey}`) -// expect(res.status).toBe(404) -// expect(res.body.message).toBe(`Email message with ID ${id} not found.`) -// }) - -// test('should return 404 if the transactional email message belongs to another user', async () => { -// const anotherUser = await User.create({ -// id: 2, -// email: 'user_2@agency.gov.sg', -// } as User) -// const { plainTextKey: anotherApiKey } = await ( -// app as any as { credentialService: CredentialService } -// ).credentialService.generateApiKey(anotherUser.id, 'another test api key', [ -// anotherUser.email, -// ]) -// const message = await EmailMessageTransactional.create({ -// userId: user.id, -// recipient: 'recipient@agency.gov.sg', -// from: 'Postman ', -// params: { -// from: 'Postman ', -// subject: 'Test', -// body: 'Test Body', -// }, -// status: TransactionalEmailMessageStatus.Delivered, -// } as unknown as EmailMessageTransactional) -// const res = await request(app) -// .get(`${endpoint}/${message.id}`) -// .set('Authorization', `Bearer ${anotherApiKey}`) -// expect(res.status).toBe(404) -// expect(res.body.message).toBe( -// `Email message with ID ${message.id} not found.` -// ) -// }) -// }) +import request from 'supertest' +import { Sequelize } from 'sequelize-typescript' + +import { User } from '@core/models' +import { + CredentialService, + FileExtensionService, + UNSUPPORTED_FILE_TYPE_ERROR_CODE, +} from '@core/services' +import { + INVALID_FROM_ADDRESS_ERROR_MESSAGE, + TRANSACTIONAL_EMAIL_WINDOW, + UNVERIFIED_FROM_ADDRESS_ERROR_MESSAGE, +} from '@email/middlewares' +import { + EmailFromAddress, + EmailMessageTransactional, + TransactionalEmailMessageStatus, +} from '@email/models' +import { + BLACKLISTED_RECIPIENT_ERROR_CODE, + EmailService, + EMPTY_MESSAGE_ERROR_CODE, +} from '@email/services' + +import initialiseServer from '@test-utils/server' +import sequelizeLoader from '@test-utils/sequelize-loader' + +let sequelize: Sequelize +let user: User +let apiKey: string +let mockSendEmail: jest.SpyInstance + +const app = initialiseServer(false) +const userEmail = 'user@agency.gov.sg' + +beforeEach(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') + // Flush the rate limit redis database + await new Promise((resolve) => + (app as any).redisService.rateLimitClient.flushdb(resolve) + ) + user = await User.create({ + id: 1, + email: userEmail, + rateLimit: 1, // for ease of testing, so second API call within a second would fail + } as User) + const { plainTextKey } = await ( + app as any as { credentialService: CredentialService } + ).credentialService.generateApiKey(user.id, 'test api key', [user.email]) + apiKey = plainTextKey +}) + +afterEach(async () => { + jest.restoreAllMocks() + await EmailMessageTransactional.destroy({ where: {} }) + await User.destroy({ where: {} }) + await EmailFromAddress.destroy({ where: {} }) + await sequelize.close() +}) + +afterAll(async () => { + await new Promise((resolve) => + (app as any).redisService.rateLimitClient.flushdb(resolve) + ) + await (app as any).cleanup() +}) + +const emailTransactionalRoute = '/transactional/email' + +describe(`${emailTransactionalRoute}/send`, () => { + const endpoint = `${emailTransactionalRoute}/send` + const validApiCall = { + recipient: 'recipient@agency.gov.sg', + subject: 'subject', + body: '

body

', + from: 'Postman ', + reply_to: 'user@agency.gov.sg', + } + const generateRandomSmallFile = () => { + const randomFile = Buffer.from(Math.random().toString(36).substring(2)) + return randomFile + } + const generateRandomFileSizeInMb = (sizeInMb: number) => { + const randomFile = Buffer.alloc(sizeInMb * 1024 * 1024, '.') + return randomFile + } + + // attachment only allowed when sent from user's own email + const validApiCallAttachment = { + ...validApiCall, + from: `User <${userEmail}>`, + } + const validAttachment = generateRandomSmallFile() + const validAttachmentName = 'hi.txt' + const validAttachmentHashRegex = /^[a-f0-9]{32}$/ // MD5 32 characters + const validAttachmentSize = Buffer.byteLength(validAttachment) + + test('Should throw an error if API key is invalid', async () => { + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer invalid-${apiKey}`) + .send({}) + + expect(res.status).toBe(401) + }) + + test('Should throw an error if API key is valid but payload is not', async () => { + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send({}) + + expect(res.status).toBe(400) + }) + + test('Should send email successfully and metadata is saved correctly in db', async () => { + mockSendEmail = jest + .spyOn(EmailService, 'sendEmail') + .mockResolvedValue(true) + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send(validApiCall) + + expect(res.status).toBe(201) + expect(res.body).toBeDefined() + expect(typeof res.body.id).toBe('string') + expect(mockSendEmail).toBeCalledTimes(1) + const transactionalEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(transactionalEmail).not.toBeNull() + expect(transactionalEmail).toMatchObject({ + recipient: validApiCall.recipient, + from: validApiCall.from, + status: TransactionalEmailMessageStatus.Accepted, + errorCode: null, + }) + expect(transactionalEmail?.params).toMatchObject({ + subject: validApiCall.subject, + body: validApiCall.body, + from: validApiCall.from, + reply_to: validApiCall.reply_to, + }) + }) + + test('Should send a message with valid custom from name', async () => { + const mockSendEmail = jest + .spyOn(EmailService, 'sendEmail') + .mockResolvedValue(true) + + const from = 'Hello ' + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send({ + recipient: 'recipient@agency.gov.sg', + subject: 'subject', + body: '

body

', + from, + reply_to: user.email, + }) + + expect(res.status).toBe(201) + expect(res.body).toBeDefined() + expect(res.body.from).toBe(from) + expect(mockSendEmail).toBeCalledTimes(1) + const transactionalEmail = await EmailMessageTransactional.findOne({ + where: { id: res.body.id }, + }) + expect(transactionalEmail).not.toBeNull() + expect(transactionalEmail).toMatchObject({ + recipient: validApiCall.recipient, + from: 'Hello ', + status: TransactionalEmailMessageStatus.Accepted, + errorCode: null, + }) + expect(transactionalEmail?.params).toMatchObject({ + subject: validApiCall.subject, + body: validApiCall.body, + from: 'Hello ', + reply_to: user.email, + }) + }) + + test('Should send a message with valid custom from address', async () => { + const mockSendEmail = jest + .spyOn(EmailService, 'sendEmail') + .mockResolvedValue(true) + + await EmailFromAddress.create({ + email: user.email, + name: 'Agency ABC', + } as EmailFromAddress) + const from = `Hello <${user.email}>` + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send({ + recipient: 'recipient@agency.gov.sg', + subject: 'subject', + body: '

body

', + from, + reply_to: user.email, + }) + + expect(res.status).toBe(201) + expect(res.body).toBeDefined() + expect(res.body.from).toBe(from) + expect(mockSendEmail).toBeCalledTimes(1) + const transactionalEmail = await EmailMessageTransactional.findOne({ + where: { id: res.body.id }, + }) + expect(transactionalEmail).not.toBeNull() + expect(transactionalEmail).toMatchObject({ + recipient: validApiCall.recipient, + from: `Hello <${user.email}>`, + status: TransactionalEmailMessageStatus.Accepted, + errorCode: null, + }) + expect(transactionalEmail?.params).toMatchObject({ + subject: validApiCall.subject, + body: validApiCall.body, + from: `Hello <${user.email}>`, + reply_to: user.email, + }) + }) + + test('Should throw an error with invalid custom from address (not user email)', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send({ + ...validApiCall, + from: 'Hello ', + reply_to: user.email, + }) + + expect(res.status).toBe(400) + expect(mockSendEmail).not.toBeCalled() + const transactionalEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(transactionalEmail).not.toBeNull() + expect(transactionalEmail).toMatchObject({ + recipient: validApiCall.recipient, + from: 'Hello ', + status: TransactionalEmailMessageStatus.Unsent, + errorCode: `Error 400: ${INVALID_FROM_ADDRESS_ERROR_MESSAGE}`, + }) + }) + + test('Should throw an error with invalid custom from address (user email not added into EmailFromAddress table)', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + const from = `Hello <${user.email}>` + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send({ + recipient: 'recipient@agency.gov.sg', + subject: 'subject', + body: '

body

', + from, + reply_to: user.email, + }) + + expect(res.status).toBe(400) + expect(mockSendEmail).not.toBeCalled() + const transactionalEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(transactionalEmail).not.toBeNull() + expect(transactionalEmail).toMatchObject({ + recipient: validApiCall.recipient, + from: `Hello <${user.email}>`, + status: TransactionalEmailMessageStatus.Unsent, + errorCode: `Error 400: ${UNVERIFIED_FROM_ADDRESS_ERROR_MESSAGE}`, + }) + }) + + test('Should throw an error if email subject or body is empty after removing invalid HTML tags and correct error is saved in db', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + const invalidHtmlTagsSubjectAndBody = { + subject: '\n\n\n', + body: '', + } + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send({ + ...validApiCall, + subject: invalidHtmlTagsSubjectAndBody.subject, + body: invalidHtmlTagsSubjectAndBody.body, + }) + + expect(res.status).toBe(400) + expect(mockSendEmail).not.toBeCalled() + + const transactionalEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(transactionalEmail).not.toBeNull() + expect(transactionalEmail).toMatchObject({ + recipient: validApiCall.recipient, + from: validApiCall.from, + status: TransactionalEmailMessageStatus.Unsent, + }) + expect(transactionalEmail?.params).toMatchObject({ + // NB sanitisation only occurs at sending step, doesn't affect saving in params + subject: invalidHtmlTagsSubjectAndBody.subject, + body: invalidHtmlTagsSubjectAndBody.body, + from: validApiCall.from, + reply_to: validApiCall.reply_to, + }) + expect(transactionalEmail?.errorCode).toBe(EMPTY_MESSAGE_ERROR_CODE) + }) + + test('Should send email if subject and body are not empty after removing invalid HTML tags and metadata is saved correctly in db', async () => { + mockSendEmail = jest + .spyOn(EmailService, 'sendEmail') + .mockResolvedValue(true) + const invalidHtmlTagsSubjectAndBody = { + subject: 'HELLO', + body: '', + } + + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send({ + ...validApiCall, + subject: invalidHtmlTagsSubjectAndBody.subject, + body: invalidHtmlTagsSubjectAndBody.body, + }) + + expect(res.status).toBe(201) + expect(mockSendEmail).toBeCalled() + + const transactionalEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(transactionalEmail).not.toBeNull() + expect(transactionalEmail).toMatchObject({ + recipient: validApiCall.recipient, + from: validApiCall.from, + status: TransactionalEmailMessageStatus.Accepted, + }) + expect(transactionalEmail?.params).toMatchObject({ + // NB sanitisation only occurs at sending step, doesn't affect saving in params + subject: invalidHtmlTagsSubjectAndBody.subject, + body: invalidHtmlTagsSubjectAndBody.body, + from: validApiCall.from, + reply_to: validApiCall.reply_to, + }) + expect(transactionalEmail?.errorCode).toBe(null) + + expect(mockSendEmail).toBeCalledWith( + { + subject: 'HELLO', + from: validApiCall.from, + body: 'alert("hello")', + recipients: [validApiCall.recipient], + replyTo: validApiCall.reply_to, + messageId: ( + transactionalEmail as EmailMessageTransactional + ).id.toString(), + attachments: undefined, + }, + { disableTracking: false, extraSmtpHeaders: { isTransactional: true } } + ) + }) + test('Should throw a 400 error if the body size is too large (JSON payload)', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + const body = 'a'.repeat(1024 * 1024 * 5) // 5MB + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send({ + ...validApiCall, + body, + }) + expect(res.status).toBe(400) + expect(mockSendEmail).not.toBeCalled() + }) + + test('Should throw a 413 error if body size is wayyy too large (JSON payload)', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + const body = 'a'.repeat(1024 * 1024 * 2) // 2MB + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send({ + ...validApiCall, + body, + }) + // note: in practice, size of payload is limited by size specified in backend/.platform/nginx/conf.d/client_max_body_size.conf + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + code: 'api_validation', + message: + 'body is a required string whose size must be less than or equal to 1MB in UTF-8 encoding', + }) + expect(mockSendEmail).not.toBeCalled() + }) + + test('Should throw 400 error if body size is too large (URL encoded payload)', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + const body = 'a'.repeat(1024 * 1024 * 5) // 5MB + const res = await request(app) + .post(endpoint) + .type('form') + .set('Authorization', `Bearer ${apiKey}`) + .send({ + ...validApiCall, + body, + }) + expect(res.status).toBe(400) + }) + + test('Should throw 413 error if body size is wayy too large (URL encoded payload)', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + const body = 'a'.repeat(1024 * 1024 * 2) // 15MB + const res = await request(app) + .post(endpoint) + .type('form') + .set('Authorization', `Bearer ${apiKey}`) + .send({ + ...validApiCall, + body, + }) + // note: in practice, size of payload is limited by size specified in backend/.platform/nginx/conf.d/client_max_body_size.conf + expect(res.status).toBe(400) + expect(res.body).toStrictEqual({ + code: 'api_validation', + message: + 'body is a required string whose size must be less than or equal to 1MB in UTF-8 encoding', + }) + expect(mockSendEmail).not.toBeCalled() + }) + + test('Should throw a 400 error if the body size is too large (multipart payload)', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + const body = 'a'.repeat(1024 * 1024 * 5) // 5MB + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .field('recipient', validApiCall.recipient) + .field('subject', validApiCall.subject) + .field('from', validApiCall.from) + .field('reply_to', validApiCall.reply_to) + .field('body', body) + expect(res.status).toBe(400) + expect(mockSendEmail).not.toBeCalled() + }) + + test('Should throw a 400 error even if body size is wayyy too large because of truncation (multipart payload)', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + const body = 'a'.repeat(1024 * 1024 * 15) // 15MB + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .field('recipient', validApiCall.recipient) + .field('subject', validApiCall.subject) + .field('from', validApiCall.from) + .field('reply_to', validApiCall.reply_to) + .field('body', body) + expect(res.status).toBe(400) + // note: in practice, size of payload is limited by size specified in backend/.platform/nginx/conf.d/client_max_body_size.conf + expect(mockSendEmail).not.toBeCalled() + }) + + test('Show throw 403 error is user is sending attachment from default email address', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .field('recipient', validApiCallAttachment.recipient) + .field('subject', validApiCallAttachment.subject) + .field('body', validApiCallAttachment.body) + .field('from', 'Postman ') + .field('reply_to', validApiCallAttachment.reply_to) + .attach('attachments', validAttachment, validAttachmentName) + expect(res.status).toBe(403) + expect(mockSendEmail).not.toBeCalled() + }) + + test('Should throw an error if file type of attachment is not supported and correct error is saved in db', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + // not actually an invalid file type; FileExtensionService checks magic number + const invalidFileTypeAttachment = generateRandomFileSizeInMb(1) + const invalidFileTypeAttachmentName = 'invalid.exe' + // instead, we just mock the service to return false + const mockFileTypeCheck = jest + .spyOn(FileExtensionService, 'hasAllowedExtensions') + .mockResolvedValue(false) + + await EmailFromAddress.create({ + email: user.email, + name: 'Agency ABC', + } as EmailFromAddress) + + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .field('recipient', validApiCallAttachment.recipient) + .field('subject', validApiCallAttachment.subject) + .field('body', validApiCallAttachment.body) + .field('from', validApiCallAttachment.from) + .field('reply_to', validApiCallAttachment.reply_to) + .attach( + 'attachments', + invalidFileTypeAttachment, + invalidFileTypeAttachmentName + ) + + expect(res.status).toBe(400) + expect(mockSendEmail).not.toBeCalled() + expect(mockFileTypeCheck).toBeCalledTimes(1) + mockFileTypeCheck.mockClear() + + const transactionalEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(transactionalEmail).not.toBeNull() + expect(transactionalEmail).toMatchObject({ + recipient: validApiCallAttachment.recipient, + from: validApiCallAttachment.from, + status: TransactionalEmailMessageStatus.Unsent, + }) + expect(transactionalEmail?.params).toMatchObject({ + from: validApiCallAttachment.from, + reply_to: validApiCallAttachment.reply_to, + }) + expect(transactionalEmail?.errorCode).toBe(UNSUPPORTED_FILE_TYPE_ERROR_CODE) + }) + + test('Should throw an error if recipient is blacklisted and correct error is saved in db', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + // not actually a blacklisted recipient + const blacklistedRecipient = 'blacklisted@baddomain.com' + // instead, mock to return recipient as blacklisted + const mockIsBlacklisted = jest + .spyOn(EmailService, 'findBlacklistedRecipients') + .mockResolvedValue(['blacklisted@baddomain.com']) + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send({ + ...validApiCall, + recipient: blacklistedRecipient, + }) + + expect(res.status).toBe(400) + expect(mockSendEmail).not.toBeCalled() + expect(mockIsBlacklisted).toBeCalledTimes(1) + mockIsBlacklisted.mockClear() + + const transactionalEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(transactionalEmail).not.toBeNull() + expect(transactionalEmail).toMatchObject({ + recipient: blacklistedRecipient, + from: validApiCall.from, + status: TransactionalEmailMessageStatus.Unsent, + }) + expect(transactionalEmail?.params).toMatchObject({ + from: validApiCall.from, + reply_to: validApiCall.reply_to, + }) + expect(transactionalEmail?.errorCode).toBe(BLACKLISTED_RECIPIENT_ERROR_CODE) + }) + + test('Should send email with a valid attachment and attachment metadata is saved correctly in db', async () => { + mockSendEmail = jest + .spyOn(EmailService, 'sendEmail') + .mockResolvedValue(true) + + await EmailFromAddress.create({ + email: user.email, + name: 'Agency ABC', + } as EmailFromAddress) + + // request.send() cannot be used with file attachments + // substitute form values with request.field(). refer to + // https://visionmedia.github.io/superagent/#multipart-requests + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .field('recipient', validApiCallAttachment.recipient) + .field('subject', validApiCallAttachment.subject) + .field('body', validApiCallAttachment.body) + .field('from', validApiCallAttachment.from) + .field('reply_to', validApiCallAttachment.reply_to) + .attach('attachments', validAttachment, validAttachmentName) + + expect(res.status).toBe(201) + expect(res.body).toBeDefined() + expect(res.body.attachments_metadata).toBeDefined() + expect(mockSendEmail).toBeCalledTimes(1) + expect(mockSendEmail).toBeCalledWith( + { + body: validApiCallAttachment.body, + from: validApiCallAttachment.from, + replyTo: validApiCallAttachment.reply_to, + subject: validApiCallAttachment.subject, + recipients: [validApiCallAttachment.recipient], + messageId: expect.any(String), + attachments: [ + { + content: expect.any(Buffer), + filename: validAttachmentName, + }, + ], + }, + { + disableTracking: false, + extraSmtpHeaders: { isTransactional: true }, + } + ) + const transactionalEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(transactionalEmail).not.toBeNull() + expect(transactionalEmail).toMatchObject({ + recipient: validApiCallAttachment.recipient, + from: validApiCallAttachment.from, + status: TransactionalEmailMessageStatus.Accepted, + errorCode: null, + }) + expect(transactionalEmail?.params).toMatchObject({ + subject: validApiCallAttachment.subject, + body: validApiCallAttachment.body, + from: validApiCallAttachment.from, + reply_to: validApiCallAttachment.reply_to, + }) + expect(transactionalEmail?.attachmentsMetadata).not.toBeNull() + expect(transactionalEmail?.attachmentsMetadata).toHaveLength(1) + expect(transactionalEmail?.attachmentsMetadata).toMatchObject([ + { + fileName: validAttachmentName, + fileSize: validAttachmentSize, + hash: expect.stringMatching(validAttachmentHashRegex), + }, + ]) + }) + + test('Should send email with a valid attachment and attachment metadata is saved correctly in db (with content id tag)', async () => { + mockSendEmail = jest + .spyOn(EmailService, 'sendEmail') + .mockResolvedValue(true) + + await EmailFromAddress.create({ + email: user.email, + name: 'Agency ABC', + } as EmailFromAddress) + + // request.send() cannot be used with file attachments + // substitute form values with request.field(). refer to + // https://visionmedia.github.io/superagent/#multipart-requests + const bodyWithContentIdTag = + validApiCallAttachment.body + '' + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .field('recipient', validApiCallAttachment.recipient) + .field('subject', validApiCallAttachment.subject) + .field('body', bodyWithContentIdTag) + .field('from', validApiCallAttachment.from) + .field('reply_to', validApiCallAttachment.reply_to) + .attach('attachments', validAttachment, validAttachmentName) + + expect(res.status).toBe(201) + expect(res.body).toBeDefined() + expect(res.body.attachments_metadata).toBeDefined() + expect(mockSendEmail).toBeCalledTimes(1) + expect(mockSendEmail).toBeCalledWith( + { + body: bodyWithContentIdTag, + from: validApiCallAttachment.from, + replyTo: validApiCallAttachment.reply_to, + subject: validApiCallAttachment.subject, + recipients: [validApiCallAttachment.recipient], + messageId: expect.any(String), + attachments: [ + { + cid: '0', + content: expect.any(Buffer), + filename: validAttachmentName, + }, + ], + }, + { + disableTracking: false, + extraSmtpHeaders: { isTransactional: true }, + } + ) + const transactionalEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(transactionalEmail).not.toBeNull() + expect(transactionalEmail).toMatchObject({ + recipient: validApiCallAttachment.recipient, + from: validApiCallAttachment.from, + status: TransactionalEmailMessageStatus.Accepted, + errorCode: null, + }) + expect(transactionalEmail?.params).toMatchObject({ + subject: validApiCallAttachment.subject, + body: bodyWithContentIdTag, + from: validApiCallAttachment.from, + reply_to: validApiCallAttachment.reply_to, + }) + expect(transactionalEmail?.attachmentsMetadata).not.toBeNull() + expect(transactionalEmail?.attachmentsMetadata).toHaveLength(1) + expect(transactionalEmail?.attachmentsMetadata).toMatchObject([ + { + fileName: validAttachmentName, + fileSize: validAttachmentSize, + hash: expect.stringMatching(validAttachmentHashRegex), + }, + ]) + }) + + test('Email with attachment that exceeds size limit should fail', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + const invalidAttachmentTooBig = generateRandomFileSizeInMb(10) + const invalidAttachmentTooBigName = 'too big.txt' + + await EmailFromAddress.create({ + email: user.email, + name: 'Agency ABC', + } as EmailFromAddress) + + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .field('recipient', validApiCallAttachment.recipient) + .field('subject', validApiCallAttachment.subject) + .field('body', validApiCallAttachment.body) + .field('from', validApiCallAttachment.from) + .field('reply_to', validApiCallAttachment.reply_to) + .attach( + 'attachments', + invalidAttachmentTooBig, + invalidAttachmentTooBigName + ) + + expect(res.status).toBe(413) + expect(mockSendEmail).not.toBeCalled() + // no need to check EmailMessageTransactional since this is rejected before db record is saved + }) + test('Email with more than 10MB cumulative attachments should fail', async () => { + mockSendEmail = jest.spyOn(EmailService, 'sendEmail') + await EmailFromAddress.create({ + email: user.email, + name: 'Agency ABC', + } as EmailFromAddress) + const onepointnineMbAttachment = generateRandomFileSizeInMb(1.9) + + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .field('recipient', validApiCallAttachment.recipient) + .field('subject', validApiCallAttachment.subject) + .field('body', validApiCallAttachment.body) + .field('from', validApiCallAttachment.from) + .field('reply_to', validApiCallAttachment.reply_to) + .attach('attachments', onepointnineMbAttachment, 'attachment1') + .attach('attachments', onepointnineMbAttachment, 'attachment2') + .attach('attachments', onepointnineMbAttachment, 'attachment3') + .attach('attachments', onepointnineMbAttachment, 'attachment4') + .attach('attachments', onepointnineMbAttachment, 'attachment5') + .attach('attachments', onepointnineMbAttachment, 'attachment6') + + expect(res.status).toBe(413) + expect(mockSendEmail).not.toBeCalled() + // no need to check EmailMessageTransactional since this is rejected before db record is saved + }) + + test('Should send email with two valid attachments and metadata is saved correctly in db', async () => { + mockSendEmail = jest + .spyOn(EmailService, 'sendEmail') + .mockResolvedValue(true) + + await EmailFromAddress.create({ + email: user.email, + name: 'Agency ABC', + } as EmailFromAddress) + + const validAttachment2 = generateRandomSmallFile() + const validAttachment2Name = 'hey.txt' + const validAttachment2Size = Buffer.byteLength(validAttachment2) + + const res = await request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .field('recipient', validApiCallAttachment.recipient) + .field('subject', validApiCallAttachment.subject) + .field('body', validApiCallAttachment.body) + .field('from', validApiCallAttachment.from) + .field('reply_to', validApiCallAttachment.reply_to) + .attach('attachments', validAttachment, validAttachmentName) + .attach('attachments', validAttachment2, validAttachment2Name) + + expect(res.status).toBe(201) + expect(mockSendEmail).toBeCalledTimes(1) + expect(mockSendEmail).toBeCalledWith( + { + body: validApiCallAttachment.body, + from: validApiCallAttachment.from, + replyTo: validApiCallAttachment.reply_to, + subject: validApiCallAttachment.subject, + recipients: [validApiCallAttachment.recipient], + messageId: expect.any(String), + attachments: [ + { + content: expect.any(Buffer), + filename: validAttachmentName, + }, + { + content: expect.any(Buffer), + filename: validAttachment2Name, + }, + ], + }, + { + disableTracking: false, + extraSmtpHeaders: { isTransactional: true }, + } + ) + const transactionalEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(transactionalEmail).not.toBeNull() + expect(transactionalEmail).toMatchObject({ + recipient: validApiCallAttachment.recipient, + from: validApiCallAttachment.from, + status: TransactionalEmailMessageStatus.Accepted, + errorCode: null, + }) + expect(transactionalEmail?.params).toMatchObject({ + subject: validApiCallAttachment.subject, + body: validApiCallAttachment.body, + from: validApiCallAttachment.from, + reply_to: validApiCallAttachment.reply_to, + }) + expect(transactionalEmail?.attachmentsMetadata).not.toBeNull() + expect(transactionalEmail?.attachmentsMetadata).toHaveLength(2) + expect(transactionalEmail?.attachmentsMetadata).toMatchObject([ + { + fileName: validAttachmentName, + fileSize: validAttachmentSize, + hash: expect.stringMatching(validAttachmentHashRegex), + }, + { + fileName: validAttachment2Name, + fileSize: validAttachment2Size, + hash: expect.stringMatching(validAttachmentHashRegex), + }, + ]) + }) + + test('Requests should be rate limited and metadata and error code is saved correctly in db', async () => { + mockSendEmail = jest + .spyOn(EmailService, 'sendEmail') + .mockResolvedValue(true) + const send = (): Promise => { + return request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send(validApiCall) + } + + // First request passes + let res = await send() + expect(res.status).toBe(201) + expect(mockSendEmail).toBeCalledTimes(1) + mockSendEmail.mockClear() + const firstEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(firstEmail).not.toBeNull() + expect(firstEmail).toMatchObject({ + recipient: validApiCall.recipient, + from: validApiCall.from, + status: TransactionalEmailMessageStatus.Accepted, + errorCode: null, + }) + expect(firstEmail?.params).toMatchObject({ + subject: validApiCall.subject, + body: validApiCall.body, + from: validApiCall.from, + reply_to: validApiCall.reply_to, + }) + + // Second request rate limited + res = await send() + expect(res.status).toBe(429) + expect(mockSendEmail).not.toBeCalled() + mockSendEmail.mockClear() + }) + + test('Requests should not be rate limited after window elasped and metadata is saved correctly in db', async () => { + mockSendEmail = jest + .spyOn(EmailService, 'sendEmail') + .mockResolvedValue(true) + const send = (): Promise => { + return request(app) + .post(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + .send(validApiCall) + } + // First request passes + let res = await send() + expect(res.status).toBe(201) + expect(mockSendEmail).toBeCalledTimes(1) + mockSendEmail.mockClear() + const firstEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(firstEmail).not.toBeNull() + expect(firstEmail).toMatchObject({ + recipient: validApiCall.recipient, + from: validApiCall.from, + status: TransactionalEmailMessageStatus.Accepted, + errorCode: null, + }) + expect(firstEmail?.params).toMatchObject({ + subject: validApiCall.subject, + body: validApiCall.body, + from: validApiCall.from, + }) + + // Second request rate limited + res = await send() + expect(res.status).toBe(429) + expect(mockSendEmail).not.toBeCalled() + mockSendEmail.mockClear() + // Third request passes after 1s + await new Promise((resolve) => + setTimeout(resolve, TRANSACTIONAL_EMAIL_WINDOW * 1000) + ) + res = await send() + expect(res.status).toBe(201) + expect(mockSendEmail).toBeCalledTimes(1) + mockSendEmail.mockClear() + const thirdEmail = await EmailMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + order: [['createdAt', 'DESC']], + }) + expect(thirdEmail).not.toBeNull() + expect(thirdEmail).toMatchObject({ + recipient: validApiCall.recipient, + from: validApiCall.from, + status: TransactionalEmailMessageStatus.Accepted, + errorCode: null, + }) + expect(thirdEmail?.params).toMatchObject({ + subject: validApiCall.subject, + body: validApiCall.body, + from: validApiCall.from, + reply_to: validApiCall.reply_to, + }) + }) +}) + +describe(`GET ${emailTransactionalRoute}`, () => { + const endpoint = emailTransactionalRoute + const acceptedMessage = { + recipient: 'recipient@gmail.com', + from: 'Postman ', + params: { + from: 'Postman ', + subject: 'Test', + body: 'Test Body', + }, + status: TransactionalEmailMessageStatus.Accepted, + } + const sentMessage = { + recipient: 'recipient@agency.gov.sg', + from: 'Postman ', + params: { + from: 'Postman ', + subject: 'Test', + body: 'Test Body', + }, + status: TransactionalEmailMessageStatus.Sent, + } + const deliveredMessage = { + recipient: 'recipient3@agency.gov.sg', + from: 'Postman ', + params: { + from: 'Postman ', + subject: 'Test', + body: 'Test Body', + }, + status: TransactionalEmailMessageStatus.Delivered, + } + test('Should return 200 with empty array when no messages are found', async () => { + const res = await request(app) + .get(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + expect(res.status).toBe(200) + expect(res.body.has_more).toBe(false) + expect(res.body.data).toEqual([]) + }) + + test('Should return 200 with descending array of messages when messages are found', async () => { + const message = await EmailMessageTransactional.create({ + ...deliveredMessage, + userId: user.id, + } as unknown as EmailMessageTransactional) + const message2 = await EmailMessageTransactional.create({ + ...acceptedMessage, + userId: user.id, + } as unknown as EmailMessageTransactional) + const res = await request(app) + .get(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + expect(res.status).toBe(200) + expect(res.body.has_more).toBe(false) + expect(res.body.data).toMatchObject([ + // descending by default + { + id: message2.id, + recipient: message2.recipient, + from: message2.from, + params: message2.params, + status: message2.status, + }, + { + id: message.id, + recipient: message.recipient, + from: message.from, + params: message.params, + status: message.status, + }, + ]) + }) + test('Should return 400 when invalid query params are provided', async () => { + const resInvalidLimit = await request(app) + .get(`${endpoint}?limit=invalid`) + .set('Authorization', `Bearer ${apiKey}`) + expect(resInvalidLimit.status).toBe(400) + const resInvalidLimitTooLarge = await request(app) + .get(`${endpoint}?limit=1001`) + .set('Authorization', `Bearer ${apiKey}`) + expect(resInvalidLimitTooLarge.status).toBe(400) + const resInvalidOffset = await request(app) + .get(`${endpoint}?offset=blahblah`) + .set('Authorization', `Bearer ${apiKey}`) + expect(resInvalidOffset.status).toBe(400) + const resInvalidOffsetNegative = await request(app) + .get(`${endpoint}?offset=-1`) + .set('Authorization', `Bearer ${apiKey}`) + expect(resInvalidOffsetNegative.status).toBe(400) + const resInvalidStatus = await request(app) + .get(`${endpoint}?status=blacksheep`) + .set('Authorization', `Bearer ${apiKey}`) + expect(resInvalidStatus.status).toBe(400) + // repeated params should throw an error too + const resInvalidStatus2 = await request(app) + .get(`${endpoint}?status=sent&status=delivered`) + .set('Authorization', `Bearer ${apiKey}`) + expect(resInvalidStatus2.status).toBe(400) + const resInvalidCreatedAt = await request(app) + .get(`${endpoint}?created_at=haveyouanywool`) + .set('Authorization', `Bearer ${apiKey}`) + expect(resInvalidCreatedAt.status).toBe(400) + const resInvalidCreatedAtDateFormat = await request(app) + .get(`${endpoint}?created_at[gte]=20200101`) + .set('Authorization', `Bearer ${apiKey}`) + expect(resInvalidCreatedAtDateFormat.status).toBe(400) + const resInvalidSortBy = await request(app) + .get(`${endpoint}?sort_by=threebagsfull`) + .set('Authorization', `Bearer ${apiKey}`) + expect(resInvalidSortBy.status).toBe(400) + const resInvalidSortByPrefix = await request(app) + .get(endpoint) + // need to use query() instead of get() for operator to be processed correctly + .query({ sort_by: '*created_at' }) + .set('Authorization', `Bearer ${apiKey}`) + expect(resInvalidSortByPrefix.status).toBe(400) + }) + test('default values of limit and offset should be 10 and 0 respectively', async () => { + for (let i = 0; i < 15; i++) { + await EmailMessageTransactional.create({ + ...deliveredMessage, + userId: user.id, + } as unknown as EmailMessageTransactional) + } + const res = await request(app) + .get(endpoint) + .set('Authorization', `Bearer ${apiKey}`) + expect(res.status).toBe(200) + expect(res.body.has_more).toBe(true) + expect(res.body.data.length).toBe(10) + + const res2 = await request(app) + .get(`${endpoint}?offset=10`) + .set('Authorization', `Bearer ${apiKey}`) + expect(res2.status).toBe(200) + expect(res2.body.has_more).toBe(false) + expect(res2.body.data.length).toBe(5) + + const res3 = await request(app) + .get(`${endpoint}?offset=15`) + .set('Authorization', `Bearer ${apiKey}`) + expect(res3.status).toBe(200) + expect(res3.body.has_more).toBe(false) + expect(res3.body.data.length).toBe(0) + + const res4 = await request(app) + .get(`${endpoint}?limit=5`) + .set('Authorization', `Bearer ${apiKey}`) + expect(res4.status).toBe(200) + expect(res4.body.has_more).toBe(true) + expect(res4.body.data.length).toBe(5) + + const res5 = await request(app) + .get(`${endpoint}?limit=15`) + .set('Authorization', `Bearer ${apiKey}`) + expect(res5.status).toBe(200) + expect(res5.body.has_more).toBe(false) + expect(res5.body.data.length).toBe(15) + }) + + test('status filter should work', async () => { + for (let i = 0; i < 5; i++) { + await EmailMessageTransactional.create({ + ...deliveredMessage, + userId: user.id, + } as unknown as EmailMessageTransactional) + } + for (let i = 0; i < 5; i++) { + await EmailMessageTransactional.create({ + ...acceptedMessage, + userId: user.id, + } as unknown as EmailMessageTransactional) + } + for (let i = 0; i < 5; i++) { + await EmailMessageTransactional.create({ + ...sentMessage, + userId: user.id, + } as unknown as EmailMessageTransactional) + } + const res = await request(app) + .get(`${endpoint}?status=delivered`) // case-insensitive + .set('Authorization', `Bearer ${apiKey}`) + expect(res.status).toBe(200) + expect(res.body.has_more).toBe(false) + expect(res.body.data.length).toBe(5) + res.body.data.forEach((message: EmailMessageTransactional) => { + expect(message.status).toBe(TransactionalEmailMessageStatus.Delivered) + }) + const res2 = await request(app) + .get(`${endpoint}?status=aCcEPteD`) + .set('Authorization', `Bearer ${apiKey}`) + expect(res2.status).toBe(200) + expect(res2.body.has_more).toBe(false) + expect(res2.body.data.length).toBe(5) + res2.body.data.forEach((message: EmailMessageTransactional) => { + expect(message.status).toBe(TransactionalEmailMessageStatus.Accepted) + }) + const res3 = await request(app) + .get(`${endpoint}?status=SENT`) + .set('Authorization', `Bearer ${apiKey}`) + expect(res3.status).toBe(200) + expect(res3.body.has_more).toBe(false) + expect(res3.body.data.length).toBe(5) + res3.body.data.forEach((message: EmailMessageTransactional) => { + expect(message.status).toBe(TransactionalEmailMessageStatus.Sent) + }) + // duplicate status params should throw an error + const res4 = await request(app) + .get(`${endpoint}?status=SENT&status=ACCEPTED`) + .set('Authorization', `Bearer ${apiKey}`) + expect(res4.status).toBe(400) + }) + test('created_at filter range should work', async () => { + const messages = [] + const now = new Date() + for (let i = 0; i < 10; i++) { + const message = await EmailMessageTransactional.create({ + ...deliveredMessage, + userId: user.id, + createdAt: new Date(now.getTime() - 100000 + i * 1000), // inserting in chronological order + } as unknown as EmailMessageTransactional) + messages.push(message) + } + const res = await request(app) + .get( + `${endpoint}?created_at[gte]=${messages[0].createdAt.toISOString()}&created_at[lte]=${messages[4].createdAt.toISOString()}` + ) + .set('Authorization', `Bearer ${apiKey}`) + expect(res.status).toBe(200) + expect(res.body.has_more).toBe(false) + expect(res.body.data.length).toBe(5) + + const res2 = await request(app) + .get( + `${endpoint}?created_at[gt]=${messages[0].createdAt.toISOString()}&created_at[lt]=${messages[4].createdAt.toISOString()}` + ) + .set('Authorization', `Bearer ${apiKey}`) + expect(res2.status).toBe(200) + expect(res2.body.has_more).toBe(false) + expect(res2.body.data.length).toBe(3) + + // repeated operators should throw an error + const res3 = await request(app) + .get( + `${endpoint}?created_at[gte]=${messages[0].createdAt.toISOString()}&created_at[lte]=${messages[4].createdAt.toISOString()}&created_at[gte]=${messages[0].createdAt.toISOString()}` + ) + .set('Authorization', `Bearer ${apiKey}`) + expect(res3.status).toBe(400) + // if gt and lt are used, gte and lte should be ignored + const res4 = await request(app) + .get( + `${endpoint}?created_at[gt]=${messages[0].createdAt.toISOString()}&created_at[lt]=${messages[4].createdAt.toISOString()}&created_at[gte]=${messages[0].createdAt.toISOString()}` + ) + .set('Authorization', `Bearer ${apiKey}`) + expect(res4.status).toBe(200) + expect(res4.body.has_more).toBe(false) + expect(res4.body.data.length).toBe(3) + }) + test('sort_by should work', async () => { + const messages = [] + const now = new Date() + for (let i = 0; i < 10; i++) { + const message = await EmailMessageTransactional.create({ + ...deliveredMessage, + userId: user.id, + createdAt: new Date(now.getTime() - 100000 + i * 1000), // inserting in chronological order + } as unknown as EmailMessageTransactional) + messages.push(message) + } + + const res = await request(app) + .get(`${endpoint}?sort_by=created_at`) + .set('Authorization', `Bearer ${apiKey}`) + expect(res.status).toBe(200) + expect(res.body.has_more).toBe(false) + expect(res.body.data.length).toBe(10) + // default descending order + expect(res.body.data[0].id).toBe(messages[9].id) + expect(res.body.data[9].id).toBe(messages[0].id) + + const res2 = await request(app) + .get(endpoint) + // need to use query() instead of get() for operator to be processed correctly + .query({ sort_by: '+created_at' }) + .set('Authorization', `Bearer ${apiKey}`) + expect(res2.status).toBe(200) + expect(res2.body.has_more).toBe(false) + expect(res2.body.data.length).toBe(10) + expect(res2.body.data[0].id).toBe(messages[0].id) + expect(res2.body.data[9].id).toBe(messages[9].id) + + const res3 = await request(app) + .get(endpoint) + // need to use query() instead of get() for operator to be processed correctly + .query({ sort_by: '-created_at' }) + .set('Authorization', `Bearer ${apiKey}`) + expect(res3.status).toBe(200) + expect(res3.body.has_more).toBe(false) + expect(res3.body.data.length).toBe(10) + expect(res3.body.data[0].id).toBe(messages[9].id) + expect(res3.body.data[9].id).toBe(messages[0].id) + + const res4 = await request(app) + .get(endpoint) + // this is basically testing for repeating sort_by params twice, e.g. endpoint?sort_by=+created_at&sort_by=created_at + .query({ sort_by: ['created_at', '+created_at'] }) + .set('Authorization', `Bearer ${apiKey}`) + expect(res4.status).toBe(400) + }) + test('combination of query params should work', async () => { + const messages = [] + const now = new Date() + for (let i = 0; i < 15; i++) { + // mixing up different messages + const messageParams = + i % 3 === 0 + ? deliveredMessage + : i % 3 === 1 + ? sentMessage + : acceptedMessage + const message = await EmailMessageTransactional.create({ + ...messageParams, + userId: user.id, + createdAt: new Date(now.getTime() - 100000 + i * 1000), // inserting in chronological order + } as unknown as EmailMessageTransactional) + messages.push(message) + } + const res = await request(app) + .get( + `${endpoint}?created_at[gte]=${messages[0].createdAt.toISOString()}&created_at[lte]=${messages[4].createdAt.toISOString()}&sort_by=created_at` + ) + .set('Authorization', `Bearer ${apiKey}`) + expect(res.status).toBe(200) + expect(res.body.has_more).toBe(false) + expect(res.body.data.length).toBe(5) + expect(res.body.data[0].id).toBe(messages[4].id) + expect(res.body.data[4].id).toBe(messages[0].id) + + const res2 = await request(app) + .get(endpoint) + .query({ status: 'delivered', sort_by: '+created_at', limit: '4' }) + .set('Authorization', `Bearer ${apiKey}`) + + expect(res2.status).toBe(200) + expect(res2.body.has_more).toBe(true) + expect(res2.body.data.length).toBe(4) + res2.body.data.forEach((message: EmailMessageTransactional) => { + expect(message.status).toBe(TransactionalEmailMessageStatus.Delivered) + }) + expect(new Date(res2.body.data[3].created_at).getTime()).toBeGreaterThan( + // check that it is ascending + new Date(res2.body.data[2].created_at).getTime() + ) + }) +}) + +describe(`GET ${emailTransactionalRoute}/:emailId`, () => { + const endpoint = emailTransactionalRoute + test('should return a transactional email message with corresponding ID', async () => { + const message = await EmailMessageTransactional.create({ + userId: user.id, + recipient: 'recipient@agency.gov.sg', + from: 'Postman ', + params: { + from: 'Postman ', + subject: 'Test', + body: 'Test Body', + }, + status: TransactionalEmailMessageStatus.Delivered, + } as unknown as EmailMessageTransactional) + const res = await request(app) + .get(`${endpoint}/${message.id}`) + .set('Authorization', `Bearer ${apiKey}`) + expect(res.status).toBe(200) + expect(res.body).toBeDefined() + expect(res.body.id).toBe(message.id) + }) + + test('should return 404 if the transactional email message ID not found', async () => { + const id = 69 + const res = await request(app) + .get(`${endpoint}/${id}`) + .set('Authorization', `Bearer ${apiKey}`) + expect(res.status).toBe(404) + expect(res.body.message).toBe(`Email message with ID ${id} not found.`) + }) + + test('should return 404 if the transactional email message belongs to another user', async () => { + const anotherUser = await User.create({ + id: 2, + email: 'user_2@agency.gov.sg', + } as User) + const { plainTextKey: anotherApiKey } = await ( + app as any as { credentialService: CredentialService } + ).credentialService.generateApiKey(anotherUser.id, 'another test api key', [ + anotherUser.email, + ]) + const message = await EmailMessageTransactional.create({ + userId: user.id, + recipient: 'recipient@agency.gov.sg', + from: 'Postman ', + params: { + from: 'Postman ', + subject: 'Test', + body: 'Test Body', + }, + status: TransactionalEmailMessageStatus.Delivered, + } as unknown as EmailMessageTransactional) + const res = await request(app) + .get(`${endpoint}/${message.id}`) + .set('Authorization', `Bearer ${anotherApiKey}`) + expect(res.status).toBe(404) + expect(res.body.message).toBe( + `Email message with ID ${message.id} not found.` + ) + }) +}) diff --git a/backend/src/sms/routes/tests/sms-callback.routes.test.ts b/backend/src/sms/routes/tests/sms-callback.routes.test.ts index 7347ea746..d02c7287e 100644 --- a/backend/src/sms/routes/tests/sms-callback.routes.test.ts +++ b/backend/src/sms/routes/tests/sms-callback.routes.test.ts @@ -1,189 +1,189 @@ -// import { Sequelize } from 'sequelize-typescript' -// import { Credential, User, UserCredential } from '@core/models' -// import initialiseServer from '@test-utils/server' -// import { ChannelType } from '@core/constants' -// import sequelizeLoader from '@test-utils/sequelize-loader' -// import { -// SmsMessageTransactional, -// TransactionalSmsMessageStatus, -// } from '@sms/models' -// import request from 'supertest' -// import { SmsCallbackService, SmsService } from '@sms/services' -// import { mockSecretsManager } from '@mocks/@aws-sdk/client-secrets-manager' -// import { CredentialService } from '@core/services' - -// const TEST_TWILIO_CREDENTIALS = { -// accountSid: '', -// apiKey: '', -// apiSecret: '', -// messagingServiceSid: '', -// } - -// let sequelize: Sequelize -// let user: User -// let apiKey: string -// let credential: Credential - -// const app = initialiseServer(false) - -// beforeEach(async () => { -// user = await User.create({ -// email: 'sms_callback@agency.gov.sg', -// } as User) -// const userId = user.id -// const { plainTextKey } = await ( -// app as any as { credentialService: CredentialService } -// ).credentialService.generateApiKey(user.id, 'test api key', [user.email]) -// apiKey = plainTextKey -// credential = await Credential.create({ name: 'twilio' } as Credential) -// await UserCredential.create({ -// label: `twilio-callback-${userId}`, -// type: ChannelType.SMS, -// credName: credential.name, -// userId, -// } as UserCredential) -// }) - -// beforeAll(async () => { -// sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') -// }) - -// afterEach(async () => { -// jest.clearAllMocks() -// await SmsMessageTransactional.destroy({ where: {} }) -// await User.destroy({ where: {} }) -// await UserCredential.destroy({ where: {} }) -// await Credential.destroy({ where: {} }) -// }) - -// afterAll(async () => { -// await sequelize.close() -// await (app as any).cleanup() -// }) - -// describe('On successful message send, status should update according to Twilio response', () => { -// const validApiCall = { -// body: 'Hello world', -// recipient: '98765432', -// label: 'twilio-callback-1', -// } -// test('Should send a message successfully', async () => { -// const mockSendMessageResolvedValue = 'message_id_callback' -// const mockSendMessage = jest -// .spyOn(SmsService, 'sendMessage') -// .mockResolvedValue(mockSendMessageResolvedValue) -// mockSecretsManager.getSecretValue.mockResolvedValueOnce({ -// SecretString: JSON.stringify(TEST_TWILIO_CREDENTIALS), -// }) -// const res = await request(app) -// .post('/transactional/sms/send') -// .set('Authorization', `Bearer ${apiKey}`) -// .send(validApiCall) - -// expect(res.status).toBe(201) -// expect(mockSendMessage).toBeCalledTimes(1) - -// const transactionalSms = await SmsMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// order: [['createdAt', 'DESC']], -// }) -// const transactionalSmsId = transactionalSms?.id - -// const getByIdRes = await request(app) -// .get(`/transactional/sms/${transactionalSmsId}`) -// .set('Authorization', `Bearer ${apiKey}`) -// .send() -// expect(getByIdRes.status).toBe(200) -// expect(getByIdRes.body.status).toBe(TransactionalSmsMessageStatus.Unsent) -// expect(getByIdRes.body.body).toEqual('Hello world') -// expect(getByIdRes.body.recipient).toEqual('98765432') -// expect(getByIdRes.body.credentialsLabel).toEqual('twilio-callback-1') -// expect(getByIdRes.body.accepted_at).not.toBeNull() -// expect(getByIdRes.body.sent_at).toBeNull() -// expect(getByIdRes.body.errored_at).toBeNull() -// expect(getByIdRes.body.delivered_at).toBeNull() - -// const sampleTwilioCallback = { -// SmsSid: mockSendMessageResolvedValue, -// SmsStatus: 'sent', -// MessageStatus: 'sent', -// To: '+1512zzzyyyy', -// MessageSid: mockSendMessageResolvedValue, -// AccountSid: 'ACxxxxxxx', -// From: '+1512xxxyyyy', -// ApiVersion: '2010-04-01', -// } - -// jest -// .spyOn(SmsCallbackService, 'isAuthenticatedTransactional') -// .mockReturnValue(true) -// let callbackRes = await request(app) -// .post('/callback/sms') -// .set('Authorization', 'Basic sampleAuthKey') -// .send(sampleTwilioCallback) - -// expect(callbackRes.status).toBe(200) -// const postCallbackGetByIdRes = await request(app) -// .get(`/transactional/sms/${transactionalSmsId}`) -// .set('Authorization', `Bearer ${apiKey}`) -// .send() -// expect(postCallbackGetByIdRes.status).toBe(200) -// expect(postCallbackGetByIdRes.body.status).toBe( -// TransactionalSmsMessageStatus.Sent -// ) -// expect(postCallbackGetByIdRes.body.accepted_at).not.toBeNull() -// expect(postCallbackGetByIdRes.body.sent_at).not.toBeNull() -// expect(postCallbackGetByIdRes.body.errored_at).toBeNull() -// expect(postCallbackGetByIdRes.body.delivered_at).toBeNull() -// const sampleTwilioCallbackError = { -// ...sampleTwilioCallback, -// MessageStatus: 'failed', -// ErrorCode: 'ERRORBOI', -// } - -// callbackRes = await request(app) -// .post('/callback/sms') -// .set('Authorization', 'Basic sampleAuthKey') -// .send(sampleTwilioCallbackError) - -// expect(callbackRes.status).toBe(200) - -// const errorCallbackGetByIdRes = await request(app) -// .get(`/transactional/sms/${transactionalSmsId}`) -// .set('Authorization', `Bearer ${apiKey}`) -// .send() -// expect(errorCallbackGetByIdRes.status).toBe(200) -// expect(errorCallbackGetByIdRes.body.status).toBe( -// TransactionalSmsMessageStatus.Error -// ) -// expect(errorCallbackGetByIdRes.body.accepted_at).not.toBeNull() -// expect(errorCallbackGetByIdRes.body.sent_at).not.toBeNull() -// expect(errorCallbackGetByIdRes.body.errored_at).not.toBeNull() -// expect(errorCallbackGetByIdRes.body.delivered_at).toBeNull() - -// const sampleTwilioCallbackDelivered = { -// ...sampleTwilioCallback, -// MessageStatus: 'delivered', -// } -// callbackRes = await request(app) -// .post('/callback/sms') -// .set('Authorization', 'Basic sampleAuthKey') -// .send(sampleTwilioCallbackDelivered) - -// expect(callbackRes.status).toBe(200) - -// const finalCallbackGetByIdRes = await request(app) -// .get(`/transactional/sms/${transactionalSmsId}`) -// .set('Authorization', `Bearer ${apiKey}`) -// .send() -// expect(finalCallbackGetByIdRes.status).toBe(200) -// expect(finalCallbackGetByIdRes.body.status).toBe( -// TransactionalSmsMessageStatus.Error -// ) -// expect(finalCallbackGetByIdRes.body.accepted_at).not.toBeNull() -// expect(finalCallbackGetByIdRes.body.sent_at).not.toBeNull() -// expect(finalCallbackGetByIdRes.body.errored_at).not.toBeNull() -// expect(finalCallbackGetByIdRes.body.delivered_at).toBeNull() -// mockSendMessage.mockReset() -// }) -// }) +import { Sequelize } from 'sequelize-typescript' +import { Credential, User, UserCredential } from '@core/models' +import initialiseServer from '@test-utils/server' +import { ChannelType } from '@core/constants' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { + SmsMessageTransactional, + TransactionalSmsMessageStatus, +} from '@sms/models' +import request from 'supertest' +import { SmsCallbackService, SmsService } from '@sms/services' +import { mockSecretsManager } from '@mocks/@aws-sdk/client-secrets-manager' +import { CredentialService } from '@core/services' + +const TEST_TWILIO_CREDENTIALS = { + accountSid: '', + apiKey: '', + apiSecret: '', + messagingServiceSid: '', +} + +let sequelize: Sequelize +let user: User +let apiKey: string +let credential: Credential + +const app = initialiseServer(false) + +beforeEach(async () => { + user = await User.create({ + email: 'sms_callback@agency.gov.sg', + } as User) + const userId = user.id + const { plainTextKey } = await ( + app as any as { credentialService: CredentialService } + ).credentialService.generateApiKey(user.id, 'test api key', [user.email]) + apiKey = plainTextKey + credential = await Credential.create({ name: 'twilio' } as Credential) + await UserCredential.create({ + label: `twilio-callback-${userId}`, + type: ChannelType.SMS, + credName: credential.name, + userId, + } as UserCredential) +}) + +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') +}) + +afterEach(async () => { + jest.clearAllMocks() + await SmsMessageTransactional.destroy({ where: {} }) + await User.destroy({ where: {} }) + await UserCredential.destroy({ where: {} }) + await Credential.destroy({ where: {} }) +}) + +afterAll(async () => { + await sequelize.close() + await (app as any).cleanup() +}) + +describe('On successful message send, status should update according to Twilio response', () => { + const validApiCall = { + body: 'Hello world', + recipient: '98765432', + label: 'twilio-callback-1', + } + test('Should send a message successfully', async () => { + const mockSendMessageResolvedValue = 'message_id_callback' + const mockSendMessage = jest + .spyOn(SmsService, 'sendMessage') + .mockResolvedValue(mockSendMessageResolvedValue) + mockSecretsManager.getSecretValue.mockResolvedValueOnce({ + SecretString: JSON.stringify(TEST_TWILIO_CREDENTIALS), + }) + const res = await request(app) + .post('/transactional/sms/send') + .set('Authorization', `Bearer ${apiKey}`) + .send(validApiCall) + + expect(res.status).toBe(201) + expect(mockSendMessage).toBeCalledTimes(1) + + const transactionalSms = await SmsMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + order: [['createdAt', 'DESC']], + }) + const transactionalSmsId = transactionalSms?.id + + const getByIdRes = await request(app) + .get(`/transactional/sms/${transactionalSmsId}`) + .set('Authorization', `Bearer ${apiKey}`) + .send() + expect(getByIdRes.status).toBe(200) + expect(getByIdRes.body.status).toBe(TransactionalSmsMessageStatus.Unsent) + expect(getByIdRes.body.body).toEqual('Hello world') + expect(getByIdRes.body.recipient).toEqual('98765432') + expect(getByIdRes.body.credentialsLabel).toEqual('twilio-callback-1') + expect(getByIdRes.body.accepted_at).not.toBeNull() + expect(getByIdRes.body.sent_at).toBeNull() + expect(getByIdRes.body.errored_at).toBeNull() + expect(getByIdRes.body.delivered_at).toBeNull() + + const sampleTwilioCallback = { + SmsSid: mockSendMessageResolvedValue, + SmsStatus: 'sent', + MessageStatus: 'sent', + To: '+1512zzzyyyy', + MessageSid: mockSendMessageResolvedValue, + AccountSid: 'ACxxxxxxx', + From: '+1512xxxyyyy', + ApiVersion: '2010-04-01', + } + + jest + .spyOn(SmsCallbackService, 'isAuthenticatedTransactional') + .mockReturnValue(true) + let callbackRes = await request(app) + .post('/callback/sms') + .set('Authorization', 'Basic sampleAuthKey') + .send(sampleTwilioCallback) + + expect(callbackRes.status).toBe(200) + const postCallbackGetByIdRes = await request(app) + .get(`/transactional/sms/${transactionalSmsId}`) + .set('Authorization', `Bearer ${apiKey}`) + .send() + expect(postCallbackGetByIdRes.status).toBe(200) + expect(postCallbackGetByIdRes.body.status).toBe( + TransactionalSmsMessageStatus.Sent + ) + expect(postCallbackGetByIdRes.body.accepted_at).not.toBeNull() + expect(postCallbackGetByIdRes.body.sent_at).not.toBeNull() + expect(postCallbackGetByIdRes.body.errored_at).toBeNull() + expect(postCallbackGetByIdRes.body.delivered_at).toBeNull() + const sampleTwilioCallbackError = { + ...sampleTwilioCallback, + MessageStatus: 'failed', + ErrorCode: 'ERRORBOI', + } + + callbackRes = await request(app) + .post('/callback/sms') + .set('Authorization', 'Basic sampleAuthKey') + .send(sampleTwilioCallbackError) + + expect(callbackRes.status).toBe(200) + + const errorCallbackGetByIdRes = await request(app) + .get(`/transactional/sms/${transactionalSmsId}`) + .set('Authorization', `Bearer ${apiKey}`) + .send() + expect(errorCallbackGetByIdRes.status).toBe(200) + expect(errorCallbackGetByIdRes.body.status).toBe( + TransactionalSmsMessageStatus.Error + ) + expect(errorCallbackGetByIdRes.body.accepted_at).not.toBeNull() + expect(errorCallbackGetByIdRes.body.sent_at).not.toBeNull() + expect(errorCallbackGetByIdRes.body.errored_at).not.toBeNull() + expect(errorCallbackGetByIdRes.body.delivered_at).toBeNull() + + const sampleTwilioCallbackDelivered = { + ...sampleTwilioCallback, + MessageStatus: 'delivered', + } + callbackRes = await request(app) + .post('/callback/sms') + .set('Authorization', 'Basic sampleAuthKey') + .send(sampleTwilioCallbackDelivered) + + expect(callbackRes.status).toBe(200) + + const finalCallbackGetByIdRes = await request(app) + .get(`/transactional/sms/${transactionalSmsId}`) + .set('Authorization', `Bearer ${apiKey}`) + .send() + expect(finalCallbackGetByIdRes.status).toBe(200) + expect(finalCallbackGetByIdRes.body.status).toBe( + TransactionalSmsMessageStatus.Error + ) + expect(finalCallbackGetByIdRes.body.accepted_at).not.toBeNull() + expect(finalCallbackGetByIdRes.body.sent_at).not.toBeNull() + expect(finalCallbackGetByIdRes.body.errored_at).not.toBeNull() + expect(finalCallbackGetByIdRes.body.delivered_at).toBeNull() + mockSendMessage.mockReset() + }) +}) diff --git a/backend/src/sms/routes/tests/sms-campaign.routes.test.ts b/backend/src/sms/routes/tests/sms-campaign.routes.test.ts index 2114c025a..24877a0ae 100644 --- a/backend/src/sms/routes/tests/sms-campaign.routes.test.ts +++ b/backend/src/sms/routes/tests/sms-campaign.routes.test.ts @@ -1,479 +1,479 @@ -// import request from 'supertest' -// import { Sequelize } from 'sequelize-typescript' -// import initialiseServer from '@test-utils/server' -// import { Campaign, User, Credential } from '@core/models' -// import sequelizeLoader from '@test-utils/sequelize-loader' -// import { UploadService } from '@core/services' -// import { DefaultCredentialName } from '@core/constants' -// import { formatDefaultCredentialName } from '@core/utils' -// import { SmsMessage, SmsTemplate } from '@sms/models' -// import { ChannelType } from '@core/constants' -// import { mockSecretsManager } from '@mocks/@aws-sdk/client-secrets-manager' -// import { SmsService } from '@sms/services' - -// const app = initialiseServer(true) -// let sequelize: Sequelize -// let campaignId: number - -// // Helper function to create demo/non-demo campaign based on parameters -// const createCampaign = async ({ -// isDemo, -// }: { -// isDemo: boolean -// }): Promise => -// await Campaign.create({ -// name: 'test-campaign', -// userId: 1, -// type: ChannelType.SMS, -// protect: false, -// valid: false, -// demoMessageLimit: isDemo ? 20 : null, -// } as Campaign) - -// beforeAll(async () => { -// sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') -// await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) -// const campaign = await createCampaign({ isDemo: false }) -// campaignId = campaign.id -// jest.mock('@aws-sdk/client-secrets-manager') -// }) - -// afterEach(async () => { -// await SmsMessage.destroy({ where: {} }) -// await SmsTemplate.destroy({ where: {} }) -// }) - -// afterAll(async () => { -// await SmsMessage.destroy({ where: {} }) -// await Campaign.destroy({ where: {}, force: true }) -// await User.destroy({ where: {} }) -// await sequelize.close() -// await UploadService.destroyUploadQueue() -// await (app as any).cleanup() -// }) - -// describe('GET /campaign/{id}/sms', () => { -// test('Get SMS campaign details', async () => { -// const campaign = await Campaign.create({ -// name: 'campaign-1', -// userId: 1, -// type: 'SMS', -// valid: false, -// protect: false, -// } as Campaign) -// const { id, name, type } = campaign -// const TEST_TWILIO_CREDENTIALS = { -// accountSid: '', -// apiKey: '', -// apiSecret: '', -// messagingServiceSid: '', -// } -// const mockGetCampaign = jest -// .spyOn(SmsService, 'getTwilioCostPerOutgoingSMSSegmentUSD') -// .mockResolvedValue(0.0395) // exact value unimportant for test to pass -// // needed because demo credentials are extracted from secrets manager to get -// // credentials to call Twilio API for SMS price -// mockSecretsManager.getSecretValue.mockResolvedValue({ -// SecretString: JSON.stringify(TEST_TWILIO_CREDENTIALS), -// }) -// const res = await request(app).get(`/campaign/${campaign.id}/sms`) -// expect(res.status).toBe(200) -// expect(res.body).toEqual(expect.objectContaining({ id, name, type })) -// mockGetCampaign.mockRestore() -// }) -// }) - -// describe('POST /campaign/{campaignId}/sms/credentials', () => { -// afterEach(async () => { -// // Reset number of calls for mocked functions -// jest.clearAllMocks() -// }) - -// test('Non-Demo campaign should not be able to use demo credentials', async () => { -// const nonDemoCampaign = await createCampaign({ isDemo: false }) - -// const res = await request(app) -// .post(`/campaign/${nonDemoCampaign.id}/sms/credentials`) -// .send({ -// label: DefaultCredentialName.SMS, -// recipient: '98765432', -// }) - -// expect(res.status).toBe(403) -// expect(res.body).toEqual({ -// code: 'unauthorized', -// message: `Campaign cannot use demo credentials. ${DefaultCredentialName.SMS} is not allowed.`, -// }) -// }) - -// test('Demo Campaign should not be able to use non-demo credentials', async () => { -// const demoCampaign = await createCampaign({ isDemo: true }) - -// const NON_DEMO_CREDENTIAL_LABEL = 'Some Credential' - -// const res = await request(app) -// .post(`/campaign/${demoCampaign.id}/sms/credentials`) -// .send({ -// label: NON_DEMO_CREDENTIAL_LABEL, -// recipient: '98765432', -// }) - -// expect(res.status).toBe(403) -// expect(res.body).toEqual({ -// code: 'unauthorized', -// message: `Demo campaign must use demo credentials. ${NON_DEMO_CREDENTIAL_LABEL} is not allowed.`, -// }) - -// expect(mockSecretsManager.getSecretValue).not.toHaveBeenCalled() -// }) - -// test('Demo Campaign should be able to use demo credentials', async () => { -// const demoCampaign = await createCampaign({ isDemo: true }) - -// const TEST_TWILIO_CREDENTIALS = { -// accountSid: '', -// apiKey: '', -// apiSecret: '', -// messagingServiceSid: '', -// } -// mockSecretsManager.getSecretValue.mockResolvedValue({ -// SecretString: JSON.stringify(TEST_TWILIO_CREDENTIALS), -// }) - -// const mockSendCampaignMessage = jest -// .spyOn(SmsService, 'sendCampaignMessage') -// .mockResolvedValue() - -// const res = await request(app) -// .post(`/campaign/${demoCampaign.id}/sms/credentials`) -// .send({ -// label: DefaultCredentialName.SMS, -// recipient: '98765432', -// }) - -// expect(res.status).toBe(200) - -// expect(mockSecretsManager.getSecretValue).toHaveBeenCalledWith({ -// SecretId: formatDefaultCredentialName(DefaultCredentialName.SMS), -// }) - -// mockSecretsManager.getSecretValue.mockReset() -// mockSendCampaignMessage.mockRestore() -// }) -// }) - -// describe('POST /campaign/{campaignId}/sms/new-credentials', () => { -// afterEach(async () => { -// // Reset number of calls for mocked functions -// jest.clearAllMocks() -// }) - -// test('Demo Campaign should not be able to create custom credential', async () => { -// const demoCampaign = await createCampaign({ isDemo: true }) - -// const res = await request(app) -// .post(`/campaign/${demoCampaign.id}/sms/new-credentials`) -// .send({ -// recipient: '81234567', -// twilio_account_sid: 'twilio_account_sid', -// twilio_api_key: 'twilio_api_key', -// twilio_api_secret: 'twilio_api_secret', -// twilio_messaging_service_sid: 'twilio_messaging_service_sid', -// }) - -// expect(res.status).toBe(403) -// expect(res.body).toEqual({ -// code: 'unauthorized', -// message: `Action not allowed for demo campaign`, -// }) - -// expect(mockSecretsManager.createSecret).not.toHaveBeenCalled() -// }) - -// test('User should not be able to add custom credential using invalid Twilio API key', async () => { -// const nonDemoCampaign = await createCampaign({ isDemo: false }) - -// // Mock Twilio API to fail -// const ERROR_MESSAGE = 'Some Error' -// const mockSendCampaignMessage = jest -// .spyOn(SmsService, 'sendCampaignMessage') -// .mockRejectedValue(new Error(ERROR_MESSAGE)) - -// const res = await request(app) -// .post(`/campaign/${nonDemoCampaign.id}/sms/new-credentials`) -// .send({ -// recipient: '81234567', -// twilio_account_sid: 'twilio_account_sid', -// twilio_api_key: 'twilio_api_key', -// twilio_api_secret: 'twilio_api_secret', -// twilio_messaging_service_sid: 'twilio_messaging_service_sid', -// }) - -// expect(res.status).toBe(400) -// expect(res.body).toEqual({ -// code: 'invalid_credentials', -// message: 'Some Error', -// }) - -// expect(mockSecretsManager.createSecret).not.toHaveBeenCalled() -// mockSendCampaignMessage.mockRestore() -// }) - -// test('User should be able to add custom credential using valid Twilio API key', async () => { -// const nonDemoCampaign = await createCampaign({ isDemo: false }) - -// const mockSendCampaignMessage = jest -// .spyOn(SmsService, 'sendCampaignMessage') -// .mockResolvedValue() - -// const EXPECTED_CRED_NAME = 'MOCKED_UUID' - -// const res = await request(app) -// .post(`/campaign/${nonDemoCampaign.id}/sms/new-credentials`) -// .send({ -// recipient: '81234567', -// twilio_account_sid: 'twilio_account_sid', -// twilio_api_key: 'twilio_api_key', -// twilio_api_secret: 'twilio_api_secret', -// twilio_messaging_service_sid: 'twilio_messaging_service_sid', -// }) - -// expect(res.status).toBe(200) - -// expect(mockSecretsManager.createSecret).toHaveBeenCalledWith( -// expect.objectContaining({ -// Name: EXPECTED_CRED_NAME, -// SecretString: JSON.stringify({ -// accountSid: 'twilio_account_sid', -// apiKey: 'twilio_api_key', -// apiSecret: 'twilio_api_secret', -// messagingServiceSid: 'twilio_messaging_service_sid', -// }), -// }) -// ) - -// // Ensure credential was added into DB -// const dbCredential = await Credential.findOne({ -// where: { -// name: EXPECTED_CRED_NAME, -// }, -// }) -// expect(dbCredential).not.toBe(null) -// mockSendCampaignMessage.mockRestore() -// }) -// }) - -// describe('PUT /campaign/{id}/sms/template', () => { -// test('Successfully update template for SMS campaign', async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/sms/template`) -// .query({ campaignId: campaignId }) -// .send({ -// body: 'test {{variable}}', -// }) -// expect(res.status).toBe(200) -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: `Template for campaign ${campaignId} updated`, -// num_recipients: 0, -// template: { -// body: 'test {{variable}}', -// params: ['variable'], -// }, -// }) -// ) -// }) - -// test('Receive message to re-upload recipient when template has changed', async () => { -// await request(app) -// .put(`/campaign/${campaignId}/sms/template`) -// .query({ campaignId: campaignId }) -// .send({ -// body: 'test {{variable1}}', -// }) -// .expect(200) - -// await SmsMessage.create({ -// campaignId: campaignId, -// params: { variable1: 'abc' }, -// } as SmsMessage) - -// const res = await request(app) -// .put(`/campaign/${campaignId}/sms/template`) -// .query({ campaignId: campaignId }) -// .send({ -// body: 'test {{variable2}}', -// }) -// expect(res.status).toBe(200) -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: -// 'Please re-upload your recipient list as template has changed.', -// extra_keys: ['variable2'], -// num_recipients: 0, -// template: { -// body: 'test {{variable2}}', -// params: ['variable2'], -// }, -// }) -// ) -// }) - -// test('Fail to update template for SMS campaign', async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/sms/template`) -// .query({ campaignId: campaignId }) -// .send({ -// body: '

', -// }) -// expect(res.status).toBe(400) -// expect(res.body).toEqual({ -// code: 'invalid_template', -// message: -// 'Message template is invalid as it only contains invalid HTML tags!', -// }) -// }) - -// test('Template with only invalid HTML tags is not accepted', async () => { -// const testBody = await request(app) -// .put(`/campaign/${campaignId}/sms/template`) -// .send({ -// body: '', -// }) - -// expect(testBody.status).toBe(400) -// expect(testBody.body).toEqual({ -// code: 'invalid_template', -// message: -// 'Message template is invalid as it only contains invalid HTML tags!', -// }) -// }) - -// test('Existing populated messages are removed when template has new variables', async () => { -// await SmsMessage.create({ -// campaignId, -// recipient: 'user@agency.gov.sg', -// params: { recipient: 'user@agency.gov.sg' }, -// } as SmsMessage) -// const res = await request(app) -// .put(`/campaign/${campaignId}/sms/template`) -// .send({ -// body: 'test {{name}}', -// }) - -// expect(res.status).toBe(200) -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: -// 'Please re-upload your recipient list as template has changed.', -// template: expect.objectContaining({ -// params: ['name'], -// }), -// }) -// ) - -// const smsMessages = await SmsMessage.count({ -// where: { campaignId }, -// }) -// expect(smsMessages).toEqual(0) -// }) - -// test('Successfully update template', async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/sms/template`) -// .send({ -// body: 'test {{name}}', -// }) - -// expect(res.status).toBe(200) -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: `Template for campaign ${campaignId} updated`, -// template: { body: 'test {{name}}', params: ['name'] }, -// }) -// ) -// }) -// }) - -// describe('GET /campaign/{id}/sms/upload/start', () => { -// test('Fail to generate presigned URL when invalid md5 provided', async () => { -// const mockGetUploadParameters = jest -// .spyOn(UploadService, 'getUploadParameters') -// .mockRejectedValue({ message: 'hello' }) - -// const res = await request(app) -// .get(`/campaign/${campaignId}/sms/upload/start`) -// .query({ -// mime_type: 'text/csv', -// md5: 'invalid md5 checksum', -// }) - -// expect(res.status).toBe(500) -// expect(res.body).toEqual({ -// code: 'internal_server', -// message: 'Unable to generate presigned URL', -// }) -// mockGetUploadParameters.mockRestore() -// }) - -// test('Successfully generate presigned URL for valid md5', async () => { -// const mockGetUploadParameters = jest -// .spyOn(UploadService, 'getUploadParameters') -// .mockReturnValue( -// Promise.resolve({ presignedUrl: 'url', signedKey: 'key' }) -// ) - -// const res = await request(app) -// .get(`/campaign/${campaignId}/sms/upload/start`) -// .query({ -// mime_type: 'text/csv', -// md5: 'valid md5 checksum', -// }) - -// expect(res.status).toBe(200) -// expect(res.body).toEqual({ presigned_url: 'url', transaction_id: 'key' }) -// mockGetUploadParameters.mockRestore() -// }) -// }) - -// describe('POST /campaign/{id}/sms/upload/complete', () => { -// test('Fails to complete upload if invalid transaction id provided', async () => { -// const res = await request(app) -// .post(`/campaign/${campaignId}/sms/upload/complete`) -// .send({ transaction_id: '123', filename: 'abc', etag: '123' }) - -// expect(res.status).toEqual(500) -// }) - -// test('Fails to complete upload if template is missing', async () => { -// const mockExtractParamsFromJwt = jest -// .spyOn(UploadService, 'extractParamsFromJwt') -// .mockReturnValue({ s3Key: 'key' }) - -// const res = await request(app) -// .post(`/campaign/${campaignId}/sms/upload/complete`) -// .send({ transaction_id: '123', filename: 'abc', etag: '123' }) - -// expect(res.status).toEqual(500) -// mockExtractParamsFromJwt.mockRestore() -// }) - -// test('Successfully starts recipient list processing', async () => { -// await SmsTemplate.create({ -// campaignId: campaignId, -// params: ['variable1'], -// body: 'test {{variable1}}', -// } as SmsTemplate) - -// const mockExtractParamsFromJwt = jest -// .spyOn(UploadService, 'extractParamsFromJwt') -// .mockReturnValue({ s3Key: 'key' }) - -// const res = await request(app) -// .post(`/campaign/${campaignId}/sms/upload/complete`) -// .send({ transaction_id: '123', filename: 'abc', etag: '123' }) - -// expect(res.status).toEqual(202) -// mockExtractParamsFromJwt.mockRestore() -// }) -// }) +import request from 'supertest' +import { Sequelize } from 'sequelize-typescript' +import initialiseServer from '@test-utils/server' +import { Campaign, User, Credential } from '@core/models' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { UploadService } from '@core/services' +import { DefaultCredentialName } from '@core/constants' +import { formatDefaultCredentialName } from '@core/utils' +import { SmsMessage, SmsTemplate } from '@sms/models' +import { ChannelType } from '@core/constants' +import { mockSecretsManager } from '@mocks/@aws-sdk/client-secrets-manager' +import { SmsService } from '@sms/services' + +const app = initialiseServer(true) +let sequelize: Sequelize +let campaignId: number + +// Helper function to create demo/non-demo campaign based on parameters +const createCampaign = async ({ + isDemo, +}: { + isDemo: boolean +}): Promise => + await Campaign.create({ + name: 'test-campaign', + userId: 1, + type: ChannelType.SMS, + protect: false, + valid: false, + demoMessageLimit: isDemo ? 20 : null, + } as Campaign) + +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') + await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) + const campaign = await createCampaign({ isDemo: false }) + campaignId = campaign.id + jest.mock('@aws-sdk/client-secrets-manager') +}) + +afterEach(async () => { + await SmsMessage.destroy({ where: {} }) + await SmsTemplate.destroy({ where: {} }) +}) + +afterAll(async () => { + await SmsMessage.destroy({ where: {} }) + await Campaign.destroy({ where: {}, force: true }) + await User.destroy({ where: {} }) + await sequelize.close() + await UploadService.destroyUploadQueue() + await (app as any).cleanup() +}) + +describe('GET /campaign/{id}/sms', () => { + test('Get SMS campaign details', async () => { + const campaign = await Campaign.create({ + name: 'campaign-1', + userId: 1, + type: 'SMS', + valid: false, + protect: false, + } as Campaign) + const { id, name, type } = campaign + const TEST_TWILIO_CREDENTIALS = { + accountSid: '', + apiKey: '', + apiSecret: '', + messagingServiceSid: '', + } + const mockGetCampaign = jest + .spyOn(SmsService, 'getTwilioCostPerOutgoingSMSSegmentUSD') + .mockResolvedValue(0.0395) // exact value unimportant for test to pass + // needed because demo credentials are extracted from secrets manager to get + // credentials to call Twilio API for SMS price + mockSecretsManager.getSecretValue.mockResolvedValue({ + SecretString: JSON.stringify(TEST_TWILIO_CREDENTIALS), + }) + const res = await request(app).get(`/campaign/${campaign.id}/sms`) + expect(res.status).toBe(200) + expect(res.body).toEqual(expect.objectContaining({ id, name, type })) + mockGetCampaign.mockRestore() + }) +}) + +describe('POST /campaign/{campaignId}/sms/credentials', () => { + afterEach(async () => { + // Reset number of calls for mocked functions + jest.clearAllMocks() + }) + + test('Non-Demo campaign should not be able to use demo credentials', async () => { + const nonDemoCampaign = await createCampaign({ isDemo: false }) + + const res = await request(app) + .post(`/campaign/${nonDemoCampaign.id}/sms/credentials`) + .send({ + label: DefaultCredentialName.SMS, + recipient: '98765432', + }) + + expect(res.status).toBe(403) + expect(res.body).toEqual({ + code: 'unauthorized', + message: `Campaign cannot use demo credentials. ${DefaultCredentialName.SMS} is not allowed.`, + }) + }) + + test('Demo Campaign should not be able to use non-demo credentials', async () => { + const demoCampaign = await createCampaign({ isDemo: true }) + + const NON_DEMO_CREDENTIAL_LABEL = 'Some Credential' + + const res = await request(app) + .post(`/campaign/${demoCampaign.id}/sms/credentials`) + .send({ + label: NON_DEMO_CREDENTIAL_LABEL, + recipient: '98765432', + }) + + expect(res.status).toBe(403) + expect(res.body).toEqual({ + code: 'unauthorized', + message: `Demo campaign must use demo credentials. ${NON_DEMO_CREDENTIAL_LABEL} is not allowed.`, + }) + + expect(mockSecretsManager.getSecretValue).not.toHaveBeenCalled() + }) + + test('Demo Campaign should be able to use demo credentials', async () => { + const demoCampaign = await createCampaign({ isDemo: true }) + + const TEST_TWILIO_CREDENTIALS = { + accountSid: '', + apiKey: '', + apiSecret: '', + messagingServiceSid: '', + } + mockSecretsManager.getSecretValue.mockResolvedValue({ + SecretString: JSON.stringify(TEST_TWILIO_CREDENTIALS), + }) + + const mockSendCampaignMessage = jest + .spyOn(SmsService, 'sendCampaignMessage') + .mockResolvedValue() + + const res = await request(app) + .post(`/campaign/${demoCampaign.id}/sms/credentials`) + .send({ + label: DefaultCredentialName.SMS, + recipient: '98765432', + }) + + expect(res.status).toBe(200) + + expect(mockSecretsManager.getSecretValue).toHaveBeenCalledWith({ + SecretId: formatDefaultCredentialName(DefaultCredentialName.SMS), + }) + + mockSecretsManager.getSecretValue.mockReset() + mockSendCampaignMessage.mockRestore() + }) +}) + +describe('POST /campaign/{campaignId}/sms/new-credentials', () => { + afterEach(async () => { + // Reset number of calls for mocked functions + jest.clearAllMocks() + }) + + test('Demo Campaign should not be able to create custom credential', async () => { + const demoCampaign = await createCampaign({ isDemo: true }) + + const res = await request(app) + .post(`/campaign/${demoCampaign.id}/sms/new-credentials`) + .send({ + recipient: '81234567', + twilio_account_sid: 'twilio_account_sid', + twilio_api_key: 'twilio_api_key', + twilio_api_secret: 'twilio_api_secret', + twilio_messaging_service_sid: 'twilio_messaging_service_sid', + }) + + expect(res.status).toBe(403) + expect(res.body).toEqual({ + code: 'unauthorized', + message: `Action not allowed for demo campaign`, + }) + + expect(mockSecretsManager.createSecret).not.toHaveBeenCalled() + }) + + test('User should not be able to add custom credential using invalid Twilio API key', async () => { + const nonDemoCampaign = await createCampaign({ isDemo: false }) + + // Mock Twilio API to fail + const ERROR_MESSAGE = 'Some Error' + const mockSendCampaignMessage = jest + .spyOn(SmsService, 'sendCampaignMessage') + .mockRejectedValue(new Error(ERROR_MESSAGE)) + + const res = await request(app) + .post(`/campaign/${nonDemoCampaign.id}/sms/new-credentials`) + .send({ + recipient: '81234567', + twilio_account_sid: 'twilio_account_sid', + twilio_api_key: 'twilio_api_key', + twilio_api_secret: 'twilio_api_secret', + twilio_messaging_service_sid: 'twilio_messaging_service_sid', + }) + + expect(res.status).toBe(400) + expect(res.body).toEqual({ + code: 'invalid_credentials', + message: 'Some Error', + }) + + expect(mockSecretsManager.createSecret).not.toHaveBeenCalled() + mockSendCampaignMessage.mockRestore() + }) + + test('User should be able to add custom credential using valid Twilio API key', async () => { + const nonDemoCampaign = await createCampaign({ isDemo: false }) + + const mockSendCampaignMessage = jest + .spyOn(SmsService, 'sendCampaignMessage') + .mockResolvedValue() + + const EXPECTED_CRED_NAME = 'MOCKED_UUID' + + const res = await request(app) + .post(`/campaign/${nonDemoCampaign.id}/sms/new-credentials`) + .send({ + recipient: '81234567', + twilio_account_sid: 'twilio_account_sid', + twilio_api_key: 'twilio_api_key', + twilio_api_secret: 'twilio_api_secret', + twilio_messaging_service_sid: 'twilio_messaging_service_sid', + }) + + expect(res.status).toBe(200) + + expect(mockSecretsManager.createSecret).toHaveBeenCalledWith( + expect.objectContaining({ + Name: EXPECTED_CRED_NAME, + SecretString: JSON.stringify({ + accountSid: 'twilio_account_sid', + apiKey: 'twilio_api_key', + apiSecret: 'twilio_api_secret', + messagingServiceSid: 'twilio_messaging_service_sid', + }), + }) + ) + + // Ensure credential was added into DB + const dbCredential = await Credential.findOne({ + where: { + name: EXPECTED_CRED_NAME, + }, + }) + expect(dbCredential).not.toBe(null) + mockSendCampaignMessage.mockRestore() + }) +}) + +describe('PUT /campaign/{id}/sms/template', () => { + test('Successfully update template for SMS campaign', async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/sms/template`) + .query({ campaignId: campaignId }) + .send({ + body: 'test {{variable}}', + }) + expect(res.status).toBe(200) + expect(res.body).toEqual( + expect.objectContaining({ + message: `Template for campaign ${campaignId} updated`, + num_recipients: 0, + template: { + body: 'test {{variable}}', + params: ['variable'], + }, + }) + ) + }) + + test('Receive message to re-upload recipient when template has changed', async () => { + await request(app) + .put(`/campaign/${campaignId}/sms/template`) + .query({ campaignId: campaignId }) + .send({ + body: 'test {{variable1}}', + }) + .expect(200) + + await SmsMessage.create({ + campaignId: campaignId, + params: { variable1: 'abc' }, + } as SmsMessage) + + const res = await request(app) + .put(`/campaign/${campaignId}/sms/template`) + .query({ campaignId: campaignId }) + .send({ + body: 'test {{variable2}}', + }) + expect(res.status).toBe(200) + expect(res.body).toEqual( + expect.objectContaining({ + message: + 'Please re-upload your recipient list as template has changed.', + extra_keys: ['variable2'], + num_recipients: 0, + template: { + body: 'test {{variable2}}', + params: ['variable2'], + }, + }) + ) + }) + + test('Fail to update template for SMS campaign', async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/sms/template`) + .query({ campaignId: campaignId }) + .send({ + body: '

', + }) + expect(res.status).toBe(400) + expect(res.body).toEqual({ + code: 'invalid_template', + message: + 'Message template is invalid as it only contains invalid HTML tags!', + }) + }) + + test('Template with only invalid HTML tags is not accepted', async () => { + const testBody = await request(app) + .put(`/campaign/${campaignId}/sms/template`) + .send({ + body: '', + }) + + expect(testBody.status).toBe(400) + expect(testBody.body).toEqual({ + code: 'invalid_template', + message: + 'Message template is invalid as it only contains invalid HTML tags!', + }) + }) + + test('Existing populated messages are removed when template has new variables', async () => { + await SmsMessage.create({ + campaignId, + recipient: 'user@agency.gov.sg', + params: { recipient: 'user@agency.gov.sg' }, + } as SmsMessage) + const res = await request(app) + .put(`/campaign/${campaignId}/sms/template`) + .send({ + body: 'test {{name}}', + }) + + expect(res.status).toBe(200) + expect(res.body).toEqual( + expect.objectContaining({ + message: + 'Please re-upload your recipient list as template has changed.', + template: expect.objectContaining({ + params: ['name'], + }), + }) + ) + + const smsMessages = await SmsMessage.count({ + where: { campaignId }, + }) + expect(smsMessages).toEqual(0) + }) + + test('Successfully update template', async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/sms/template`) + .send({ + body: 'test {{name}}', + }) + + expect(res.status).toBe(200) + expect(res.body).toEqual( + expect.objectContaining({ + message: `Template for campaign ${campaignId} updated`, + template: { body: 'test {{name}}', params: ['name'] }, + }) + ) + }) +}) + +describe('GET /campaign/{id}/sms/upload/start', () => { + test('Fail to generate presigned URL when invalid md5 provided', async () => { + const mockGetUploadParameters = jest + .spyOn(UploadService, 'getUploadParameters') + .mockRejectedValue({ message: 'hello' }) + + const res = await request(app) + .get(`/campaign/${campaignId}/sms/upload/start`) + .query({ + mime_type: 'text/csv', + md5: 'invalid md5 checksum', + }) + + expect(res.status).toBe(500) + expect(res.body).toEqual({ + code: 'internal_server', + message: 'Unable to generate presigned URL', + }) + mockGetUploadParameters.mockRestore() + }) + + test('Successfully generate presigned URL for valid md5', async () => { + const mockGetUploadParameters = jest + .spyOn(UploadService, 'getUploadParameters') + .mockReturnValue( + Promise.resolve({ presignedUrl: 'url', signedKey: 'key' }) + ) + + const res = await request(app) + .get(`/campaign/${campaignId}/sms/upload/start`) + .query({ + mime_type: 'text/csv', + md5: 'valid md5 checksum', + }) + + expect(res.status).toBe(200) + expect(res.body).toEqual({ presigned_url: 'url', transaction_id: 'key' }) + mockGetUploadParameters.mockRestore() + }) +}) + +describe('POST /campaign/{id}/sms/upload/complete', () => { + test('Fails to complete upload if invalid transaction id provided', async () => { + const res = await request(app) + .post(`/campaign/${campaignId}/sms/upload/complete`) + .send({ transaction_id: '123', filename: 'abc', etag: '123' }) + + expect(res.status).toEqual(500) + }) + + test('Fails to complete upload if template is missing', async () => { + const mockExtractParamsFromJwt = jest + .spyOn(UploadService, 'extractParamsFromJwt') + .mockReturnValue({ s3Key: 'key' }) + + const res = await request(app) + .post(`/campaign/${campaignId}/sms/upload/complete`) + .send({ transaction_id: '123', filename: 'abc', etag: '123' }) + + expect(res.status).toEqual(500) + mockExtractParamsFromJwt.mockRestore() + }) + + test('Successfully starts recipient list processing', async () => { + await SmsTemplate.create({ + campaignId: campaignId, + params: ['variable1'], + body: 'test {{variable1}}', + } as SmsTemplate) + + const mockExtractParamsFromJwt = jest + .spyOn(UploadService, 'extractParamsFromJwt') + .mockReturnValue({ s3Key: 'key' }) + + const res = await request(app) + .post(`/campaign/${campaignId}/sms/upload/complete`) + .send({ transaction_id: '123', filename: 'abc', etag: '123' }) + + expect(res.status).toEqual(202) + mockExtractParamsFromJwt.mockRestore() + }) +}) diff --git a/backend/src/sms/routes/tests/sms-settings.routes.test.ts b/backend/src/sms/routes/tests/sms-settings.routes.test.ts index f4616db26..23c17a4a4 100644 --- a/backend/src/sms/routes/tests/sms-settings.routes.test.ts +++ b/backend/src/sms/routes/tests/sms-settings.routes.test.ts @@ -1,106 +1,106 @@ -// import request from 'supertest' -// import { Sequelize } from 'sequelize-typescript' -// import initialiseServer from '@test-utils/server' -// import { Credential, UserCredential, User } from '@core/models' -// import sequelizeLoader from '@test-utils/sequelize-loader' -// import { ChannelType } from '@core/constants' -// import { mockSecretsManager } from '@mocks/@aws-sdk/client-secrets-manager' -// import { SmsService } from '@sms/services' +import request from 'supertest' +import { Sequelize } from 'sequelize-typescript' +import initialiseServer from '@test-utils/server' +import { Credential, UserCredential, User } from '@core/models' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { ChannelType } from '@core/constants' +import { mockSecretsManager } from '@mocks/@aws-sdk/client-secrets-manager' +import { SmsService } from '@sms/services' -// const app = initialiseServer(true) -// let sequelize: Sequelize +const app = initialiseServer(true) +let sequelize: Sequelize -// beforeAll(async () => { -// sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') -// await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) -// }) +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') + await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) +}) -// afterAll(async () => { -// await UserCredential.destroy({ where: {} }) -// await Credential.destroy({ where: {} }) -// await User.destroy({ where: {} }) -// await sequelize.close() -// await (app as any).cleanup() -// }) +afterAll(async () => { + await UserCredential.destroy({ where: {} }) + await Credential.destroy({ where: {} }) + await User.destroy({ where: {} }) + await sequelize.close() + await (app as any).cleanup() +}) -// describe('POST /settings/sms/credentials', () => { -// afterEach(async () => { -// // Reset number of calls for mocked functions -// jest.clearAllMocks() -// }) +describe('POST /settings/sms/credentials', () => { + afterEach(async () => { + // Reset number of calls for mocked functions + jest.clearAllMocks() + }) -// test('User should not be able to add custom credential using invalid Twilio API key', async () => { -// // Mock Twilio API to fail -// const ERROR_MESSAGE = 'Some Error' -// const mockSendValidationMessage = jest -// .spyOn(SmsService, 'sendValidationMessage') -// .mockRejectedValue(new Error(ERROR_MESSAGE)) + test('User should not be able to add custom credential using invalid Twilio API key', async () => { + // Mock Twilio API to fail + const ERROR_MESSAGE = 'Some Error' + const mockSendValidationMessage = jest + .spyOn(SmsService, 'sendValidationMessage') + .mockRejectedValue(new Error(ERROR_MESSAGE)) -// const res = await request(app).post('/settings/sms/credentials').send({ -// label: 'sms-credential-1', -// recipient: '81234567', -// twilio_account_sid: 'twilio_account_sid', -// twilio_api_key: 'twilio_api_key', -// twilio_api_secret: 'twilio_api_secret', -// twilio_messaging_service_sid: 'twilio_messaging_service_sid', -// }) -// expect(res.status).toBe(400) -// expect(res.body).toEqual({ -// code: 'invalid_credentials', -// message: ERROR_MESSAGE, -// }) + const res = await request(app).post('/settings/sms/credentials').send({ + label: 'sms-credential-1', + recipient: '81234567', + twilio_account_sid: 'twilio_account_sid', + twilio_api_key: 'twilio_api_key', + twilio_api_secret: 'twilio_api_secret', + twilio_messaging_service_sid: 'twilio_messaging_service_sid', + }) + expect(res.status).toBe(400) + expect(res.body).toEqual({ + code: 'invalid_credentials', + message: ERROR_MESSAGE, + }) -// expect(mockSecretsManager.createSecret).not.toHaveBeenCalled() -// mockSendValidationMessage.mockRestore() -// }) + expect(mockSecretsManager.createSecret).not.toHaveBeenCalled() + mockSendValidationMessage.mockRestore() + }) -// test('User should be able to add custom credential using valid Twilio API key', async () => { -// const CREDENTIAL_LABEL = 'sms-credential-1' -// const mockSendValidationMessage = jest -// .spyOn(SmsService, 'sendValidationMessage') -// .mockResolvedValue() -// const EXPECTED_CRED_NAME = 'MOCKED_UUID' + test('User should be able to add custom credential using valid Twilio API key', async () => { + const CREDENTIAL_LABEL = 'sms-credential-1' + const mockSendValidationMessage = jest + .spyOn(SmsService, 'sendValidationMessage') + .mockResolvedValue() + const EXPECTED_CRED_NAME = 'MOCKED_UUID' -// const res = await request(app).post('/settings/sms/credentials').send({ -// label: CREDENTIAL_LABEL, -// recipient: '81234567', -// twilio_account_sid: 'twilio_account_sid', -// twilio_api_key: 'twilio_api_key', -// twilio_api_secret: 'twilio_api_secret', -// twilio_messaging_service_sid: 'twilio_messaging_service_sid', -// }) + const res = await request(app).post('/settings/sms/credentials').send({ + label: CREDENTIAL_LABEL, + recipient: '81234567', + twilio_account_sid: 'twilio_account_sid', + twilio_api_key: 'twilio_api_key', + twilio_api_secret: 'twilio_api_secret', + twilio_messaging_service_sid: 'twilio_messaging_service_sid', + }) -// expect(res.status).toBe(200) + expect(res.status).toBe(200) -// expect(mockSecretsManager.createSecret).toHaveBeenCalledWith( -// expect.objectContaining({ -// Name: EXPECTED_CRED_NAME, -// SecretString: JSON.stringify({ -// accountSid: 'twilio_account_sid', -// apiKey: 'twilio_api_key', -// apiSecret: 'twilio_api_secret', -// messagingServiceSid: 'twilio_messaging_service_sid', -// }), -// }) -// ) + expect(mockSecretsManager.createSecret).toHaveBeenCalledWith( + expect.objectContaining({ + Name: EXPECTED_CRED_NAME, + SecretString: JSON.stringify({ + accountSid: 'twilio_account_sid', + apiKey: 'twilio_api_key', + apiSecret: 'twilio_api_secret', + messagingServiceSid: 'twilio_messaging_service_sid', + }), + }) + ) -// // Ensure credential was added into DB -// const dbCredential = await Credential.findOne({ -// where: { -// name: EXPECTED_CRED_NAME, -// }, -// }) -// expect(dbCredential).not.toBe(null) + // Ensure credential was added into DB + const dbCredential = await Credential.findOne({ + where: { + name: EXPECTED_CRED_NAME, + }, + }) + expect(dbCredential).not.toBe(null) -// const dbUserCredential = await UserCredential.findOne({ -// where: { -// label: CREDENTIAL_LABEL, -// type: ChannelType.SMS, -// credName: EXPECTED_CRED_NAME, -// userId: 1, -// }, -// }) -// expect(dbUserCredential).not.toBe(null) -// mockSendValidationMessage.mockRestore() -// }) -// }) + const dbUserCredential = await UserCredential.findOne({ + where: { + label: CREDENTIAL_LABEL, + type: ChannelType.SMS, + credName: EXPECTED_CRED_NAME, + userId: 1, + }, + }) + expect(dbUserCredential).not.toBe(null) + mockSendValidationMessage.mockRestore() + }) +}) diff --git a/backend/src/sms/routes/tests/sms-transactional.routes.test.ts b/backend/src/sms/routes/tests/sms-transactional.routes.test.ts index 58e08776a..b0e34349c 100644 --- a/backend/src/sms/routes/tests/sms-transactional.routes.test.ts +++ b/backend/src/sms/routes/tests/sms-transactional.routes.test.ts @@ -1,166 +1,166 @@ -// import request from 'supertest' -// import { Sequelize } from 'sequelize-typescript' - -// import { Credential, User, UserCredential } from '@core/models' -// import { ChannelType } from '@core/constants' -// import { InvalidRecipientError } from '@core/errors' -// import { SmsService } from '@sms/services' - -// import { mockSecretsManager } from '@mocks/@aws-sdk/client-secrets-manager' -// import initialiseServer from '@test-utils/server' -// import sequelizeLoader from '@test-utils/sequelize-loader' -// import { SmsMessageTransactional } from '@sms/models' -// import { CredentialService } from '@core/services' -// import { RateLimitError } from '@shared/clients/twilio-client.class/errors' - -// const TEST_TWILIO_CREDENTIALS = { -// accountSid: '', -// apiKey: '', -// apiSecret: '', -// messagingServiceSid: '', -// } - -// let sequelize: Sequelize -// let user: User -// let apiKey: string -// let credential: Credential - -// const app = initialiseServer(false) - -// beforeEach(async () => { -// user = await User.create({ -// id: 1, -// email: 'user_1@agency.gov.sg', -// } as User) -// const userId = user.id -// const { plainTextKey } = await ( -// app as any as { credentialService: CredentialService } -// ).credentialService.generateApiKey(user.id, 'test api key', [user.email]) -// apiKey = plainTextKey - -// credential = await Credential.create({ name: 'twilio' } as Credential) -// await UserCredential.create({ -// label: `twilio-${userId}`, -// type: ChannelType.SMS, -// credName: credential.name, -// userId, -// } as UserCredential) -// }) - -// beforeAll(async () => { -// sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') -// }) - -// afterEach(async () => { -// jest.clearAllMocks() -// await SmsMessageTransactional.destroy({ where: {} }) -// await User.destroy({ where: {} }) -// await UserCredential.destroy({ where: {} }) -// await Credential.destroy({ where: {} }) -// }) - -// afterAll(async () => { -// await sequelize.close() -// await (app as any).cleanup() -// }) - -// describe('POST /transactional/sms/send', () => { -// const validApiCall = { -// body: 'Hello world', -// recipient: '98765432', -// label: 'twilio-1', -// } - -// test('Should throw an error if API key is invalid', async () => { -// const res = await request(app) -// .post('/transactional/sms/send') -// .set('Authorization', `Bearer invalid-${apiKey}`) -// .send({}) - -// expect(res.status).toBe(401) -// }) - -// test('Should throw an error if API key is valid but payload is not', async () => { -// const res = await request(app) -// .post('/transactional/sms/send') -// .set('Authorization', `Bearer ${apiKey}`) -// .send({}) - -// expect(res.status).toBe(400) -// }) - -// test('Should send a message successfully', async () => { -// const mockSendMessageResolvedValue = 'message_id' -// const mockSendMessage = jest -// .spyOn(SmsService, 'sendMessage') -// .mockResolvedValue(mockSendMessageResolvedValue) -// mockSecretsManager.getSecretValue.mockResolvedValueOnce({ -// SecretString: JSON.stringify(TEST_TWILIO_CREDENTIALS), -// }) - -// const res = await request(app) -// .post('/transactional/sms/send') -// .set('Authorization', `Bearer ${apiKey}`) -// .send(validApiCall) - -// expect(res.status).toBe(201) -// expect(mockSendMessage).toBeCalledTimes(1) -// const transactionalSms = await SmsMessageTransactional.findOne({ -// where: { userId: user.id.toString() }, -// }) -// expect(transactionalSms).not.toBeNull() -// expect(transactionalSms).toMatchObject({ -// recipient: validApiCall.recipient, -// body: validApiCall.body, -// userId: user.id.toString(), -// credentialsLabel: validApiCall.label, -// messageId: mockSendMessageResolvedValue, -// }) - -// const listRes = await request(app) -// .get('/transactional/sms') -// .set('Authorization', `Bearer ${apiKey}`) -// .send() -// expect(listRes.body.data[0].body).toEqual('Hello world') -// expect(listRes.body.data[0].recipient).toEqual('98765432') -// expect(listRes.body.data[0].credentialsLabel).toEqual('twilio-1') -// expect(listRes.status).toBe(200) - -// mockSendMessage.mockReset() -// }) - -// test('Should return a HTTP 400 when recipient is not valid', async () => { -// const mockSendMessage = jest -// .spyOn(SmsService, 'sendMessage') -// .mockRejectedValueOnce(new InvalidRecipientError()) -// mockSecretsManager.getSecretValue.mockResolvedValueOnce({ -// SecretString: JSON.stringify(TEST_TWILIO_CREDENTIALS), -// }) - -// const res = await request(app) -// .post('/transactional/sms/send') -// .set('Authorization', `Bearer ${apiKey}`) -// .send(validApiCall) - -// expect(res.status).toBe(400) -// expect(mockSendMessage).toBeCalledTimes(1) -// mockSendMessage.mockReset() -// }) -// test('Should return a HTTP 429 when Twilio rate limits request', async () => { -// const mockSendMessage = jest -// .spyOn(SmsService, 'sendMessage') -// .mockRejectedValueOnce(new RateLimitError()) -// mockSecretsManager.getSecretValue.mockResolvedValueOnce({ -// SecretString: JSON.stringify(TEST_TWILIO_CREDENTIALS), -// }) - -// const res = await request(app) -// .post('/transactional/sms/send') -// .set('Authorization', `Bearer ${apiKey}`) -// .send(validApiCall) - -// expect(res.status).toBe(429) -// expect(mockSendMessage).toBeCalledTimes(1) -// mockSendMessage.mockReset() -// }) -// }) +import request from 'supertest' +import { Sequelize } from 'sequelize-typescript' + +import { Credential, User, UserCredential } from '@core/models' +import { ChannelType } from '@core/constants' +import { InvalidRecipientError } from '@core/errors' +import { SmsService } from '@sms/services' + +import { mockSecretsManager } from '@mocks/@aws-sdk/client-secrets-manager' +import initialiseServer from '@test-utils/server' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { SmsMessageTransactional } from '@sms/models' +import { CredentialService } from '@core/services' +import { RateLimitError } from '@shared/clients/twilio-client.class/errors' + +const TEST_TWILIO_CREDENTIALS = { + accountSid: '', + apiKey: '', + apiSecret: '', + messagingServiceSid: '', +} + +let sequelize: Sequelize +let user: User +let apiKey: string +let credential: Credential + +const app = initialiseServer(false) + +beforeEach(async () => { + user = await User.create({ + id: 1, + email: 'user_1@agency.gov.sg', + } as User) + const userId = user.id + const { plainTextKey } = await ( + app as any as { credentialService: CredentialService } + ).credentialService.generateApiKey(user.id, 'test api key', [user.email]) + apiKey = plainTextKey + + credential = await Credential.create({ name: 'twilio' } as Credential) + await UserCredential.create({ + label: `twilio-${userId}`, + type: ChannelType.SMS, + credName: credential.name, + userId, + } as UserCredential) +}) + +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') +}) + +afterEach(async () => { + jest.clearAllMocks() + await SmsMessageTransactional.destroy({ where: {} }) + await User.destroy({ where: {} }) + await UserCredential.destroy({ where: {} }) + await Credential.destroy({ where: {} }) +}) + +afterAll(async () => { + await sequelize.close() + await (app as any).cleanup() +}) + +describe('POST /transactional/sms/send', () => { + const validApiCall = { + body: 'Hello world', + recipient: '98765432', + label: 'twilio-1', + } + + test('Should throw an error if API key is invalid', async () => { + const res = await request(app) + .post('/transactional/sms/send') + .set('Authorization', `Bearer invalid-${apiKey}`) + .send({}) + + expect(res.status).toBe(401) + }) + + test('Should throw an error if API key is valid but payload is not', async () => { + const res = await request(app) + .post('/transactional/sms/send') + .set('Authorization', `Bearer ${apiKey}`) + .send({}) + + expect(res.status).toBe(400) + }) + + test('Should send a message successfully', async () => { + const mockSendMessageResolvedValue = 'message_id' + const mockSendMessage = jest + .spyOn(SmsService, 'sendMessage') + .mockResolvedValue(mockSendMessageResolvedValue) + mockSecretsManager.getSecretValue.mockResolvedValueOnce({ + SecretString: JSON.stringify(TEST_TWILIO_CREDENTIALS), + }) + + const res = await request(app) + .post('/transactional/sms/send') + .set('Authorization', `Bearer ${apiKey}`) + .send(validApiCall) + + expect(res.status).toBe(201) + expect(mockSendMessage).toBeCalledTimes(1) + const transactionalSms = await SmsMessageTransactional.findOne({ + where: { userId: user.id.toString() }, + }) + expect(transactionalSms).not.toBeNull() + expect(transactionalSms).toMatchObject({ + recipient: validApiCall.recipient, + body: validApiCall.body, + userId: user.id.toString(), + credentialsLabel: validApiCall.label, + messageId: mockSendMessageResolvedValue, + }) + + const listRes = await request(app) + .get('/transactional/sms') + .set('Authorization', `Bearer ${apiKey}`) + .send() + expect(listRes.body.data[0].body).toEqual('Hello world') + expect(listRes.body.data[0].recipient).toEqual('98765432') + expect(listRes.body.data[0].credentialsLabel).toEqual('twilio-1') + expect(listRes.status).toBe(200) + + mockSendMessage.mockReset() + }) + + test('Should return a HTTP 400 when recipient is not valid', async () => { + const mockSendMessage = jest + .spyOn(SmsService, 'sendMessage') + .mockRejectedValueOnce(new InvalidRecipientError()) + mockSecretsManager.getSecretValue.mockResolvedValueOnce({ + SecretString: JSON.stringify(TEST_TWILIO_CREDENTIALS), + }) + + const res = await request(app) + .post('/transactional/sms/send') + .set('Authorization', `Bearer ${apiKey}`) + .send(validApiCall) + + expect(res.status).toBe(400) + expect(mockSendMessage).toBeCalledTimes(1) + mockSendMessage.mockReset() + }) + test('Should return a HTTP 429 when Twilio rate limits request', async () => { + const mockSendMessage = jest + .spyOn(SmsService, 'sendMessage') + .mockRejectedValueOnce(new RateLimitError()) + mockSecretsManager.getSecretValue.mockResolvedValueOnce({ + SecretString: JSON.stringify(TEST_TWILIO_CREDENTIALS), + }) + + const res = await request(app) + .post('/transactional/sms/send') + .set('Authorization', `Bearer ${apiKey}`) + .send(validApiCall) + + expect(res.status).toBe(429) + expect(mockSendMessage).toBeCalledTimes(1) + mockSendMessage.mockReset() + }) +}) diff --git a/backend/src/telegram/routes/tests/telegram-campaign.routes.test.ts b/backend/src/telegram/routes/tests/telegram-campaign.routes.test.ts index c7a191913..a4ed557e7 100644 --- a/backend/src/telegram/routes/tests/telegram-campaign.routes.test.ts +++ b/backend/src/telegram/routes/tests/telegram-campaign.routes.test.ts @@ -1,289 +1,289 @@ -// import request from 'supertest' -// import { Sequelize } from 'sequelize-typescript' -// import initialiseServer from '@test-utils/server' -// import { Campaign, User, Credential } from '@core/models' -// import sequelizeLoader from '@test-utils/sequelize-loader' -// import { DefaultCredentialName } from '@core/constants' -// import { formatDefaultCredentialName } from '@core/utils' -// import { UploadService } from '@core/services' -// import { TelegramMessage } from '@telegram/models' -// import { ChannelType } from '@core/constants' -// import { mockSecretsManager } from '@mocks/@aws-sdk/client-secrets-manager' -// import { mockTelegram, Telegram } from '@mocks/telegraf' - -// const app = initialiseServer(true) -// let sequelize: Sequelize -// let campaignId: number - -// // Helper function to create demo/non-demo campaign based on parameters -// const createCampaign = async ({ -// isDemo, -// }: { -// isDemo: boolean -// }): Promise => -// await Campaign.create({ -// name: 'test-campaign', -// userId: 1, -// type: ChannelType.Telegram, -// protect: false, -// valid: false, -// demoMessageLimit: isDemo ? 20 : null, -// } as Campaign) - -// beforeAll(async () => { -// sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') -// await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) -// const campaign = await createCampaign({ isDemo: false }) -// await Credential.create({ name: '12345' } as Credential) -// campaignId = campaign.id -// }) - -// afterAll(async () => { -// await TelegramMessage.destroy({ where: {} }) -// await Campaign.destroy({ where: {}, force: true }) -// await Credential.destroy({ where: {} }) -// await User.destroy({ where: {} }) -// await sequelize.close() -// await UploadService.destroyUploadQueue() -// await (app as any).cleanup() -// }) - -// describe('POST /campaign/{campaignId}/telegram/credentials', () => { -// beforeAll(async () => { -// // Mock telegram to always accept credential -// mockTelegram.setWebhook.mockResolvedValue(true) -// mockTelegram.setMyCommands.mockResolvedValue(true) -// mockTelegram.getMe.mockResolvedValue({ id: 1 }) -// }) - -// afterAll(async () => { -// mockTelegram.setWebhook.mockReset() -// mockTelegram.setMyCommands.mockReset() -// mockTelegram.getMe.mockReset() -// }) - -// afterEach(async () => { -// // Reset number of calls for mocked functions -// jest.clearAllMocks() -// }) - -// test('Non-Demo campaign should not be able to use demo credentials', async () => { -// const nonDemoCampaign = await createCampaign({ isDemo: false }) - -// const res = await request(app) -// .post(`/campaign/${nonDemoCampaign.id}/telegram/credentials`) -// .send({ -// label: DefaultCredentialName.Telegram, -// }) - -// expect(res.status).toBe(403) -// expect(res.body).toEqual({ -// code: 'unauthorized', -// message: `Campaign cannot use demo credentials. ${DefaultCredentialName.Telegram} is not allowed.`, -// }) - -// expect(mockSecretsManager.getSecretValue).not.toHaveBeenCalled() -// }) - -// test('Demo Campaign should not be able to use non-demo credentials', async () => { -// const demoCampaign = await createCampaign({ isDemo: true }) - -// const NON_DEMO_CREDENTIAL_LABEL = 'Some Credential' - -// const res = await request(app) -// .post(`/campaign/${demoCampaign.id}/telegram/credentials`) -// .send({ -// label: NON_DEMO_CREDENTIAL_LABEL, -// }) - -// expect(res.status).toBe(403) -// expect(res.body).toEqual({ -// code: 'unauthorized', -// message: `Demo campaign must use demo credentials. ${NON_DEMO_CREDENTIAL_LABEL} is not allowed.`, -// }) - -// expect(mockSecretsManager.getSecretValue).not.toHaveBeenCalled() -// }) - -// test('Demo Campaign should be able to use demo credentials', async () => { -// const demoCampaign = await createCampaign({ isDemo: true }) - -// const DEFAULT_TELEGRAM_CREDENTIAL = '12345' -// mockSecretsManager.getSecretValue.mockResolvedValue({ -// SecretString: DEFAULT_TELEGRAM_CREDENTIAL, -// }) - -// const res = await request(app) -// .post(`/campaign/${demoCampaign.id}/telegram/credentials`) -// .send({ -// label: DefaultCredentialName.Telegram, -// }) - -// expect(res.status).toBe(200) -// expect(mockSecretsManager.getSecretValue).toHaveBeenCalledWith({ -// SecretId: formatDefaultCredentialName(DefaultCredentialName.Telegram), -// }) -// expect(Telegram).toHaveBeenCalledWith(DEFAULT_TELEGRAM_CREDENTIAL) - -// mockSecretsManager.getSecretValue.mockReset() -// }) -// }) - -// describe('POST /campaign/{campaignId}/telegram/new-credentials', () => { -// beforeAll(async () => { -// // Mock telegram to always accept credential -// mockTelegram.setWebhook.mockResolvedValue(true) -// mockTelegram.setMyCommands.mockResolvedValue(true) -// }) - -// afterAll(async () => { -// mockTelegram.setWebhook.mockReset() -// mockTelegram.setMyCommands.mockReset() -// }) - -// afterEach(async () => { -// // Reset number of calls for mocked functions -// jest.clearAllMocks() -// }) - -// test('Demo Campaign should not be able to create custom credential', async () => { -// const demoCampaign = await createCampaign({ isDemo: true }) - -// const FAKE_API_TOKEN = 'Some API Token' - -// const res = await request(app) -// .post(`/campaign/${demoCampaign.id}/telegram/new-credentials`) -// .send({ -// telegram_bot_token: FAKE_API_TOKEN, -// }) - -// expect(res.status).toBe(403) -// expect(res.body).toEqual({ -// code: 'unauthorized', -// message: 'Action not allowed for demo campaign', -// }) - -// expect(mockSecretsManager.createSecret).not.toHaveBeenCalled() -// }) - -// test('User should not be able to add custom credential using invalid Telegram API key', async () => { -// const nonDemoCampaign = await createCampaign({ isDemo: false }) - -// const INVALID_API_TOKEN = 'Some Invalid API Token' - -// // Mock Telegram API to return 404 error (invalid token) -// const TELEGRAM_ERROR_STRING = '404: Not Found' -// mockTelegram.getMe.mockRejectedValue(new Error(TELEGRAM_ERROR_STRING)) - -// const res = await request(app) -// .post(`/campaign/${nonDemoCampaign.id}/telegram/new-credentials`) -// .send({ -// telegram_bot_token: INVALID_API_TOKEN, -// }) - -// expect(res.status).toBe(400) -// expect(res.body).toEqual({ -// code: 'invalid_credentials', -// message: `Invalid token. ${TELEGRAM_ERROR_STRING}`, -// }) - -// expect(mockSecretsManager.createSecret).not.toHaveBeenCalled() -// mockTelegram.getMe.mockReset() -// }) - -// test('User should be able to add custom credential using valid Telegram API key', async () => { -// const nonDemoCampaign = await createCampaign({ isDemo: false }) - -// const VALID_API_TOKEN = '12345:Some Valid API Token' - -// // Mock Telegram API to return a bot with user id 12345 -// mockTelegram.getMe.mockResolvedValue({ id: 12345 }) - -// const res = await request(app) -// .post(`/campaign/${nonDemoCampaign.id}/telegram/new-credentials`) -// .send({ -// telegram_bot_token: VALID_API_TOKEN, -// }) - -// expect(res.status).toBe(200) - -// const secretName = `${process.env.APP_ENV}-12345` -// expect(mockSecretsManager.createSecret).toHaveBeenCalledWith( -// expect.objectContaining({ -// Name: secretName, -// SecretString: VALID_API_TOKEN, -// }) -// ) - -// // Ensure credential was added into DB -// const dbCredential = await Credential.findOne({ -// where: { -// name: secretName, -// }, -// }) -// expect(dbCredential).not.toBe(null) -// mockTelegram.getMe.mockReset() -// }) -// }) - -// describe('PUT /campaign/{campaignId}/telegram/template', () => { -// test('Template with only invalid HTML tags is not accepted', async () => { -// const testBody = await request(app) -// .put(`/campaign/${campaignId}/telegram/template`) -// .send({ -// body: '', -// }) - -// expect(testBody.status).toBe(400) -// expect(testBody.body).toEqual({ -// code: 'invalid_template', -// message: -// 'Message template is invalid as it only contains invalid HTML tags!', -// }) -// }) - -// test('Existing populated messages are removed when template has new variables', async () => { -// await TelegramMessage.create({ -// campaignId, -// recipient: 'user@agency.gov.sg', -// params: { recipient: 'user@agency.gov.sg' }, -// } as TelegramMessage) -// const res = await request(app) -// .put(`/campaign/${campaignId}/telegram/template`) -// .send({ -// body: 'test {{name}}', -// }) - -// expect(res.status).toBe(200) -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: -// 'Please re-upload your recipient list as template has changed.', -// template: expect.objectContaining({ -// params: ['name'], -// }), -// }) -// ) - -// const telegramMessages = await TelegramMessage.count({ -// where: { campaignId }, -// }) -// expect(telegramMessages).toEqual(0) -// }) - -// test('Successfully update template', async () => { -// const res = await request(app) -// .put(`/campaign/${campaignId}/telegram/template`) -// .send({ -// body: 'test {{name}}', -// }) - -// expect(res.status).toBe(200) -// expect(res.body).toEqual( -// expect.objectContaining({ -// message: `Template for campaign ${campaignId} updated`, -// template: { body: 'test {{name}}', params: ['name'] }, -// }) -// ) -// }) -// }) +import request from 'supertest' +import { Sequelize } from 'sequelize-typescript' +import initialiseServer from '@test-utils/server' +import { Campaign, User, Credential } from '@core/models' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { DefaultCredentialName } from '@core/constants' +import { formatDefaultCredentialName } from '@core/utils' +import { UploadService } from '@core/services' +import { TelegramMessage } from '@telegram/models' +import { ChannelType } from '@core/constants' +import { mockSecretsManager } from '@mocks/@aws-sdk/client-secrets-manager' +import { mockTelegram, Telegram } from '@mocks/telegraf' + +const app = initialiseServer(true) +let sequelize: Sequelize +let campaignId: number + +// Helper function to create demo/non-demo campaign based on parameters +const createCampaign = async ({ + isDemo, +}: { + isDemo: boolean +}): Promise => + await Campaign.create({ + name: 'test-campaign', + userId: 1, + type: ChannelType.Telegram, + protect: false, + valid: false, + demoMessageLimit: isDemo ? 20 : null, + } as Campaign) + +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') + await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) + const campaign = await createCampaign({ isDemo: false }) + await Credential.create({ name: '12345' } as Credential) + campaignId = campaign.id +}) + +afterAll(async () => { + await TelegramMessage.destroy({ where: {} }) + await Campaign.destroy({ where: {}, force: true }) + await Credential.destroy({ where: {} }) + await User.destroy({ where: {} }) + await sequelize.close() + await UploadService.destroyUploadQueue() + await (app as any).cleanup() +}) + +describe('POST /campaign/{campaignId}/telegram/credentials', () => { + beforeAll(async () => { + // Mock telegram to always accept credential + mockTelegram.setWebhook.mockResolvedValue(true) + mockTelegram.setMyCommands.mockResolvedValue(true) + mockTelegram.getMe.mockResolvedValue({ id: 1 }) + }) + + afterAll(async () => { + mockTelegram.setWebhook.mockReset() + mockTelegram.setMyCommands.mockReset() + mockTelegram.getMe.mockReset() + }) + + afterEach(async () => { + // Reset number of calls for mocked functions + jest.clearAllMocks() + }) + + test('Non-Demo campaign should not be able to use demo credentials', async () => { + const nonDemoCampaign = await createCampaign({ isDemo: false }) + + const res = await request(app) + .post(`/campaign/${nonDemoCampaign.id}/telegram/credentials`) + .send({ + label: DefaultCredentialName.Telegram, + }) + + expect(res.status).toBe(403) + expect(res.body).toEqual({ + code: 'unauthorized', + message: `Campaign cannot use demo credentials. ${DefaultCredentialName.Telegram} is not allowed.`, + }) + + expect(mockSecretsManager.getSecretValue).not.toHaveBeenCalled() + }) + + test('Demo Campaign should not be able to use non-demo credentials', async () => { + const demoCampaign = await createCampaign({ isDemo: true }) + + const NON_DEMO_CREDENTIAL_LABEL = 'Some Credential' + + const res = await request(app) + .post(`/campaign/${demoCampaign.id}/telegram/credentials`) + .send({ + label: NON_DEMO_CREDENTIAL_LABEL, + }) + + expect(res.status).toBe(403) + expect(res.body).toEqual({ + code: 'unauthorized', + message: `Demo campaign must use demo credentials. ${NON_DEMO_CREDENTIAL_LABEL} is not allowed.`, + }) + + expect(mockSecretsManager.getSecretValue).not.toHaveBeenCalled() + }) + + test('Demo Campaign should be able to use demo credentials', async () => { + const demoCampaign = await createCampaign({ isDemo: true }) + + const DEFAULT_TELEGRAM_CREDENTIAL = '12345' + mockSecretsManager.getSecretValue.mockResolvedValue({ + SecretString: DEFAULT_TELEGRAM_CREDENTIAL, + }) + + const res = await request(app) + .post(`/campaign/${demoCampaign.id}/telegram/credentials`) + .send({ + label: DefaultCredentialName.Telegram, + }) + + expect(res.status).toBe(200) + expect(mockSecretsManager.getSecretValue).toHaveBeenCalledWith({ + SecretId: formatDefaultCredentialName(DefaultCredentialName.Telegram), + }) + expect(Telegram).toHaveBeenCalledWith(DEFAULT_TELEGRAM_CREDENTIAL) + + mockSecretsManager.getSecretValue.mockReset() + }) +}) + +describe('POST /campaign/{campaignId}/telegram/new-credentials', () => { + beforeAll(async () => { + // Mock telegram to always accept credential + mockTelegram.setWebhook.mockResolvedValue(true) + mockTelegram.setMyCommands.mockResolvedValue(true) + }) + + afterAll(async () => { + mockTelegram.setWebhook.mockReset() + mockTelegram.setMyCommands.mockReset() + }) + + afterEach(async () => { + // Reset number of calls for mocked functions + jest.clearAllMocks() + }) + + test('Demo Campaign should not be able to create custom credential', async () => { + const demoCampaign = await createCampaign({ isDemo: true }) + + const FAKE_API_TOKEN = 'Some API Token' + + const res = await request(app) + .post(`/campaign/${demoCampaign.id}/telegram/new-credentials`) + .send({ + telegram_bot_token: FAKE_API_TOKEN, + }) + + expect(res.status).toBe(403) + expect(res.body).toEqual({ + code: 'unauthorized', + message: 'Action not allowed for demo campaign', + }) + + expect(mockSecretsManager.createSecret).not.toHaveBeenCalled() + }) + + test('User should not be able to add custom credential using invalid Telegram API key', async () => { + const nonDemoCampaign = await createCampaign({ isDemo: false }) + + const INVALID_API_TOKEN = 'Some Invalid API Token' + + // Mock Telegram API to return 404 error (invalid token) + const TELEGRAM_ERROR_STRING = '404: Not Found' + mockTelegram.getMe.mockRejectedValue(new Error(TELEGRAM_ERROR_STRING)) + + const res = await request(app) + .post(`/campaign/${nonDemoCampaign.id}/telegram/new-credentials`) + .send({ + telegram_bot_token: INVALID_API_TOKEN, + }) + + expect(res.status).toBe(400) + expect(res.body).toEqual({ + code: 'invalid_credentials', + message: `Invalid token. ${TELEGRAM_ERROR_STRING}`, + }) + + expect(mockSecretsManager.createSecret).not.toHaveBeenCalled() + mockTelegram.getMe.mockReset() + }) + + test('User should be able to add custom credential using valid Telegram API key', async () => { + const nonDemoCampaign = await createCampaign({ isDemo: false }) + + const VALID_API_TOKEN = '12345:Some Valid API Token' + + // Mock Telegram API to return a bot with user id 12345 + mockTelegram.getMe.mockResolvedValue({ id: 12345 }) + + const res = await request(app) + .post(`/campaign/${nonDemoCampaign.id}/telegram/new-credentials`) + .send({ + telegram_bot_token: VALID_API_TOKEN, + }) + + expect(res.status).toBe(200) + + const secretName = `${process.env.APP_ENV}-12345` + expect(mockSecretsManager.createSecret).toHaveBeenCalledWith( + expect.objectContaining({ + Name: secretName, + SecretString: VALID_API_TOKEN, + }) + ) + + // Ensure credential was added into DB + const dbCredential = await Credential.findOne({ + where: { + name: secretName, + }, + }) + expect(dbCredential).not.toBe(null) + mockTelegram.getMe.mockReset() + }) +}) + +describe('PUT /campaign/{campaignId}/telegram/template', () => { + test('Template with only invalid HTML tags is not accepted', async () => { + const testBody = await request(app) + .put(`/campaign/${campaignId}/telegram/template`) + .send({ + body: '', + }) + + expect(testBody.status).toBe(400) + expect(testBody.body).toEqual({ + code: 'invalid_template', + message: + 'Message template is invalid as it only contains invalid HTML tags!', + }) + }) + + test('Existing populated messages are removed when template has new variables', async () => { + await TelegramMessage.create({ + campaignId, + recipient: 'user@agency.gov.sg', + params: { recipient: 'user@agency.gov.sg' }, + } as TelegramMessage) + const res = await request(app) + .put(`/campaign/${campaignId}/telegram/template`) + .send({ + body: 'test {{name}}', + }) + + expect(res.status).toBe(200) + expect(res.body).toEqual( + expect.objectContaining({ + message: + 'Please re-upload your recipient list as template has changed.', + template: expect.objectContaining({ + params: ['name'], + }), + }) + ) + + const telegramMessages = await TelegramMessage.count({ + where: { campaignId }, + }) + expect(telegramMessages).toEqual(0) + }) + + test('Successfully update template', async () => { + const res = await request(app) + .put(`/campaign/${campaignId}/telegram/template`) + .send({ + body: 'test {{name}}', + }) + + expect(res.status).toBe(200) + expect(res.body).toEqual( + expect.objectContaining({ + message: `Template for campaign ${campaignId} updated`, + template: { body: 'test {{name}}', params: ['name'] }, + }) + ) + }) +}) diff --git a/backend/src/telegram/routes/tests/telegram-settings.routes.test.ts b/backend/src/telegram/routes/tests/telegram-settings.routes.test.ts index b3b8909b6..878adfdca 100644 --- a/backend/src/telegram/routes/tests/telegram-settings.routes.test.ts +++ b/backend/src/telegram/routes/tests/telegram-settings.routes.test.ts @@ -1,105 +1,105 @@ -// import request from 'supertest' -// import { Sequelize } from 'sequelize-typescript' -// import initialiseServer from '@test-utils/server' -// import { Credential, UserCredential, User } from '@core/models' -// import sequelizeLoader from '@test-utils/sequelize-loader' -// import { ChannelType } from '@core/constants' -// import { mockTelegram } from '@mocks/telegraf' -// import { mockSecretsManager } from '@mocks/@aws-sdk/client-secrets-manager' - -// const app = initialiseServer(true) -// let sequelize: Sequelize - -// beforeAll(async () => { -// sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') -// await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) -// }) - -// afterAll(async () => { -// await UserCredential.destroy({ where: {} }) -// await User.destroy({ where: {} }) -// await sequelize.close() -// await (app as any).cleanup() -// }) - -// describe('POST /settings/telegram/credentials', () => { -// beforeAll(async () => { -// // Mock telegram to always accept credential -// mockTelegram.setWebhook.mockResolvedValue(true) -// mockTelegram.setMyCommands.mockResolvedValue(true) -// }) - -// afterAll(async () => { -// mockTelegram.setWebhook.mockReset() -// mockTelegram.setMyCommands.mockReset() -// }) - -// afterEach(async () => { -// // Reset number of calls for mocked functions -// jest.clearAllMocks() -// }) - -// test('User should not be able to add custom credential using invalid Telegram API key', async () => { -// const INVALID_API_TOKEN = 'Some Invalid API Token' - -// // Mock Telegram API to return 404 error (invalid token) -// const TELEGRAM_ERROR_STRING = '404: Not Found' -// mockTelegram.getMe.mockRejectedValue(new Error(TELEGRAM_ERROR_STRING)) - -// const res = await request(app).post('/settings/telegram/credentials').send({ -// label: 'telegram-credential-1', -// telegram_bot_token: INVALID_API_TOKEN, -// }) - -// expect(res.status).toBe(400) -// expect(res.body).toEqual({ -// code: 'invalid_credentials', -// message: `Invalid token. ${TELEGRAM_ERROR_STRING}`, -// }) - -// expect(mockSecretsManager.createSecret).not.toHaveBeenCalled() -// mockTelegram.getMe.mockReset() -// }) - -// test('User should be able to add custom credential using valid Telegram API key', async () => { -// const VALID_API_TOKEN = '12345:Some Valid API Token' -// const CREDENTIAL_LABEL = 'telegram-credential-1' - -// // Mock Telegram API to return a bot with user id 12345 -// mockTelegram.getMe.mockResolvedValue({ id: 12345 }) - -// const res = await request(app).post('/settings/telegram/credentials').send({ -// label: CREDENTIAL_LABEL, -// telegram_bot_token: VALID_API_TOKEN, -// }) - -// expect(res.status).toBe(200) - -// const secretName = `${process.env.APP_ENV}-12345` -// expect(mockSecretsManager.createSecret).toHaveBeenCalledWith( -// expect.objectContaining({ -// Name: secretName, -// SecretString: VALID_API_TOKEN, -// }) -// ) - -// // Ensure credential was added into DB -// const dbCredential = await Credential.findOne({ -// where: { -// name: secretName, -// }, -// }) -// expect(dbCredential).not.toBe(null) - -// const dbUserCredential = await UserCredential.findOne({ -// where: { -// label: CREDENTIAL_LABEL, -// type: ChannelType.Telegram, -// credName: secretName, -// userId: 1, -// }, -// }) -// expect(dbUserCredential).not.toBe(null) -// mockTelegram.getMe.mockReset() -// }) -// }) +import request from 'supertest' +import { Sequelize } from 'sequelize-typescript' +import initialiseServer from '@test-utils/server' +import { Credential, UserCredential, User } from '@core/models' +import sequelizeLoader from '@test-utils/sequelize-loader' +import { ChannelType } from '@core/constants' +import { mockTelegram } from '@mocks/telegraf' +import { mockSecretsManager } from '@mocks/@aws-sdk/client-secrets-manager' + +const app = initialiseServer(true) +let sequelize: Sequelize + +beforeAll(async () => { + sequelize = await sequelizeLoader(process.env.JEST_WORKER_ID || '1') + await User.create({ id: 1, email: 'user@agency.gov.sg' } as User) +}) + +afterAll(async () => { + await UserCredential.destroy({ where: {} }) + await User.destroy({ where: {} }) + await sequelize.close() + await (app as any).cleanup() +}) + +describe('POST /settings/telegram/credentials', () => { + beforeAll(async () => { + // Mock telegram to always accept credential + mockTelegram.setWebhook.mockResolvedValue(true) + mockTelegram.setMyCommands.mockResolvedValue(true) + }) + + afterAll(async () => { + mockTelegram.setWebhook.mockReset() + mockTelegram.setMyCommands.mockReset() + }) + + afterEach(async () => { + // Reset number of calls for mocked functions + jest.clearAllMocks() + }) + + test('User should not be able to add custom credential using invalid Telegram API key', async () => { + const INVALID_API_TOKEN = 'Some Invalid API Token' + + // Mock Telegram API to return 404 error (invalid token) + const TELEGRAM_ERROR_STRING = '404: Not Found' + mockTelegram.getMe.mockRejectedValue(new Error(TELEGRAM_ERROR_STRING)) + + const res = await request(app).post('/settings/telegram/credentials').send({ + label: 'telegram-credential-1', + telegram_bot_token: INVALID_API_TOKEN, + }) + + expect(res.status).toBe(400) + expect(res.body).toEqual({ + code: 'invalid_credentials', + message: `Invalid token. ${TELEGRAM_ERROR_STRING}`, + }) + + expect(mockSecretsManager.createSecret).not.toHaveBeenCalled() + mockTelegram.getMe.mockReset() + }) + + test('User should be able to add custom credential using valid Telegram API key', async () => { + const VALID_API_TOKEN = '12345:Some Valid API Token' + const CREDENTIAL_LABEL = 'telegram-credential-1' + + // Mock Telegram API to return a bot with user id 12345 + mockTelegram.getMe.mockResolvedValue({ id: 12345 }) + + const res = await request(app).post('/settings/telegram/credentials').send({ + label: CREDENTIAL_LABEL, + telegram_bot_token: VALID_API_TOKEN, + }) + + expect(res.status).toBe(200) + + const secretName = `${process.env.APP_ENV}-12345` + expect(mockSecretsManager.createSecret).toHaveBeenCalledWith( + expect.objectContaining({ + Name: secretName, + SecretString: VALID_API_TOKEN, + }) + ) + + // Ensure credential was added into DB + const dbCredential = await Credential.findOne({ + where: { + name: secretName, + }, + }) + expect(dbCredential).not.toBe(null) + + const dbUserCredential = await UserCredential.findOne({ + where: { + label: CREDENTIAL_LABEL, + type: ChannelType.Telegram, + credName: secretName, + userId: 1, + }, + }) + expect(dbUserCredential).not.toBe(null) + mockTelegram.getMe.mockReset() + }) +})