templating api

This commit is contained in:
2026-06-19 22:20:43 -07:00
parent 08d7a80f56
commit 84f894c356
110 changed files with 12432 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
# API service Tests
## Implementation
Tests are written in [vitest](https://vitest.dev/guide/)
Test initialization goes like this:
1. `api/vitest.config.mts` loads the ts config and finds the appropriate setup functions.
2. Before running the tests, it runs the `globalSetup` function from `api/tests/global-setup.ts`. This does things like setting up the database and running migrations and base seeds.
3. Next it loads a specific test file triggers the `setupFiles` files, currently only `api/tests/setup.ts`. These setup files add callbacks that will run before/after _each test file_ runs, so they should be performant. Mostly cleanup functions.
4. It runs the actual tests in the loaded file.
5. (Currently) Runs `beforeEach` callback that cleans the database before each test file is run.
6. Runs the next test file, and repeats from step 3.
## General Notes About Tests
1. Tests should map to a specific file in the api/src folder.
e.g.
- `api/src/models/funding-submission-line-json.ts` maps to `api/tests/models/funding-submission-line-json.test.ts`
- `api/src/services/centre-services.ts` maps to `api/tests/services/centre-services.test.ts`
2. Tests should follow the naming convention `{filename}.test.{extension}`.
3. Test file location should be moved if a given file is moved, and deleted if the file under test is deleted.
4. A good general pattern for a test is
```typescript
describe("api/src/services/centre-services.ts", () => { // references file under test
describe("CentreServices", () => { // references class or model under test
describe(".create", () => { // referneces a specific method on the class or model
test("creates a new centre in the database", async () => { // descriptive message about the specific behaviour under test
})
})
})
```
5. I'm using a plugin that lets me switch between the test and non-test file, and creates the test file if it does not exist. It's not great, but it mostly works. See <https://marketplace.visualstudio.com/items?itemName=klondikemarlen.create-test-file>
It requires this config (in your workspace or `.vscode/settings.json`).
> Note that if this is in your worspace config must be inside the "settings" entry. i.e. `{ "settings": { // these settings } }`.
```json
{
"createTestFile.nameTemplate": "{filename}.test.{extension}",
"createTestFile.languages": {
"[vue]": {
"createTestFile.nameTemplate": "{filename}.test.{extension}.ts"
}
},
"createTestFile.pathMaps": [
{
// Other examples
// "pathPattern": "/?(.*)",
// "testFilePathPattern": "spec/$1"
"pathPattern": "(api)/src/?(.*)",
"testFilePathPattern": "$1/tests/$2"
},
{
"pathPattern": "(web)/src/?(.*)",
"testFilePathPattern": "$1/tests/$2"
}
],
"createTestFile.isTestFileMatchers": [
"^(?:test|spec)s?/",
"/(?:test|spec)s?/",
"/?(?:test|spec)s?_",
"/?_(?:test|spec)s?",
"/?\\.(?:test|spec)s?",
"/?(?:test|spec)s?\\."
]
}
```
@@ -0,0 +1,25 @@
import { User } from "@/models"
import { userFactory } from "@/factories"
import { mockCurrentUser, request } from "@/support"
describe("api/src/controllers/current-user-controller.ts", () => {
describe("CurrentUserController", () => {
describe("#show", () => {
test("it returns the policy alongside the user", async () => {
// Arrange
const currentUser = await userFactory.create({
roles: [User.Roles.SYSTEM_ADMIN],
})
mockCurrentUser(currentUser)
// Act
const response = await request().get("/api/current-user")
// Assert
expect(response.status).toBe(200)
expect(response.body.policy).toBeDefined()
})
})
})
})
@@ -0,0 +1,32 @@
import { User } from "@/models"
import { userFactory } from "@/factories"
import { mockCurrentUser, request } from "@/support"
describe("api/src/controllers/users-controller.ts", () => {
beforeEach(async () => {
const currentUser = await userFactory.create({
roles: [User.Roles.SYSTEM_ADMIN],
})
mockCurrentUser(currentUser)
})
describe("UsersController", () => {
describe("#create", () => {
test("when creating a new user as a system admin, it creates the user", async () => {
// Arrange
const attributes = {
email: "test_create@example.com",
auth0Subject: "test_create@example.com",
}
// Act
const response = await request().post("/api/users").send(attributes)
// Assert
expect(response.status).toBe(201)
expect(response.body.user.email).toEqual(attributes.email)
})
})
})
})
+15
View File
@@ -0,0 +1,15 @@
import { BuildOptions, DeepPartial, Factory } from "fishery"
import { Model } from "@sequelize/core"
// Must keep type signature in sync with fishery's Factory type
// See api/node_modules/fishery/dist/factory.d.ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class BaseFactory<T extends Model, I = any, C = T> extends Factory<T, I, C> {
// See https://thoughtbot.github.io/factory_bot/ref/build-strategies.html#attributes_for
attributesFor(params?: DeepPartial<T>, options?: BuildOptions<T, I>): T {
const model = this.build(params, options)
return model.dataValues
}
}
export default BaseFactory
+1
View File
@@ -0,0 +1 @@
export { nestedSaveAndAssociateIfNew } from "./nested-save-and-associate-if-new"
@@ -0,0 +1,162 @@
import { Model } from "@sequelize/core"
/**
* Recursively saves a Sequelize model instance along with its associated models if they are new (unsaved).
*
* It handles `BelongsTo` associations and ensures associated model is saved _before_ the current model,
* then propagates the foreign key relationship before saving the current model.
*
* It handles `HasMany` associations and ensures associated models are saved _after_ the current model,
* propagating the foreign key relationship before saving.
*
* It handles `HasOne` associations and ensures the associated model is saved _after_ the current model,
* propagating the foreign key relationship before saving.
*
* @template M - A Sequelize model type extending `Model`.
* @param {M} modelInstance - The Sequelize model instance to be saved along with its associated models.
* @returns {Promise<M>} - Returns the saved model instance.
*
* @example
* // Given a non-persisted TravelSegment model instance, being passed via a HasMany association to a TravelAuthorization model.
* // The TravelAuthorization is saved first, then the TravelSegment is updated with the travelAuthorizationId before it is saved.
*
* const travelSegment = travelSegmentFactory.build({
* departureOn: faker.date.past(),
* })
* const travelAuthorization = await travelAuthorizationFactory
* .associations({
* travelSegments: [travelSegment],
* })
* .transient({
* include: ["user", "travelSegments"],
* })
* .create({
* status: TravelAuthorization.Statuses.SUBMITTED,
* })
*
* @example
* // Given a persisted TravelAuthorization passed to a TravelSegment model instance via a BelongsTo association.
* // The TravelSegment is updated with the travelAuthorizationId before it is saved.
* const travelAuthorization = await travelAuthorizationFactory
* .transient({ roundTrip: true })
* .create()
* const whitehorse = await locationFactory.create({ city: "Whitehorse", province: "YT" })
* const vancouver = await locationFactory.create({ city: "Vancouver", province: "BC" })
* const travelSegment1 = await travelSegmentFactory
* .associations({
* travelAuthorization,
* departureLocation: whitehorse,
* arrivalLocation: vancouver,
* })
* .create({
* segmentNumber: 1,
* departureOn: new Date("2022-06-05"),
* departureTime: Stop.BEGINNING_OF_DAY,
* modeOfTransport: Stop.TravelMethods.AIRCRAFT,
* accommodationType: Stop.AccommodationTypes.HOTEL,
* })
*
* @example
* // Given a generic TravelAuthorization creation.
* // The factory will create the purpose and user associations if they are not provided.
* // Then the onCreated hook will call nestedSaveAndAssociateIfNew to save
* // the purpose and user associations as they are new.
* // Then those associations will propagate their foreign keys to the travelAuthorization before it is saved.
* const travelAuthorization = await travelAuthorizationFactory.create()
*/
export async function nestedSaveAndAssociateIfNew<M extends Model>(modelInstance: M): Promise<M> {
const modelClass = modelInstance.constructor as typeof Model
const associations = modelClass.associations
const belongsToAssociationNames: string[] = []
const hasManyAssociationNames: string[] = []
const hasOneAssociationNames: string[] = []
for (const [associationName, { associationType }] of Object.entries(associations)) {
if (associationType === "BelongsTo") {
belongsToAssociationNames.push(associationName)
} else if (associationType === "HasMany") {
hasManyAssociationNames.push(associationName)
} else if (associationType === "HasOne") {
hasOneAssociationNames.push(associationName)
} else {
// no-op - maybe I should warn?
}
}
for (const associationName of belongsToAssociationNames) {
const associationDefinition = associations[associationName]
const foreignKeyName = associationDefinition.foreignKey
let foreignKeyValue = modelInstance.get(foreignKeyName)
if (foreignKeyValue !== undefined) continue
// Maybe should be associationAccessor instead of as?
const associationAlias = associationDefinition.as as keyof M
const associatedInstance = modelInstance[associationAlias] as Model | undefined
if (associatedInstance === undefined) continue
// @ts-expect-error - TS doesn't know that targetKey is a property of associationDefinition
const { targetKey }: { targetKey: keyof typeof associatedInstance } = associationDefinition
if (associatedInstance.isNewRecord !== true) {
foreignKeyValue = associatedInstance.get(targetKey)
modelInstance.set(foreignKeyName, foreignKeyValue)
continue
}
const updatedAssocationInstance = await nestedSaveAndAssociateIfNew(associatedInstance)
foreignKeyValue = updatedAssocationInstance.get(targetKey)
modelInstance.set(foreignKeyName, foreignKeyValue)
}
if (modelInstance.isNewRecord === true) {
await modelInstance.save()
}
for (const associationName of hasManyAssociationNames) {
const associationDefinition = associations[associationName]
// Maybe should be associationAccessor instead of as?
const associationAlias = associationDefinition.as as keyof M
const associatedInstances = modelInstance[associationAlias] as Model[] | undefined
if (associatedInstances === undefined) continue
for (const associatedInstance of associatedInstances) {
if (associatedInstance.isNewRecord !== true) continue
// @ts-expect-error - TS doesn't know that sourceKey is a property of associationDefinition
const {
sourceKey,
foreignKey,
}: { sourceKey: keyof M; foreignKey: keyof typeof associatedInstance } = associationDefinition
const sourceKeyValue = modelInstance.get(sourceKey)
associatedInstance.set(foreignKey, sourceKeyValue)
await nestedSaveAndAssociateIfNew(associatedInstance)
}
}
for (const associationName of hasOneAssociationNames) {
const associationDefinition = associations[associationName]
// Maybe should be associationAccessor instead of as?
const associationAlias = associationDefinition.as as keyof M
const associatedInstance = modelInstance[associationAlias] as Model | undefined
if (associatedInstance === undefined) continue
if (associatedInstance.isNewRecord !== true) continue
// @ts-expect-error - TS doesn't know that sourceKey is a property of associationDefinition
const {
sourceKey,
foreignKey,
}: { sourceKey: keyof M; foreignKey: keyof typeof associatedInstance } = associationDefinition
const sourceKeyValue = modelInstance.get(sourceKey)
associatedInstance.set(foreignKey, sourceKeyValue)
await nestedSaveAndAssociateIfNew(associatedInstance)
}
return modelInstance
}
export default nestedSaveAndAssociateIfNew
+2
View File
@@ -0,0 +1,2 @@
// Factories
export { userFactory } from "./user-factory"
+32
View File
@@ -0,0 +1,32 @@
import { Factory } from "fishery"
import { faker } from "@faker-js/faker/locale/en_CA"
import { User } from "@/models"
export const userFactory = Factory.define<User>(({ sequence, params, onCreate }) => {
onCreate((user) => {
try {
return user.save()
} catch (error) {
console.error(error)
throw new Error(
`Could not create User with attributes: ${JSON.stringify(user.dataValues, null, 2)}`
)
}
})
const firstName = params.firstName || `${faker.person.firstName()}-${sequence}`
const lastName = params.lastName || faker.person.lastName()
const email = params.email || faker.internet.email({ firstName, lastName })
return User.build({
email,
auth0Subject: params.auth0Subject || email,
firstName,
lastName,
displayName: `${firstName} ${lastName}`,
roles: [User.Roles.USER],
})
})
export default userFactory
+11
View File
@@ -0,0 +1,11 @@
import { execSync } from 'child_process';
export default async function globalSetup() {
try {
// Keep in sync with api/bin/boot-app.sh
execSync(`npm run initializers`, { stdio: 'inherit' });
} catch (error) {
console.error('Failed to run importAndExecuteInitializers:', error);
process.exit(1);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { userFactory } from "@/factories"
import { UserRoles } from "@/models/user"
describe("api/src/models/user.ts", () => {
describe("User", () => {
describe("#isSystemAdmin", () => {
test("when user has system_admin role, it returns true", async () => {
// Arrange
const user = await userFactory.create({ roles: [UserRoles.SYSTEM_ADMIN] })
// Act & Assert
expect(user.isSystemAdmin).toBe(true)
})
test("when user does not have system_admin role, it returns false", async () => {
// Arrange
const user = await userFactory.create({ roles: [UserRoles.USER] })
// Act & Assert
expect(user.isSystemAdmin).toBe(false)
})
})
})
})
+29
View File
@@ -0,0 +1,29 @@
/**
* See https://vitest.dev/config/#setupfiles
*
* Run some code before each test file.
*
* WARNING: Be very careful of imports in this file!!!
* Vitest will not mock modules that were imported inside a setup file because they are
* cached by the time a test file is running.
* You can do
* ```ts
* vi.hoisted(() => {
* vi.resetModules()
* })
* ```
* to clear all module caches before running a test file.
* See: https://vitest.dev/api/vi#vi-mock
*/
// Global Mocks
import cleanDatabase from "@/support/clean-database"
import mockedAxios from "@/support/mock-axios"
beforeEach(async () => {
await cleanDatabase()
})
afterEach(() => {
mockedAxios.reset()
})
+57
View File
@@ -0,0 +1,57 @@
import { QueryTypes } from "@sequelize/core"
import { isNil } from "lodash"
import db from "@/db/db-client"
async function getTableNames() {
const query = /* sql */ `
SELECT
table_name as "tableName"
FROM
information_schema.tables
WHERE
table_schema = 'public'
AND table_type = 'BASE TABLE'
AND table_name != 'SequelizeMeta'
AND table_name != 'knex_migrations'
AND table_name != 'knex_migrations_lock';
`
try {
const result = await db.query<{ tableName: string }>(query, { type: QueryTypes.SELECT })
const tableNames = result.map((row) => row.tableName)
return tableNames
} catch (error) {
console.error("Error fetching table names:", error)
throw error
}
}
async function buildCleanDatabaseQuery() {
const tableNames = await getTableNames()
const quotedTableNames = tableNames.map((name) => `"${name}"`)
return /* sql */ `
TRUNCATE TABLE ${quotedTableNames.join(",\n ")} RESTART IDENTITY CASCADE;
`
}
let cleanDatabaseQuery: string | null = null
export async function cleanDatabase() {
if (isNil(cleanDatabaseQuery)) {
cleanDatabaseQuery = await buildCleanDatabaseQuery()
}
try {
// TODO: once all tables are in Sequelize models, use this instead:
// await db.truncate({ cascade: true, restartIdentity: true })
await db.query(cleanDatabaseQuery, { raw: true })
return true
} catch (error) {
console.error(error)
return false
}
}
export default cleanDatabase
+6
View File
@@ -0,0 +1,6 @@
export { cleanDatabase } from "./clean-database"
export { loadTestData } from "./load-test-data"
export { mockCurrentUser } from "./mock-current-user"
export { mockedAxios } from "./mock-axios"
export { request } from "./request"
export { testWithCustomLogLevel } from "./test-with-custom-log-level"
+39
View File
@@ -0,0 +1,39 @@
import path from "path"
import { readFileSync } from "fs"
import { APP_ROOT_PATH } from "@/config"
import arrayWrap from "@/utils/array-wrap"
/**
* Usage:
* - `testDataPath('path/to/my-file.json')`
* - `testDataPath('path', 'to', 'my-file.json')`
*/
export function testDataPath(pathOrPathSegment: string | string[]): string {
const pathSegments = arrayWrap(pathOrPathSegment)
return path.join(APP_ROOT_PATH, "tests", "data", ...pathSegments)
}
/**
* Usage:
* - `specData('path/to/my-file.json')`
* - `specData('path', 'to', 'my-file.json')`
*
* Returns:
* - JSON parsed object if file is a JSON file
* - Raw file content otherwise
*/
export function loadTestData(pathOrPathSegment: string | string[]): string | object {
const filePath = testDataPath(pathOrPathSegment)
const rawData = readFileSync(filePath)
const extension = path.extname(filePath)
switch (extension) {
case ".json":
return JSON.parse(rawData.toString())
default:
return rawData.toString()
}
}
export default loadTestData
+6
View File
@@ -0,0 +1,6 @@
import axios from "axios"
import MockAdapter from "axios-mock-adapter"
export const mockedAxios = new MockAdapter(axios)
export default mockedAxios
+38
View File
@@ -0,0 +1,38 @@
import { Request, Response, NextFunction } from "express"
import {
findAndAuthorizeCurrentUserMiddleware,
type AuthorizationRequest,
} from "@/middlewares/find-and-authorize-current-user-middleware"
import { User } from "@/models"
/**
* Usage:
* At the top level of a test file import:
* import { mockCurrentUser } from "@/support"
*
* Then where you want to set the current user:
* mockCurrentUser(currentUser)
*
* @param newCurrentUser - The user to set as the current user
*/
export function mockCurrentUser(newCurrentUser: User) {
vi.mock("@/middlewares/jwt-middleware", () => ({
default: async (_req: Request, _res: Response, next: NextFunction) => next(),
jwtMiddleware: async (_req: Request, _res: Response, next: NextFunction) => next(),
}))
vi.mock("@/middlewares/find-and-authorize-current-user-middleware")
const findAndAuthorizeCurrentUserMiddlewareMock = vi.mocked(findAndAuthorizeCurrentUserMiddleware)
findAndAuthorizeCurrentUserMiddlewareMock.mockImplementation(
async (req: AuthorizationRequest, _res: Response, next: NextFunction) => {
const currentUser = await User.withScope(["asCurrentUser"]).findByPk(newCurrentUser.id, {
rejectOnEmpty: true,
})
req.currentUser = currentUser
next()
}
)
}
+10
View File
@@ -0,0 +1,10 @@
import supertest, { AgentOptions } from "supertest"
import { App } from "supertest/types"
import defaultApp from "@/app"
export function request(options?: AgentOptions | undefined, app: App = defaultApp) {
return supertest(app, options)
}
export default request
@@ -0,0 +1,19 @@
import logger from "@/utils/logger"
function setLogLevel(level: string) {
logger.level = level
}
export const testWithCustomLogLevel = test.extend<{
setLogLevel: (level: string) => void
}>({
// eslint-disable-next-line no-empty-pattern
setLogLevel: async ({}, use) => {
const originalLogLevel = logger.level
try {
await use(setLogLevel)
} finally {
setLogLevel(originalLogLevel)
}
},
})
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./../tsconfig.json",
"compilerOptions": {
"baseUrl": "./../",
"paths": {
"@/*": ["./src/*", "./tests/*"]
},
"typeRoots": ["../node_modules/@types", "../@types", "./@types"],
"types": ["node", "../vitest/globals"]
},
"include": ["../src/**/*", "../tests/**/*", ""]
}