Add Github action to run jest tests (#20)

* Add Github action to run jest tests

* Change how DB is create

* Starts adding campaign tests

* Fixes imports

* Fixes spy/mocks for test cases

* Tweaks when tests are run
This commit is contained in:
Chris Anderson 2022-10-07 16:26:04 -07:00 committed by GitHub
parent 2514dcc53f
commit 2daee6127c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 315 additions and 17 deletions

View file

@ -1,6 +1,7 @@
name: Build
on:
pull_request:
name: Test
on: pull_request
jobs:
lint:
runs-on: ubuntu-latest
@ -20,4 +21,57 @@ jobs:
npm install
- name: Lint
run: |
npm run lint
npm run lint
test:
needs: lint
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16.x]
services:
mysql:
image: mysql:8.0.27
env:
MYSQL_ALLOW_EMPTY_PASSWORD: 'true'
MYSQL_DATABASE: 'parcelvoy'
ports:
- 3306:3306
options: >-
--tmpfs="/var/lib/mysql"
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
steps:
- uses: actions/checkout@v2
- name: Node 16.x
uses: actions/setup-node@v2
with:
node-version: 16.x
- name: Cache NPM
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install Dependencies
run: npm install && npm install --save-dev
- name: 'Run Jest Tests'
run: npm test
env:
NODE_ENV: test
APP_SECRET: ${{ secrets.APP_SECRET }}
DB_CLIENT: mysql2
DB_DATABASE: parcelvoy
DB_USERNAME: root
DB_PORT: 3306
APP_BASE_URL: https://parcelvoy.com
QUEUE_DRIVER: memory

View file

