Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/point-of-sale/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ 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'
import {
CreateMerchantContext,
DeleteMerchantContext,
MerchantRoutes
} from './merchant/routes'

export interface AppServices {
logger: Promise<Logger>
Expand Down Expand Up @@ -65,6 +69,13 @@ export class App {
merchantRoutes.create
)

// DELETE /merchants/:merchantId
// Delete merchant
router.delete<DefaultState, DeleteMerchantContext>(
'/merchants/:merchantId',
merchantRoutes.delete
)

koa.use(cors())
koa.use(router.routes())

Expand Down
7 changes: 4 additions & 3 deletions packages/point-of-sale/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,12 @@ export function initIocContainer(
})

container.singleton('merchantService', async (deps) => {
const [logger, knex] = await Promise.all([
const [logger, knex, posDeviceService] = await Promise.all([
deps.use('logger'),
deps.use('knex')
deps.use('knex'),
deps.use('posDeviceService')
])
return createMerchantService({ logger, knex })
return createMerchantService({ logger, knex, posDeviceService })
})

container.singleton('merchantRoutes', async (deps) => {
Expand Down
72 changes: 72 additions & 0 deletions packages/point-of-sale/src/merchant/devices/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,78 @@ describe('POS Device Service', () => {
})
})

describe('revokeAllByMerchantId', () => {
test('revokes all devices for a merchant', async () => {
const merchant = await merchantService.create('Test Merchant')

const device1 = await posDeviceService.registerDevice({
merchantId: merchant.id,
publicKey: 'publicKey1',
deviceName: 'device1',
walletAddress: 'walletAddress1',
algorithm: 'ecdsa-p256-sha256'
})

const device2 = await posDeviceService.registerDevice({
merchantId: merchant.id,
publicKey: 'publicKey2',
deviceName: 'device2',
walletAddress: 'walletAddress2',
algorithm: 'ecdsa-p256-sha256'
})

assert(!isPosDeviceError(device1))
assert(!isPosDeviceError(device2))
expect(device1.status).toBe(DeviceStatus.Active)
expect(device2.status).toBe(DeviceStatus.Active)

const revokedCount = await posDeviceService.revokeAllByMerchantId(
merchant.id
)
expect(revokedCount).toBe(2)

const knex = await deps.use('knex')
const revokedDevices = await PosDevice.query(knex)
.where('merchantId', merchant.id)
.whereNotNull('deletedAt')

expect(revokedDevices).toHaveLength(2)
expect(revokedDevices[0].status).toBe(DeviceStatus.Revoked)
expect(revokedDevices[0].deletedAt).toBeDefined()
expect(revokedDevices[1].status).toBe(DeviceStatus.Revoked)
expect(revokedDevices[1].deletedAt).toBeDefined()
})

test('returns 0 when no devices exist for merchant', async () => {
const merchant = await merchantService.create('Test Merchant')
const revokedCount = await posDeviceService.revokeAllByMerchantId(
merchant.id
)
expect(revokedCount).toBe(0)
})

test('returns 0 when all devices are already revoked', async () => {
const merchant = await merchantService.create('Test Merchant')

const device = await posDeviceService.registerDevice({
merchantId: merchant.id,
publicKey: 'publicKey',
deviceName: 'device',
walletAddress: 'walletAddress',
algorithm: 'ecdsa-p256-sha256'
})

assert(!isPosDeviceError(device))

await posDeviceService.revoke(device.id)

const revokedCount = await posDeviceService.revokeAllByMerchantId(
merchant.id
)
expect(revokedCount).toBe(0)
})
})

async function createDeviceWithMerchant(): Promise<PosDevice> {
const merchant = await merchantService.create('merchant')
const createOptions: CreateOptions = {
Expand Down
21 changes: 20 additions & 1 deletion packages/point-of-sale/src/merchant/devices/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface PosDeviceService {
getByKeyId(keyId: string): Promise<PosDevice | void>

revoke(id: string): Promise<PosDevice | PosDeviceError>

revokeAllByMerchantId(merchantId: string): Promise<number>
}

export interface CreateOptions {
Expand Down Expand Up @@ -43,7 +45,9 @@ export async function createPosDeviceService({
return {
registerDevice: (options) => registerDevice(deps, options),
getByKeyId: (keyId) => getByKeyId(deps, keyId),
revoke: (id) => revoke(deps, id)
revoke: (id) => revoke(deps, id),
revokeAllByMerchantId: (merchantId) =>
revokeAllByMerchantId(deps, merchantId)
}
}

Expand Down Expand Up @@ -100,6 +104,21 @@ async function revoke(
}
}

async function revokeAllByMerchantId(
deps: ServiceDependencies,
merchantId: string
): Promise<number> {
const revokedCount = await PosDevice.query(deps.knex)
.patch({
status: DeviceStatus.Revoked,
deletedAt: new Date()
})
.where('merchantId', merchantId)
.whereNull('deletedAt')

return revokedCount
}

function generateKeyId(deviceName: string): string {
const deviceNameTrimmed = deviceName.replace(/\s/g, '')
const PREFIX = 'pos:'
Expand Down
67 changes: 62 additions & 5 deletions packages/point-of-sale/src/merchant/routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { IocContract } from '@adonisjs/fold'
import { createContext } from '../tests/context'
import { createTestApp, TestContainer } from '../tests/app'
import { Config } from '../config/app'
import { v4 as uuid } from 'uuid'
import { initIocContainer } from '..'
import { AppServices } from '../app'
import { Config } from '../config/app'
import { createTestApp, TestContainer } from '../tests/app'
import { createContext } from '../tests/context'
import { truncateTables } from '../tests/tableManager'
import {
CreateMerchantContext,
MerchantRoutes,
createMerchantRoutes
createMerchantRoutes,
DeleteMerchantContext,
MerchantRoutes
} from './routes'
import { MerchantService } from './service'

Expand Down Expand Up @@ -64,4 +66,59 @@ describe('Merchant Routes', (): void => {
})
})
})

describe('delete', (): void => {
test('Deletes a merchant', async (): Promise<void> => {
const merchant = await merchantService.create('Test Merchant')

const ctx = createContext<DeleteMerchantContext>(
{
headers: {
Accept: 'application/json'
}
},
{}
)
ctx.request.params = { merchantId: merchant.id }

await merchantRoutes.delete(ctx)

expect(ctx.status).toBe(204)
})

test('Returns 404 for non-existent merchant', async (): Promise<void> => {
const ctx = createContext<DeleteMerchantContext>(
{
headers: {
Accept: 'application/json'
}
},
{}
)
ctx.request.params = { merchantId: uuid() }

await expect(merchantRoutes.delete(ctx)).rejects.toThrow(
'Merchant not found'
)
})

test('Returns 404 for already deleted merchant', async (): Promise<void> => {
const merchant = await merchantService.create('Test Merchant')
await merchantService.delete(merchant.id)

const ctx = createContext<DeleteMerchantContext>(
{
headers: {
Accept: 'application/json'
}
},
{}
)
ctx.request.params = { merchantId: merchant.id }

await expect(merchantRoutes.delete(ctx)).rejects.toThrow(
'Merchant not found'
)
})
})
})
35 changes: 34 additions & 1 deletion packages/point-of-sale/src/merchant/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,19 @@ export type CreateMerchantContext = Exclude<AppContext, 'request'> & {
request: CreateMerchantRequest
}

