core-services-sdk 1.3.56 → 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,113 @@
1
+ // @ts-nocheck
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
3
+
4
+ import { createRequestLogger } from '../../../src/http/helpers/create-request-logger.js'
5
+ import { Context } from '../../../src/util/context.js'
6
+ //@ts-ignore
7
+ describe('createRequestLogger', () => {
8
+ let baseLogger
9
+
10
+ beforeEach(() => {
11
+ baseLogger = {
12
+ child: vi.fn(),
13
+ }
14
+ })
15
+
16
+ it('creates a child logger with context and request data', () => {
17
+ const request = {
18
+ method: 'GET',
19
+ url: '/test',
20
+ raw: { url: '/raw-test' },
21
+ }
22
+
23
+ const childLogger = {}
24
+ baseLogger.child.mockReturnValue(childLogger)
25
+
26
+ Context.run(
27
+ {
28
+ correlationId: 'corr-123',
29
+ ip: '127.0.0.1',
30
+ userAgent: 'vitest-agent',
31
+ },
32
+ () => {
33
+ const result = createRequestLogger(request, baseLogger)
34
+
35
+ expect(baseLogger.child).toHaveBeenCalledOnce()
36
+ expect(baseLogger.child).toHaveBeenCalledWith({
37
+ ip: '127.0.0.1',
38
+ userAgent: 'vitest-agent',
39
+ correlationId: 'corr-123',
40
+ op: 'GET /test',
41
+ })
42
+
43
+ expect(result).toBe(childLogger)
44
+ },
45
+ )
46
+ })
47
+
48
+ it('falls back to routeOptions.url when request.url is missing', () => {
49
+ const request = {
50
+ method: 'POST',
51
+ routeOptions: { url: '/route-url' },
52
+ raw: { url: '/raw-url' },
53
+ }
54
+
55
+ Context.run(
56
+ {
57
+ correlationId: 'corr-456',
58
+ ip: '10.0.0.1',
59
+ userAgent: 'agent-x',
60
+ },
61
+ () => {
62
+ createRequestLogger(request, baseLogger)
63
+
64
+ expect(baseLogger.child).toHaveBeenCalledWith(
65
+ expect.objectContaining({
66
+ op: 'POST /route-url',
67
+ }),
68
+ )
69
+ },
70
+ )
71
+ })
72
+
73
+ it('falls back to request.raw.url when neither url nor routeOptions.url exist', () => {
74
+ const request = {
75
+ method: 'PUT',
76
+ raw: { url: '/raw-only' },
77
+ }
78
+
79
+ Context.run(
80
+ {
81
+ correlationId: 'corr-789',
82
+ ip: '192.168.1.1',
83
+ userAgent: 'agent-y',
84
+ },
85
+ () => {
86
+ createRequestLogger(request, baseLogger)
87
+
88
+ expect(baseLogger.child).toHaveBeenCalledWith(
89
+ expect.objectContaining({
90
+ op: 'PUT /raw-only',
91
+ }),
92
+ )
93
+ },
94
+ )
95
+ })
96
+
97
+ it('handles missing context gracefully when no Context is active', () => {
98
+ const request = {
99
+ method: 'DELETE',
100
+ url: '/delete',
101
+ raw: { url: '/delete' },
102
+ }
103
+
104
+ createRequestLogger(request, baseLogger)
105
+
106
+ expect(baseLogger.child).toHaveBeenCalledWith({
107
+ ip: undefined,
108
+ userAgent: undefined,
109
+ correlationId: undefined,
110
+ op: 'DELETE /delete',
111
+ })
112
+ })
113
+ })
@@ -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
  }
@@ -0,0 +1,4 @@
1
+ export function createRequestLogger(
2
+ request: import('fastify').FastifyRequest,
3
+ log: import('pino').Logger,
4
+ ): import('pino').Logger
@@ -2,3 +2,4 @@ export * from './http.js'
2
2
  export * from './HttpError.js'
3
3
  export * from './http-method.js'
4
4
  export * from './responseType.js'
5
+ export * from './helpers/create-request-logger.js'
@@ -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