@ -41,7 +41,6 @@ exports.up = function(knex) {
.onDelete('CASCADE')
table.integer('list_id')
.unsigned()
.notNullable()
.references('id')
.inTable('lists')
.onDelete('CASCADE')

View file

@ -28,9 +28,12 @@ export const createCampaign = async (params: CampaignParams): Promise<Campaign>
})
}
export function sendCampaign(campaign: Campaign, user: User, event?: UserEvent): Promise<void>
export function sendCampaign(campaign: Campaign, userId: number, eventId?: number): Promise<void>
export async function sendCampaign(campaign: Campaign, user: User | number, event?: UserEvent | number): Promise<void> {
type SendCampaign = {
(campaign: Campaign, user: User, event?: UserEvent): Promise<void>,
(campaign: Campaign, userId: number, eventId?: number): Promise<void>,
}
export const sendCampaign: SendCampaign = async (campaign: Campaign, user: User | number, event?: UserEvent | number): Promise<void> => {
// TODO: Might also need to check for unsubscribe in here since we can
// do individual sends
@ -72,9 +75,10 @@ export const recipientQuery = (campaign: Campaign) => {
// Merge user subscription state in to filter out anyone
// who we can't send do
return UserList.query()
.select('user_list.user_id')
.where('list_id', campaign.list_id)
.leftJoin('user_subscription', qb => {
qb.on('lists.user_id', 'user_subscription.user_id')
qb.on('user_list.user_id', 'user_subscription.user_id')
.andOn('user_subscription.subscription_id', '=', UserList.raw(campaign.subscription_id))
})
.where(qb => {

View file

@ -0,0 +1,226 @@
import App from '../../app'
import EmailJob from '../../channels/email/EmailJob'
import { RequestError } from '../../core/errors'
import { addUserToList, createList } from '../../lists/ListService'
import { createProject } from '../../projects/ProjectService'
import { createTemplate } from '../../render/TemplateService'
import { createSubscription } from '../../subscriptions/SubscriptionService'
import { User } from '../../users/User'
import { uuid } from '../../utilities'
import Campaign from '../Campaign'
import { allCampaigns, createCampaign, getCampaign, sendCampaign, sendList } from '../CampaignService'
import * as CampaignService from '../CampaignService'
afterEach(() => {
jest.clearAllMocks()
})
describe('CampaignService', () => {
interface CampaignRefs {
project_id: number
template_id: number
subscription_id: number
}
const createCampaignDependencies = async (): Promise<CampaignRefs> => {
const project = await createProject({ name: uuid() })
const subscription = await createSubscription({
project_id: project.id,
name: uuid(),
channel: 'email',
})
const template = await createTemplate({
project_id: project.id,
name: uuid(),
type: 'email',
data: {},
})
return {
project_id: project.id,
subscription_id: subscription.id,
template_id: template.id,
}
}
const createTestCampaign = async (params?: CampaignRefs, extras?: Partial<Campaign>) => {
params = params || await createCampaignDependencies()
const campaign = await createCampaign({
name: uuid(),
...params,
...extras,
})
return campaign
}
const createUser = async (project_id: number): Promise<User> => {
return await User.insertAndFetch({
project_id,
external_id: uuid(),
data: {},
})
}
describe('allCampaigns', () => {
test('return a list of campaigns', async () => {
const params = await createCampaignDependencies()
for (let i = 0; i < 20; i++) {
await createTestCampaign(params)
}
const campaigns = await allCampaigns(params.project_id)
expect(campaigns.length).toEqual(20)
expect(campaigns[0].template_id).toEqual(params.template_id)
expect(campaigns[0].subscription_id).toEqual(params.subscription_id)
})
test('campaigns in other projects wont come back', async () => {
const params1 = await createCampaignDependencies()
const params2 = await createCampaignDependencies()
for (let i = 0; i < 10; i++) {
await createTestCampaign(params1)
}
for (let i = 0; i < 10; i++) {
await createTestCampaign(params2)
}
const campaigns = await allCampaigns(params1.project_id)
expect(campaigns.length).toEqual(10)
expect(campaigns[0].template_id).toEqual(params1.template_id)
expect(campaigns[0].subscription_id).toEqual(params1.subscription_id)
})
})
describe('getCampaign', () => {
test('return a single campaign', async () => {
const response = await createTestCampaign()
const campaign = await getCampaign(response.id, response.project_id)
expect(campaign?.id).toEqual(response.id)
})
test('a single campaign in a different project shouldnt return', async () => {
const response = await createTestCampaign()
const badParams = await createCampaignDependencies()
const campaign = await getCampaign(response.id, badParams.project_id)
expect(campaign?.id).toBeUndefined()
})
})
describe('createCampaign', () => {
test('create a single campaign', async () => {
const params = await createCampaignDependencies()
const name = uuid()
const campaign = await createCampaign({
...params,
name,
})
expect(campaign.name).toEqual(name)
expect(campaign.subscription_id).toEqual(params.subscription_id)
expect(campaign.project_id).toEqual(params.project_id)
expect(campaign.template_id).toEqual(params.template_id)
})
test('fail to create a campaign with a bad subscription', async () => {
const params = await createCampaignDependencies()
const name = uuid()
const promise = createCampaign({
project_id: params.project_id,
template_id: params.template_id,
subscription_id: 0,
name,
})
await expect(promise).rejects.toThrowError(RequestError)
})
})
describe('sendCampaign', () => {
test('enqueue an email job', async () => {
const spy = jest.spyOn(App.main.queue, 'enqueue')
const campaign = await createTestCampaign()
const user = await createUser(campaign.project_id)
await sendCampaign(campaign, user)
expect(spy).toHaveBeenCalledTimes(1)
expect(spy.mock.calls[0][0]).toBeInstanceOf(EmailJob)
})
})
describe('sendList', () => {
test('enqueue sends for a list of people', async () => {
const spy = jest.spyOn(CampaignService, 'sendCampaign')
const params = await createCampaignDependencies()
const list = await createList({
name: uuid(),
project_id: params.project_id,
rules: [],
})
const campaign = await createTestCampaign(params, {
list_id: list.id,
})
for (let i = 0; i < 20; i++) {
const user = await createUser(params.project_id)
await addUserToList(user, list)
}
await sendList(campaign)
expect(spy).toHaveBeenCalledTimes(20)
expect(spy.mock.calls[0][0].id).toEqual(campaign.id)
})
test('users outside of list arent sent the campaign', async () => {
const spy = jest.spyOn(CampaignService, 'sendCampaign')
const params = await createCampaignDependencies()
const list = await createList({
name: uuid(),
project_id: params.project_id,
rules: [],
})
const list2 = await createList({
name: uuid(),
project_id: params.project_id,
rules: [],
})
const campaign = await createTestCampaign(params, {
list_id: list.id,
})
const inclusiveIds = []
for (let i = 0; i < 20; i++) {
const user = await createUser(params.project_id)
await addUserToList(user, list)
inclusiveIds.push(user.id)
}
for (let i = 0; i < 20; i++) {
const user = await createUser(params.project_id)
await addUserToList(user, list2)
}
await sendList(campaign)
expect(spy).toHaveBeenCalledTimes(20)
expect(spy.mock.calls[0][0].id).toEqual(campaign.id)
expect(spy.mock.calls[0][1]).toEqual(inclusiveIds[0])
})
})
})

View file

@ -28,7 +28,7 @@ export default class JourneyDelayJob extends Job {
for await (const chunk of stream) {
// TODO: Room for improvement here by not reprocessing
// the entire queue but instead just this step (could
// the entire journey but instead just this step (could
// have some downsides through)
App.main.queue.enqueue(
JourneyProcessJob.from({

View file

@ -21,6 +21,14 @@ export const createList = async (params: ListParams): Promise<List> => {
return await List.insertAndFetch(params)
}
export const addUserToList = async (user: User, list: List, event?: UserEvent) => {
return await UserList.insert({
user_id: user.id,
list_id: list.id,
event_id: event?.id ?? undefined,
})
}
export const updateLists = async (user: User, event?: UserEvent) => {
const lists = await List.all(qb => qb.where('project_id', user.project_id))
const existingLists = await getUserListIds(user.id)
@ -36,11 +44,7 @@ export const updateLists = async (user: User, event?: UserEvent) => {
// If check passes and user isn't already in the list, add
if (result && !existingLists.includes(list.id)) {
await UserList.insert({
user_id: user.id,
list_id: list.id,
event_id: event?.id ?? undefined,
})
await addUserToList(user, list, event)
// Find all associated journeys based on list and enter user
await enterJourneyFromList(list, user, event)

View file

@ -1,6 +1,6 @@
import Model from '../core/Model'
import Model, { ModelParams } from '../core/Model'
class Template extends Model {
export class Template extends Model {
project_id!: number
name!: string
type!: 'email' | 'text' | 'push_notification' | 'webhook'
@ -9,6 +9,8 @@ class Template extends Model {
static tableName = 'templates'
}
export type TemplateParams = Omit<Template, ModelParams>
export class EmailTemplate extends Template {
to!: string
from!: string

View file

@ -0,0 +1,5 @@
import { Template, TemplateParams } from './Template'
export const createTemplate = async (params: TemplateParams) => {
return await Template.insertAndFetch(params)
}

View file

@ -6,6 +6,10 @@ export const random = <T>(array: T[]): T => array[Math.floor(Math.random() * arr
export const pascalToSnakeCase = (str: string): string => str.split(/(?=[A-Z])/).join('_').toLowerCase()
export const uuid = (): string => {
return crypto.randomUUID()
}
export const encrypt = (str: string): string => {
const iv = crypto.randomBytes(16).toString('hex').slice(0, 16)
const encrypter = crypto.createCipheriv('aes-256-cbc', process.env.APP_SECRET!, iv)