type DeleteMerchantRequest = Exclude<AppContext['request'], 'params'> & {
params: {
merchantId: string
}
}

export type DeleteMerchantContext = Exclude<AppContext, 'request'> & {
request: DeleteMerchantRequest
}

export interface MerchantRoutes {
create(ctx: CreateMerchantContext): Promise<void>
delete(ctx: DeleteMerchantContext): Promise<void>
}

export function createMerchantRoutes(
Expand All @@ -34,7 +45,8 @@ export function createMerchantRoutes(
}

return {
create: (ctx: CreateMerchantContext) => createMerchant(deps, ctx)
create: (ctx: CreateMerchantContext) => createMerchant(deps, ctx),
delete: (ctx: DeleteMerchantContext) => deleteMerchant(deps, ctx)
}
}

Expand All @@ -52,3 +64,24 @@ async function createMerchant(
throw new POSMerchantRouteError(400, 'Could not create merchant', { err })
}
}

async function deleteMerchant(
deps: ServiceDependencies,
ctx: DeleteMerchantContext
): Promise<void> {
const { merchantId } = ctx.request.params
try {
const deleted = await deps.merchantService.delete(merchantId)

if (!deleted) {
throw new POSMerchantRouteError(404, 'Merchant not found')
}

ctx.status = 204
} catch (err) {
if (err instanceof POSMerchantRouteError) {
throw err
}
throw new POSMerchantRouteError(400, 'Could not delete merchant', { err })
}
}
63 changes: 62 additions & 1 deletion packages/point-of-sale/src/merchant/service.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { IocContract } from '@adonisjs/fold'

