core-services-sdk 1.3.57 → 1.3.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm test:*)",
5
+ "Bash(docker rm:*)",
6
+ "Bash(docker run:*)",
7
+ "Bash(mongosh:*)",
8
+ "Bash(for:*)",
9
+ "Bash(do if mongosh --port 27099 --eval \"db.runCommand({ ping: 1 })\")",
10
+ "Bash(then echo \"Connected after $i attempts\")",
11
+ "Bash(break)",
12
+ "Bash(fi)",
13
+ "Bash(echo:*)",
14
+ "Bash(done)",
15
+ "Bash(docker logs:*)",
16
+ "Bash(docker system:*)",
17
+ "Bash(docker volume prune:*)",
18
+ "Bash(docker image prune:*)",
19
+ "Bash(docker builder prune:*)"
20
+ ]
21
+ }
22
+ }
package/.prettierrc.json CHANGED
@@ -4,7 +4,6 @@
4
4
  "endOfLine": "lf",
5
5
  "requirePragma": false,
6
6
  "insertPragma": false,
7
- "jsxBracketSameLine": false,
8
7
  "jsxSingleQuote": true,
9
8
  "printWidth": 80,
10
9
  "quoteProps": "consistent",
package/package.json CHANGED
@@ -1,15 +1,17 @@
1
1
  {
2
2
  "name": "core-services-sdk",
3
- "version": "1.3.57",
3
+ "version": "1.3.59",
4
4
  "main": "src/index.js",
5
5
  "type": "module",
6
6
  "types": "types/index.d.ts",
7
7
  "scripts": {
8
8
  "lint": "eslint .",
9
9
  "lint:fix": "eslint . --fix",
10
- "test": "vitest run --coverage",
11
10
  "format": "prettier --write .",
11
+ "test": "vitest run --coverage",
12
+ "check-format": "prettier --check .",
12
13
  "bump": "node ./scripts/bump-version.js",
14
+ "check": "npm run lint && npm run check-format && npm run test",
13
15
  "build:types": "tsc --project tsconfig.json && prettier --write ."
14
16
  },
15
17
  "repository": {
@@ -3,3 +3,4 @@ export * from './paginate.js'
3
3
  export * from './dsl-to-mongo.js'
4
4
  export * from './initialize-mongodb.js'
5
5
  export * from './validate-mongo-uri.js'
6
+ export { paginate as mongoPaginate } from './paginate.js'
@@ -1,3 +1,4 @@
1
+ export * from './paginate.js'
1
2
  export * from './connect-to-pg.js'
2
3
  export * from './validate-schema.js'
3
4
  export * from './start-stop-postgres-docker.js'
@@ -0,0 +1,61 @@
1
+ import { toSnakeCase } from '../core/case-mapper.js'
2
+ import { normalizeNumberOrDefault } from '../core/normalize-premitives-types-or-default.js'
3
+
4
+ /**
5
+ * Generic pagination utility.
6
+ *
7
+ * @async
8
+ * @param {Object} params
9
+ * @param {Object} params.db
10
+ * @param {string} params.tableName
11
+ * @param {number} [params.page=1]
12
+ * @param {number} [params.limit=10]
13
+ * @param {Object} [params.filter={}]
14
+ * @param {Object} [params.orderBy]
15
+ * @param {string} params.orderBy.column
16
+ * @param {'asc'|'desc'} [params.orderBy.direction='asc']
17
+ * @returns {Promise<{
18
+ * list: any[],
19
+ * totalCount: number,
20
+ * totalPages: number,
21
+ * currentPage: number,
22
+ * hasNext: boolean,
23
+ * hasPrevious: boolean
24
+ * }>}
25
+ */
26
+
27
+ export const sqlPaginate = async ({
28
+ db,
29
+ mapRow,
30
+ orderBy,
31
+ page = 1,
32
+ limit = 10,
33
+ tableName,
34
+ filter = {},
35
+ } = {}) => {
36
+ const offset = (page - 1) * limit
37
+
38
+ const query = orderBy?.column
39
+ ? db(tableName)
40
+ .select('*')
41
+ .where(toSnakeCase(filter))
42
+ .orderBy(orderBy.column, orderBy.direction || 'asc')
43
+ : db(tableName).select('*').where(toSnakeCase(filter))
44
+
45
+ const [rows, countResult] = await Promise.all([
46
+ query.limit(limit).offset(offset),
47
+ db(tableName).where(toSnakeCase(filter)).count('* as count').first(),
48
+ ])
49
+
50
+ const totalCount = normalizeNumberOrDefault(countResult?.count || 0)
51
+ const totalPages = Math.ceil(totalCount / limit)
52
+
53
+ return {
54
+ list: mapRow ? rows.map(mapRow) : rows,
55
+ totalCount,
56
+ totalPages,
57
+ currentPage: page,
58
+ hasPrevious: page > 1,
59
+ hasNext: page < totalPages,
60
+ }
61
+ }
@@ -124,7 +124,7 @@ export function isPostgresReady(containerName, user, db) {
124
124
  export function waitForPostgres(containerName, user, db) {
125
125
  console.log(`[PgTest] Waiting for PostgreSQL to be ready...`)
126
126
 
127
- const maxRetries = 20
127
+ const maxRetries = 60
128
128
  let retries = 0
129
129
  let ready = false
130
130
 
@@ -1 +1,2 @@
1
1
  export * from './rabbit-mq.js'
2
+ export * from './start-stop-rabbitmq.js'
@@ -89,17 +89,97 @@ const parseMessage = (msgInfo) => {
89
89
  }
90
90
 
91
91
  /**
92
- * Subscribes to a queue to receive messages.
92
+ * Creates an unsubscribe function for a RabbitMQ consumer.
93
+ *
94
+ * This is a higher-order function that captures the RabbitMQ channel,
95
+ * consumerTag, and logger in a closure, and returns an async function
96
+ * that cancels the consumer when invoked.
97
+ *
98
+ * The returned function is idempotent and safe to call multiple times.
99
+ * If the channel or consumer is already closed, the call is silently ignored.
100
+ *
101
+ * Usage:
102
+ * const unsubscribe = createUnsubscribe({ channel, consumerTag, logger })
103
+ * await unsubscribe()
104
+ *
105
+ * @param {Object} params
106
+ * @param {import('amqplib').Channel} params.channel
107
+ * The RabbitMQ channel on which the consumer was created.
108
+ *
109
+ * @param {string} params.consumerTag
110
+ * The consumer tag returned by `channel.consume`, uniquely identifying
111
+ * the active consumer within the channel.
112
+ *
113
+ * @param {import('pino').Logger} params.logger
114
+ * Base logger instance used to create a child logger for unsubscribe logs.
115
+ *
116
+ * @returns {() => Promise<void>}
117
+ * An async function that cancels this specific RabbitMQ consumer.
118
+ *
119
+ * @throws {Error}
120
+ * Throws for unexpected errors during consumer cancellation.
121
+ * Errors caused by an already closed channel are silently ignored.
122
+ */
123
+ const unsubscribe =
124
+ ({ channel, consumerTag, logger }) =>
125
+ async () => {
126
+ if (!channel || channel.closed) {
127
+ return
128
+ }
129
+
130
+ const t0 = Date.now()
131
+ const child = logger.child({ op: 'unsubscribe', consumerTag })
132
+
133
+ try {
134
+ child.debug('start')
135
+ await channel.cancel(consumerTag)
136
+ child.info({ event: 'ok', ms: Date.now() - t0 })
137
+ } catch (err) {
138
+ if (err?.message?.includes('Channel closed')) {
139
+ child.debug({
140
+ event: 'already-closed',
141
+ reason: 'channel-already-closed',
142
+ })
143
+ return
144
+ }
145
+
146
+ child.error(err, {
147
+ event: 'error',
148
+ ms: Date.now() - t0,
149
+ })
150
+ throw err
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Subscribes to a RabbitMQ queue and returns an unsubscribe function
156
+ * that cancels this specific consumer.
157
+ *
158
+ * Each call creates an independent consumer with its own consumerTag.
159
+ * Calling the returned unsubscribe function affects only this consumer
160
+ * and does not impact other consumers or services.
93
161
  *
94
162
  * @param {Object} options
95
- * @param {import('amqplib').Channel} options.channel - RabbitMQ channel
96
- * @param {string} options.queue - Queue name to subscribe to
97
- * @param {(data: any, correlationId?: string) => Promise<void>} options.onReceive - Async handler
98
- * @param {import('pino').Logger} options.log - Base logger
99
- * @param {boolean} [options.nackOnError=false] - Whether to nack the message on error (default: false)
100
- * @param {number} [options.prefetch=1] - Max unacked messages per consumer (default: 1)
163
+ * @param {import('amqplib').Channel} options.channel
164
+ * RabbitMQ channel used to create the consumer.
165
+ *
166
+ * @param {string} options.queue
167
+ * Queue name to subscribe to.
101
168
  *
102
- * @returns {Promise<string>} Returns the consumer tag for later cancellation
169
+ * @param {(data: any, correlationId?: string) => Promise<void>} options.onReceive
170
+ * Async handler invoked for each received message payload.
171
+ *
172
+ * @param {import('pino').Logger} options.log
173
+ * Base logger instance.
174
+ *
175
+ * @param {boolean} [options.nackOnError=false]
176
+ * Whether to nack the message on handler error instead of ack.
177
+ *
178
+ * @param {number} [options.prefetch=1]
179
+ * Maximum number of unacknowledged messages for this consumer.
180
+ *
181
+ * @returns {Promise<() => Promise<void>>}
182
+ * Resolves to an unsubscribe function that cancels this specific consumer.
103
183
  */
104
184
  export const subscribeToQueue = async ({
105
185
  log,
@@ -113,14 +193,17 @@ export const subscribeToQueue = async ({
113
193
 
114
194
  try {
115
195
  await channel.assertQueue(queue, { durable: true })
116
- !!prefetch && (await channel.prefetch(prefetch))
196
+
197
+ if (prefetch) {
198
+ await channel.prefetch(prefetch)
199
+ }
117
200
 
118
201
  const { consumerTag } = await channel.consume(queue, async (msgInfo) => {
119
202
  if (!msgInfo) {
120
203
  return
121
204
  }
122
- const t0 = Date.now()
123
205
 
206
+ const t0 = Date.now()
124
207
  const { msgId, data, correlationId } = parseMessage(msgInfo)
125
208
  const child = logger.child({ msgId, correlationId })
126
209
 
@@ -131,28 +214,26 @@ export const subscribeToQueue = async ({
131
214
  await onReceive(data, correlationId)
132
215
  channel.ack(msgInfo)
133
216
 
134
- child.info({
135
- event: 'ok',
136
- ms: Date.now() - t0,
137
- })
138
- return
217
+ child.info({ event: 'ok', ms: Date.now() - t0 })
139
218
  } catch (err) {
140
- nackOnError ? channel.nack(msgInfo) : channel.ack(msgInfo)
219
+ if (nackOnError) {
220
+ channel.nack(msgInfo)
221
+ } else {
222
+ channel.ack(msgInfo)
223
+ }
141
224
 
142
225
  child.error(err, {
143
226
  event: 'error',
144
227
  ms: Date.now() - t0,
145
228
  })
146
- return
147
229
  }
148
230
  })
149
231
 
150
232
  logger.debug({ consumerTag }, 'consumer-started')
151
- return consumerTag
233
+
234
+ return unsubscribe({ channel, consumerTag, logger })
152
235
  } catch (err) {
153
- logger.error(err, {
154
- event: 'error',
155
- })
236
+ logger.error(err, { event: 'error' })
156
237
  throw err
157
238
  }
158
239
  }
@@ -0,0 +1,152 @@
1
+ import { execSync } from 'node:child_process'
2
+
3
+ /**
4
+ * Starts a RabbitMQ Docker container for testing purposes.
5
+ *
6
+ * If a container with the same name already exists, it will be removed first.
7
+ * The container is started with the RabbitMQ Management plugin enabled and
8
+ * waits until the health check reports the container as healthy.
9
+ *
10
+ * @param {Object} options
11
+ * @param {string} options.containerName
12
+ * Docker container name.
13
+ *
14
+ * @param {number} options.amqpPort
15
+ * Host port mapped to RabbitMQ AMQP port (5672).
16
+ *
17
+ * @param {number} options.uiPort
18
+ * Host port mapped to RabbitMQ Management UI port (15672).
19
+ *
20
+ * @param {string} options.user
21
+ * Default RabbitMQ username.
22
+ *
23
+ * @param {string} options.pass
24
+ * Default RabbitMQ password.
25
+ *
26
+ * @returns {void}
27
+ *
28
+ * @throws {Error}
29
+ * Throws if the RabbitMQ container fails to become healthy within the timeout.
30
+ */
31
+ export function startRabbit({ containerName, ...rest }) {
32
+ console.log(`[RabbitTest] Starting RabbitMQ...`)
33
+
34
+ try {
35
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' })
36
+ } catch {}
37
+
38
+ execSync(
39
+ `docker run -d \
40
+ --name ${containerName} \
41
+ -e RABBITMQ_DEFAULT_USER=${rest.user} \
42
+ -e RABBITMQ_DEFAULT_PASS=${rest.pass} \
43
+ -p ${rest.amqpPort}:5672 \
44
+ -p ${rest.uiPort}:15672 \
45
+ --health-cmd="rabbitmq-diagnostics -q ping" \
46
+ --health-interval=5s \
47
+ --health-timeout=5s \
48
+ --health-retries=10 \
49
+ rabbitmq:3-management`,
50
+ { stdio: 'inherit' },
51
+ )
52
+
53
+ waitForRabbitHealthy(containerName)
54
+ }
55
+
56
+ /**
57
+ * Stops and removes a RabbitMQ Docker container.
58
+ *
59
+ * This function is safe to call even if the container does not exist.
60
+ *
61
+ * @param {string} [containerName='rabbit-test']
62
+ * Docker container name.
63
+ *
64
+ * @returns {void}
65
+ */
66
+ export function stopRabbit(containerName = 'rabbit-test') {
67
+ console.log(`[RabbitTest] Stopping RabbitMQ...`)
68
+ try {
69
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' })
70
+ } catch (error) {
71
+ console.error(`[RabbitTest] Failed to stop RabbitMQ: ${error}`)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Waits until the RabbitMQ Docker container reports a healthy status.
77
+ *
78
+ * Polls the container health status using `docker inspect` and retries
79
+ * for a fixed amount of time before failing.
80
+ *
81
+ * @param {string} containerName
82
+ * Docker container name.
83
+ *
84
+ * @returns {void}
85
+ *
86
+ * @throws {Error}
87
+ * Throws if the container does not become healthy within the timeout.
88
+ */
89
+ function waitForRabbitHealthy(containerName) {
90
+ console.log(`[RabbitTest] Waiting for RabbitMQ to be healthy...`)
91
+
92
+ const maxRetries = 60
93
+ let retries = 0
94
+
95
+ while (retries < maxRetries) {
96
+ try {
97
+ const output = execSync(
98
+ `docker inspect --format='{{.State.Health.Status}}' ${containerName}`,
99
+ { encoding: 'utf8' },
100
+ ).trim()
101
+
102
+ if (output === 'healthy') {
103
+ console.log(`[RabbitTest] RabbitMQ is ready.`)
104
+ return
105
+ }
106
+
107
+ if (retries % 10 === 0 && retries > 0) {
108
+ console.log(
109
+ `[RabbitTest] Still waiting... Status: ${output} (${retries}/${maxRetries})`,
110
+ )
111
+ }
112
+ } catch {
113
+ if (retries % 10 === 0 && retries > 0) {
114
+ console.log(
115
+ `[RabbitTest] Container not ready yet (${retries}/${maxRetries})`,
116
+ )
117
+ }
118
+ }
119
+
120
+ retries++
121
+ execSync(`sleep 1`)
122
+ }
123
+
124
+ console.log('[RabbitTest] Failed to start. Container logs:')
125
+ try {
126
+ execSync(`docker logs --tail 30 ${containerName}`, { stdio: 'inherit' })
127
+ } catch {}
128
+
129
+ throw new Error(
130
+ `[RabbitTest] RabbitMQ failed to become healthy within ${maxRetries} seconds`,
131
+ )
132
+ }
133
+
134
+ /**
135
+ * Builds a RabbitMQ AMQP connection URI for local testing.
136
+ *
137
+ * @param {Object} options
138
+ * @param {string} options.user
139
+ * RabbitMQ username.
140
+ *
141
+ * @param {string} options.pass
142
+ * RabbitMQ password.
143
+ *
144
+ * @param {number} options.port
145
+ * Host port mapped to RabbitMQ AMQP port.
146
+ *
147
+ * @returns {string}
148
+ * RabbitMQ AMQP connection URI.
149
+ */
150
+ export function buildRabbitUri({ user, pass, port }) {
151
+ return `amqp://${user}:${pass}@localhost:${port}`
152
+ }
@@ -0,0 +1,165 @@
1
+ import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'
2
+ import knex from 'knex'
3
+
4
+ import {
5
+ stopPostgres,
6
+ startPostgres,
7
+ buildPostgresUri,
8
+ } from '../../src/postgresql/start-stop-postgres-docker.js'
9
+
10
+ import { sqlPaginate } from '../../src/postgresql/paginate.js'
11
+
12
+ const PG_OPTIONS = {
13
+ port: 5443,
14
+ containerName: 'postgres-paginate-test',
15
+ user: 'testuser',
16
+ pass: 'testpass',
17
+ db: 'testdb',
18
+ }
19
+
20
+ const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
21
+
22
+ let db
23
+
24
+ beforeAll(async () => {
25
+ startPostgres(PG_OPTIONS)
26
+
27
+ db = knex({
28
+ client: 'pg',
29
+ connection: DATABASE_URI,
30
+ })
31
+
32
+ await db.schema.createTable('tenants', (table) => {
33
+ table.uuid('id').primary()
34
+ table.string('name').notNullable()
35
+ table.string('type').notNullable()
36
+ table.timestamp('created_at').notNullable()
37
+ })
38
+ })
39
+
40
+ afterAll(async () => {
41
+ if (db) {
42
+ await db.destroy()
43
+ }
44
+ stopPostgres(PG_OPTIONS.containerName)
45
+ })
46
+
47
+ beforeEach(async () => {
48
+ await db('tenants').truncate()
49
+
50
+ await db('tenants').insert([
51
+ {
52
+ id: '00000000-0000-0000-0000-000000000001',
53
+ name: 'Tenant A',
54
+ type: 'business',
55
+ created_at: new Date('2024-01-01'),
56
+ },
57
+ {
58
+ id: '00000000-0000-0000-0000-000000000002',
59
+ name: 'Tenant B',
60
+ type: 'business',
61
+ created_at: new Date('2024-01-02'),
62
+ },
63
+ {
64
+ id: '00000000-0000-0000-0000-000000000003',
65
+ name: 'Tenant C',
66
+ type: 'cpa',
67
+ created_at: new Date('2024-01-03'),
68
+ },
69
+ ])
70
+ })
71
+
72
+ describe('paginate integration', () => {
73
+ it('returns first page without ordering guarantees', async () => {
74
+ const result = await sqlPaginate({
75
+ db,
76
+ tableName: 'tenants',
77
+ page: 1,
78
+ limit: 2,
79
+ })
80
+
81
+ expect(result.totalCount).toBe(3)
82
+ expect(result.totalPages).toBe(2)
83
+ expect(result.currentPage).toBe(1)
84
+ expect(result.hasPrevious).toBe(false)
85
+ expect(result.hasNext).toBe(true)
86
+
87
+ expect(result.list).toHaveLength(2)
88
+ })
89
+
90
+ it('returns second page without ordering guarantees', async () => {
91
+ const result = await sqlPaginate({
92
+ db,
93
+ tableName: 'tenants',
94
+ page: 2,
95
+ limit: 2,
96
+ })
97
+
98
+ expect(result.totalCount).toBe(3)
99
+ expect(result.totalPages).toBe(2)
100
+ expect(result.currentPage).toBe(2)
101
+ expect(result.hasPrevious).toBe(true)
102
+ expect(result.hasNext).toBe(false)
103
+
104
+ expect(result.list).toHaveLength(1)
105
+ })
106
+
107
+ it('applies filters correctly without ordering guarantees', async () => {
108
+ const result = await sqlPaginate({
109
+ db,
110
+ tableName: 'tenants',
111
+ filter: { type: 'business' },
112
+ limit: 10,
113
+ })
114
+
115
+ expect(result.totalCount).toBe(2)
116
+
117
+ const names = result.list.map((t) => t.name).sort()
118
+ expect(names).toEqual(['Tenant A', 'Tenant B'])
119
+ })
120
+
121
+ it('supports custom ordering', async () => {
122
+ const result = await sqlPaginate({
123
+ db,
124
+ tableName: 'tenants',
125
+ orderBy: {
126
+ column: 'created_at',
127
+ direction: 'asc',
128
+ },
129
+ })
130
+
131
+ expect(result.list.map((t) => t.name)).toEqual([
132
+ 'Tenant A',
133
+ 'Tenant B',
134
+ 'Tenant C',
135
+ ])
136
+ })
137
+
138
+ it('supports row mapping', async () => {
139
+ const result = await sqlPaginate({
140
+ db,
141
+ tableName: 'tenants',
142
+ mapRow: (row) => ({
143
+ ...row,
144
+ mapped: true,
145
+ }),
146
+ })
147
+
148
+ expect(result.list.length).toBeGreaterThan(0)
149
+ expect(result.list[0].mapped).toBe(true)
150
+ })
151
+
152
+ it('returns empty list when no records match filter', async () => {
153
+ const result = await sqlPaginate({
154
+ db,
155
+ tableName: 'tenants',
156
+ filter: { type: 'non-existing' },
157
+ })
158
+
159
+ expect(result.totalCount).toBe(0)
160
+ expect(result.totalPages).toBe(0)
161
+ expect(result.list).toEqual([])
162
+ expect(result.hasNext).toBe(false)
163
+ expect(result.hasPrevious).toBe(false)
164
+ })
165
+ })
@@ -1,69 +1,139 @@
1
- import { describe, it, expect, beforeAll } from 'vitest'
1
+ import pino from 'pino'
2
+ import { describe, it, beforeAll, afterAll, expect } from 'vitest'
2
3
 
3
- import { initializeQueue, rabbitUriFromEnv } from '../../src/rabbit-mq/index.js'
4
+ import {
5
+ stopRabbit,
6
+ startRabbit,
7
+ buildRabbitUri,
8
+ } from '../../src/rabbit-mq/start-stop-rabbitmq.js'
4
9
 
5
- const sleep = (ms) => new Promise((res) => setTimeout(res, ms))
10
+ import { initializeQueue } from '../../src/rabbit-mq/index.js'
6
11
 
7
- const testLog = {
8
- info: () => {},
9
- error: console.error,
10
- debug: console.debug,
11
- child() {
12
- return testLog
13
- },
14
- }
12
+ const RABBIT_CONTAINER = 'rabbit-test'
13
+ const AMQP_PORT = 5679
14
+ const UI_PORT = 15679
15
+ const USER = 'test'
16
+ const PASS = 'test'
17
+ const QUEUE = 'integration-test-queue'
15
18
 
16
- describe('RabbitMQ SDK', () => {
17
- const testQueue = 'testQueue'
18
- const host = 'amqp://botq:botq@0.0.0.0:5672'
19
- const testMessage = { text: 'Hello Rabbit' }
19
+ const log = pino({
20
+ level: 'silent',
21
+ })
22
+ // @ts-ignore
23
+ async function waitForRabbitConnection({ uri, log, timeoutMs = 10000 }) {
24
+ const start = Date.now()
20
25
 
21
- let sdk
22
- let received = null
26
+ while (Date.now() - start < timeoutMs) {
27
+ try {
28
+ const rabbit = await initializeQueue({ host: uri, log })
29
+ return rabbit
30
+ } catch {
31
+ await sleep(300)
32
+ }
33
+ }
34
+
35
+ throw new Error('RabbitMQ AMQP endpoint did not become ready in time')
36
+ }
37
+
38
+ describe('RabbitMQ integration', () => {
39
+ // @ts-ignore
40
+ let rabbit
41
+ // @ts-ignore
42
+ let unsubscribe
43
+ // @ts-ignore
44
+ let receivedMessages
23
45
 
24
46
  beforeAll(async () => {
25
- // @ts-ignore
26
- sdk = await initializeQueue({ host, log: testLog })
47
+ startRabbit({
48
+ containerName: RABBIT_CONTAINER,
49
+ amqpPort: AMQP_PORT,
50
+ uiPort: UI_PORT,
51
+ user: USER,
52
+ pass: PASS,
53
+ })
54
+
55
+ const uri = buildRabbitUri({
56
+ user: USER,
57
+ pass: PASS,
58
+ port: AMQP_PORT,
59
+ })
60
+
61
+ rabbit = await waitForRabbitConnection({
62
+ uri,
63
+ log,
64
+ })
65
+ }, 60_000)
27
66
 
28
- await sdk.subscribe({
29
- queue: testQueue,
67
+ afterAll(async () => {
68
+ try {
69
+ // @ts-ignore
70
+ if (unsubscribe) {
71
+ await unsubscribe()
72
+ }
73
+ // @ts-ignore
74
+ if (rabbit) {
75
+ await rabbit.close()
76
+ }
77
+ } finally {
78
+ stopRabbit(RABBIT_CONTAINER)
79
+ }
80
+ })
81
+
82
+ it('should publish, consume, unsubscribe, and stop consuming', async () => {
83
+ receivedMessages = []
84
+
85
+ // @ts-ignore
86
+ unsubscribe = await rabbit.subscribe({
87
+ queue: QUEUE,
88
+ // @ts-ignore
30
89
  onReceive: async (data) => {
31
- received = data
90
+ receivedMessages.push(data)
32
91
  },
33
92
  })
34
- })
35
93
 
36
- it('should publish and receive message from queue', async () => {
37
- await sdk.publish(testQueue, testMessage)
38
- await sleep(300) // let the consumer process the message
39
- expect(received).toEqual(testMessage)
40
- })
41
- })
94
+ // @ts-ignore
95
+ await rabbit.publish(QUEUE, { step: 1 })
96
+ await waitFor(() => receivedMessages.length === 1)
42
97
 
43
- describe('rabbitUriFromEnv', () => {
44
- it('should build valid amqp URI from env', () => {
45
- const env = {
46
- RABBIT_PORT: 5672,
47
- RABBIT_HOST: '0.0.0.0',
48
- RABBIT_USERNAME: 'botq',
49
- RABBIT_PASSWORD: 'botq',
50
- _RABBIT_HOST: '0.0.0.0',
51
- RABBIT_PROTOCOL: 'amqp',
52
- }
98
+ // @ts-ignore
99
+ expect(receivedMessages).toEqual([{ step: 1 }])
53
100
 
54
- const uri = rabbitUriFromEnv(env)
55
- expect(uri).toBe('amqp://botq:botq@0.0.0.0:5672')
56
- })
101
+ await unsubscribe()
57
102
 
58
- it('should fallback to amqp when protocol is missing', () => {
59
- const env = {
60
- RABBIT_HOST: '0.0.0.0',
61
- RABBIT_PORT: 5672,
62
- RABBIT_USERNAME: 'botq',
63
- RABBIT_PASSWORD: 'botq',
64
- }
103
+ // @ts-ignore
104
+ await rabbit.publish(QUEUE, { step: 2 })
105
+
106
+ await sleep(1000)
65
107
 
66
- const uri = rabbitUriFromEnv(env)
67
- expect(uri).toBe('amqp://botq:botq@0.0.0.0:5672')
108
+ // @ts-ignore
109
+ expect(receivedMessages).toEqual([{ step: 1 }])
68
110
  })
69
111
  })
112
+
113
+ /**
114
+ * Waits until a condition becomes true or times out.
115
+ *
116
+ * @param {() => boolean} predicate
117
+ * @param {number} timeoutMs
118
+ */
119
+ async function waitFor(predicate, timeoutMs = 5000) {
120
+ const start = Date.now()
121
+
122
+ while (Date.now() - start < timeoutMs) {
123
+ if (predicate()) {
124
+ return
125
+ }
126
+ await sleep(50)
127
+ }
128
+
129
+ throw new Error('Condition not met within timeout')
130
+ }
131
+
132
+ /**
133
+ * Sleeps for the given number of milliseconds.
134
+ *
135
+ * @param {number} ms
136
+ */
137
+ function sleep(ms) {
138
+ return new Promise((resolve) => setTimeout(resolve, ms))
139
+ }
@@ -80,15 +80,13 @@ function isConnected(port) {
80
80
  */
81
81
  function waitForMongo(port) {
82
82
  console.log(`[MongoTest] Waiting for MongoDB to be ready...`)
83
- const maxRetries = 20
83
+ const maxRetries = 60
84
84
  let retries = 0
85
85
  let connected = false
86
86
 
87
87
  while (!connected && retries < maxRetries) {
88
- try {
89
- connected = isConnected(port)
90
- retries++
91
- } catch {
88
+ connected = isConnected(port)
89
+ if (!connected) {
92
90
  retries++
93
91
  execSync(`sleep 1`)
94
92
  }
@@ -3,3 +3,4 @@ export * from './paginate.js'
3
3
  export * from './dsl-to-mongo.js'
4
4
  export * from './initialize-mongodb.js'
5
5
  export * from './validate-mongo-uri.js'
6
+ export { paginate as mongoPaginate } from './paginate.js'
@@ -1,3 +1,4 @@
1
+ export * from './paginate.js'
1
2
  export * from './connect-to-pg.js'
2
3
  export * from './validate-schema.js'
3
4
  export * from './start-stop-postgres-docker.js'
@@ -0,0 +1,26 @@
1
+ export function sqlPaginate({
2
+ db,
3
+ mapRow,
4
+ orderBy,
5
+ page,
6
+ limit,
7
+ tableName,
8
+ filter,
9
+ }?: {
10
+ db: any
11
+ tableName: string
12
+ page?: number
13
+ limit?: number
14
+ filter?: any
15
+ orderBy?: {
16
+ column: string
17
+ direction?: 'asc' | 'desc'
18
+ }
19
+ }): Promise<{
20
+ list: any[]
21
+ totalCount: number
22
+ totalPages: number
23
+ currentPage: number
24
+ hasNext: boolean
25
+ hasPrevious: boolean
26
+ }>
@@ -1 +1,2 @@
1
1
  export * from './rabbit-mq.js'
2
+ export * from './start-stop-rabbitmq.js'
@@ -29,7 +29,7 @@ export function subscribeToQueue({
29
29
  log: import('pino').Logger
30
30
  nackOnError?: boolean
31
31
  prefetch?: number
32
- }): Promise<string>
32
+ }): Promise<() => Promise<void>>
33
33
  export function initializeQueue({
34
34
  host,
35
35
  log,
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Starts a RabbitMQ Docker container for testing purposes.
3
+ *
4
+ * If a container with the same name already exists, it will be removed first.
5
+ * The container is started with the RabbitMQ Management plugin enabled and
6
+ * waits until the health check reports the container as healthy.
7
+ *
8
+ * @param {Object} options
9
+ * @param {string} options.containerName
10
+ * Docker container name.
11
+ *
12
+ * @param {number} options.amqpPort
13
+ * Host port mapped to RabbitMQ AMQP port (5672).
14
+ *
15
+ * @param {number} options.uiPort
16
+ * Host port mapped to RabbitMQ Management UI port (15672).
17
+ *
18
+ * @param {string} options.user
19
+ * Default RabbitMQ username.
20
+ *
21
+ * @param {string} options.pass
22
+ * Default RabbitMQ password.
23
+ *
24
+ * @returns {void}
25
+ *
26
+ * @throws {Error}
27
+ * Throws if the RabbitMQ container fails to become healthy within the timeout.
28
+ */
29
+ export function startRabbit({
30
+ containerName,
31
+ ...rest
32
+ }: {
33
+ containerName: string
34
+ amqpPort: number
35
+ uiPort: number
36
+ user: string
37
+ pass: string
38
+ }): void
39
+ /**
40
+ * Stops and removes a RabbitMQ Docker container.
41
+ *
42
+ * This function is safe to call even if the container does not exist.
43
+ *
44
+ * @param {string} [containerName='rabbit-test']
45
+ * Docker container name.
46
+ *
47
+ * @returns {void}
48
+ */
49
+ export function stopRabbit(containerName?: string): void
50
+ /**
51
+ * Builds a RabbitMQ AMQP connection URI for local testing.
52
+ *
53
+ * @param {Object} options
54
+ * @param {string} options.user
55
+ * RabbitMQ username.
56
+ *
57
+ * @param {string} options.pass
58
+ * RabbitMQ password.
59
+ *
60
+ * @param {number} options.port
61
+ * Host port mapped to RabbitMQ AMQP port.
62
+ *
63
+ * @returns {string}
64
+ * RabbitMQ AMQP connection URI.
65
+ */
66
+ export function buildRabbitUri({
67
+ user,
68
+ pass,
69
+ port,
70
+ }: {
71
+ user: string
72
+ pass: string
73
+ port: number
74
+ }): string