diff --git a/packages/point-of-sale/jest.config.js b/packages/point-of-sale/jest.config.js new file mode 100644 index 0000000000..cdb688ff95 --- /dev/null +++ b/packages/point-of-sale/jest.config.js @@ -0,0 +1,30 @@ +'use strict' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const baseConfig = require('../../jest.config.base.js') +// eslint-disable-next-line @typescript-eslint/no-var-requires +const packageName = require('./package.json').name + +module.exports = { + ...baseConfig, + clearMocks: true, + testTimeout: 30000, + roots: [`/packages/${packageName}`], + setupFiles: [`/packages/${packageName}/jest.env.js`], + globalSetup: `/packages/${packageName}/jest.setup.ts`, + globalTeardown: `/packages/${packageName}/jest.teardown.js`, + testRegex: `(packages/${packageName}/.*/__tests__/.*|\\.(test|spec))\\.tsx?$`, + testEnvironment: `/packages/${packageName}/jest.custom-environment.ts`, + moduleDirectories: [ + `node_modules`, + `packages/${packageName}/node_modules`, + `/node_modules` + ], + modulePaths: [ + `node_modules`, + `/packages/${packageName}/src/`, + `/node_modules` + ], + id: packageName, + displayName: packageName, + rootDir: '../..' +} diff --git a/packages/point-of-sale/jest.custom-environment.ts b/packages/point-of-sale/jest.custom-environment.ts new file mode 100644 index 0000000000..d725c84b67 --- /dev/null +++ b/packages/point-of-sale/jest.custom-environment.ts @@ -0,0 +1,9 @@ +import { TestEnvironment } from 'jest-environment-node' +import nock from 'nock' + +export default class CustomEnvironment extends TestEnvironment { + constructor(config, context) { + super(config, context) + this.global.nock = nock + } +} diff --git a/packages/point-of-sale/jest.env.js b/packages/point-of-sale/jest.env.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/point-of-sale/jest.setup.ts b/packages/point-of-sale/jest.setup.ts new file mode 100644 index 0000000000..1e38fe1c26 --- /dev/null +++ b/packages/point-of-sale/jest.setup.ts @@ -0,0 +1,74 @@ +import { knex } from 'knex' +import { GenericContainer, Wait } from 'testcontainers' +require('./jest.env') // set environment variables + +const POSTGRES_PORT = 5432 + +const setup = async (globalConfig): Promise => { + const workers = globalConfig.maxWorkers + + const setupDatabase = async () => { + if (!process.env.DATABASE_URL) { + const postgresContainer = await new GenericContainer('postgres:15') + .withExposedPorts(POSTGRES_PORT) + .withBindMounts([ + { + source: __dirname + '/scripts/init.sh', + target: '/docker-entrypoint-initdb.d/init.sh' + } + ]) + .withEnvironment({ + POSTGRES_PASSWORD: 'password' + }) + .withHealthCheck({ + test: ['CMD-SHELL', 'pg_isready -d testing'], + interval: 10000, + timeout: 5000, + retries: 5 + }) + .withWaitStrategy(Wait.forHealthCheck()) + .start() + + process.env.DATABASE_URL = `postgresql://postgres:password@localhost:${postgresContainer.getMappedPort( + POSTGRES_PORT + )}/testing` + + global.__POS_POSTGRES__ = postgresContainer + } + + const db = knex({ + client: 'postgresql', + connection: process.env.DATABASE_URL, + pool: { + min: 2, + max: 10 + }, + migrations: { + tableName: 'pos_knex_migrations' + } + }) + + // node pg defaults to returning bigint as string. This ensures it parses to bigint + db.client.driver.types.setTypeParser( + db.client.driver.types.builtins.INT8, + 'text', + BigInt + ) + await db.migrate.latest({ + directory: __dirname + '/migrations' + }) + + for (let i = 1; i <= workers; i++) { + const workerDatabaseName = `testing_${i}` + + await db.raw(`DROP DATABASE IF EXISTS ${workerDatabaseName}`) + await db.raw(`CREATE DATABASE ${workerDatabaseName} TEMPLATE testing`) + } + + global.__POS_KNEX__ = db + } + + await Promise.all([setupDatabase()]) +} + +export default setup diff --git a/packages/point-of-sale/jest.teardown.js b/packages/point-of-sale/jest.teardown.js new file mode 100644 index 0000000000..12f33b679b --- /dev/null +++ b/packages/point-of-sale/jest.teardown.js @@ -0,0 +1,10 @@ +module.exports = async () => { + await global.__POS_KNEX__.migrate.rollback( + { directory: __dirname + '/migrations' }, + true + ) + await global.__POS_KNEX__.destroy() + if (global.__POS_POSTGRES__) { + await global.__POS_POSTGRES__.stop() + } +} diff --git a/packages/point-of-sale/package.json b/packages/point-of-sale/package.json index 75ba472fde..3d94916d10 100644 --- a/packages/point-of-sale/package.json +++ b/packages/point-of-sale/package.json @@ -4,7 +4,11 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests --maxWorkers=50%", + "test:ci": "NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests --maxWorkers=2", + "test:cov": "pnpm test -- --coverage", + "test:sincemain": "pnpm test -- --changedSince=main", + "test:sincemain:cov": "pnpm test:sincemain --coverage", "knex": "knex", "dev": "ts-node-dev --inspect=0.0.0.0:9229 --respawn --transpile-only src/index.ts", "build": "pnpm build:deps && pnpm clean && tsc --build tsconfig.json", @@ -35,6 +39,12 @@ "@types/koa-bodyparser": "^4.3.12", "@types/koa__cors": "^5.0.0", "@types/koa__router": "^12.0.4", - "@types/uuid": "^9.0.8" + "@types/uuid": "^9.0.8", + "nock": "14.0.0-beta.19", + "jest-environment-node": "^29.7.0", + "jest-openapi": "^0.14.2", + "testcontainers": "^10.16.0", + "tmp": "^0.2.3", + "@types/tmp": "^0.2.6" } } diff --git a/packages/point-of-sale/scripts/init.sh b/packages/point-of-sale/scripts/init.sh new file mode 100755 index 0000000000..a1fa255c7d --- /dev/null +++ b/packages/point-of-sale/scripts/init.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + DROP DATABASE IF EXISTS TESTING; + CREATE DATABASE testing; + CREATE DATABASE development; +EOSQL diff --git a/packages/point-of-sale/src/config/app.ts b/packages/point-of-sale/src/config/app.ts index 2691d7397b..78cb416d37 100644 --- a/packages/point-of-sale/src/config/app.ts +++ b/packages/point-of-sale/src/config/app.ts @@ -34,5 +34,6 @@ export const Config = { env: envString('NODE_ENV', 'development'), port: envInt('PORT', 3008), trustProxy: envBool('TRUST_PROXY', false), - enableManualMigrations: envBool('ENABLE_MANUAl_MIGRATIONS', false) + enableManualMigrations: envBool('ENABLE_MANUAl_MIGRATIONS', false), + dbSchema: undefined as string | undefined } diff --git a/packages/point-of-sale/src/index.ts b/packages/point-of-sale/src/index.ts index d9d359db53..e48406774f 100644 --- a/packages/point-of-sale/src/index.ts +++ b/packages/point-of-sale/src/index.ts @@ -4,6 +4,7 @@ import { Model } from 'objection' import { Config } from './config/app' import { App, AppServices } from './app' import createLogger from 'pino' +import { createMerchantService } from './merchant/service' export function initIocContainer( config: typeof Config @@ -55,6 +56,15 @@ export function initIocContainer( ) return db }) + + container.singleton('merchantService', async (deps) => { + const [logger, knex] = await Promise.all([ + deps.use('logger'), + deps.use('knex') + ]) + return createMerchantService({ logger, knex }) + }) + return container } diff --git a/packages/point-of-sale/src/merchant/service.test.ts b/packages/point-of-sale/src/merchant/service.test.ts new file mode 100644 index 0000000000..61fa24cc16 --- /dev/null +++ b/packages/point-of-sale/src/merchant/service.test.ts @@ -0,0 +1,65 @@ +import { IocContract } from '@adonisjs/fold' + +import { Merchant } from './model' +import { MerchantService } from './service' + +import { createTestApp, TestContainer } from '../tests/app' +import { truncateTables } from '../tests/tableManager' +import { Config } from '../config/app' + +import { initIocContainer } from '../' +import { AppServices } from '../app' + +describe('Merchant Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let merchantService: MerchantService + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config + }) + + appContainer = await createTestApp(deps) + merchantService = await deps.use('merchantService') + }) + + afterEach(async (): Promise => { + jest.useRealTimers() + await truncateTables(deps) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('create', (): void => { + test('creates a merchant', async (): Promise => { + const merchant = await merchantService.create('Test merchant') + expect(merchant).toEqual({ id: merchant.id, name: 'Test merchant' }) + }) + }) + + describe('delete', (): void => { + test('soft deletes an existing merchant', async (): Promise => { + const created = await merchantService.create('Test merchant') + + const result = await merchantService.delete(created.id) + expect(result).toBe(true) + + const deletedMerchant = await Merchant.query() + .findById(created.id) + .whereNotNull('deletedAt') + expect(deletedMerchant).toBeDefined() + expect(deletedMerchant?.deletedAt).toBeDefined() + }) + + test('returns false for already deleted merchant', async (): Promise => { + const created = await merchantService.create('Test merchant') + + await merchantService.delete(created.id) + const secondDelete = await merchantService.delete(created.id) + expect(secondDelete).toBe(false) + }) + }) +}) diff --git a/packages/point-of-sale/src/merchant/service.ts b/packages/point-of-sale/src/merchant/service.ts new file mode 100644 index 0000000000..8540c74c54 --- /dev/null +++ b/packages/point-of-sale/src/merchant/service.ts @@ -0,0 +1,48 @@ +import { BaseService } from '../shared/baseService' +import { TransactionOrKnex } from 'objection' +import { Merchant } from './model' + +export interface MerchantService { + create(name: string): Promise + delete(id: string): Promise +} + +interface ServiceDependencies extends BaseService { + knex: TransactionOrKnex +} + +export async function createMerchantService({ + logger, + knex +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'MerchantService' + }) + const deps: ServiceDependencies = { + logger: log, + knex + } + + return { + create: (input: string) => createMerchant(deps, input), + delete: (id: string) => deleteMerchant(deps, id) + } +} + +async function createMerchant( + deps: ServiceDependencies, + name: string +): Promise { + return await Merchant.query(deps.knex).insert({ name }) +} + +async function deleteMerchant( + deps: ServiceDependencies, + id: string +): Promise { + const deleted = await Merchant.query(deps.knex) + .patch({ deletedAt: new Date() }) + .whereNull('deletedAt') + .where('id', id) + return deleted > 0 +} diff --git a/packages/point-of-sale/src/tests/app.ts b/packages/point-of-sale/src/tests/app.ts new file mode 100644 index 0000000000..09a18d756d --- /dev/null +++ b/packages/point-of-sale/src/tests/app.ts @@ -0,0 +1,41 @@ +import { Knex } from 'knex' +import { IocContract } from '@adonisjs/fold' + +import { start, gracefulShutdown } from '..' +import { App, AppServices } from '../app' + +export interface TestContainer { + app: App + knex: Knex + connectionUrl: string + shutdown: () => Promise + container: IocContract +} + +export const createTestApp = async ( + container: IocContract +): Promise => { + const config = await container.use('config') + + const app = new App(container) + await start(container, app) + + const nock = (global as unknown as { nock: typeof import('nock') }).nock + + const knex = await container.use('knex') + + return { + app, + knex, + connectionUrl: config.databaseUrl, + shutdown: async () => { + nock.cleanAll() + nock.abortPendingRequests() + nock.restore() + nock.activate() + + await gracefulShutdown(container, app) + }, + container + } +} diff --git a/packages/point-of-sale/src/tests/tableManager.ts b/packages/point-of-sale/src/tests/tableManager.ts new file mode 100644 index 0000000000..665a67713d --- /dev/null +++ b/packages/point-of-sale/src/tests/tableManager.ts @@ -0,0 +1,45 @@ +import { IocContract } from '@adonisjs/fold' +import { Knex } from 'knex' +import { AppServices } from '../app' + +export async function truncateTable( + knex: Knex, + tableName: string +): Promise { + const RAW = `TRUNCATE TABLE "${tableName}" RESTART IDENTITY` + await knex.raw(RAW) +} + +export async function truncateTables( + deps: IocContract +): Promise { + const knex = await deps.use('knex') + const config = await deps.use('config') + const dbSchema = config.dbSchema ?? 'public' + + const ignoreTables = [ + 'knex_migrations', + 'knex_migrations_lock', + 'pos_knex_migrations', + 'pos_knex_migrations_lock' + ] + const tables = await getTables(knex, dbSchema, ignoreTables) + const RAW = `TRUNCATE TABLE "${tables}" RESTART IDENTITY` + await knex.raw(RAW) +} + +async function getTables( + knex: Knex, + dbSchema: string = 'public', + ignoredTables: string[] +): Promise { + const result = await knex.raw( + `SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname='${dbSchema}'` + ) + return result.rows + .map((val: { tablename: string }) => { + if (!ignoredTables.includes(val.tablename)) return val.tablename + }) + .filter(Boolean) + .join('","') +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cf9679cad..9ae09e7007 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -827,9 +827,27 @@ importers: '@types/koa__router': specifier: ^12.0.4 version: 12.0.4 + '@types/tmp': + specifier: ^0.2.6 + version: 0.2.6 '@types/uuid': specifier: ^9.0.8 version: 9.0.8 + jest-environment-node: + specifier: ^29.7.0 + version: 29.7.0 + jest-openapi: + specifier: ^0.14.2 + version: 0.14.2 + nock: + specifier: 14.0.0-beta.19 + version: 14.0.0-beta.19 + testcontainers: + specifier: ^10.16.0 + version: 10.16.0 + tmp: + specifier: ^0.2.3 + version: 0.2.3 packages/token-introspection: dependencies: