core-services-sdk 1.3.83 → 1.3.85
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.
- package/package.json +1 -1
- package/src/http/http-method.js +2 -1
- package/src/http/http.js +30 -1
- package/src/postgresql/pagination/paginate.js +2 -1
- package/src/postgresql/repositories/BaseRepository.js +7 -4
- package/tests/http/http-method.unit.test.js +2 -1
- package/tests/http/http.unit.test.js +141 -0
- package/tests/repositories/BaseRepository.int.test.js +172 -0
- package/tests/repositories/BaseRepository.test.js +73 -22
- package/tests/repositories/TenantScopedRepository.int.test.js +173 -0
- package/types/http/http-method.d.ts +2 -1
- package/types/http/http.d.ts +22 -1
- package/types/postgresql/pagination/paginate.d.ts +1 -0
- package/types/postgresql/repositories/BaseRepository.d.ts +7 -4
package/package.json
CHANGED
package/src/http/http-method.js
CHANGED
package/src/http/http.js
CHANGED
|
@@ -55,6 +55,13 @@
|
|
|
55
55
|
* @property {ResponseTypeValue} [expectedType] - Expected response type.
|
|
56
56
|
*/
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} HttpHeadOptions
|
|
60
|
+
* @property {string} url - The URL to send the request to.
|
|
61
|
+
* @property {Record<string, string>} [headers] - Optional HTTP headers.
|
|
62
|
+
* @property {RequestInit} [extraParams] - Additional fetch options.
|
|
63
|
+
*/
|
|
64
|
+
|
|
58
65
|
import httpStatus from 'http-status'
|
|
59
66
|
import { parseStringPromise } from 'xml2js'
|
|
60
67
|
|
|
@@ -294,6 +301,25 @@ export const deleteApi = async ({
|
|
|
294
301
|
return getResponsePayload(response, expectedType)
|
|
295
302
|
}
|
|
296
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Sends an HTTP HEAD request.
|
|
306
|
+
*
|
|
307
|
+
* @param {HttpHeadOptions} options - The request options.
|
|
308
|
+
* @returns {Promise<Response>} The raw fetch response (headers only).
|
|
309
|
+
* @throws {HttpError} If the response status is not successful.
|
|
310
|
+
*/
|
|
311
|
+
export const head = async ({ url, headers = {}, extraParams = {} }) => {
|
|
312
|
+
const response = await fetch(url, {
|
|
313
|
+
...extraParams,
|
|
314
|
+
method: HTTP_METHODS.HEAD,
|
|
315
|
+
headers: { ...JSON_HEADER, ...headers },
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
await checkStatus(response)
|
|
319
|
+
|
|
320
|
+
return response
|
|
321
|
+
}
|
|
322
|
+
|
|
297
323
|
/**
|
|
298
324
|
* Consolidated HTTP client with methods for common HTTP operations.
|
|
299
325
|
*
|
|
@@ -302,13 +328,16 @@ export const deleteApi = async ({
|
|
|
302
328
|
* put: (options: HttpPutOptions) => Promise<any>,
|
|
303
329
|
* post: (options: HttpPostOptions) => Promise<any>,
|
|
304
330
|
* patch: (options: HttpPatchOptions) => Promise<any>,
|
|
305
|
-
* deleteApi: (options: HttpDeleteOptions) => Promise<any
|
|
331
|
+
* deleteApi: (options: HttpDeleteOptions) => Promise<any>,
|
|
332
|
+
* head: (options: HttpHeadOptions) => Promise<Response>
|
|
306
333
|
* }}
|
|
307
334
|
*/
|
|
335
|
+
|
|
308
336
|
export const http = {
|
|
309
337
|
get,
|
|
310
338
|
put,
|
|
311
339
|
post,
|
|
312
340
|
patch,
|
|
313
341
|
deleteApi,
|
|
342
|
+
head,
|
|
314
343
|
}
|
|
@@ -73,6 +73,7 @@ export async function sqlPaginate({
|
|
|
73
73
|
page = 1,
|
|
74
74
|
baseQuery,
|
|
75
75
|
limit = 10,
|
|
76
|
+
columns = '*',
|
|
76
77
|
filter = {},
|
|
77
78
|
snakeCase = true,
|
|
78
79
|
distinctOn,
|
|
@@ -89,7 +90,7 @@ export async function sqlPaginate({
|
|
|
89
90
|
applyPagination({ query: listQuery, page, limit })
|
|
90
91
|
|
|
91
92
|
const [rows, countResult] = await Promise.all([
|
|
92
|
-
listQuery.select(
|
|
93
|
+
listQuery.select(columns),
|
|
93
94
|
distinctOn
|
|
94
95
|
? countQuery.countDistinct(`${distinctOn} as count`).first()
|
|
95
96
|
: countQuery.count('* as count').first(),
|
|
@@ -14,13 +14,14 @@ import { toSnakeCase } from '../../core/case-mapper.js'
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
export class BaseRepository {
|
|
17
|
-
constructor({ db, log, baseQueryBuilder } = {}) {
|
|
17
|
+
constructor({ db, log, columns, baseQueryBuilder } = {}) {
|
|
18
18
|
if (!db) {
|
|
19
19
|
throw new Error('BaseRepository requires db instance')
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
this.db = db
|
|
23
23
|
this.log = log
|
|
24
|
+
this._columns = columns ?? '*'
|
|
24
25
|
this._baseQueryBuilder = baseQueryBuilder
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -48,7 +49,7 @@ export class BaseRepository {
|
|
|
48
49
|
|
|
49
50
|
if (this._baseQueryBuilder) {
|
|
50
51
|
// Pass full params for optional dynamic shaping
|
|
51
|
-
|
|
52
|
+
this._baseQueryBuilder(qb, params)
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
return qb
|
|
@@ -59,11 +60,12 @@ export class BaseRepository {
|
|
|
59
60
|
*/
|
|
60
61
|
async find({
|
|
61
62
|
page = 1,
|
|
63
|
+
columns,
|
|
62
64
|
limit = 10,
|
|
63
65
|
filter = {},
|
|
64
|
-
orderBy = { column: 'created_at', direction: 'desc' },
|
|
65
66
|
options = {},
|
|
66
67
|
mapRow = (row) => row,
|
|
68
|
+
orderBy = { column: 'created_at', direction: 'desc' },
|
|
67
69
|
...restParams
|
|
68
70
|
}) {
|
|
69
71
|
try {
|
|
@@ -79,10 +81,11 @@ export class BaseRepository {
|
|
|
79
81
|
return await sqlPaginate({
|
|
80
82
|
page,
|
|
81
83
|
limit,
|
|
84
|
+
mapRow,
|
|
82
85
|
orderBy,
|
|
83
86
|
baseQuery: qb,
|
|
84
|
-
mapRow,
|
|
85
87
|
filter: toSnakeCase(filter),
|
|
88
|
+
columns: columns ?? this._columns,
|
|
86
89
|
})
|
|
87
90
|
} catch (error) {
|
|
88
91
|
if (this.log) {
|
|
@@ -6,9 +6,10 @@ import { HTTP_METHODS } from '../../src/http/http-method.js'
|
|
|
6
6
|
describe('HTTP_METHODS constant', () => {
|
|
7
7
|
it('should include all standard HTTP methods', () => {
|
|
8
8
|
expect(HTTP_METHODS).toEqual({
|
|
9
|
+
PUT: 'PUT',
|
|
9
10
|
GET: 'GET',
|
|
11
|
+
HEAD: 'HEAD',
|
|
10
12
|
POST: 'POST',
|
|
11
|
-
PUT: 'PUT',
|
|
12
13
|
PATCH: 'PATCH',
|
|
13
14
|
DELETE: 'DELETE',
|
|
14
15
|
})
|
|
@@ -20,6 +20,7 @@ const createMockResponse = ({
|
|
|
20
20
|
statusText,
|
|
21
21
|
text: vi.fn().mockResolvedValue(body),
|
|
22
22
|
headers: {
|
|
23
|
+
// @ts-ignore
|
|
23
24
|
get: vi.fn().mockImplementation((k) => headers[k]),
|
|
24
25
|
},
|
|
25
26
|
}
|
|
@@ -255,3 +256,143 @@ describe('extraParams support', () => {
|
|
|
255
256
|
)
|
|
256
257
|
})
|
|
257
258
|
})
|
|
259
|
+
|
|
260
|
+
describe('HEAD', () => {
|
|
261
|
+
it('should send a HEAD request and return response object', async () => {
|
|
262
|
+
const mockResponse = createMockResponse({
|
|
263
|
+
status: 200,
|
|
264
|
+
headers: { 'content-length': '1234' },
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
mockFetch.mockResolvedValueOnce(mockResponse)
|
|
268
|
+
|
|
269
|
+
const result = await http.head({
|
|
270
|
+
url: 'http://test.com',
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
expect(result).toBe(mockResponse)
|
|
274
|
+
|
|
275
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
276
|
+
'http://test.com',
|
|
277
|
+
expect.objectContaining({
|
|
278
|
+
method: HTTP_METHODS.HEAD,
|
|
279
|
+
}),
|
|
280
|
+
)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('should expose response headers', async () => {
|
|
284
|
+
const mockResponse = createMockResponse({
|
|
285
|
+
headers: { etag: 'abc123' },
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
mockFetch.mockResolvedValueOnce(mockResponse)
|
|
289
|
+
|
|
290
|
+
const result = await http.head({
|
|
291
|
+
url: 'http://test.com',
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
expect(result.headers.get('etag')).toBe('abc123')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('should throw HttpError on non-2xx status', async () => {
|
|
298
|
+
mockFetch.mockResolvedValueOnce(
|
|
299
|
+
createMockResponse({
|
|
300
|
+
status: 500,
|
|
301
|
+
body: 'server error',
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
await expect(
|
|
306
|
+
http.head({
|
|
307
|
+
url: 'http://test.com',
|
|
308
|
+
}),
|
|
309
|
+
).rejects.toThrow(HttpError)
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
describe('edge cases', () => {
|
|
314
|
+
it('should fallback to text when JSON parsing fails', async () => {
|
|
315
|
+
const invalidJson = '{invalid json'
|
|
316
|
+
|
|
317
|
+
mockFetch.mockResolvedValueOnce(createMockResponse({ body: invalidJson }))
|
|
318
|
+
|
|
319
|
+
const result = await http.get({
|
|
320
|
+
url: 'http://test.com',
|
|
321
|
+
expectedType: ResponseType.json,
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
expect(result).toBe(invalidJson)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('should fallback to text when XML parsing fails', async () => {
|
|
328
|
+
const invalidXml = '<note><invalid></note>'
|
|
329
|
+
|
|
330
|
+
mockFetch.mockResolvedValueOnce(createMockResponse({ body: invalidXml }))
|
|
331
|
+
|
|
332
|
+
const result = await http.get({
|
|
333
|
+
url: 'http://test.com',
|
|
334
|
+
expectedType: ResponseType.xml,
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
expect(result).toBe(invalidXml)
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('should allow overriding headers', async () => {
|
|
341
|
+
mockFetch.mockResolvedValueOnce(
|
|
342
|
+
createMockResponse({ body: JSON.stringify({ ok: true }) }),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
await http.get({
|
|
346
|
+
url: 'http://test.com',
|
|
347
|
+
headers: {
|
|
348
|
+
Authorization: 'Bearer token123',
|
|
349
|
+
},
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
353
|
+
'http://test.com',
|
|
354
|
+
expect.objectContaining({
|
|
355
|
+
headers: expect.objectContaining({
|
|
356
|
+
Authorization: 'Bearer token123',
|
|
357
|
+
}),
|
|
358
|
+
}),
|
|
359
|
+
)
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('should send DELETE request without body', async () => {
|
|
363
|
+
mockFetch.mockResolvedValueOnce(
|
|
364
|
+
createMockResponse({ body: JSON.stringify({ ok: true }) }),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
await http.deleteApi({
|
|
368
|
+
url: 'http://test.com',
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
372
|
+
'http://test.com',
|
|
373
|
+
expect.objectContaining({
|
|
374
|
+
method: HTTP_METHODS.DELETE,
|
|
375
|
+
}),
|
|
376
|
+
)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('should include default JSON content-type header', async () => {
|
|
380
|
+
mockFetch.mockResolvedValueOnce(
|
|
381
|
+
createMockResponse({ body: JSON.stringify({ ok: true }) }),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
await http.post({
|
|
385
|
+
url: 'http://test.com',
|
|
386
|
+
body: { a: 1 },
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
390
|
+
'http://test.com',
|
|
391
|
+
expect.objectContaining({
|
|
392
|
+
headers: expect.objectContaining({
|
|
393
|
+
'Content-Type': 'application/json',
|
|
394
|
+
}),
|
|
395
|
+
}),
|
|
396
|
+
)
|
|
397
|
+
})
|
|
398
|
+
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'
|
|
3
|
+
import knex from 'knex'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
stopPostgres,
|
|
7
|
+
startPostgres,
|
|
8
|
+
buildPostgresUri,
|
|
9
|
+
} from '../../src/postgresql/start-stop-postgres-docker.js'
|
|
10
|
+
|
|
11
|
+
import { BaseRepository } from '../../src/postgresql/repositories/BaseRepository.js'
|
|
12
|
+
|
|
13
|
+
const PG_OPTIONS = {
|
|
14
|
+
port: 5445,
|
|
15
|
+
db: 'testdb',
|
|
16
|
+
user: 'testuser',
|
|
17
|
+
pass: 'testpass',
|
|
18
|
+
containerName: 'postgres-base-repo-test-5445',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
|
|
22
|
+
|
|
23
|
+
let db
|
|
24
|
+
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
startPostgres(PG_OPTIONS)
|
|
27
|
+
|
|
28
|
+
db = knex({
|
|
29
|
+
client: 'pg',
|
|
30
|
+
connection: DATABASE_URI,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
await db.schema.createTable('customers', (table) => {
|
|
34
|
+
table.uuid('id').primary()
|
|
35
|
+
table.string('name').notNullable()
|
|
36
|
+
table.timestamp('created_at').notNullable()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
await db.schema.createTable('orders', (table) => {
|
|
40
|
+
table.uuid('id').primary()
|
|
41
|
+
table.uuid('customer_id').notNullable()
|
|
42
|
+
table.decimal('amount', 10, 2)
|
|
43
|
+
table.timestamp('created_at').notNullable()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterAll(async () => {
|
|
48
|
+
if (db) {
|
|
49
|
+
await db.destroy()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
stopPostgres(PG_OPTIONS.containerName)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
await db('orders').truncate()
|
|
57
|
+
await db('customers').truncate()
|
|
58
|
+
|
|
59
|
+
await db('customers').insert([
|
|
60
|
+
{
|
|
61
|
+
id: '00000000-0000-0000-0000-000000000001',
|
|
62
|
+
name: 'Alice',
|
|
63
|
+
created_at: new Date('2024-01-01'),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: '00000000-0000-0000-0000-000000000002',
|
|
67
|
+
name: 'Bob',
|
|
68
|
+
created_at: new Date('2024-01-02'),
|
|
69
|
+
},
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
await db('orders').insert([
|
|
73
|
+
{
|
|
74
|
+
id: '00000000-0000-0000-0000-000000000101',
|
|
75
|
+
customer_id: '00000000-0000-0000-0000-000000000001',
|
|
76
|
+
amount: 100,
|
|
77
|
+
created_at: new Date('2024-01-10'),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: '00000000-0000-0000-0000-000000000102',
|
|
81
|
+
customer_id: '00000000-0000-0000-0000-000000000001',
|
|
82
|
+
amount: 200,
|
|
83
|
+
created_at: new Date('2024-01-11'),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: '00000000-0000-0000-0000-000000000103',
|
|
87
|
+
customer_id: '00000000-0000-0000-0000-000000000002',
|
|
88
|
+
amount: 300,
|
|
89
|
+
created_at: new Date('2024-01-12'),
|
|
90
|
+
},
|
|
91
|
+
])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
class OrdersRepository extends BaseRepository {
|
|
95
|
+
static tableName = 'orders'
|
|
96
|
+
|
|
97
|
+
constructor(deps) {
|
|
98
|
+
super({
|
|
99
|
+
...deps,
|
|
100
|
+
baseQueryBuilder(qb) {
|
|
101
|
+
qb.innerJoin('customers', 'customers.id', 'orders.customer_id')
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('BaseRepository integration', () => {
|
|
108
|
+
it('baseQueryBuilder applies JOIN correctly', async () => {
|
|
109
|
+
const repo = new OrdersRepository({ db })
|
|
110
|
+
|
|
111
|
+
const qb = repo.baseQuery()
|
|
112
|
+
|
|
113
|
+
const rows = await qb.select('orders.id', 'customers.name')
|
|
114
|
+
|
|
115
|
+
expect(rows).toHaveLength(3)
|
|
116
|
+
expect(rows[0]).toHaveProperty('name')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('find returns joined data using columns', async () => {
|
|
120
|
+
const repo = new OrdersRepository({ db })
|
|
121
|
+
|
|
122
|
+
const result = await repo.find({
|
|
123
|
+
columns: ['orders.id', 'orders.amount', 'customers.name'],
|
|
124
|
+
limit: 10,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
expect(result.list.length).toBe(3)
|
|
128
|
+
|
|
129
|
+
const row = result.list[0]
|
|
130
|
+
|
|
131
|
+
expect(row).toHaveProperty('amount')
|
|
132
|
+
expect(row).toHaveProperty('name')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('pagination works with joins', async () => {
|
|
136
|
+
const repo = new OrdersRepository({ db })
|
|
137
|
+
|
|
138
|
+
const result = await repo.find({
|
|
139
|
+
columns: ['orders.id', 'customers.name'],
|
|
140
|
+
limit: 2,
|
|
141
|
+
page: 1,
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
expect(result.list.length).toBe(2)
|
|
145
|
+
expect(result.totalCount).toBe(3)
|
|
146
|
+
expect(result.pages).toBe(2)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('constructor default columns are used', async () => {
|
|
150
|
+
class RepoWithColumns extends BaseRepository {
|
|
151
|
+
static tableName = 'orders'
|
|
152
|
+
|
|
153
|
+
constructor(deps) {
|
|
154
|
+
super({
|
|
155
|
+
...deps,
|
|
156
|
+
columns: ['orders.id'],
|
|
157
|
+
baseQueryBuilder(qb) {
|
|
158
|
+
qb.innerJoin('customers', 'customers.id', 'orders.customer_id')
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const repo = new RepoWithColumns({ db })
|
|
165
|
+
|
|
166
|
+
const result = await repo.find({})
|
|
167
|
+
|
|
168
|
+
const row = result.list[0]
|
|
169
|
+
|
|
170
|
+
expect(Object.keys(row)).toEqual(['id'])
|
|
171
|
+
})
|
|
172
|
+
})
|
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
import { BaseRepository } from '../../src/postgresql/repositories/BaseRepository.js'
|
|
3
3
|
|
|
4
|
-
//
|
|
4
|
+
// mock paginate
|
|
5
5
|
vi.mock('../../src/postgresql/pagination/paginate.js', () => ({
|
|
6
|
-
sqlPaginate: vi.fn()
|
|
6
|
+
sqlPaginate: vi.fn(),
|
|
7
7
|
}))
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
vi.mock('../../filters/apply-filter.js', () => ({
|
|
14
|
-
applyFilter: vi.fn(),
|
|
15
|
-
}))
|
|
16
|
-
|
|
17
|
-
vi.mock('../../core/normalize-premitives-types-or-default.js', () => ({
|
|
18
|
-
normalizeNumberOrDefault: vi.fn((v) => Number(v)),
|
|
9
|
+
// mock snakeCase mapper
|
|
10
|
+
vi.mock('../../src/core/case-mapper.js', () => ({
|
|
11
|
+
toSnakeCase: vi.fn((v) => v),
|
|
19
12
|
}))
|
|
20
13
|
|
|
21
14
|
// import AFTER mocks
|
|
@@ -24,6 +17,7 @@ import { toSnakeCase } from '../../src/core/case-mapper.js'
|
|
|
24
17
|
|
|
25
18
|
describe('BaseRepository', () => {
|
|
26
19
|
let dbMock
|
|
20
|
+
let qbMock
|
|
27
21
|
let logMock
|
|
28
22
|
|
|
29
23
|
class TestRepo extends BaseRepository {
|
|
@@ -31,13 +25,21 @@ describe('BaseRepository', () => {
|
|
|
31
25
|
}
|
|
32
26
|
|
|
33
27
|
beforeEach(() => {
|
|
34
|
-
|
|
35
|
-
clone: vi.fn(),
|
|
36
|
-
}
|
|
28
|
+
qbMock = {
|
|
29
|
+
clone: vi.fn().mockReturnThis(),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
dbMock = vi.fn(() => qbMock)
|
|
37
33
|
|
|
38
34
|
logMock = {
|
|
39
35
|
error: vi.fn(),
|
|
40
36
|
}
|
|
37
|
+
|
|
38
|
+
sqlPaginate.mockResolvedValue({ page: 1 })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.clearAllMocks()
|
|
41
43
|
})
|
|
42
44
|
|
|
43
45
|
it('throws if db not provided', () => {
|
|
@@ -58,7 +60,7 @@ describe('BaseRepository', () => {
|
|
|
58
60
|
})
|
|
59
61
|
|
|
60
62
|
it('baseQuery uses trx when provided', () => {
|
|
61
|
-
const trxMock = vi.fn()
|
|
63
|
+
const trxMock = vi.fn(() => qbMock)
|
|
62
64
|
const repo = new TestRepo({ db: dbMock })
|
|
63
65
|
|
|
64
66
|
repo.baseQuery({ trx: trxMock })
|
|
@@ -68,7 +70,8 @@ describe('BaseRepository', () => {
|
|
|
68
70
|
})
|
|
69
71
|
|
|
70
72
|
it('baseQuery applies baseQueryBuilder', () => {
|
|
71
|
-
const builderMock = vi.fn(
|
|
73
|
+
const builderMock = vi.fn()
|
|
74
|
+
|
|
72
75
|
const repo = new TestRepo({
|
|
73
76
|
db: dbMock,
|
|
74
77
|
baseQueryBuilder: builderMock,
|
|
@@ -76,7 +79,7 @@ describe('BaseRepository', () => {
|
|
|
76
79
|
|
|
77
80
|
repo.baseQuery({}, { some: 'param' })
|
|
78
81
|
|
|
79
|
-
expect(builderMock).
|
|
82
|
+
expect(builderMock).toHaveBeenCalledWith(qbMock, { some: 'param' })
|
|
80
83
|
})
|
|
81
84
|
|
|
82
85
|
it('find calls sqlPaginate with correct params', async () => {
|
|
@@ -88,17 +91,65 @@ describe('BaseRepository', () => {
|
|
|
88
91
|
filter: { a: 1 },
|
|
89
92
|
})
|
|
90
93
|
|
|
91
|
-
expect(
|
|
94
|
+
expect(toSnakeCase).toHaveBeenCalledWith({ a: 1 })
|
|
95
|
+
|
|
96
|
+
expect(sqlPaginate).toHaveBeenCalledWith(
|
|
97
|
+
expect.objectContaining({
|
|
98
|
+
page: 2,
|
|
99
|
+
limit: 5,
|
|
100
|
+
baseQuery: qbMock,
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
92
103
|
})
|
|
93
104
|
|
|
94
105
|
it('logs error if sqlPaginate throws', async () => {
|
|
95
|
-
// @ts-ignore
|
|
96
106
|
sqlPaginate.mockRejectedValueOnce(new Error('fail'))
|
|
97
107
|
|
|
98
108
|
const repo = new TestRepo({ db: dbMock, log: logMock })
|
|
99
109
|
|
|
100
|
-
await expect(repo.find({ filter: {} })).rejects.toThrow()
|
|
110
|
+
await expect(repo.find({ filter: {} })).rejects.toThrow('fail')
|
|
101
111
|
|
|
102
112
|
expect(logMock.error).toHaveBeenCalled()
|
|
103
113
|
})
|
|
114
|
+
|
|
115
|
+
it('find uses columns passed to find()', async () => {
|
|
116
|
+
const repo = new TestRepo({ db: dbMock })
|
|
117
|
+
|
|
118
|
+
await repo.find({
|
|
119
|
+
columns: ['id', 'name'],
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(sqlPaginate).toHaveBeenCalledWith(
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
columns: ['id', 'name'],
|
|
125
|
+
}),
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('find falls back to constructor columns', async () => {
|
|
130
|
+
const repo = new TestRepo({
|
|
131
|
+
db: dbMock,
|
|
132
|
+
columns: ['id', 'email'],
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
await repo.find({})
|
|
136
|
+
|
|
137
|
+
expect(sqlPaginate).toHaveBeenCalledWith(
|
|
138
|
+
expect.objectContaining({
|
|
139
|
+
columns: ['id', 'email'],
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('find falls back to "*" when no columns provided', async () => {
|
|
145
|
+
const repo = new TestRepo({ db: dbMock })
|
|
146
|
+
|
|
147
|
+
await repo.find({})
|
|
148
|
+
|
|
149
|
+
expect(sqlPaginate).toHaveBeenCalledWith(
|
|
150
|
+
expect.objectContaining({
|
|
151
|
+
columns: '*',
|
|
152
|
+
}),
|
|
153
|
+
)
|
|
154
|
+
})
|
|
104
155
|
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
import { describe, it, beforeAll, afterAll, beforeEach, expect } from 'vitest'
|
|
3
|
+
import knex from 'knex'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
stopPostgres,
|
|
7
|
+
startPostgres,
|
|
8
|
+
buildPostgresUri,
|
|
9
|
+
} from '../../src/postgresql/start-stop-postgres-docker.js'
|
|
10
|
+
|
|
11
|
+
import { TenantScopedRepository } from '../../src/postgresql/repositories/TenantScopedRepository.js'
|
|
12
|
+
|
|
13
|
+
const PG_OPTIONS = {
|
|
14
|
+
port: 5446,
|
|
15
|
+
db: 'testdb',
|
|
16
|
+
user: 'testuser',
|
|
17
|
+
pass: 'testpass',
|
|
18
|
+
containerName: 'postgres-tenant-repo-test-5446',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DATABASE_URI = buildPostgresUri(PG_OPTIONS)
|
|
22
|
+
|
|
23
|
+
const TENANT_A = '00000000-0000-0000-0000-000000000010'
|
|
24
|
+
const TENANT_B = '00000000-0000-0000-0000-000000000020'
|
|
25
|
+
|
|
26
|
+
let db
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
startPostgres(PG_OPTIONS)
|
|
30
|
+
|
|
31
|
+
db = knex({
|
|
32
|
+
client: 'pg',
|
|
33
|
+
connection: DATABASE_URI,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await db.schema.createTable('customers', (table) => {
|
|
37
|
+
table.uuid('id').primary()
|
|
38
|
+
table.uuid('tenant_id').notNullable()
|
|
39
|
+
table.string('name')
|
|
40
|
+
table.timestamp('created_at').notNullable()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
await db.schema.createTable('orders', (table) => {
|
|
44
|
+
table.uuid('id').primary()
|
|
45
|
+
table.uuid('tenant_id').notNullable()
|
|
46
|
+
table.uuid('customer_id')
|
|
47
|
+
table.decimal('amount', 10, 2)
|
|
48
|
+
table.timestamp('created_at').notNullable()
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
if (db) {
|
|
54
|
+
await db.destroy()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
stopPostgres(PG_OPTIONS.containerName)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
beforeEach(async () => {
|
|
61
|
+
await db('orders').truncate()
|
|
62
|
+
await db('customers').truncate()
|
|
63
|
+
|
|
64
|
+
await db('customers').insert([
|
|
65
|
+
{
|
|
66
|
+
id: '00000000-0000-0000-0000-000000000001',
|
|
67
|
+
tenant_id: TENANT_A,
|
|
68
|
+
name: 'Alice',
|
|
69
|
+
created_at: new Date('2024-01-01'),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: '00000000-0000-0000-0000-000000000002',
|
|
73
|
+
tenant_id: TENANT_B,
|
|
74
|
+
name: 'Bob',
|
|
75
|
+
created_at: new Date('2024-01-02'),
|
|
76
|
+
},
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
await db('orders').insert([
|
|
80
|
+
{
|
|
81
|
+
id: '00000000-0000-0000-0000-000000000101',
|
|
82
|
+
tenant_id: TENANT_A,
|
|
83
|
+
customer_id: '00000000-0000-0000-0000-000000000001',
|
|
84
|
+
amount: 100,
|
|
85
|
+
created_at: new Date('2024-01-10'),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: '00000000-0000-0000-0000-000000000102',
|
|
89
|
+
tenant_id: TENANT_A,
|
|
90
|
+
customer_id: '00000000-0000-0000-0000-000000000001',
|
|
91
|
+
amount: 200,
|
|
92
|
+
created_at: new Date('2024-01-11'),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: '00000000-0000-0000-0000-000000000103',
|
|
96
|
+
tenant_id: TENANT_B,
|
|
97
|
+
customer_id: '00000000-0000-0000-0000-000000000002',
|
|
98
|
+
amount: 300,
|
|
99
|
+
created_at: new Date('2024-01-12'),
|
|
100
|
+
},
|
|
101
|
+
])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
class OrdersRepository extends TenantScopedRepository {
|
|
105
|
+
static tableName = 'orders'
|
|
106
|
+
|
|
107
|
+
constructor(deps) {
|
|
108
|
+
super({
|
|
109
|
+
...deps,
|
|
110
|
+
baseQueryBuilder(qb) {
|
|
111
|
+
qb.innerJoin('customers', 'customers.id', 'orders.customer_id')
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe('TenantScopedRepository integration', () => {
|
|
118
|
+
it('throws if tenantId missing', async () => {
|
|
119
|
+
const repo = new OrdersRepository({ db })
|
|
120
|
+
|
|
121
|
+
await expect(
|
|
122
|
+
repo.find({
|
|
123
|
+
filter: {},
|
|
124
|
+
}),
|
|
125
|
+
).rejects.toThrow('tenantId is required')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('returns only rows for tenant', async () => {
|
|
129
|
+
const repo = new OrdersRepository({ db })
|
|
130
|
+
|
|
131
|
+
const result = await repo.find({
|
|
132
|
+
filter: {
|
|
133
|
+
tenantId: TENANT_A,
|
|
134
|
+
},
|
|
135
|
+
columns: ['orders.id', 'orders.amount'],
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
expect(result.list.length).toBe(2)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('works with join and columns', async () => {
|
|
142
|
+
const repo = new OrdersRepository({ db })
|
|
143
|
+
|
|
144
|
+
const result = await repo.find({
|
|
145
|
+
filter: {
|
|
146
|
+
tenantId: TENANT_A,
|
|
147
|
+
},
|
|
148
|
+
columns: ['orders.id', 'orders.amount', 'customers.name'],
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const row = result.list[0]
|
|
152
|
+
|
|
153
|
+
expect(row).toHaveProperty('amount')
|
|
154
|
+
expect(row).toHaveProperty('name')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('pagination respects tenant scope', async () => {
|
|
158
|
+
const repo = new OrdersRepository({ db })
|
|
159
|
+
|
|
160
|
+
const result = await repo.find({
|
|
161
|
+
filter: {
|
|
162
|
+
tenantId: TENANT_A,
|
|
163
|
+
},
|
|
164
|
+
limit: 1,
|
|
165
|
+
page: 1,
|
|
166
|
+
columns: ['orders.id'],
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
expect(result.list.length).toBe(1)
|
|
170
|
+
expect(result.totalCount).toBe(2)
|
|
171
|
+
expect(result.pages).toBe(2)
|
|
172
|
+
})
|
|
173
|
+
})
|
package/types/http/http.d.ts
CHANGED
|
@@ -32,6 +32,11 @@ export function deleteApi({
|
|
|
32
32
|
extraParams,
|
|
33
33
|
expectedType,
|
|
34
34
|
}: HttpDeleteOptions): Promise<any>
|
|
35
|
+
export function head({
|
|
36
|
+
url,
|
|
37
|
+
headers,
|
|
38
|
+
extraParams,
|
|
39
|
+
}: HttpHeadOptions): Promise<Response>
|
|
35
40
|
/**
|
|
36
41
|
* Consolidated HTTP client with methods for common HTTP operations.
|
|
37
42
|
*
|
|
@@ -40,7 +45,8 @@ export function deleteApi({
|
|
|
40
45
|
* put: (options: HttpPutOptions) => Promise<any>,
|
|
41
46
|
* post: (options: HttpPostOptions) => Promise<any>,
|
|
42
47
|
* patch: (options: HttpPatchOptions) => Promise<any>,
|
|
43
|
-
* deleteApi: (options: HttpDeleteOptions) => Promise<any
|
|
48
|
+
* deleteApi: (options: HttpDeleteOptions) => Promise<any>,
|
|
49
|
+
* head: (options: HttpHeadOptions) => Promise<Response>
|
|
44
50
|
* }}
|
|
45
51
|
*/
|
|
46
52
|
export const http: {
|
|
@@ -49,6 +55,7 @@ export const http: {
|
|
|
49
55
|
post: (options: HttpPostOptions) => Promise<any>
|
|
50
56
|
patch: (options: HttpPatchOptions) => Promise<any>
|
|
51
57
|
deleteApi: (options: HttpDeleteOptions) => Promise<any>
|
|
58
|
+
head: (options: HttpHeadOptions) => Promise<Response>
|
|
52
59
|
}
|
|
53
60
|
export type ResponseTypeValue = 'json' | 'xml' | 'text' | 'raw' | 'file'
|
|
54
61
|
export type HttpGetOptions = {
|
|
@@ -157,3 +164,17 @@ export type HttpDeleteOptions = {
|
|
|
157
164
|
*/
|
|
158
165
|
expectedType?: ResponseTypeValue
|
|
159
166
|
}
|
|
167
|
+
export type HttpHeadOptions = {
|
|
168
|
+
/**
|
|
169
|
+
* - The URL to send the request to.
|
|
170
|
+
*/
|
|
171
|
+
url: string
|
|
172
|
+
/**
|
|
173
|
+
* - Optional HTTP headers.
|
|
174
|
+
*/
|
|
175
|
+
headers?: Record<string, string>
|
|
176
|
+
/**
|
|
177
|
+
* - Additional fetch options.
|
|
178
|
+
*/
|
|
179
|
+
extraParams?: RequestInit
|
|
180
|
+
}
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* Does NOT enforce tenant isolation.
|
|
11
11
|
*/
|
|
12
12
|
export class BaseRepository {
|
|
13
|
-
constructor({ db, log, baseQueryBuilder }?: {})
|
|
13
|
+
constructor({ db, log, columns, baseQueryBuilder }?: {})
|
|
14
14
|
db: any
|
|
15
15
|
log: any
|
|
16
|
+
_columns: any
|
|
16
17
|
_baseQueryBuilder: any
|
|
17
18
|
/**
|
|
18
19
|
* Each concrete repository must define:
|
|
@@ -29,22 +30,24 @@ export class BaseRepository {
|
|
|
29
30
|
*/
|
|
30
31
|
find({
|
|
31
32
|
page,
|
|
33
|
+
columns,
|
|
32
34
|
limit,
|
|
33
35
|
filter,
|
|
34
|
-
orderBy,
|
|
35
36
|
options,
|
|
36
37
|
mapRow,
|
|
38
|
+
orderBy,
|
|
37
39
|
...restParams
|
|
38
40
|
}: {
|
|
39
41
|
[x: string]: any
|
|
40
42
|
page?: number
|
|
43
|
+
columns: any
|
|
41
44
|
limit?: number
|
|
42
45
|
filter?: {}
|
|
46
|
+
options?: {}
|
|
47
|
+
mapRow?: (row: any) => any
|
|
43
48
|
orderBy?: {
|
|
44
49
|
column: string
|
|
45
50
|
direction: string
|
|
46
51
|
}
|
|
47
|
-
options?: {}
|
|
48
|
-
mapRow?: (row: any) => any
|
|
49
52
|
}): Promise<any>
|
|
50
53
|
}
|