import { v4 as uuid } from 'uuid'

import { isPosDeviceError } from './devices/errors'
import { DeviceStatus, PosDevice } from './devices/model'
import { PosDeviceService } from './devices/service'
import { Merchant } from './model'
import { MerchantService } from './service'

import { Config } from '../config/app'
import { createTestApp, TestContainer } from '../tests/app'
import { truncateTables } from '../tests/tableManager'
import { Config } from '../config/app'

import { initIocContainer } from '../'
import { AppServices } from '../app'
Expand All @@ -14,6 +19,7 @@ describe('Merchant Service', (): void => {
let deps: IocContract<AppServices>
let appContainer: TestContainer
let merchantService: MerchantService
let posDeviceService: PosDeviceService

beforeAll(async (): Promise<void> => {
deps = initIocContainer({
Expand All @@ -22,6 +28,7 @@ describe('Merchant Service', (): void => {

appContainer = await createTestApp(deps)
merchantService = await deps.use('merchantService')
posDeviceService = await deps.use('posDeviceService')
})

afterEach(async (): Promise<void> => {
Expand Down Expand Up @@ -67,5 +74,59 @@ describe('Merchant Service', (): void => {
const secondDelete = await merchantService.delete(created.id)
expect(secondDelete).toBe(false)
})

test('soft deletes and revokes associated devices when deleting merchant', async (): Promise<void> => {
const knex = await deps.use('knex')
const created = await merchantService.create('Test merchant')

const device1Result = await posDeviceService.registerDevice({
merchantId: created.id,
walletAddress: 'wallet1',
algorithm: 'ecdsa-p256-sha256',
deviceName: 'Device 1',
publicKey: 'test-public-key-1'
})

const device2Result = await posDeviceService.registerDevice({
merchantId: created.id,
walletAddress: 'wallet2',
algorithm: 'ecdsa-p256-sha256',
deviceName: 'Device 2',
publicKey: 'test-public-key-2'
})

expect(isPosDeviceError(device1Result)).toBe(false)
expect(isPosDeviceError(device2Result)).toBe(false)
if (!isPosDeviceError(device1Result)) {
expect(device1Result.status).toBe(DeviceStatus.Active)
}
if (!isPosDeviceError(device2Result)) {
expect(device2Result.status).toBe(DeviceStatus.Active)
}

const result = await merchantService.delete(created.id)
expect(result).toBe(true)

const deletedMerchant = await Merchant.query(knex)
.findById(created.id)
.whereNotNull('deletedAt')
expect(deletedMerchant).toBeDefined()
expect(deletedMerchant?.deletedAt).toBeDefined()

const deletedDevices = await PosDevice.query(knex)
.where('merchantId', created.id)
.whereNotNull('deletedAt')

expect(deletedDevices).toHaveLength(2)
expect(deletedDevices[0].status).toBe(DeviceStatus.Revoked)
expect(deletedDevices[0].deletedAt).toBeDefined()
expect(deletedDevices[1].status).toBe(DeviceStatus.Revoked)
expect(deletedDevices[1].deletedAt).toBeDefined()
})

test('returns false for non-existent merchant', async (): Promise<void> => {
const result = await merchantService.delete(uuid())
expect(result).toBe(false)
})
})
})
Loading
Loading