@byline/db-postgres 1.12.0 → 1.12.2
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/dist/lib/test-bootstrap.d.ts +8 -0
- package/dist/lib/test-bootstrap.js +27 -0
- package/dist/lib/test-db.d.ts +39 -0
- package/dist/lib/test-db.js +106 -0
- package/dist/lib/test-helper.js +7 -8
- package/dist/modules/admin/tests/auth-integration.test.js +91 -73
- package/dist/modules/admin/tests/session-provider.test.js +77 -60
- package/dist/modules/storage/tests/storage-document-paths.test.js +14 -32
- package/dist/modules/storage/tests/storage-field-types.test.js +13 -14
- package/dist/modules/storage/tests/storage-flatten-reconstruct.test.js +22 -23
- package/dist/modules/storage/tests/storage-restore.test.js +10 -21
- package/dist/modules/storage/tests/storage-store-manifest.test.js +15 -16
- package/dist/modules/storage/tests/storage-versioning.test.js +8 -9
- package/package.json +15 -7
|
@@ -5,11 +5,10 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import assert from 'node:assert';
|
|
9
|
-
import { after, afterEach, before, describe, it } from 'node:test';
|
|
10
8
|
import { hashPassword, JwtSessionProvider } from '@byline/admin/auth';
|
|
11
9
|
import { AdminAuth, AuthError, AuthErrorCodes } from '@byline/auth';
|
|
12
10
|
import { eq, inArray } from 'drizzle-orm';
|
|
11
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
13
12
|
import { adminUsers } from '../../../database/schema/auth.js';
|
|
14
13
|
import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
|
|
15
14
|
import { createAdminStore } from '../admin-store.js';
|
|
@@ -26,6 +25,13 @@ import { createAdminStore } from '../admin-store.js';
|
|
|
26
25
|
let db;
|
|
27
26
|
let store;
|
|
28
27
|
const trackedUserIds = new Set();
|
|
28
|
+
// Pure-JS argon2id takes ~1 s per hash; most tests here use the same
|
|
29
|
+
// 'pw' password for both the create and the subsequent sign-in. Hash it
|
|
30
|
+
// once and reuse — saves ~10–15 s on every run. Tests that need a
|
|
31
|
+
// different password (alice's 'pw-alice', bob's 'correct') pay the
|
|
32
|
+
// per-hash cost on demand.
|
|
33
|
+
const SHARED_PASSWORD = 'pw';
|
|
34
|
+
let SHARED_HASH = '';
|
|
29
35
|
const SIGNING_SECRET = 'test-signing-secret-at-least-32-bytes-long-here';
|
|
30
36
|
function makeProvider(options) {
|
|
31
37
|
return new JwtSessionProvider({
|
|
@@ -36,12 +42,15 @@ function makeProvider(options) {
|
|
|
36
42
|
now: options?.now,
|
|
37
43
|
});
|
|
38
44
|
}
|
|
45
|
+
async function hashFor(password) {
|
|
46
|
+
return password === SHARED_PASSWORD ? SHARED_HASH : await hashPassword(password);
|
|
47
|
+
}
|
|
39
48
|
async function createEnabledUser(email, password) {
|
|
40
49
|
// Clear any stale row left by a crashed prior run.
|
|
41
50
|
await db.delete(adminUsers).where(eq(adminUsers.email, email.toLowerCase()));
|
|
42
51
|
const row = await store.adminUsers.create({
|
|
43
52
|
email,
|
|
44
|
-
password_hash: await
|
|
53
|
+
password_hash: await hashFor(password),
|
|
45
54
|
is_enabled: true,
|
|
46
55
|
});
|
|
47
56
|
trackedUserIds.add(row.id);
|
|
@@ -51,7 +60,7 @@ async function createDisabledUser(email, password) {
|
|
|
51
60
|
await db.delete(adminUsers).where(eq(adminUsers.email, email.toLowerCase()));
|
|
52
61
|
const row = await store.adminUsers.create({
|
|
53
62
|
email,
|
|
54
|
-
password_hash: await
|
|
63
|
+
password_hash: await hashFor(password),
|
|
55
64
|
is_enabled: false,
|
|
56
65
|
});
|
|
57
66
|
trackedUserIds.add(row.id);
|
|
@@ -65,25 +74,26 @@ async function cleanupTrackedRows() {
|
|
|
65
74
|
}
|
|
66
75
|
// ---------------------------------------------------------------------------
|
|
67
76
|
describe('JwtSessionProvider', () => {
|
|
68
|
-
|
|
77
|
+
beforeAll(async () => {
|
|
69
78
|
const testDB = setupTestDB([]);
|
|
70
79
|
db = testDB.db;
|
|
71
80
|
store = createAdminStore(db);
|
|
81
|
+
SHARED_HASH = await hashPassword(SHARED_PASSWORD);
|
|
72
82
|
});
|
|
73
83
|
afterEach(async () => {
|
|
74
84
|
await cleanupTrackedRows();
|
|
75
85
|
});
|
|
76
|
-
|
|
86
|
+
afterAll(async () => {
|
|
77
87
|
await cleanupTrackedRows();
|
|
78
88
|
await teardownTestDB();
|
|
79
89
|
});
|
|
80
90
|
describe('construction', () => {
|
|
81
91
|
it('rejects a short signing secret', () => {
|
|
82
|
-
|
|
92
|
+
expect(() => new JwtSessionProvider({ store, signingSecret: 'too-short' })).toThrow(/at least 32 bytes/);
|
|
83
93
|
});
|
|
84
94
|
it('declares capabilities', () => {
|
|
85
95
|
const provider = makeProvider();
|
|
86
|
-
|
|
96
|
+
expect(provider.capabilities).toEqual({
|
|
87
97
|
passwordChange: true,
|
|
88
98
|
magicLink: false,
|
|
89
99
|
sso: false,
|
|
@@ -100,12 +110,12 @@ describe('JwtSessionProvider', () => {
|
|
|
100
110
|
ip: '10.0.0.1',
|
|
101
111
|
userAgent: 'test',
|
|
102
112
|
});
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
113
|
+
expect(result.accessToken).toBeTruthy();
|
|
114
|
+
expect(result.refreshToken).toBeTruthy();
|
|
115
|
+
expect(result.actor instanceof AdminAuth).toBeTruthy();
|
|
116
|
+
expect(result.actor.isSuperAdmin).toBe(false);
|
|
117
|
+
expect(result.accessTokenExpiresAt.getTime() > Date.now()).toBeTruthy();
|
|
118
|
+
expect(result.refreshTokenExpiresAt.getTime() > Date.now()).toBeTruthy();
|
|
109
119
|
});
|
|
110
120
|
it('throws ERR_INVALID_CREDENTIALS on unknown email', async () => {
|
|
111
121
|
const provider = makeProvider();
|
|
@@ -114,11 +124,11 @@ describe('JwtSessionProvider', () => {
|
|
|
114
124
|
email: 'nobody@example.com',
|
|
115
125
|
password: 'whatever',
|
|
116
126
|
});
|
|
117
|
-
|
|
127
|
+
throw new Error('expected ERR_INVALID_CREDENTIALS');
|
|
118
128
|
}
|
|
119
129
|
catch (err) {
|
|
120
|
-
|
|
121
|
-
|
|
130
|
+
expect(err instanceof AuthError).toBeTruthy();
|
|
131
|
+
expect(err.code).toBe(AuthErrorCodes.INVALID_CREDENTIALS);
|
|
122
132
|
}
|
|
123
133
|
// Note: signInWithPassword runs a timing-equaliser argon2 verify on
|
|
124
134
|
// the unknown-email path so the wrong-email and wrong-password
|
|
@@ -130,23 +140,23 @@ describe('JwtSessionProvider', () => {
|
|
|
130
140
|
const provider = makeProvider();
|
|
131
141
|
try {
|
|
132
142
|
await provider.signInWithPassword({ email: 'bob@example.com', password: 'wrong' });
|
|
133
|
-
|
|
143
|
+
throw new Error('expected ERR_INVALID_CREDENTIALS');
|
|
134
144
|
}
|
|
135
145
|
catch (err) {
|
|
136
|
-
|
|
146
|
+
expect(err.code).toBe(AuthErrorCodes.INVALID_CREDENTIALS);
|
|
137
147
|
}
|
|
138
148
|
const row = await store.adminUsers.getById(user.id);
|
|
139
|
-
|
|
149
|
+
expect(row?.failed_login_attempts).toBe(1);
|
|
140
150
|
});
|
|
141
151
|
it('throws ERR_ACCOUNT_DISABLED for a correct-password but disabled account', async () => {
|
|
142
152
|
await createDisabledUser('disabled@example.com', 'pw');
|
|
143
153
|
const provider = makeProvider();
|
|
144
154
|
try {
|
|
145
155
|
await provider.signInWithPassword({ email: 'disabled@example.com', password: 'pw' });
|
|
146
|
-
|
|
156
|
+
throw new Error('expected ERR_ACCOUNT_DISABLED');
|
|
147
157
|
}
|
|
148
158
|
catch (err) {
|
|
149
|
-
|
|
159
|
+
expect(err.code).toBe(AuthErrorCodes.ACCOUNT_DISABLED);
|
|
150
160
|
}
|
|
151
161
|
});
|
|
152
162
|
it('persists a refresh-token row with the recorded ip and user agent', async () => {
|
|
@@ -159,10 +169,10 @@ describe('JwtSessionProvider', () => {
|
|
|
159
169
|
userAgent: 'Mozilla/test',
|
|
160
170
|
});
|
|
161
171
|
const rows = await store.refreshTokens.listAllForUser(user.id);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
expect(rows.length).toBe(1);
|
|
173
|
+
expect(rows[0]?.ip).toBe('192.168.1.5');
|
|
174
|
+
expect(rows[0]?.user_agent).toBe('Mozilla/test');
|
|
175
|
+
expect(rows[0]?.revoked_at).toBe(null);
|
|
166
176
|
});
|
|
167
177
|
});
|
|
168
178
|
describe('verifyAccessToken', () => {
|
|
@@ -174,16 +184,16 @@ describe('JwtSessionProvider', () => {
|
|
|
174
184
|
password: 'pw',
|
|
175
185
|
});
|
|
176
186
|
const { actor } = await provider.verifyAccessToken(accessToken);
|
|
177
|
-
|
|
187
|
+
expect(actor.id).toBe(user.id);
|
|
178
188
|
});
|
|
179
189
|
it('throws ERR_INVALID_TOKEN for gibberish', async () => {
|
|
180
190
|
const provider = makeProvider();
|
|
181
191
|
try {
|
|
182
192
|
await provider.verifyAccessToken('not-a-jwt');
|
|
183
|
-
|
|
193
|
+
throw new Error('expected ERR_INVALID_TOKEN');
|
|
184
194
|
}
|
|
185
195
|
catch (err) {
|
|
186
|
-
|
|
196
|
+
expect(err.code).toBe(AuthErrorCodes.INVALID_TOKEN);
|
|
187
197
|
}
|
|
188
198
|
});
|
|
189
199
|
it('throws ERR_INVALID_TOKEN for an expired token', async () => {
|
|
@@ -198,10 +208,10 @@ describe('JwtSessionProvider', () => {
|
|
|
198
208
|
const freshProvider = makeProvider();
|
|
199
209
|
try {
|
|
200
210
|
await freshProvider.verifyAccessToken(accessToken);
|
|
201
|
-
|
|
211
|
+
throw new Error('expected ERR_INVALID_TOKEN');
|
|
202
212
|
}
|
|
203
213
|
catch (err) {
|
|
204
|
-
|
|
214
|
+
expect(err.code).toBe(AuthErrorCodes.INVALID_TOKEN);
|
|
205
215
|
}
|
|
206
216
|
});
|
|
207
217
|
it('throws ERR_ACCOUNT_DISABLED when the subject has been disabled since issuance', async () => {
|
|
@@ -214,10 +224,10 @@ describe('JwtSessionProvider', () => {
|
|
|
214
224
|
await store.adminUsers.setEnabled(user.id, false);
|
|
215
225
|
try {
|
|
216
226
|
await provider.verifyAccessToken(accessToken);
|
|
217
|
-
|
|
227
|
+
throw new Error('expected ERR_ACCOUNT_DISABLED');
|
|
218
228
|
}
|
|
219
229
|
catch (err) {
|
|
220
|
-
|
|
230
|
+
expect(err.code).toBe(AuthErrorCodes.ACCOUNT_DISABLED);
|
|
221
231
|
}
|
|
222
232
|
});
|
|
223
233
|
it('throws ERR_INVALID_TOKEN when the signature is tampered', async () => {
|
|
@@ -228,17 +238,24 @@ describe('JwtSessionProvider', () => {
|
|
|
228
238
|
password: 'pw',
|
|
229
239
|
});
|
|
230
240
|
const parts = accessToken.split('.');
|
|
231
|
-
// Flip the
|
|
232
|
-
|
|
233
|
-
|
|
241
|
+
// Flip a char in the middle of the signature, not at either end.
|
|
242
|
+
// HS256 sigs are 32 bytes → 43 base64url chars → 258 bits of capacity,
|
|
243
|
+
// so the *last* char carries 2 padding zero bits. A naive last-char
|
|
244
|
+
// flip can land on a different encoded char whose top 4 bits map to
|
|
245
|
+
// the same signature byte (e.g. `Y` ↔ `a` both decode to top-4=`0110`),
|
|
246
|
+
// leaving the signature unchanged and the test flaky. Middle chars
|
|
247
|
+
// span byte boundaries cleanly, so any flip guarantees a byte change.
|
|
248
|
+
const sig = parts[2] ?? '';
|
|
249
|
+
const mid = Math.floor(sig.length / 2);
|
|
250
|
+
const flipped = `${sig.slice(0, mid)}${sig[mid] === 'a' ? 'b' : 'a'}${sig.slice(mid + 1)}`;
|
|
234
251
|
parts[2] = flipped;
|
|
235
252
|
const tampered = parts.join('.');
|
|
236
253
|
try {
|
|
237
254
|
await provider.verifyAccessToken(tampered);
|
|
238
|
-
|
|
255
|
+
throw new Error('expected ERR_INVALID_TOKEN');
|
|
239
256
|
}
|
|
240
257
|
catch (err) {
|
|
241
|
-
|
|
258
|
+
expect(err.code).toBe(AuthErrorCodes.INVALID_TOKEN);
|
|
242
259
|
}
|
|
243
260
|
});
|
|
244
261
|
});
|
|
@@ -251,27 +268,27 @@ describe('JwtSessionProvider', () => {
|
|
|
251
268
|
password: 'pw',
|
|
252
269
|
});
|
|
253
270
|
const refreshed = await provider.refreshSession({ refreshToken: signIn.refreshToken });
|
|
254
|
-
|
|
255
|
-
|
|
271
|
+
expect(refreshed.refreshToken).not.toBe(signIn.refreshToken);
|
|
272
|
+
expect(refreshed.accessToken).not.toBe(signIn.accessToken);
|
|
256
273
|
// Old token is now revoked and points at the new one
|
|
257
274
|
const rows = await store.refreshTokens.listAllForUser(user.id);
|
|
258
|
-
|
|
275
|
+
expect(rows.length).toBe(2);
|
|
259
276
|
const [oldRow, newRow] = rows;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
277
|
+
expect(oldRow?.revoked_at).toBeTruthy();
|
|
278
|
+
expect(oldRow?.rotated_to_id).toBe(newRow?.id);
|
|
279
|
+
expect(newRow?.revoked_at).toBe(null);
|
|
263
280
|
// The new token verifies
|
|
264
281
|
const { actor } = await provider.verifyAccessToken(refreshed.accessToken);
|
|
265
|
-
|
|
282
|
+
expect(actor.id).toBe(user.id);
|
|
266
283
|
});
|
|
267
284
|
it('throws ERR_INVALID_TOKEN for an unknown refresh token', async () => {
|
|
268
285
|
const provider = makeProvider();
|
|
269
286
|
try {
|
|
270
287
|
await provider.refreshSession({ refreshToken: 'totally-bogus' });
|
|
271
|
-
|
|
288
|
+
throw new Error('expected ERR_INVALID_TOKEN');
|
|
272
289
|
}
|
|
273
290
|
catch (err) {
|
|
274
|
-
|
|
291
|
+
expect(err.code).toBe(AuthErrorCodes.INVALID_TOKEN);
|
|
275
292
|
}
|
|
276
293
|
});
|
|
277
294
|
it('throws ERR_INVALID_TOKEN for an expired refresh token', async () => {
|
|
@@ -285,10 +302,10 @@ describe('JwtSessionProvider', () => {
|
|
|
285
302
|
const freshProvider = makeProvider();
|
|
286
303
|
try {
|
|
287
304
|
await freshProvider.refreshSession({ refreshToken });
|
|
288
|
-
|
|
305
|
+
throw new Error('expected ERR_INVALID_TOKEN');
|
|
289
306
|
}
|
|
290
307
|
catch (err) {
|
|
291
|
-
|
|
308
|
+
expect(err.code).toBe(AuthErrorCodes.INVALID_TOKEN);
|
|
292
309
|
}
|
|
293
310
|
});
|
|
294
311
|
it('throws ERR_REVOKED_TOKEN when replaying a rotated token — and revokes the chain', async () => {
|
|
@@ -301,19 +318,19 @@ describe('JwtSessionProvider', () => {
|
|
|
301
318
|
// Legitimate rotation: sign-in → refresh-1 → refresh-2
|
|
302
319
|
const r1 = await provider.refreshSession({ refreshToken: signIn.refreshToken });
|
|
303
320
|
const r2 = await provider.refreshSession({ refreshToken: r1.refreshToken });
|
|
304
|
-
|
|
321
|
+
expect(r2.refreshToken).toBeTruthy();
|
|
305
322
|
// An attacker replays the original (already-rotated) refreshToken.
|
|
306
323
|
try {
|
|
307
324
|
await provider.refreshSession({ refreshToken: signIn.refreshToken });
|
|
308
|
-
|
|
325
|
+
throw new Error('expected ERR_REVOKED_TOKEN');
|
|
309
326
|
}
|
|
310
327
|
catch (err) {
|
|
311
|
-
|
|
328
|
+
expect(err.code).toBe(AuthErrorCodes.REVOKED_TOKEN);
|
|
312
329
|
}
|
|
313
330
|
// The entire chain descended from the replayed token is now revoked.
|
|
314
331
|
const rows = await store.refreshTokens.listAllForUser(user.id);
|
|
315
332
|
for (const row of rows) {
|
|
316
|
-
|
|
333
|
+
expect(row.revoked_at, `row ${row.id} expected revoked, got null`).toBeTruthy();
|
|
317
334
|
}
|
|
318
335
|
});
|
|
319
336
|
it('throws ERR_REVOKED_TOKEN for an explicitly revoked token', async () => {
|
|
@@ -326,10 +343,10 @@ describe('JwtSessionProvider', () => {
|
|
|
326
343
|
await provider.revokeSession(signIn.refreshToken);
|
|
327
344
|
try {
|
|
328
345
|
await provider.refreshSession({ refreshToken: signIn.refreshToken });
|
|
329
|
-
|
|
346
|
+
throw new Error('expected ERR_REVOKED_TOKEN');
|
|
330
347
|
}
|
|
331
348
|
catch (err) {
|
|
332
|
-
|
|
349
|
+
expect(err.code).toBe(AuthErrorCodes.REVOKED_TOKEN);
|
|
333
350
|
}
|
|
334
351
|
});
|
|
335
352
|
});
|
|
@@ -344,8 +361,8 @@ describe('JwtSessionProvider', () => {
|
|
|
344
361
|
await provider.revokeSession(signIn.refreshToken);
|
|
345
362
|
await provider.revokeSession(signIn.refreshToken); // idempotent
|
|
346
363
|
const rows = await store.refreshTokens.listAllForUser(user.id);
|
|
347
|
-
|
|
348
|
-
|
|
364
|
+
expect(rows.length).toBe(1);
|
|
365
|
+
expect(rows[0]?.revoked_at).toBeTruthy();
|
|
349
366
|
});
|
|
350
367
|
it('is a no-op for unknown tokens', async () => {
|
|
351
368
|
const provider = makeProvider();
|
|
@@ -357,13 +374,13 @@ describe('JwtSessionProvider', () => {
|
|
|
357
374
|
const user = await createEnabledUser('m@example.com', 'pw');
|
|
358
375
|
const provider = makeProvider();
|
|
359
376
|
const actor = await provider.resolveActor(user.id);
|
|
360
|
-
|
|
361
|
-
|
|
377
|
+
expect(actor instanceof AdminAuth).toBeTruthy();
|
|
378
|
+
expect(actor?.id).toBe(user.id);
|
|
362
379
|
});
|
|
363
380
|
it('returns null for a disabled user', async () => {
|
|
364
381
|
const user = await createDisabledUser('n@example.com', 'pw');
|
|
365
382
|
const provider = makeProvider();
|
|
366
|
-
|
|
383
|
+
expect(await provider.resolveActor(user.id)).toBe(null);
|
|
367
384
|
});
|
|
368
385
|
});
|
|
369
386
|
});
|
|
@@ -5,25 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
|
|
9
|
-
* Integration tests for the byline_document_paths layer.
|
|
10
|
-
*
|
|
11
|
-
* Exercises the storage adapter directly (not the lifecycle) so each test
|
|
12
|
-
* isolates one storage-level invariant:
|
|
13
|
-
*
|
|
14
|
-
* - per-(collection, locale) path uniqueness — the second insert with
|
|
15
|
-
* the same `(collection_id, locale, path)` triggers Postgres SQLSTATE
|
|
16
|
-
* 23505 on `idx_document_paths_collection_locale_path`.
|
|
17
|
-
* - locale fallback in reads — `getDocumentByPath` with a non-default
|
|
18
|
-
* `locale` resolves through the priority chain `[requested, default]`
|
|
19
|
-
* and finds the default-locale row when no row exists for the
|
|
20
|
-
* requested locale.
|
|
21
|
-
* - upsert-on-self — re-issuing `createDocumentVersion` with the same
|
|
22
|
-
* `path` for the same `documentId` succeeds (the conflict target is
|
|
23
|
-
* `(document_id, locale)`, so the existing row is updated in place).
|
|
24
|
-
*/
|
|
25
|
-
import assert from 'node:assert';
|
|
26
|
-
import { after, before, describe, it } from 'node:test';
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
27
9
|
import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
|
|
28
10
|
let commandBuilders;
|
|
29
11
|
let queryBuilders;
|
|
@@ -35,7 +17,7 @@ const PathsCollectionConfig = {
|
|
|
35
17
|
};
|
|
36
18
|
let testCollection = {};
|
|
37
19
|
describe('byline_document_paths integration', () => {
|
|
38
|
-
|
|
20
|
+
beforeAll(async () => {
|
|
39
21
|
const testDB = setupTestDB([PathsCollectionConfig]);
|
|
40
22
|
commandBuilders = testDB.commandBuilders;
|
|
41
23
|
queryBuilders = testDB.queryBuilders;
|
|
@@ -46,7 +28,7 @@ describe('byline_document_paths integration', () => {
|
|
|
46
28
|
}
|
|
47
29
|
testCollection = { id: collection.id, name: collection.path };
|
|
48
30
|
});
|
|
49
|
-
|
|
31
|
+
afterAll(async () => {
|
|
50
32
|
try {
|
|
51
33
|
await commandBuilders.collections.delete(testCollection.id);
|
|
52
34
|
}
|
|
@@ -86,14 +68,14 @@ describe('byline_document_paths integration', () => {
|
|
|
86
68
|
catch (err) {
|
|
87
69
|
caught = err;
|
|
88
70
|
}
|
|
89
|
-
|
|
71
|
+
expect(caught, 'expected unique-constraint violation on duplicate path').toBeTruthy();
|
|
90
72
|
// Drizzle wraps pg errors in DrizzleQueryError with the original error
|
|
91
73
|
// attached as `cause`. The lifecycle layer's rethrowPathConflict reads
|
|
92
74
|
// both the wrapper and the cause to detect 23505 + the path constraint
|
|
93
75
|
// name; mirror that here.
|
|
94
76
|
const original = caught.cause ?? caught;
|
|
95
|
-
|
|
96
|
-
|
|
77
|
+
expect(original.code, `expected SQLSTATE 23505, got ${original?.code}`).toBe('23505');
|
|
78
|
+
expect(String(original.constraint ?? ''), `constraint name should reference the path index, got ${original?.constraint}`).toMatch(/document_paths_collection_locale_path/);
|
|
97
79
|
});
|
|
98
80
|
it('upserts in place when the same document re-saves the same path', async () => {
|
|
99
81
|
const sharedPath = `same-doc-${Date.now()}`;
|
|
@@ -122,7 +104,7 @@ describe('byline_document_paths integration', () => {
|
|
|
122
104
|
status: 'draft',
|
|
123
105
|
previousVersionId: first.document.id,
|
|
124
106
|
});
|
|
125
|
-
|
|
107
|
+
expect(second.document.document_id, 'same logical document').toBe(documentId);
|
|
126
108
|
});
|
|
127
109
|
it('updates the path row in place when a document changes its path', async () => {
|
|
128
110
|
const originalPath = `original-${Date.now()}`;
|
|
@@ -156,14 +138,14 @@ describe('byline_document_paths integration', () => {
|
|
|
156
138
|
path: updatedPath,
|
|
157
139
|
reconstruct: false,
|
|
158
140
|
});
|
|
159
|
-
|
|
160
|
-
|
|
141
|
+
expect(found, 'updated path should resolve').toBeTruthy();
|
|
142
|
+
expect(found?.document_id).toBe(documentId);
|
|
161
143
|
const oldNotFound = await queryBuilders.documents.getDocumentByPath({
|
|
162
144
|
collection_id: testCollection.id,
|
|
163
145
|
path: originalPath,
|
|
164
146
|
reconstruct: false,
|
|
165
147
|
});
|
|
166
|
-
|
|
148
|
+
expect(oldNotFound, 'original path no longer resolves').toBe(null);
|
|
167
149
|
});
|
|
168
150
|
it('falls back to the default-locale path row when the requested locale has no row', async () => {
|
|
169
151
|
const onlyDefaultPath = `default-only-${Date.now()}`;
|
|
@@ -186,9 +168,9 @@ describe('byline_document_paths integration', () => {
|
|
|
186
168
|
locale: 'fr',
|
|
187
169
|
reconstruct: false,
|
|
188
170
|
});
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
171
|
+
expect(found, 'fallback chain should resolve via the en row').toBeTruthy();
|
|
172
|
+
expect(found?.document_id).toBe(documentId);
|
|
173
|
+
expect(found?.path).toBe(onlyDefaultPath);
|
|
192
174
|
});
|
|
193
175
|
it('returns null on getDocumentByPath when no row matches in any locale', async () => {
|
|
194
176
|
const result = await queryBuilders.documents.getDocumentByPath({
|
|
@@ -197,6 +179,6 @@ describe('byline_document_paths integration', () => {
|
|
|
197
179
|
locale: 'fr',
|
|
198
180
|
reconstruct: false,
|
|
199
181
|
});
|
|
200
|
-
|
|
182
|
+
expect(result).toBe(null);
|
|
201
183
|
});
|
|
202
184
|
});
|
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import assert from 'node:assert';
|
|
9
|
-
import { after, before, describe, it } from 'node:test';
|
|
10
8
|
import { v7 as uuidv7 } from 'uuid';
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
11
10
|
import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
|
|
12
11
|
// Test database setup
|
|
13
12
|
let commandBuilders;
|
|
@@ -63,7 +62,7 @@ const sampleDocument = {
|
|
|
63
62
|
// Global test variables
|
|
64
63
|
let testCollection = {};
|
|
65
64
|
describe('02 Field Types', () => {
|
|
66
|
-
|
|
65
|
+
beforeAll(async () => {
|
|
67
66
|
// Connect to test database
|
|
68
67
|
const testDB = setupTestDB([FieldTypesCollectionConfig]);
|
|
69
68
|
commandBuilders = testDB.commandBuilders;
|
|
@@ -77,7 +76,7 @@ describe('02 Field Types', () => {
|
|
|
77
76
|
testCollection = { id: collection.id, name: collection.path };
|
|
78
77
|
console.log('Test collection created:', testCollection);
|
|
79
78
|
});
|
|
80
|
-
|
|
79
|
+
afterAll(async () => {
|
|
81
80
|
// Clean up test collection (cascades to documents and fields)
|
|
82
81
|
try {
|
|
83
82
|
await commandBuilders.collections.delete(testCollection.id);
|
|
@@ -122,24 +121,24 @@ describe('02 Field Types', () => {
|
|
|
122
121
|
locale: 'en',
|
|
123
122
|
fields: ['title', 'views'],
|
|
124
123
|
});
|
|
125
|
-
|
|
124
|
+
expect(result.documents.length > 0, 'should return at least one document').toBeTruthy();
|
|
126
125
|
const doc = result.documents[0];
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
126
|
+
expect(doc.fields, 'document should have fields').toBeTruthy();
|
|
127
|
+
expect(doc.fields.title, 'should include title field').toBeTruthy();
|
|
128
|
+
expect(doc.fields.views, 'should include views field').toBe(100);
|
|
130
129
|
// Fields not requested should be absent or empty
|
|
131
|
-
|
|
132
|
-
|
|
130
|
+
expect(doc.fields.price, 'should not include unrequested decimal field').toBe(undefined);
|
|
131
|
+
expect(doc.fields.attachment, 'should not include unrequested file field').toBe(undefined);
|
|
133
132
|
});
|
|
134
133
|
it('should return all fields when no fields parameter is provided', async () => {
|
|
135
134
|
const result = await queryBuilders.documents.findDocuments({
|
|
136
135
|
collection_id: testCollection.id,
|
|
137
136
|
locale: 'en',
|
|
138
137
|
});
|
|
139
|
-
|
|
138
|
+
expect(result.documents.length > 0, 'should return at least one document').toBeTruthy();
|
|
140
139
|
const doc = result.documents[0];
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
expect(doc.fields, 'document should have fields').toBeTruthy();
|
|
141
|
+
expect(doc.fields.title, 'should include title').toBeTruthy();
|
|
142
|
+
expect(doc.path, 'should include the system path on the document envelope').toBeTruthy();
|
|
144
143
|
});
|
|
145
144
|
});
|
|
@@ -5,10 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
|
-
import assert from 'node:assert';
|
|
9
|
-
import { describe, it } from 'node:test';
|
|
10
8
|
import { defineCollection } from '@byline/core';
|
|
11
9
|
import { v7 as uuidv7 } from 'uuid';
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
12
11
|
import { flattenFieldSetData } from '../storage-flatten.js';
|
|
13
12
|
import { restoreFieldSetData } from '../storage-restore.js';
|
|
14
13
|
import { resolveStoreTypes } from '../storage-utils.js';
|
|
@@ -227,26 +226,26 @@ const expectedRestored = {
|
|
|
227
226
|
describe('01 Document Flattening and Reconstruction', () => {
|
|
228
227
|
it('should flatten and reconstruct a document via schema-aware round-trip', () => {
|
|
229
228
|
const flattened = flattenFieldSetData(DocsCollectionConfig.fields, sampleDocument, 'all');
|
|
230
|
-
|
|
231
|
-
|
|
229
|
+
expect(flattened, 'Flattened document should not be null or undefined').toBeTruthy();
|
|
230
|
+
expect(flattened.length > 0, 'Flattened document should contain field values').toBe(true);
|
|
232
231
|
const { data: restored, warnings } = restoreFieldSetData(DocsCollectionConfig.fields, flattened);
|
|
233
|
-
|
|
234
|
-
|
|
232
|
+
expect(restored, 'Restored document should not be null or undefined').toBeTruthy();
|
|
233
|
+
expect(warnings, 'Round-trip restore should produce no warnings').toEqual([]);
|
|
235
234
|
const restoredJson = JSON.stringify(restored, null, 2);
|
|
236
235
|
const expectedJson = JSON.stringify(expectedRestored, null, 2);
|
|
237
|
-
|
|
236
|
+
expect(JSON.parse(restoredJson), 'Restored document should match the expected flat block shape').toEqual(JSON.parse(expectedJson));
|
|
238
237
|
});
|
|
239
238
|
it('should resolve localized fields when a specific locale is requested', () => {
|
|
240
239
|
const flattened = flattenFieldSetData(DocsCollectionConfig.fields, sampleDocument, 'all');
|
|
241
240
|
const { data: restored } = restoreFieldSetData(DocsCollectionConfig.fields, flattened, 'en');
|
|
242
|
-
|
|
243
|
-
|
|
241
|
+
expect(restored.title).toBe('My First Document');
|
|
242
|
+
expect(restored.summary).toBe('This is a sample document for testing purposes.');
|
|
244
243
|
});
|
|
245
244
|
});
|
|
246
245
|
describe('resolveStoreTypes', () => {
|
|
247
246
|
it('should resolve text fields to text store', () => {
|
|
248
247
|
const stores = resolveStoreTypes(DocsCollectionConfig.fields, ['path', 'title', 'summary']);
|
|
249
|
-
|
|
248
|
+
expect([...stores].sort()).toEqual(['text']);
|
|
250
249
|
});
|
|
251
250
|
it('should resolve mixed field types to their respective stores', () => {
|
|
252
251
|
const stores = resolveStoreTypes(DocsCollectionConfig.fields, [
|
|
@@ -256,21 +255,21 @@ describe('resolveStoreTypes', () => {
|
|
|
256
255
|
'views',
|
|
257
256
|
'price',
|
|
258
257
|
]);
|
|
259
|
-
|
|
258
|
+
expect([...stores].sort()).toEqual(['boolean', 'datetime', 'numeric', 'text']);
|
|
260
259
|
});
|
|
261
260
|
it('should resolve blocks field to all child store types', () => {
|
|
262
261
|
const stores = resolveStoreTypes(DocsCollectionConfig.fields, ['content']);
|
|
263
262
|
// content blocks contain: richText (json), boolean, text, image (file)
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
263
|
+
expect(stores.has('json'), 'should include json for richText').toBeTruthy();
|
|
264
|
+
expect(stores.has('boolean'), 'should include boolean for constrainedWidth').toBeTruthy();
|
|
265
|
+
expect(stores.has('text'), 'should include text for display/alt').toBeTruthy();
|
|
266
|
+
expect(stores.has('file'), 'should include file for photo/image').toBeTruthy();
|
|
268
267
|
});
|
|
269
268
|
it('should resolve array field to child store types', () => {
|
|
270
269
|
const stores = resolveStoreTypes(DocsCollectionConfig.fields, ['reviews']);
|
|
271
270
|
// reviews array contains group with: integer (numeric), richText (json)
|
|
272
|
-
|
|
273
|
-
|
|
271
|
+
expect(stores.has('numeric'), 'should include numeric for rating').toBeTruthy();
|
|
272
|
+
expect(stores.has('json'), 'should include json for comment richText').toBeTruthy();
|
|
274
273
|
});
|
|
275
274
|
it('should ignore field names that do not exist in the collection', () => {
|
|
276
275
|
const stores = resolveStoreTypes(DocsCollectionConfig.fields, [
|
|
@@ -278,11 +277,11 @@ describe('resolveStoreTypes', () => {
|
|
|
278
277
|
'updated_at',
|
|
279
278
|
'nonexistent',
|
|
280
279
|
]);
|
|
281
|
-
|
|
280
|
+
expect(stores.size, 'metadata fields should not resolve to any store').toBe(0);
|
|
282
281
|
});
|
|
283
282
|
it('should return empty set for empty field list', () => {
|
|
284
283
|
const stores = resolveStoreTypes(DocsCollectionConfig.fields, []);
|
|
285
|
-
|
|
284
|
+
expect(stores.size).toBe(0);
|
|
286
285
|
});
|
|
287
286
|
});
|
|
288
287
|
describe('reserved field-name tolerance on restore', () => {
|
|
@@ -310,9 +309,9 @@ describe('reserved field-name tolerance on restore', () => {
|
|
|
310
309
|
value: 'Hello',
|
|
311
310
|
};
|
|
312
311
|
const { data: restored, warnings } = restoreFieldSetData(DocsCollectionConfig.fields, [orphanPathRow, titleRow], 'en');
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
312
|
+
expect(restored.path, 'reserved-name row must not land on the reconstructed document').toBe(undefined);
|
|
313
|
+
expect(restored.title, 'non-reserved rows must still be restored').toBe('Hello');
|
|
314
|
+
expect(warnings, 'reserved-name orphan must not surface as a restore warning').toEqual([]);
|
|
316
315
|
});
|
|
317
316
|
it('does not raise warnings when the only row present is a reserved-name orphan', () => {
|
|
318
317
|
const orphanPathRow = {
|
|
@@ -322,6 +321,6 @@ describe('reserved field-name tolerance on restore', () => {
|
|
|
322
321
|
value: 'whatever',
|
|
323
322
|
};
|
|
324
323
|
const { warnings } = restoreFieldSetData(DocsCollectionConfig.fields, [orphanPathRow]);
|
|
325
|
-
|
|
324
|
+
expect(warnings, 'a reserved-name orphan must not be treated as an unknown field').toEqual([]);
|
|
326
325
|
});
|
|
327
326
|
});
|