Skip to content

Commit c40f4e4

Browse files
zeppelin44antoniu98
authored andcommitted
feat: Integrate Redis client in Card Service for (requestId, posServiceHost) mapping (#3524)
* Integrate Redis client in Card Service for (requestId, posServiceHost) mapping * remove unused dep * prettier fix * Separate logging params from the message itself * ttl * prettier fix * Rewrite redis service tests to use testcontainers instead of mocks --------- Co-authored-by: Antoniu Neacsu <[email protected]>
1 parent 5035a9b commit c40f4e4

File tree

6 files changed

+225
-2
lines changed

6 files changed

+225
-2
lines changed

packages/card-service/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"dependencies": {
1818
"@adonisjs/fold": "^8.2.0",
1919
"@koa/cors": "^5.0.0",
20+
"ioredis": "^5.3.2",
2021
"@koa/router": "^12.0.2",
2122
"koa-bodyparser": "^4.4.1",
2223
"koa": "^2.15.4",

packages/card-service/src/config/app.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { ConnectionOptions } from 'tls'
2+
import * as fs from 'fs'
3+
14
function envString(name: string, defaultValue?: string): string {
25
const envValue = process.env[name]
36

@@ -27,7 +30,37 @@ export const Config = {
2730
enableManualMigrations: envBool('ENABLE_MANUAL_MIGRATIONS', false),
2831
trustProxy: envBool('TRUST_PROXY', false),
2932
env: envString('NODE_ENV', 'development'),
30-
cardServicePort: envInt('CARD_SERVICE_PORT', 3007)
33+
cardServicePort: envInt('CARD_SERVICE_PORT', 3007),
34+
redisUrl: envString('REDIS_URL', 'redis://127.0.0.1:6379'),
35+
redisTls: parseRedisTlsConfig(
36+
process.env.REDIS_TLS_CA_FILE_PATH,
37+
process.env.REDIS_TLS_KEY_FILE_PATH,
38+
process.env.REDIS_TLS_CERT_FILE_PATH
39+
)
40+
}
41+
42+
function parseRedisTlsConfig(
43+
caFile?: string,
44+
keyFile?: string,
45+
certFile?: string
46+
): ConnectionOptions | undefined {
47+
const options: ConnectionOptions = {}
48+
49+
// self-signed certs.
50+
if (caFile) {
51+
options.ca = fs.readFileSync(caFile)
52+
options.rejectUnauthorized = false
53+
}
54+
55+
if (certFile) {
56+
options.cert = fs.readFileSync(certFile)
57+
}
58+
59+
if (keyFile) {
60+
options.key = fs.readFileSync(keyFile)
61+
}
62+
63+
return Object.keys(options).length > 0 ? options : undefined
3164
}
3265

3366
export type IAppConfig = typeof Config

packages/card-service/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,22 @@ import { Ioc, IocContract } from '@adonisjs/fold'
44
import createLogger from 'pino'
55
import { knex } from 'knex'
66
import { Model } from 'objection'
7+
import Redis from 'ioredis'
8+
import { createPOSStore, PosStoreService } from './pos-store/service'
79

810
export function initIocContainer(
911
config: typeof Config
1012
): IocContract<AppServices> {
1113
const container: IocContract<AppServices> = new Ioc()
1214
container.singleton('config', async () => config)
15+
1316
container.singleton('logger', async (deps: IocContract<AppServices>) => {
1417
const config = await deps.use('config')
1518
const logger = createLogger()
1619
logger.level = config.logLevel
1720
return logger
1821
})
22+
1923
container.singleton('knex', async (deps: IocContract<AppServices>) => {
2024
const logger = await deps.use('logger')
2125
const config = await deps.use('config')
@@ -58,6 +62,20 @@ export function initIocContainer(
5862
return db
5963
})
6064

65+
container.singleton('redis', async (deps): Promise<Redis> => {
66+
const config = await deps.use('config')
67+
return new Redis(config.redisUrl, {
68+
tls: config.redisTls,
69+
stringNumbers: true
70+
})
71+
})
72+
73+
container.singleton('pos-store', async (deps): Promise<PosStoreService> => {
74+
const redis = await deps.use('redis')
75+
const logger = await deps.use('logger')
76+
return createPOSStore({ redis, logger })
77+
})
78+
6179
return container
6280
}
6381

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { createPOSStore } from './service'
2+
import Redis from 'ioredis'
3+
import { Logger } from 'pino'
4+
5+
describe('POS Store Service', () => {
6+
let redis: Redis
7+
let logger: jest.Mocked<Logger>
8+
let service: ReturnType<typeof createPOSStore>
9+
10+
const requestId = 'req-123'
11+
const POSHost = 'pos.example.com'
12+
13+
beforeAll(async () => {
14+
redis = new Redis(process.env.REDIS_URL!)
15+
await redis.ping()
16+
})
17+
18+
beforeEach(async () => {
19+
await redis.flushall()
20+
logger = {
21+
child: jest.fn().mockReturnThis(),
22+
info: jest.fn(),
23+
error: jest.fn()
24+
} as unknown as jest.Mocked<Logger>
25+
service = createPOSStore({ redis, logger })
26+
})
27+
28+
afterAll(async () => {
29+
await redis.quit()
30+
})
31+
32+
describe('addPOS', () => {
33+
it('should add a POS for a requestId', async () => {
34+
await service.addPOS(requestId, POSHost)
35+
const value = await redis.get(requestId)
36+
expect(value).toBe(POSHost)
37+
expect(logger.info).toHaveBeenCalledWith(
38+
{ requestId, POSHost },
39+
'POS was added for the given requestId'
40+
)
41+
})
42+
43+
it('should clear the POS after 5 minutes (TTL)', async () => {
44+
jest.useFakeTimers()
45+
await service.addPOS(requestId, POSHost)
46+
expect(await redis.get(requestId)).toBe(POSHost)
47+
48+
await expect(service.getPOS(requestId)).resolves.toBe(POSHost)
49+
50+
jest.advanceTimersByTime(300 * 1000)
51+
// Simulate TTL expiry by manually deleting the key (since fake timers don't affect Redis TTL)
52+
await redis.del(requestId)
53+
await expect(service.getPOS(requestId)).rejects.toThrow(
54+
`No POS found for requestId: ${requestId}`
55+
)
56+
jest.useRealTimers()
57+
})
58+
})
59+
60+
describe('getPOS', () => {
61+
it('should return the POSHost for a requestId', async () => {
62+
await redis.set(requestId, POSHost, 'EX', 300)
63+
await expect(service.getPOS(requestId)).resolves.toBe(POSHost)
64+
})
65+
it('should throw if no POS found', async () => {
66+
await redis.del(requestId)
67+
await expect(service.getPOS(requestId)).rejects.toThrow(
68+
`No POS found for requestId: ${requestId}`
69+
)
70+
expect(logger.error).toHaveBeenCalledWith(
71+
{ requestId: 'req-123' },
72+
'No POS found for requestId'
73+
)
74+
})
75+
})
76+
77+
describe('deletePOS', () => {
78+
it('should delete the POS record for a requestId', async () => {
79+
await redis.set(requestId, POSHost, 'EX', 300)
80+
await service.deletePOS(requestId)
81+
const value = await redis.get(requestId)
82+
expect(value).toBe(null)
83+
expect(logger.info).toHaveBeenCalledWith(
84+
`POS record was deleted for requestId: ${requestId}`
85+
)
86+
})
87+
it('should throw if no POS record was deleted', async () => {
88+
await redis.del(requestId)
89+
await expect(service.deletePOS(requestId)).rejects.toThrow(
90+
`No POS record was deleted for requestId: ${requestId}`
91+
)
92+
expect(logger.error).toHaveBeenCalledWith(
93+
`No POS record was deleted for requestId: ${requestId}`
94+
)
95+
})
96+
})
97+
})
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import Redis from 'ioredis'
2+
import { Logger } from 'pino'
3+
4+
const POS_TTL = 300 // 5 minutes
5+
6+
export type StoreDependencies = {
7+
logger: Logger
8+
redis: Redis
9+
}
10+
11+
export type PosStoreService = {
12+
addPOS: (requestId: string, POSHost: string) => Promise<void>
13+
getPOS: (requestId: string) => Promise<string>
14+
deletePOS: (requestId: string) => Promise<void>
15+
}
16+
17+
export function createPOSStore(deps_: StoreDependencies): PosStoreService {
18+
const logger = deps_.logger.child({
19+
service: 'pos-store'
20+
})
21+
const deps = {
22+
...deps_,
23+
logger
24+
}
25+
26+
return {
27+
getPOS: (requestId: string) => getPOS(deps, requestId),
28+
addPOS: (requestId: string, POSHost: string) =>
29+
addPOS(deps, requestId, POSHost),
30+
deletePOS: (requestId: string) => deletePOS(deps, requestId)
31+
}
32+
}
33+
34+
const getPOS = async (
35+
deps: StoreDependencies,
36+
requestId: string
37+
): Promise<string> => {
38+
const POSHost = await deps.redis.get(requestId)
39+
if (!POSHost) {
40+
deps.logger.error({ requestId }, `No POS found for requestId`)
41+
throw new Error(`No POS found for requestId: ${requestId}`)
42+
}
43+
44+
return POSHost
45+
}
46+
47+
const addPOS = async (
48+
deps: StoreDependencies,
49+
requestId: string,
50+
POSHost: string
51+
) => {
52+
await deps.redis.set(requestId, POSHost, 'EX', POS_TTL)
53+
deps.logger.info(
54+
{ requestId, POSHost },
55+
'POS was added for the given requestId'
56+
)
57+
}
58+
59+
const deletePOS = async (deps: StoreDependencies, requestId: string) => {
60+
const deletedRecords = await deps.redis.del([requestId])
61+
if (deletedRecords == 0) {
62+
deps.logger.error(`No POS record was deleted for requestId: ${requestId}`)
63+
throw new Error(`No POS record was deleted for requestId: ${requestId}`)
64+
}
65+
66+
deps.logger.info(`POS record was deleted for requestId: ${requestId}`)
67+
}

pnpm-lock.yaml

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)