@byline/db-postgres 1.12.1 → 2.1.0

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.
@@ -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 hashPassword(password),
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 hashPassword(password),
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
- before(() => {
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
- after(async () => {
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
- assert.throws(() => new JwtSessionProvider({ store, signingSecret: 'too-short' }), /at least 32 bytes/);
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
- assert.deepStrictEqual(provider.capabilities, {
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
- assert.ok(result.accessToken);
104
- assert.ok(result.refreshToken);
105
- assert.ok(result.actor instanceof AdminAuth);
106
- assert.strictEqual(result.actor.isSuperAdmin, false);
107
- assert.ok(result.accessTokenExpiresAt.getTime() > Date.now());
108
- assert.ok(result.refreshTokenExpiresAt.getTime() > Date.now());
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
- assert.fail('expected ERR_INVALID_CREDENTIALS');
127
+ throw new Error('expected ERR_INVALID_CREDENTIALS');
118
128
  }
119
129
  catch (err) {
120
- assert.ok(err instanceof AuthError);
121
- assert.strictEqual(err.code, AuthErrorCodes.INVALID_CREDENTIALS);
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
- assert.fail('expected ERR_INVALID_CREDENTIALS');
143
+ throw new Error('expected ERR_INVALID_CREDENTIALS');
134
144
  }
135
145
  catch (err) {
136
- assert.strictEqual(err.code, AuthErrorCodes.INVALID_CREDENTIALS);
146
+ expect(err.code).toBe(AuthErrorCodes.INVALID_CREDENTIALS);
137
147
  }
138
148
  const row = await store.adminUsers.getById(user.id);
139
- assert.strictEqual(row?.failed_login_attempts, 1);
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
- assert.fail('expected ERR_ACCOUNT_DISABLED');
156
+ throw new Error('expected ERR_ACCOUNT_DISABLED');
147
157
  }
148
158
  catch (err) {
149
- assert.strictEqual(err.code, AuthErrorCodes.ACCOUNT_DISABLED);
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
- assert.strictEqual(rows.length, 1);
163
- assert.strictEqual(rows[0]?.ip, '192.168.1.5');
164
- assert.strictEqual(rows[0]?.user_agent, 'Mozilla/test');
165
- assert.strictEqual(rows[0]?.revoked_at, null);
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
- assert.strictEqual(actor.id, user.id);
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
- assert.fail('expected ERR_INVALID_TOKEN');
193
+ throw new Error('expected ERR_INVALID_TOKEN');
184
194
  }
185
195
  catch (err) {
186
- assert.strictEqual(err.code, AuthErrorCodes.INVALID_TOKEN);
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
- assert.fail('expected ERR_INVALID_TOKEN');
211
+ throw new Error('expected ERR_INVALID_TOKEN');
202
212
  }
203
213
  catch (err) {
204
- assert.strictEqual(err.code, AuthErrorCodes.INVALID_TOKEN);
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
- assert.fail('expected ERR_ACCOUNT_DISABLED');
227
+ throw new Error('expected ERR_ACCOUNT_DISABLED');
218
228
  }
219
229
  catch (err) {
220
- assert.strictEqual(err.code, AuthErrorCodes.ACCOUNT_DISABLED);
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 last char of the signature
232
- const last = parts[2] ?? '';
233
- const flipped = `${last.slice(0, -1)}${last.at(-1) === 'a' ? 'b' : 'a'}`;
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
- assert.fail('expected ERR_INVALID_TOKEN');
255
+ throw new Error('expected ERR_INVALID_TOKEN');
239
256
  }
240
257
  catch (err) {
241
- assert.strictEqual(err.code, AuthErrorCodes.INVALID_TOKEN);
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
- assert.notStrictEqual(refreshed.refreshToken, signIn.refreshToken);
255
- assert.notStrictEqual(refreshed.accessToken, signIn.accessToken);
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
- assert.strictEqual(rows.length, 2);
275
+ expect(rows.length).toBe(2);
259
276
  const [oldRow, newRow] = rows;
260
- assert.ok(oldRow?.revoked_at);
261
- assert.strictEqual(oldRow?.rotated_to_id, newRow?.id);
262
- assert.strictEqual(newRow?.revoked_at, null);
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
- assert.strictEqual(actor.id, user.id);
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
- assert.fail('expected ERR_INVALID_TOKEN');
288
+ throw new Error('expected ERR_INVALID_TOKEN');
272
289
  }
273
290
  catch (err) {
274
- assert.strictEqual(err.code, AuthErrorCodes.INVALID_TOKEN);
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
- assert.fail('expected ERR_INVALID_TOKEN');
305
+ throw new Error('expected ERR_INVALID_TOKEN');
289
306
  }
290
307
  catch (err) {
291
- assert.strictEqual(err.code, AuthErrorCodes.INVALID_TOKEN);
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
- assert.ok(r2.refreshToken);
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
- assert.fail('expected ERR_REVOKED_TOKEN');
325
+ throw new Error('expected ERR_REVOKED_TOKEN');
309
326
  }
310
327
  catch (err) {
311
- assert.strictEqual(err.code, AuthErrorCodes.REVOKED_TOKEN);
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
- assert.ok(row.revoked_at, `row ${row.id} expected revoked, got null`);
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
- assert.fail('expected ERR_REVOKED_TOKEN');
346
+ throw new Error('expected ERR_REVOKED_TOKEN');
330
347
  }
331
348
  catch (err) {
332
- assert.strictEqual(err.code, AuthErrorCodes.REVOKED_TOKEN);
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
- assert.strictEqual(rows.length, 1);
348
- assert.ok(rows[0]?.revoked_at);
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
- assert.ok(actor instanceof AdminAuth);
361
- assert.strictEqual(actor.id, user.id);
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
- assert.strictEqual(await provider.resolveActor(user.id), null);
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
- before(async () => {
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
- after(async () => {
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
- assert.ok(caught, 'expected unique-constraint violation on duplicate path');
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
- assert.strictEqual(original.code, '23505', `expected SQLSTATE 23505, got ${original?.code}`);
96
- assert.match(String(original.constraint ?? ''), /document_paths_collection_locale_path/, `constraint name should reference the path index, got ${original?.constraint}`);
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
- assert.strictEqual(second.document.document_id, documentId, 'same logical document');
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
- assert.ok(found, 'updated path should resolve');
160
- assert.strictEqual(found.document_id, documentId);
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
- assert.strictEqual(oldNotFound, null, 'original path no longer resolves');
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
- assert.ok(found, 'fallback chain should resolve via the en row');
190
- assert.strictEqual(found.document_id, documentId);
191
- assert.strictEqual(found.path, onlyDefaultPath);
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
- assert.strictEqual(result, null);
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
- before(async () => {
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
- after(async () => {
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
- assert.ok(result.documents.length > 0, 'should return at least one document');
124
+ expect(result.documents.length > 0, 'should return at least one document').toBeTruthy();
126
125
  const doc = result.documents[0];
127
- assert.ok(doc.fields, 'document should have fields');
128
- assert.ok(doc.fields.title, 'should include title field');
129
- assert.strictEqual(doc.fields.views, 100, 'should include views field');
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
- assert.strictEqual(doc.fields.price, undefined, 'should not include unrequested decimal field');
132
- assert.strictEqual(doc.fields.attachment, undefined, 'should not include unrequested file field');
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
- assert.ok(result.documents.length > 0, 'should return at least one document');
138
+ expect(result.documents.length > 0, 'should return at least one document').toBeTruthy();
140
139
  const doc = result.documents[0];
141
- assert.ok(doc.fields, 'document should have fields');
142
- assert.ok(doc.fields.title, 'should include title');
143
- assert.ok(doc.path, 'should include the system path on the document envelope');
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
- assert(flattened, 'Flattened document should not be null or undefined');
231
- assert(flattened.length > 0, 'Flattened document should contain field values');
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
- assert(restored, 'Restored document should not be null or undefined');
234
- assert.deepStrictEqual(warnings, [], 'Round-trip restore should produce no warnings');
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
- assert.deepStrictEqual(JSON.parse(restoredJson), JSON.parse(expectedJson), 'Restored document should match the expected flat block shape');
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
- assert.strictEqual(restored.title, 'My First Document');
243
- assert.strictEqual(restored.summary, 'This is a sample document for testing purposes.');
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
- assert.deepStrictEqual([...stores].sort(), ['text']);
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
- assert.deepStrictEqual([...stores].sort(), ['boolean', 'datetime', 'numeric', 'text']);
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
- assert.ok(stores.has('json'), 'should include json for richText');
265
- assert.ok(stores.has('boolean'), 'should include boolean for constrainedWidth');
266
- assert.ok(stores.has('text'), 'should include text for display/alt');
267
- assert.ok(stores.has('file'), 'should include file for photo/image');
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
- assert.ok(stores.has('numeric'), 'should include numeric for rating');
273
- assert.ok(stores.has('json'), 'should include json for comment richText');
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
- assert.strictEqual(stores.size, 0, 'metadata fields should not resolve to any store');
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
- assert.strictEqual(stores.size, 0);
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
- assert.strictEqual(restored.path, undefined, 'reserved-name row must not land on the reconstructed document');
314
- assert.strictEqual(restored.title, 'Hello', 'non-reserved rows must still be restored');
315
- assert.deepStrictEqual(warnings, [], 'reserved-name orphan must not surface as a restore warning');
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
- assert.deepStrictEqual(warnings, [], 'a reserved-name orphan must not be treated as an unknown field');
324
+ expect(warnings, 'a reserved-name orphan must not be treated as an unknown field').toEqual([]);
326
325
  });
327
326
  });