diff --git a/packages/point-of-sale/migrations/20250708093546_create_merchants_table.js b/packages/point-of-sale/migrations/20250708093546_create_merchants_table.js index 97c7497c30..c75ff42f7f 100644 --- a/packages/point-of-sale/migrations/20250708093546_create_merchants_table.js +++ b/packages/point-of-sale/migrations/20250708093546_create_merchants_table.js @@ -10,7 +10,7 @@ exports.up = function (knex) { table.timestamp('createdAt').defaultTo(knex.fn.now()) table.timestamp('updatedAt').defaultTo(knex.fn.now()) - table.timestamp('deletedAt') + table.timestamp('deletedAt').nullable() }) } diff --git a/packages/point-of-sale/package.json b/packages/point-of-sale/package.json index 3d94916d10..246ca01f42 100644 --- a/packages/point-of-sale/package.json +++ b/packages/point-of-sale/package.json @@ -45,6 +45,7 @@ "jest-openapi": "^0.14.2", "testcontainers": "^10.16.0", "tmp": "^0.2.3", - "@types/tmp": "^0.2.6" + "@types/tmp": "^0.2.6", + "node-mocks-http": "^1.16.2" } } diff --git a/packages/point-of-sale/src/app.ts b/packages/point-of-sale/src/app.ts index 2a661fac78..f6467c33a3 100644 --- a/packages/point-of-sale/src/app.ts +++ b/packages/point-of-sale/src/app.ts @@ -7,11 +7,13 @@ import Koa, { DefaultState } from 'koa' import Router from '@koa/router' import bodyParser from 'koa-bodyparser' import cors from '@koa/cors' +import { CreateMerchantContext, MerchantRoutes } from './merchant/routes' export interface AppServices { logger: Promise knex: Promise config: Promise + merchantRoutes: Promise } export type AppContainer = IocContract @@ -25,6 +27,13 @@ export interface AppContextData { export type AppContext = Koa.ParameterizedContext +export type AppRequest = Omit< + AppContext['request'], + 'params' +> & { + params: Record +} + export class App { private posServer!: Server public isShuttingDown = false @@ -47,6 +56,15 @@ export class App { ctx.status = 200 }) + const merchantRoutes = await this.container.use('merchantRoutes') + + // POST /merchants + // Create merchant + router.post( + '/merchants', + merchantRoutes.create + ) + koa.use(cors()) koa.use(router.routes()) diff --git a/packages/point-of-sale/src/index.ts b/packages/point-of-sale/src/index.ts index e48406774f..e24ed825e1 100644 --- a/packages/point-of-sale/src/index.ts +++ b/packages/point-of-sale/src/index.ts @@ -5,6 +5,7 @@ import { Config } from './config/app' import { App, AppServices } from './app' import createLogger from 'pino' import { createMerchantService } from './merchant/service' +import { createMerchantRoutes } from './merchant/routes' export function initIocContainer( config: typeof Config @@ -65,6 +66,13 @@ export function initIocContainer( return createMerchantService({ logger, knex }) }) + container.singleton('merchantRoutes', async (deps) => { + return createMerchantRoutes({ + logger: await deps.use('logger'), + merchantService: await deps.use('merchantService') + }) + }) + return container } diff --git a/packages/point-of-sale/src/merchant/errors.ts b/packages/point-of-sale/src/merchant/errors.ts new file mode 100644 index 0000000000..c16b966dbd --- /dev/null +++ b/packages/point-of-sale/src/merchant/errors.ts @@ -0,0 +1,15 @@ +export class POSMerchantRouteError extends Error { + public status: number + public details?: Record + + constructor( + status: number, + message: string, + details?: Record + ) { + super(message) + this.name = 'POSMerchantRouteError' + this.status = status + this.details = details + } +} diff --git a/packages/point-of-sale/src/merchant/model.ts b/packages/point-of-sale/src/merchant/model.ts index 02b8c03a80..c49166f405 100644 --- a/packages/point-of-sale/src/merchant/model.ts +++ b/packages/point-of-sale/src/merchant/model.ts @@ -20,5 +20,5 @@ export class Merchant extends BaseModel { }) public name!: string - public deletedAt!: Date | null + public deletedAt?: Date | null } diff --git a/packages/point-of-sale/src/merchant/routes.test.ts b/packages/point-of-sale/src/merchant/routes.test.ts new file mode 100644 index 0000000000..49e652e4f4 --- /dev/null +++ b/packages/point-of-sale/src/merchant/routes.test.ts @@ -0,0 +1,67 @@ +import { IocContract } from '@adonisjs/fold' +import { createContext } from '../tests/context' +import { createTestApp, TestContainer } from '../tests/app' +import { Config } from '../config/app' +import { initIocContainer } from '..' +import { AppServices } from '../app' +import { truncateTables } from '../tests/tableManager' +import { + CreateMerchantContext, + MerchantRoutes, + createMerchantRoutes +} from './routes' +import { MerchantService } from './service' + +describe('Merchant Routes', (): void => { + let deps: IocContract + let appContainer: TestContainer + let merchantRoutes: MerchantRoutes + let merchantService: MerchantService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + merchantService = await deps.use('merchantService') + const logger = await deps.use('logger') + + merchantRoutes = createMerchantRoutes({ + merchantService, + logger + }) + }) + + afterEach(async (): Promise => { + await truncateTables(deps) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('create', (): void => { + test('Creates a merchant', async (): Promise => { + const merchantData = { + name: 'Test Merchant' + } + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + {} + ) + ctx.request.body = merchantData + + await merchantRoutes.create(ctx) + + expect(ctx.status).toBe(200) + expect(ctx.response.body).toEqual({ + id: expect.any(String), + name: merchantData.name + }) + }) + }) +}) diff --git a/packages/point-of-sale/src/merchant/routes.ts b/packages/point-of-sale/src/merchant/routes.ts new file mode 100644 index 0000000000..774510a324 --- /dev/null +++ b/packages/point-of-sale/src/merchant/routes.ts @@ -0,0 +1,54 @@ +import { AppContext } from '../app' +import { BaseService } from '../shared/baseService' +import { MerchantService } from './service' +import { POSMerchantRouteError } from './errors' + +interface ServiceDependencies extends BaseService { + merchantService: MerchantService +} + +type CreateMerchantRequest = Exclude & { + body: { + name: string + } +} + +export type CreateMerchantContext = Exclude & { + request: CreateMerchantRequest +} + +export interface MerchantRoutes { + create(ctx: CreateMerchantContext): Promise +} + +export function createMerchantRoutes( + deps_: ServiceDependencies +): MerchantRoutes { + const log = deps_.logger.child({ + service: 'MerchantRoutes' + }) + + const deps = { + ...deps_, + logger: log + } + + return { + create: (ctx: CreateMerchantContext) => createMerchant(deps, ctx) + } +} + +async function createMerchant( + deps: ServiceDependencies, + ctx: CreateMerchantContext +): Promise { + const { body } = ctx.request + try { + const merchant = await deps.merchantService.create(body.name) + + ctx.status = 200 + ctx.body = { id: merchant.id, name: merchant.name } + } catch (err) { + throw new POSMerchantRouteError(400, 'Could not create merchant', { err }) + } +} diff --git a/packages/point-of-sale/src/merchant/service.test.ts b/packages/point-of-sale/src/merchant/service.test.ts index 61fa24cc16..c1cd6d56b3 100644 --- a/packages/point-of-sale/src/merchant/service.test.ts +++ b/packages/point-of-sale/src/merchant/service.test.ts @@ -36,7 +36,13 @@ describe('Merchant Service', (): void => { 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' }) + expect(merchant).toMatchObject({ + name: 'Test merchant', + createdAt: expect.any(Date), + updatedAt: expect.any(Date) + }) + expect(typeof merchant.id).toBe('string') + expect(merchant.deletedAt).toBeUndefined() }) }) diff --git a/packages/point-of-sale/src/shared/baseModel.ts b/packages/point-of-sale/src/shared/baseModel.ts index 1b361238d1..e6b390d79b 100644 --- a/packages/point-of-sale/src/shared/baseModel.ts +++ b/packages/point-of-sale/src/shared/baseModel.ts @@ -127,6 +127,8 @@ export abstract class BaseModel extends PaginationModel { public $beforeInsert(context: QueryContext): void { super.$beforeInsert(context) this.id = this.id || uuid() + this.createdAt = new Date() + this.updatedAt = new Date() } public $beforeUpdate(_opts: ModelOptions, _queryContext: QueryContext): void { diff --git a/packages/point-of-sale/src/tests/context.ts b/packages/point-of-sale/src/tests/context.ts new file mode 100644 index 0000000000..fffcb0affe --- /dev/null +++ b/packages/point-of-sale/src/tests/context.ts @@ -0,0 +1,24 @@ +import { IocContract } from '@adonisjs/fold' +import * as httpMocks from 'node-mocks-http' +import Koa from 'koa' +import { AppContext, AppContextData, AppRequest } from '../app' + +export function createContext( + reqOpts: httpMocks.RequestOptions, + params: Record = {}, + container?: IocContract +): T { + const req = httpMocks.createRequest(reqOpts) + const res = httpMocks.createResponse({ req }) + const koa = new Koa() + const ctx = koa.createContext(req, res) + ctx.params = (ctx.request as AppRequest).params = params + if (reqOpts.query) { + ctx.request.query = reqOpts.query + } + if (reqOpts.body !== undefined) { + ctx.request.body = reqOpts.body + } + ctx.container = container + return ctx as T +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2095556e83..e451b37fb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -860,6 +860,9 @@ importers: nock: specifier: 14.0.0-beta.19 version: 14.0.0-beta.19 + node-mocks-http: + specifier: ^1.16.2 + version: 1.16.2(@types/node@20.14.15) testcontainers: specifier: ^10.16.0 version: 10.16.0