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
1 change: 1 addition & 0 deletions packages/card-service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"dependencies": {
"@adonisjs/fold": "^8.2.0",
"@koa/cors": "^5.0.0",
"ioredis": "^5.3.2",
"@koa/router": "^12.0.2",
"koa-bodyparser": "^4.4.1",
"koa": "^2.15.4",
Expand Down
35 changes: 34 additions & 1 deletion packages/card-service/src/config/app.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { ConnectionOptions } from 'tls'
import * as fs from 'fs'

function envString(name: string, defaultValue?: string): string {
const envValue = process.env[name]

Expand Down Expand Up @@ -27,7 +30,37 @@ export const Config = {
enableManualMigrations: envBool('ENABLE_MANUAL_MIGRATIONS', false),
trustProxy: envBool('TRUST_PROXY', false),
env: envString('NODE_ENV', 'development'),
cardServicePort: envInt('CARD_SERVICE_PORT', 3007)
cardServicePort: envInt('CARD_SERVICE_PORT', 3007),
redisUrl: envString('REDIS_URL', 'redis://127.0.0.1:6379'),
redisTls: parseRedisTlsConfig(
process.env.REDIS_TLS_CA_FILE_PATH,
process.env.REDIS_TLS_KEY_FILE_PATH,
process.env.REDIS_TLS_CERT_FILE_PATH
)
}

function parseRedisTlsConfig(
caFile?: string,
keyFile?: string,
certFile?: string
): ConnectionOptions | undefined {
const options: ConnectionOptions = {}

// self-signed certs.
if (caFile) {
options.ca = fs.readFileSync(caFile)
options.rejectUnauthorized = false
}

if (certFile) {
options.cert = fs.readFileSync(certFile)
}

if (keyFile) {
options.key = fs.readFileSync(keyFile)
}

return Object.keys(options).length > 0 ? options : undefined
}

export type IAppConfig = typeof Config
18 changes: 18 additions & 0 deletions packages/card-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,22 @@ import { Ioc, IocContract } from '@adonisjs/fold'
import createLogger from 'pino'
import { knex } from 'knex'
import { Model } from 'objection'
import Redis from 'ioredis'
import { createPOSStore, PosStoreService } from './pos-store/service'

export function initIocContainer(
config: typeof Config
): IocContract<AppServices> {
const container: IocContract<AppServices> = new Ioc()
container.singleton('config', async () => config)

container.singleton('logger', async (deps: IocContract<AppServices>) => {
const config = await deps.use('config')
const logger = createLogger()
logger.level = config.logLevel
return logger
})

container.singleton('knex', async (deps: IocContract<AppServices>) => {
const logger = await deps.use('logger')
const config = await deps.use('config')
Expand Down Expand Up @@ -58,6 +62,20 @@ export function initIocContainer(
return db
})

container.singleton('redis', async (deps): Promise<Redis> => {
const config = await deps.use('config')
return new Redis(config.redisUrl, {
tls: config.redisTls,
stringNumbers: true
})
})

container.singleton('pos-store', async (deps): Promise<PosStoreService> => {
const redis = await deps.use('redis')
const logger = await deps.use('logger')
return createPOSStore({ redis, logger })
})

return container
}

Expand Down
97 changes: 97 additions & 0 deletions packages/card-service/src/pos-store/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createPOSStore } from './service'
import Redis from 'ioredis'
import { Logger } from 'pino'

describe('POS Store Service', () => {
let redis: Redis
let logger: jest.Mocked<Logger>
let service: ReturnType<typeof createPOSStore>

const requestId = 'req-123'
const POSHost = 'pos.example.com'

beforeAll(async () => {
redis = new Redis(process.env.REDIS_URL!)
await redis.ping()
})

beforeEach(async () => {
await redis.flushall()
logger = {
child: jest.fn().mockReturnThis(),
info: jest.fn(),
error: jest.fn()
} as unknown as jest.Mocked<Logger>
service = createPOSStore({ redis, logger })
})

afterAll(async () => {
await redis.quit()
})

describe('addPOS', () => {
it('should add a POS for a requestId', async () => {
await service.addPOS(requestId, POSHost)
const value = await redis.get(requestId)
expect(value).toBe(POSHost)
expect(logger.info).toHaveBeenCalledWith(
{ requestId, POSHost },
'POS was added for the given requestId'
)
})

it('should clear the POS after 5 minutes (TTL)', async () => {
jest.useFakeTimers()
await service.addPOS(requestId, POSHost)
expect(await redis.get(requestId)).toBe(POSHost)

await expect(service.getPOS(requestId)).resolves.toBe(POSHost)

jest.advanceTimersByTime(300 * 1000)
// Simulate TTL expiry by manually deleting the key (since fake timers don't affect Redis TTL)
await redis.del(requestId)
await expect(service.getPOS(requestId)).rejects.toThrow(
`No POS found for requestId: ${requestId}`
)
jest.useRealTimers()
})
})

describe('getPOS', () => {
it('should return the POSHost for a requestId', async () => {
await redis.set(requestId, POSHost, 'EX', 300)
await expect(service.getPOS(requestId)).resolves.toBe(POSHost)
})
it('should throw if no POS found', async () => {
await redis.del(requestId)
await expect(service.getPOS(requestId)).rejects.toThrow(
`No POS found for requestId: ${requestId}`
)
expect(logger.error).toHaveBeenCalledWith(
{ requestId: 'req-123' },
'No POS found for requestId'
)
})
})

describe('deletePOS', () => {
it('should delete the POS record for a requestId', async () => {
await redis.set(requestId, POSHost, 'EX', 300)
await service.deletePOS(requestId)
const value = await redis.get(requestId)
expect(value).toBe(null)
expect(logger.info).toHaveBeenCalledWith(
`POS record was deleted for requestId: ${requestId}`
)
})
it('should throw if no POS record was deleted', async () => {
await redis.del(requestId)
await expect(service.deletePOS(requestId)).rejects.toThrow(
`No POS record was deleted for requestId: ${requestId}`
)
expect(logger.error).toHaveBeenCalledWith(
`No POS record was deleted for requestId: ${requestId}`
)
})
})
})
67 changes: 67 additions & 0 deletions packages/card-service/src/pos-store/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Redis from 'ioredis'
import { Logger } from 'pino'

const POS_TTL = 300 // 5 minutes

export type StoreDependencies = {
logger: Logger
redis: Redis
}

export type PosStoreService = {
addPOS: (requestId: string, POSHost: string) => Promise<void>
getPOS: (requestId: string) => Promise<string>
deletePOS: (requestId: string) => Promise<void>
}

export function createPOSStore(deps_: StoreDependencies): PosStoreService {
const logger = deps_.logger.child({
service: 'pos-store'
})
const deps = {
...deps_,
logger
}

return {
getPOS: (requestId: string) => getPOS(deps, requestId),
addPOS: (requestId: string, POSHost: string) =>
addPOS(deps, requestId, POSHost),
deletePOS: (requestId: string) => deletePOS(deps, requestId)
}
}

const getPOS = async (
deps: StoreDependencies,
requestId: string
): Promise<string> => {
const POSHost = await deps.redis.get(requestId)
if (!POSHost) {
deps.logger.error({ requestId }, `No POS found for requestId`)
throw new Error(`No POS found for requestId: ${requestId}`)
}

return POSHost
}

const addPOS = async (
deps: StoreDependencies,
requestId: string,
POSHost: string
) => {
await deps.redis.set(requestId, POSHost, 'EX', POS_TTL)
deps.logger.info(
{ requestId, POSHost },
'POS was added for the given requestId'
)
}

const deletePOS = async (deps: StoreDependencies, requestId: string) => {
const deletedRecords = await deps.redis.del([requestId])
if (deletedRecords == 0) {
deps.logger.error(`No POS record was deleted for requestId: ${requestId}`)
throw new Error(`No POS record was deleted for requestId: ${requestId}`)
}

deps.logger.info(`POS record was deleted for requestId: ${requestId}`)
}
Loading
Loading