@byline/core 6.7.1 → 6.7.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.
@@ -0,0 +1,3397 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
9
+ import { describe, expect, it, vi } from 'vitest';
10
+ import { BylineError, DbErrorCodes, ERR_DOCUMENT_STALE, ERR_NOT_FOUND, ErrorCodes, } from '../lib/errors.js';
11
+ import { runReadSnapshot } from '../storage/read-snapshot.js';
12
+ import { unusedRevisionStore } from '../storage/revision-store.test-helper.js';
13
+ import { cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, copyToLocale, createDocument, deleteDocument, deleteLocale, duplicateDocument, listDocumentPublishSchedules, restoreDocumentVersion, scheduleDocumentPublish, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Fixtures / Helpers
16
+ // ---------------------------------------------------------------------------
17
+ const minimalCollection = {
18
+ path: 'articles',
19
+ labels: { singular: 'Article', plural: 'Articles' },
20
+ fields: [{ name: 'title', type: 'text', label: 'Title' }],
21
+ workflow: {
22
+ statuses: [
23
+ { name: 'draft', label: 'Draft' },
24
+ { name: 'published', label: 'Published' },
25
+ { name: 'archived', label: 'Archived' },
26
+ ],
27
+ },
28
+ };
29
+ const numericCollection = {
30
+ ...minimalCollection,
31
+ fields: [
32
+ { name: 'quantity', type: 'integer' },
33
+ { name: 'score', type: 'float' },
34
+ { name: 'price', type: 'decimal' },
35
+ ],
36
+ };
37
+ function publishScheduleRow(overrides = {}) {
38
+ const now = new Date('2026-08-22T12:00:00.000Z');
39
+ return {
40
+ documentId: 'doc-1',
41
+ collectionId: 'col-1',
42
+ targetVersionId: 'ver-1',
43
+ authorizedRevision: 1,
44
+ publishAt: new Date('2026-08-23T12:00:00.000Z'),
45
+ state: 'armed',
46
+ suspendedAt: null,
47
+ suspendedReason: null,
48
+ scheduledBy: TEST_ACTOR_ID,
49
+ lastAuthorizedBy: TEST_ACTOR_ID,
50
+ lastAuthorizedAt: now,
51
+ scheduledAt: now,
52
+ updatedAt: now,
53
+ executionToken: null,
54
+ executionExpiresAt: null,
55
+ lastAttemptAt: null,
56
+ nextAttemptAt: new Date('2026-08-23T12:00:00.000Z'),
57
+ attemptCount: 0,
58
+ lastError: null,
59
+ ...overrides,
60
+ };
61
+ }
62
+ /** Build a mock IDbAdapter. Returns the adapter plus individual mock fns. */
63
+ function createMockDb() {
64
+ const createDocumentVersion = vi.fn().mockResolvedValue({
65
+ document: { id: 'ver-1', document_id: 'doc-1' },
66
+ fieldCount: 3,
67
+ });
68
+ const setDocumentStatus = vi.fn().mockResolvedValue(undefined);
69
+ const archivePublishedVersions = vi.fn().mockResolvedValue(0);
70
+ const softDeleteDocument = vi.fn().mockResolvedValue(1);
71
+ const deleteDocumentLocale = vi.fn().mockResolvedValue({ newVersionId: 'ver-2' });
72
+ const getDocumentById = vi.fn().mockImplementation(async () => {
73
+ const metadata = await getCurrentVersionMetadata();
74
+ return metadata == null ? null : { ...metadata, fields: {}, path: await getCurrentPath() };
75
+ });
76
+ const getDocumentSystemFieldsForUpdate = vi.fn().mockResolvedValue(null);
77
+ const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
78
+ const getCurrentPath = vi.fn().mockResolvedValue('current-path');
79
+ const publishSchedule = vi.fn().mockResolvedValue({ status: 'document_not_found' });
80
+ const confirmPublishSchedule = vi.fn().mockResolvedValue({ status: 'schedule_not_found' });
81
+ const cancelPublishSchedule = vi.fn().mockResolvedValue(null);
82
+ const suspendPublishSchedule = vi.fn().mockResolvedValue({ status: 'schedule_not_found' });
83
+ const claimDuePublishSchedules = vi.fn().mockResolvedValue([]);
84
+ const lockPublishScheduleClaim = vi.fn().mockResolvedValue(null);
85
+ const deletePublishScheduleClaim = vi.fn().mockResolvedValue(false);
86
+ const suspendPublishScheduleClaim = vi.fn().mockResolvedValue(false);
87
+ const releasePublishScheduleClaim = vi.fn().mockResolvedValue(false);
88
+ const getPublishSchedule = vi.fn().mockResolvedValue(null);
89
+ const listPublishSchedules = vi.fn().mockResolvedValue({ schedules: [], total: 0 });
90
+ // Audit capability (docs/07-auth-and-security/02-auditability.md — W2). `withTransaction` is a passthrough
91
+ // in unit tests (runs the unit of work immediately, no real tx); `append`
92
+ // records the calls so write-point tests can assert the audit rows emitted.
93
+ const auditAppend = vi.fn().mockResolvedValue({ id: 'audit-1' });
94
+ let transactionDepth = 0;
95
+ const revisions = new Map();
96
+ const transaction = {
97
+ run: async (fn) => {
98
+ transactionDepth++;
99
+ try {
100
+ return await fn();
101
+ }
102
+ finally {
103
+ transactionDepth--;
104
+ }
105
+ },
106
+ };
107
+ const withTransaction = vi.spyOn(transaction, 'run');
108
+ const fail = () => {
109
+ throw new Error('unexpected singleton mapping call');
110
+ };
111
+ // Fake classifier mirroring db-postgres's real one closely enough for
112
+ // rethrowPathConflict's consumption of the seam: a pg-shaped 23505 with a
113
+ // constraint name classifies as a unique violation; anything else is
114
+ // unknown. Individual tests inject pg-shaped errors on `createDocumentVersion`
115
+ // (see `translates a Postgres unique-constraint violation...` below).
116
+ const classifyError = vi.fn((err) => {
117
+ const e = err;
118
+ if (e?.code === '23505') {
119
+ return { code: DbErrorCodes.UNIQUE_VIOLATION, constraint: e.constraint };
120
+ }
121
+ return { code: DbErrorCodes.UNKNOWN };
122
+ });
123
+ const db = {
124
+ classifyError,
125
+ revisions: {
126
+ ...unusedRevisionStore,
127
+ isInTransaction: () => transactionDepth > 0,
128
+ lock: vi.fn(async (targets) => {
129
+ const observations = [];
130
+ for (const target of targets) {
131
+ const revision = revisions.get(target.documentId) ?? 1;
132
+ if (target.expectedRevision !== revision)
133
+ throw ERR_DOCUMENT_STALE({
134
+ message: 'stale',
135
+ details: {
136
+ reason: 'revision_mismatch',
137
+ documentId: target.documentId,
138
+ expectedRevision: target.expectedRevision,
139
+ currentRevision: revision,
140
+ },
141
+ });
142
+ const system = await getDocumentSystemFieldsForUpdate({
143
+ collection_id: target.collectionId,
144
+ document_id: target.documentId,
145
+ });
146
+ const source = await getDocumentById.mock.results.at(-1)?.value;
147
+ if (!system && !source)
148
+ throw ERR_NOT_FOUND({ message: 'document not found' });
149
+ observations.push({
150
+ documentId: target.documentId,
151
+ collectionId: target.collectionId,
152
+ revision,
153
+ currentVersionId: source?.document_version_id ?? 'ver-1',
154
+ status: source?.status ?? 'draft',
155
+ sourceLocale: system?.source_locale ?? source?.source_locale ?? 'en',
156
+ path: system?.path ?? source?.path ?? null,
157
+ availableLocales: system?.availableLocales ?? source?.availableLocales ?? [],
158
+ });
159
+ }
160
+ return observations;
161
+ }),
162
+ advance: vi.fn(async (locked) => {
163
+ const revision = locked.revision + 1;
164
+ revisions.set(locked.documentId, revision);
165
+ return { documentId: locked.documentId, revision };
166
+ }),
167
+ },
168
+ withReadSnapshot: (fn) => runReadSnapshot(db.queries, fn),
169
+ commands: {
170
+ collections: {
171
+ lockCollectionRegistration: vi.fn(async () => { }),
172
+ create: vi.fn(),
173
+ update: vi.fn(),
174
+ delete: vi.fn(),
175
+ },
176
+ documents: {
177
+ publishSchedules: {
178
+ lockDocuments: vi.fn(async () => { }),
179
+ schedule: publishSchedule,
180
+ confirm: confirmPublishSchedule,
181
+ cancel: cancelPublishSchedule,
182
+ suspendForContentEdit: suspendPublishSchedule,
183
+ claimDue: claimDuePublishSchedules,
184
+ lockClaim: lockPublishScheduleClaim,
185
+ deleteClaim: deletePublishScheduleClaim,
186
+ suspendClaimForContentEdit: suspendPublishScheduleClaim,
187
+ releaseClaim: releasePublishScheduleClaim,
188
+ },
189
+ createDocumentVersion,
190
+ updateDocumentPath: vi.fn().mockResolvedValue(undefined),
191
+ setDocumentAvailableLocales: vi.fn().mockResolvedValue(undefined),
192
+ setDocumentStatus,
193
+ archivePublishedVersions,
194
+ softDeleteDocument,
195
+ restoreSoftDeletedDocument: vi.fn(),
196
+ deleteDocumentLocale: deleteDocumentLocale,
197
+ setOrderKey: vi.fn(),
198
+ placeTreeNode: vi.fn(),
199
+ removeFromTree: vi.fn(),
200
+ promoteChildrenAndRemoveFromTree: vi.fn(async () => ({
201
+ removed: {
202
+ changed: false,
203
+ before: { placed: false, parentDocumentId: null, orderKey: null, index: null },
204
+ after: { placed: false, parentDocumentId: null, orderKey: null, index: null },
205
+ beforeSiblingDocumentIds: [],
206
+ beforeSubtreeDocumentIds: [],
207
+ },
208
+ promoted: [],
209
+ })),
210
+ },
211
+ counters: {
212
+ ensureCounterGroup: vi.fn(),
213
+ nextCounterValue: vi.fn(),
214
+ nextScopedCounterValue: vi.fn(),
215
+ },
216
+ audit: { append: auditAppend },
217
+ singletons: {
218
+ lockSlot: vi.fn(async () => { }),
219
+ setMapping: vi.fn(fail),
220
+ clearMapping: vi.fn(fail),
221
+ },
222
+ },
223
+ withTransaction: transaction.run,
224
+ queries: {
225
+ collections: {
226
+ getAllCollections: vi.fn(),
227
+ getCollectionByPath: vi.fn(),
228
+ getCollectionById: vi.fn(),
229
+ },
230
+ documents: {
231
+ publishSchedules: {
232
+ get: getPublishSchedule,
233
+ list: listPublishSchedules,
234
+ },
235
+ getDocumentRevision: async ({ document_id }) => revisions.get(document_id) ?? 1,
236
+ getDocumentSystemFieldsForUpdate,
237
+ getDocumentById,
238
+ getCurrentVersionMetadata,
239
+ getCurrentPath,
240
+ getDocumentByPath: vi.fn(),
241
+ getDocumentByVersion: vi.fn(),
242
+ getDocumentsByVersionIds: vi.fn(),
243
+ getDocumentsByDocumentIds: vi.fn(),
244
+ getDocumentHistory: vi.fn(),
245
+ getPublishedVersion: vi.fn(),
246
+ getPublishedDocumentIds: vi.fn(),
247
+ getDocumentCountsByStatus: vi.fn(),
248
+ findDocuments: vi.fn(),
249
+ getLastOrderKey: vi.fn(),
250
+ getNeighborOrderKeys: vi.fn(),
251
+ getCanonicalDocumentOrder: vi.fn(),
252
+ getTreeAncestors: vi.fn(),
253
+ getTreeChildren: vi.fn(),
254
+ getTreeParent: vi.fn(),
255
+ getTreeSubtree: vi.fn(),
256
+ },
257
+ audit: {
258
+ getDocumentAuditLog: vi.fn(async () => ({
259
+ entries: [],
260
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
261
+ })),
262
+ findAuditLog: vi.fn(async () => ({
263
+ entries: [],
264
+ meta: { total: 0, page: 1, pageSize: 20, totalPages: 0 },
265
+ })),
266
+ },
267
+ singletons: { getMappedDocumentId: vi.fn(fail) },
268
+ },
269
+ };
270
+ return {
271
+ db,
272
+ createDocumentVersion,
273
+ setDocumentStatus,
274
+ archivePublishedVersions,
275
+ softDeleteDocument,
276
+ deleteDocumentLocale,
277
+ getDocumentById,
278
+ getDocumentSystemFieldsForUpdate,
279
+ getCurrentVersionMetadata,
280
+ getCurrentPath,
281
+ publishSchedule,
282
+ confirmPublishSchedule,
283
+ cancelPublishSchedule,
284
+ suspendPublishSchedule,
285
+ claimDuePublishSchedules,
286
+ lockPublishScheduleClaim,
287
+ deletePublishScheduleClaim,
288
+ suspendPublishScheduleClaim,
289
+ releasePublishScheduleClaim,
290
+ getPublishSchedule,
291
+ listPublishSchedules,
292
+ auditAppend,
293
+ withTransaction,
294
+ };
295
+ }
296
+ function pathUniqueViolation() {
297
+ return Object.assign(new Error('duplicate key value violates unique constraint'), {
298
+ code: '23505',
299
+ constraint: 'idx_document_paths_collection_locale_path',
300
+ });
301
+ }
302
+ const noopLogger = {
303
+ log: vi.fn(),
304
+ fatal: vi.fn(),
305
+ error: vi.fn(),
306
+ warn: vi.fn(),
307
+ info: vi.fn(),
308
+ debug: vi.fn(),
309
+ trace: vi.fn(),
310
+ silent: vi.fn(),
311
+ };
312
+ // A real persisted-user id is a UUID; `actorId()` only attributes UUIDs (see
313
+ // the regression note below). Use a valid UUID so the default context's
314
+ // writes are attributed.
315
+ const TEST_ACTOR_ID = '01901234-0000-7000-8000-000000000001';
316
+ function buildCtx(db, definition = minimalCollection) {
317
+ return {
318
+ db,
319
+ definition,
320
+ collectionId: 'col-1',
321
+ collectionVersion: 1,
322
+ collectionPath: definition.path,
323
+ logger: noopLogger,
324
+ defaultLocale: 'en',
325
+ // Inject a super-admin context by default so the bulk of existing
326
+ // tests do not have to care about ability enforcement. The dedicated
327
+ // "enforcement" block below covers the missing-context / missing-ability
328
+ // negative cases.
329
+ requestContext: createSuperAdminContext({ id: TEST_ACTOR_ID }),
330
+ };
331
+ }
332
+ // ---------------------------------------------------------------------------
333
+ // Tests
334
+ // ---------------------------------------------------------------------------
335
+ describe('Document lifecycle service', () => {
336
+ // -----------------------------------------------------------------------
337
+ // createDocument
338
+ // -----------------------------------------------------------------------
339
+ describe('createDocument', () => {
340
+ it.each(['explicit', 'legacy'])('rejects a create-only published override (%s)', async (source) => {
341
+ const { db, createDocumentVersion } = createMockDb();
342
+ const ctx = buildCtx(db);
343
+ ctx.requestContext = createRequestContext({
344
+ actor: new AdminAuth({ id: 'editor', abilities: ['collections.articles.create'] }),
345
+ });
346
+ await expect(createDocument(ctx, {
347
+ data: { title: 'Hello', ...(source === 'legacy' ? { status: 'published' } : {}) },
348
+ ...(source === 'explicit' ? { status: 'published' } : {}),
349
+ })).rejects.toMatchObject({ code: AuthErrorCodes.FORBIDDEN });
350
+ expect(createDocumentVersion).not.toHaveBeenCalled();
351
+ });
352
+ it('rejects undeclared statuses and hook-produced publication', async () => {
353
+ const { db, createDocumentVersion } = createMockDb();
354
+ await expect(createDocument(buildCtx(db), { data: { title: 'Hello' }, status: 'bogus' })).rejects.toMatchObject({ code: ErrorCodes.VALIDATION });
355
+ const ctx = buildCtx(db, {
356
+ ...minimalCollection,
357
+ hooks: {
358
+ beforeCreate: ({ data }) => {
359
+ data.status = 'published';
360
+ },
361
+ },
362
+ });
363
+ ctx.requestContext = createRequestContext({
364
+ actor: new AdminAuth({ id: 'editor', abilities: ['collections.articles.create'] }),
365
+ });
366
+ await expect(createDocument(ctx, { data: { title: 'Hello' } })).rejects.toMatchObject({
367
+ code: AuthErrorCodes.FORBIDDEN,
368
+ });
369
+ expect(createDocumentVersion).not.toHaveBeenCalled();
370
+ });
371
+ it('preserves publication by configured default for create-only actors', async () => {
372
+ const { db, createDocumentVersion } = createMockDb();
373
+ const ctx = buildCtx(db, {
374
+ ...minimalCollection,
375
+ workflow: { statuses: [{ name: 'published', label: 'Published' }] },
376
+ });
377
+ ctx.requestContext = createRequestContext({
378
+ actor: new AdminAuth({ id: 'editor', abilities: ['collections.articles.create'] }),
379
+ });
380
+ await createDocument(ctx, { data: { title: 'Hello' } });
381
+ expect(createDocumentVersion).toHaveBeenCalledWith(expect.objectContaining({ status: 'published' }));
382
+ });
383
+ it('rejects missing required data and values invalidated by hooks before persistence', async () => {
384
+ const { db, createDocumentVersion } = createMockDb();
385
+ await expect(createDocument(buildCtx(db), { data: {} })).rejects.toMatchObject({
386
+ code: ErrorCodes.VALIDATION,
387
+ details: { reason: 'invalid_document_fields' },
388
+ });
389
+ const ctx = buildCtx(db, {
390
+ ...minimalCollection,
391
+ hooks: {
392
+ beforeCreate: ({ data }) => {
393
+ data.title = 42;
394
+ },
395
+ },
396
+ });
397
+ await expect(createDocument(ctx, { data: { title: 'Hello' } })).rejects.toMatchObject({
398
+ code: ErrorCodes.VALIDATION,
399
+ });
400
+ expect(createDocumentVersion).not.toHaveBeenCalled();
401
+ });
402
+ it('calls createDocumentVersion and returns IDs', async () => {
403
+ const { db, createDocumentVersion } = createMockDb();
404
+ const ctx = buildCtx(db);
405
+ const result = await createDocument(ctx, {
406
+ data: { title: 'Hello' },
407
+ locale: 'en',
408
+ });
409
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
410
+ expect(result.documentId).toBe('doc-1');
411
+ expect(result.documentVersionId).toBe('ver-1');
412
+ });
413
+ it('passes the acting user id as createdBy for the audit trail', async () => {
414
+ const { db, createDocumentVersion } = createMockDb();
415
+ const ctx = buildCtx(db);
416
+ await createDocument(ctx, {
417
+ data: { title: 'Hello' },
418
+ locale: 'en',
419
+ });
420
+ // Audit contract (docs/07-auth-and-security/02-auditability.md — W1): every version row
421
+ // records the actor that created it.
422
+ expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe(TEST_ACTOR_ID);
423
+ });
424
+ it('writes NULL createdBy for a synthetic (non-UUID) script/seed actor', async () => {
425
+ // Regression guard (v3.8.0): a synthetic super-admin id such as
426
+ // `import-docs-script` is not a real user and is not a UUID — writing
427
+ // it into the `created_by` UUID column crashed every import/seed. Such
428
+ // system/tooling writes must attribute to NULL, not the synthetic id.
429
+ const { db, createDocumentVersion } = createMockDb();
430
+ const ctx = buildCtx(db);
431
+ ctx.requestContext = createSuperAdminContext({ id: 'import-docs-script' });
432
+ await createDocument(ctx, { data: { title: 'Hello' }, locale: 'en' });
433
+ expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBeUndefined();
434
+ });
435
+ it('invokes beforeCreate and afterCreate hooks in order', async () => {
436
+ const callOrder = [];
437
+ const hooks = {
438
+ beforeCreate: vi.fn(async () => {
439
+ callOrder.push('before');
440
+ }),
441
+ afterCreate: vi.fn(async () => {
442
+ callOrder.push('after');
443
+ }),
444
+ };
445
+ const { db, createDocumentVersion } = createMockDb();
446
+ createDocumentVersion.mockImplementation(async () => {
447
+ callOrder.push('persist');
448
+ return { document: { id: 'ver-1', document_id: 'doc-1' }, fieldCount: 1 };
449
+ });
450
+ const definition = { ...minimalCollection, hooks };
451
+ const ctx = buildCtx(db, definition);
452
+ await createDocument(ctx, { data: { title: 'Test' } });
453
+ expect(callOrder).toEqual(['before', 'persist', 'after']);
454
+ });
455
+ it('afterCreate receives documentId and documentVersionId', async () => {
456
+ const afterCreate = vi.fn();
457
+ const { db } = createMockDb();
458
+ const definition = { ...minimalCollection, hooks: { afterCreate } };
459
+ const ctx = buildCtx(db, definition);
460
+ await createDocument(ctx, { data: { title: 'X' } });
461
+ expect(afterCreate).toHaveBeenCalledWith(expect.objectContaining({
462
+ documentId: 'doc-1',
463
+ documentVersionId: 'ver-1',
464
+ }));
465
+ });
466
+ it('classifies an afterCreate failure as post-commit and retains committed ids', async () => {
467
+ const afterCreate = vi.fn().mockRejectedValue(new Error('cache unavailable'));
468
+ const { db, createDocumentVersion } = createMockDb();
469
+ const definition = { ...minimalCollection, hooks: { afterCreate } };
470
+ await expect(createDocument(buildCtx(db, definition), { data: { title: 'X' } })).rejects.toMatchObject({
471
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
472
+ details: {
473
+ phase: 'afterCreate',
474
+ documentId: 'doc-1',
475
+ documentVersionId: 'ver-1',
476
+ sideEffectCode: ErrorCodes.UNHANDLED,
477
+ },
478
+ });
479
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
480
+ });
481
+ it('preserves committed classification when diagnostic logging also fails', async () => {
482
+ const afterCreate = vi.fn().mockRejectedValue(new Error('cache unavailable'));
483
+ const { db } = createMockDb();
484
+ const definition = { ...minimalCollection, hooks: { afterCreate } };
485
+ const ctx = buildCtx(db, definition);
486
+ ctx.logger = {
487
+ ...noopLogger,
488
+ log: vi.fn(() => {
489
+ throw new Error('logger failed');
490
+ }),
491
+ };
492
+ await expect(createDocument(ctx, { data: { title: 'X' } })).rejects.toMatchObject({
493
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
494
+ });
495
+ });
496
+ it('afterCreate receives the resolved canonical path', async () => {
497
+ const afterCreate = vi.fn();
498
+ const { db } = createMockDb();
499
+ const definition = { ...minimalCollection, hooks: { afterCreate } };
500
+ const ctx = buildCtx(db, definition);
501
+ await createDocument(ctx, { data: { title: 'X' }, path: 'my-explicit-path' });
502
+ expect(afterCreate).toHaveBeenCalledWith(expect.objectContaining({ path: 'my-explicit-path' }));
503
+ });
504
+ it('beforeCreate can mutate data before persistence', async () => {
505
+ const { db, createDocumentVersion } = createMockDb();
506
+ const definition = {
507
+ ...minimalCollection,
508
+ hooks: {
509
+ beforeCreate: vi.fn(({ data }) => {
510
+ data.title = 'Mutated';
511
+ }),
512
+ },
513
+ };
514
+ const ctx = buildCtx(db, definition);
515
+ await createDocument(ctx, { data: { title: 'Original' } });
516
+ const persistedData = createDocumentVersion.mock.calls[0]?.[0].documentData;
517
+ expect(persistedData.title).toBe('Mutated');
518
+ });
519
+ it('normalizes numeric values before and after beforeCreate', async () => {
520
+ const { db, createDocumentVersion } = createMockDb();
521
+ const beforeCreate = vi.fn(({ data }) => {
522
+ expect(data).toMatchObject({ quantity: 2, score: 1.5, price: '10.00' });
523
+ data.quantity = '3';
524
+ data.price = 12.5;
525
+ });
526
+ const definition = { ...numericCollection, hooks: { beforeCreate } };
527
+ await createDocument(buildCtx(db, definition), {
528
+ data: { quantity: '2', score: '1.5', price: ' 10.00 ' },
529
+ });
530
+ expect(createDocumentVersion.mock.calls[0]?.[0].documentData).toMatchObject({
531
+ quantity: 3,
532
+ score: 1.5,
533
+ price: '12.5',
534
+ });
535
+ });
536
+ it('allocates counters after beforeCreate and before persistence', async () => {
537
+ const callOrder = [];
538
+ const { db, createDocumentVersion } = createMockDb();
539
+ const nextCounterValue = db.commands.counters.nextCounterValue;
540
+ nextCounterValue.mockImplementation(async () => {
541
+ callOrder.push('counter');
542
+ return 42;
543
+ });
544
+ createDocumentVersion.mockImplementation(async () => {
545
+ callOrder.push('persist');
546
+ return { document: { id: 'ver-1', document_id: 'doc-1' }, fieldCount: 1 };
547
+ });
548
+ const definition = {
549
+ ...minimalCollection,
550
+ fields: [{ name: 'serial', type: 'counter', group: 'article-serials' }],
551
+ hooks: {
552
+ beforeCreate: vi.fn(({ data }) => {
553
+ callOrder.push('before');
554
+ data.serial = 999;
555
+ }),
556
+ },
557
+ };
558
+ await createDocument(buildCtx(db, definition), { data: {} });
559
+ expect(callOrder).toEqual(['before', 'counter', 'persist']);
560
+ expect(createDocumentVersion.mock.calls[0]?.[0].documentData.serial).toBe(42);
561
+ });
562
+ it('derives path from useAsPath source field via the slugifier', async () => {
563
+ const { db, createDocumentVersion } = createMockDb();
564
+ const definition = { ...minimalCollection, useAsPath: 'title' };
565
+ const ctx = buildCtx(db, definition);
566
+ await createDocument(ctx, { data: { title: 'My Great Post' } });
567
+ const persistedPath = createDocumentVersion.mock.calls[0]?.[0].path;
568
+ expect(persistedPath).toBe('my-great-post');
569
+ });
570
+ it('uses an explicit params.path verbatim, bypassing derivation', async () => {
571
+ const { db, createDocumentVersion } = createMockDb();
572
+ const definition = { ...minimalCollection, useAsPath: 'title' };
573
+ const ctx = buildCtx(db, definition);
574
+ await createDocument(ctx, {
575
+ data: { title: 'Will Be Ignored' },
576
+ path: 'custom/route',
577
+ });
578
+ const persistedPath = createDocumentVersion.mock.calls[0]?.[0].path;
579
+ expect(persistedPath).toBe('custom/route');
580
+ });
581
+ it('describes a create path conflict as ownership by a live document', async () => {
582
+ const { db, createDocumentVersion } = createMockDb();
583
+ createDocumentVersion.mockRejectedValueOnce(pathUniqueViolation());
584
+ const ctx = buildCtx(db);
585
+ await expect(createDocument(ctx, { data: { title: 'Home' }, path: 'home' })).rejects.toMatchObject({
586
+ code: ErrorCodes.PATH_CONFLICT,
587
+ message: 'cannot create a document at path "home" because a live document already uses it in this collection (locale: en)',
588
+ details: {
589
+ path: 'home',
590
+ locale: 'en',
591
+ constraint: 'idx_document_paths_collection_locale_path',
592
+ },
593
+ });
594
+ });
595
+ it('falls back to a UUID when no useAsPath and no explicit path', async () => {
596
+ const { db, createDocumentVersion } = createMockDb();
597
+ const ctx = buildCtx(db); // minimalCollection has no useAsPath
598
+ await createDocument(ctx, { data: { title: 'Anything' } });
599
+ const persistedPath = createDocumentVersion.mock.calls[0]?.[0].path;
600
+ expect(persistedPath).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
601
+ });
602
+ it('rejects creates in any non-default locale', async () => {
603
+ const { db, createDocumentVersion } = createMockDb();
604
+ const ctx = buildCtx(db);
605
+ await expect(createDocument(ctx, { data: { title: 'Hello' }, locale: 'fr' })).rejects.toMatchObject({ code: ErrorCodes.VALIDATION });
606
+ expect(createDocumentVersion).not.toHaveBeenCalled();
607
+ });
608
+ it('works when no hooks are defined', async () => {
609
+ const { db } = createMockDb();
610
+ const ctx = buildCtx(db);
611
+ // Should not throw
612
+ const result = await createDocument(ctx, { data: { title: 'OK' } });
613
+ expect(result.documentId).toBe('doc-1');
614
+ });
615
+ it('supports an array of beforeCreate hooks executed in order', async () => {
616
+ const callOrder = [];
617
+ const hooks = {
618
+ beforeCreate: [
619
+ vi.fn(async () => {
620
+ callOrder.push('hook-1');
621
+ }),
622
+ vi.fn(async () => {
623
+ callOrder.push('hook-2');
624
+ }),
625
+ vi.fn(async () => {
626
+ callOrder.push('hook-3');
627
+ }),
628
+ ],
629
+ };
630
+ const { db, createDocumentVersion } = createMockDb();
631
+ createDocumentVersion.mockImplementation(async () => {
632
+ callOrder.push('persist');
633
+ return { document: { id: 'ver-1', document_id: 'doc-1' }, fieldCount: 1 };
634
+ });
635
+ const definition = { ...minimalCollection, hooks };
636
+ const ctx = buildCtx(db, definition);
637
+ await createDocument(ctx, { data: { title: 'Test' } });
638
+ expect(callOrder).toEqual(['hook-1', 'hook-2', 'hook-3', 'persist']);
639
+ for (const fn of hooks.beforeCreate) {
640
+ expect(fn).toHaveBeenCalledOnce();
641
+ }
642
+ });
643
+ it('supports an array of afterCreate hooks executed in order', async () => {
644
+ const callOrder = [];
645
+ const hooks = {
646
+ afterCreate: [
647
+ vi.fn(async () => {
648
+ callOrder.push('after-1');
649
+ }),
650
+ vi.fn(async () => {
651
+ callOrder.push('after-2');
652
+ }),
653
+ ],
654
+ };
655
+ const { db, createDocumentVersion } = createMockDb();
656
+ createDocumentVersion.mockImplementation(async () => {
657
+ callOrder.push('persist');
658
+ return { document: { id: 'ver-1', document_id: 'doc-1' }, fieldCount: 1 };
659
+ });
660
+ const definition = { ...minimalCollection, hooks };
661
+ const ctx = buildCtx(db, definition);
662
+ await createDocument(ctx, { data: { title: 'Test' } });
663
+ expect(callOrder).toEqual(['persist', 'after-1', 'after-2']);
664
+ });
665
+ it('array of beforeCreate hooks can each mutate data cumulatively', async () => {
666
+ const { db, createDocumentVersion } = createMockDb();
667
+ const definition = {
668
+ ...minimalCollection,
669
+ hooks: {
670
+ beforeCreate: [
671
+ vi.fn(({ data }) => {
672
+ data.title = `${data.title}-A`;
673
+ }),
674
+ vi.fn(({ data }) => {
675
+ data.title = `${data.title}-B`;
676
+ }),
677
+ ],
678
+ },
679
+ };
680
+ const ctx = buildCtx(db, definition);
681
+ await createDocument(ctx, { data: { title: 'Original' } });
682
+ const persistedData = createDocumentVersion.mock.calls[0]?.[0].documentData;
683
+ expect(persistedData.title).toBe('Original-A-B');
684
+ });
685
+ });
686
+ // -----------------------------------------------------------------------
687
+ // updateDocument (PUT)
688
+ // -----------------------------------------------------------------------
689
+ describe('updateDocument', () => {
690
+ it('normalizes a stale parent from persistence without firing success hooks', async () => {
691
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
692
+ getDocumentById.mockResolvedValue({
693
+ document_version_id: 'observed',
694
+ status: 'draft',
695
+ fields: { title: 'Old' },
696
+ });
697
+ const raw = new BylineError(ErrorCodes.CONFLICT, {
698
+ message: 'previous document version is stale',
699
+ details: {
700
+ reason: 'stale',
701
+ documentId: 'doc-1',
702
+ previousVersionId: 'observed',
703
+ currentVersionId: 'concurrent',
704
+ },
705
+ });
706
+ createDocumentVersion.mockRejectedValue(raw);
707
+ const afterUpdate = vi.fn();
708
+ await expect(updateDocument(buildCtx(db, { ...minimalCollection, hooks: { afterUpdate } }), {
709
+ expectedRevision: 1,
710
+ documentId: 'doc-1',
711
+ data: { title: 'New' },
712
+ })).rejects.toMatchObject({
713
+ code: ErrorCodes.DOCUMENT_STALE,
714
+ cause: raw,
715
+ details: {
716
+ reason: 'version_parent_mismatch',
717
+ documentId: 'doc-1',
718
+ previousVersionId: 'observed',
719
+ currentVersionId: 'concurrent',
720
+ },
721
+ });
722
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
723
+ expect(afterUpdate).not.toHaveBeenCalled();
724
+ expect(db.commands.documents.publishSchedules.suspendForContentEdit).not.toHaveBeenCalled();
725
+ });
726
+ it('passes the acting user id as createdBy for the audit trail', async () => {
727
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
728
+ getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
729
+ const ctx = buildCtx(db);
730
+ await updateDocument(ctx, {
731
+ expectedRevision: 1,
732
+ documentId: 'doc-1',
733
+ data: { title: 'New' },
734
+ });
735
+ expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe(TEST_ACTOR_ID);
736
+ });
737
+ it('fetches the original before calling hooks', async () => {
738
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
739
+ getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
740
+ const beforeUpdate = vi.fn();
741
+ const definition = { ...minimalCollection, hooks: { beforeUpdate } };
742
+ const ctx = buildCtx(db, definition);
743
+ await updateDocument(ctx, {
744
+ expectedRevision: 1,
745
+ documentId: 'doc-1',
746
+ data: { title: 'New' },
747
+ });
748
+ // The hook should receive the REAL original, not the incoming data
749
+ expect(beforeUpdate).toHaveBeenCalledWith(expect.objectContaining({
750
+ originalData: expect.objectContaining({
751
+ fields: expect.objectContaining({ title: 'Old' }),
752
+ }),
753
+ data: expect.objectContaining({ title: 'New' }),
754
+ }));
755
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
756
+ });
757
+ it('normalizes numeric values before and after beforeUpdate', async () => {
758
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
759
+ getDocumentById.mockResolvedValue({ fields: { quantity: 1, score: 1, price: '1.0' } });
760
+ const beforeUpdate = vi.fn(({ data }) => {
761
+ expect(data.score).toBe(25);
762
+ data.score = '3.5';
763
+ });
764
+ const definition = { ...numericCollection, hooks: { beforeUpdate } };
765
+ await updateDocument(buildCtx(db, definition), {
766
+ expectedRevision: 1,
767
+ documentId: 'doc-1',
768
+ data: { quantity: '2', score: '2.5e1', price: 4.25 },
769
+ });
770
+ expect(createDocumentVersion.mock.calls[0]?.[0].documentData).toEqual({
771
+ quantity: 2,
772
+ score: 3.5,
773
+ price: '4.25',
774
+ });
775
+ });
776
+ it('afterUpdate receives documentId and documentVersionId', async () => {
777
+ const afterUpdate = vi.fn();
778
+ const { db, getDocumentById } = createMockDb();
779
+ getDocumentById.mockResolvedValue({ fields: { title: 'Old' } });
780
+ const definition = { ...minimalCollection, hooks: { afterUpdate } };
781
+ const ctx = buildCtx(db, definition);
782
+ await updateDocument(ctx, {
783
+ expectedRevision: 1,
784
+ documentId: 'doc-1',
785
+ data: { title: 'New' },
786
+ });
787
+ expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({
788
+ documentId: 'doc-1',
789
+ documentVersionId: 'ver-1',
790
+ }));
791
+ });
792
+ it('classifies an afterUpdate failure as post-commit and retains committed ids', async () => {
793
+ const afterUpdate = vi.fn().mockRejectedValue(new Error('search unavailable'));
794
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
795
+ getDocumentById.mockResolvedValue({ fields: { title: 'Old' } });
796
+ const definition = { ...minimalCollection, hooks: { afterUpdate } };
797
+ await expect(updateDocument(buildCtx(db, definition), {
798
+ expectedRevision: 1,
799
+ documentId: 'doc-1',
800
+ data: { title: 'New' },
801
+ })).rejects.toMatchObject({
802
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
803
+ details: {
804
+ phase: 'afterUpdate',
805
+ documentId: 'doc-1',
806
+ documentVersionId: 'ver-1',
807
+ sideEffectCode: ErrorCodes.UNHANDLED,
808
+ },
809
+ });
810
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
811
+ });
812
+ it('afterUpdate carries the sticky path forward when none is supplied', async () => {
813
+ const afterUpdate = vi.fn();
814
+ const { db, getDocumentById } = createMockDb();
815
+ getDocumentById.mockResolvedValue({
816
+ document_version_id: 'prev-ver',
817
+ path: 'sticky-path',
818
+ fields: { title: 'Old' },
819
+ });
820
+ const definition = { ...minimalCollection, hooks: { afterUpdate } };
821
+ const ctx = buildCtx(db, definition);
822
+ await updateDocument(ctx, {
823
+ expectedRevision: 1,
824
+ documentId: 'doc-1',
825
+ data: { title: 'New' },
826
+ });
827
+ expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({ path: 'sticky-path' }));
828
+ });
829
+ it('afterUpdate surfaces an explicitly-supplied path', async () => {
830
+ const afterUpdate = vi.fn();
831
+ const { db, getDocumentById } = createMockDb();
832
+ getDocumentById.mockResolvedValue({
833
+ document_version_id: 'prev-ver',
834
+ path: 'sticky-path',
835
+ fields: { title: 'Old' },
836
+ });
837
+ const definition = { ...minimalCollection, hooks: { afterUpdate } };
838
+ const ctx = buildCtx(db, definition);
839
+ await updateDocument(ctx, {
840
+ expectedRevision: 1,
841
+ documentId: 'doc-1',
842
+ data: { title: 'New' },
843
+ path: 'new-path',
844
+ });
845
+ expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({ path: 'new-path' }));
846
+ });
847
+ it('does not pass path to the storage primitive when no explicit path is supplied', async () => {
848
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
849
+ getDocumentById.mockResolvedValue({
850
+ document_version_id: 'prev-ver',
851
+ path: 'original-path',
852
+ status: 'draft',
853
+ fields: { title: 'Old' },
854
+ });
855
+ const definition = { ...minimalCollection, useAsPath: 'title' };
856
+ const ctx = buildCtx(db, definition);
857
+ await updateDocument(ctx, {
858
+ expectedRevision: 1,
859
+ documentId: 'doc-1',
860
+ data: { title: 'Brand New Title' },
861
+ });
862
+ // Sticky path semantics: the storage layer is not asked to write the
863
+ // path row; the existing byline_document_paths row stays as-is.
864
+ expect(createDocumentVersion.mock.calls[0]?.[0].path).toBeUndefined();
865
+ });
866
+ it('uses an explicit params.path verbatim on update, overriding the sticky value', async () => {
867
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
868
+ getDocumentById.mockResolvedValue({
869
+ document_version_id: 'prev-ver',
870
+ path: 'original-path',
871
+ status: 'draft',
872
+ fields: { title: 'Old' },
873
+ });
874
+ const ctx = buildCtx(db);
875
+ await updateDocument(ctx, {
876
+ expectedRevision: 1,
877
+ documentId: 'doc-1',
878
+ data: { title: 'New' },
879
+ path: 'manually-set',
880
+ });
881
+ expect(createDocumentVersion.mock.calls[0]?.[0].path).toBe('manually-set');
882
+ });
883
+ it('sets status to the default status (draft)', async () => {
884
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
885
+ getDocumentById.mockResolvedValue({
886
+ status: 'published',
887
+ fields: { title: 'Old' },
888
+ });
889
+ const ctx = buildCtx(db);
890
+ await updateDocument(ctx, {
891
+ expectedRevision: 1,
892
+ documentId: 'doc-1',
893
+ data: { title: 'Updated' },
894
+ });
895
+ expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('draft');
896
+ });
897
+ it('drops path changes silently with a logger.warn on non-source-locale (translation) saves', async () => {
898
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
899
+ getDocumentById.mockResolvedValue({
900
+ document_version_id: 'prev-ver',
901
+ path: 'about',
902
+ source_locale: 'en',
903
+ status: 'draft',
904
+ fields: { title: 'About' },
905
+ });
906
+ const ctx = buildCtx(db);
907
+ const warn = ctx.logger?.warn;
908
+ warn.mockClear();
909
+ await updateDocument(ctx, {
910
+ expectedRevision: 1,
911
+ documentId: 'doc-1',
912
+ data: { title: 'À propos' },
913
+ locale: 'fr',
914
+ path: 'a-propos',
915
+ });
916
+ // Save still proceeds — the version row is created — but the path
917
+ // row is left untouched (no `path` flows to the storage primitive).
918
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
919
+ expect(createDocumentVersion.mock.calls[0]?.[0].path).toBeUndefined();
920
+ expect(createDocumentVersion.mock.calls[0]?.[0].locale).toBe('fr');
921
+ // The caller is informed via a structured warn.
922
+ expect(warn).toHaveBeenCalledWith(expect.objectContaining({
923
+ documentId: 'doc-1',
924
+ requestedLocale: 'fr',
925
+ sourceLocale: 'en',
926
+ suppliedPath: 'a-propos',
927
+ currentPath: 'about',
928
+ }), expect.stringContaining('path changes apply only on source-locale writes'));
929
+ });
930
+ it('writes the path on a source-locale save even when it differs from the global default', async () => {
931
+ // Document anchored to de (re-anchored, or authored before a global
932
+ // default switch); the global default is en. A de save is the
933
+ // source-locale write, so the supplied path must flow through.
934
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
935
+ getDocumentById.mockResolvedValue({
936
+ document_version_id: 'prev-ver',
937
+ path: 'ueber-uns',
938
+ source_locale: 'de',
939
+ status: 'draft',
940
+ fields: { title: 'Über uns' },
941
+ });
942
+ const ctx = buildCtx(db);
943
+ await updateDocument(ctx, {
944
+ expectedRevision: 1,
945
+ documentId: 'doc-1',
946
+ data: { title: 'Über uns — neu' },
947
+ locale: 'de',
948
+ path: 'ueber-uns-neu',
949
+ });
950
+ expect(createDocumentVersion.mock.calls[0]?.[0].path).toBe('ueber-uns-neu');
951
+ });
952
+ it('does not warn when a translation save supplies the same path as current', async () => {
953
+ const { db, getDocumentById } = createMockDb();
954
+ getDocumentById.mockResolvedValue({
955
+ document_version_id: 'prev-ver',
956
+ path: 'about',
957
+ status: 'draft',
958
+ fields: { title: 'About' },
959
+ });
960
+ const ctx = buildCtx(db);
961
+ const warn = ctx.logger?.warn;
962
+ warn.mockClear();
963
+ await updateDocument(ctx, {
964
+ expectedRevision: 1,
965
+ documentId: 'doc-1',
966
+ data: { title: 'À propos' },
967
+ locale: 'fr',
968
+ path: 'about', // same as currentPath — idempotent, no warn
969
+ });
970
+ expect(warn).not.toHaveBeenCalled();
971
+ });
972
+ it('translates a Postgres unique-constraint violation on the path index to ERR_PATH_CONFLICT', async () => {
973
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
974
+ getDocumentById.mockResolvedValue({
975
+ document_version_id: 'prev-ver',
976
+ path: 'about',
977
+ status: 'draft',
978
+ fields: { title: 'About' },
979
+ });
980
+ // Simulate the pg driver throwing a unique-violation on the path
981
+ // constraint when the upsert tries to claim a slug owned by another
982
+ // document in the same (collection, locale).
983
+ createDocumentVersion.mockRejectedValueOnce(pathUniqueViolation());
984
+ const ctx = buildCtx(db);
985
+ try {
986
+ await updateDocument(ctx, {
987
+ expectedRevision: 1,
988
+ documentId: 'doc-1',
989
+ data: { title: 'About' },
990
+ path: 'home', // collides
991
+ });
992
+ throw new Error('expected ERR_PATH_CONFLICT');
993
+ }
994
+ catch (err) {
995
+ expect(err).toBeInstanceOf(BylineError);
996
+ expect(err).toMatchObject({
997
+ code: ErrorCodes.PATH_CONFLICT,
998
+ message: 'cannot update this document to path "home" because a live document already uses it in this collection (locale: en)',
999
+ details: {
1000
+ path: 'home',
1001
+ locale: 'en',
1002
+ constraint: 'idx_document_paths_collection_locale_path',
1003
+ },
1004
+ });
1005
+ }
1006
+ });
1007
+ it('rethrows non-23505 errors unchanged', async () => {
1008
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
1009
+ getDocumentById.mockResolvedValue({
1010
+ document_version_id: 'prev-ver',
1011
+ path: 'about',
1012
+ status: 'draft',
1013
+ fields: { title: 'About' },
1014
+ });
1015
+ const original = new Error('connection refused');
1016
+ createDocumentVersion.mockRejectedValueOnce(original);
1017
+ const ctx = buildCtx(db);
1018
+ await expect(updateDocument(ctx, {
1019
+ expectedRevision: 1,
1020
+ documentId: 'doc-1',
1021
+ data: { title: 'X' },
1022
+ path: 'home',
1023
+ })).rejects.toBe(original);
1024
+ });
1025
+ it('supports an array of beforeUpdate and afterUpdate hooks', async () => {
1026
+ const callOrder = [];
1027
+ const hooks = {
1028
+ beforeUpdate: [
1029
+ vi.fn(async () => {
1030
+ expect(db.revisions.isInTransaction()).toBe(false);
1031
+ callOrder.push('before-1');
1032
+ }),
1033
+ vi.fn(async () => {
1034
+ callOrder.push('before-2');
1035
+ }),
1036
+ ],
1037
+ afterSystemFieldsChange: () => {
1038
+ expect(db.revisions.isInTransaction()).toBe(false);
1039
+ callOrder.push('after-system');
1040
+ },
1041
+ afterUpdate: [
1042
+ vi.fn(async () => {
1043
+ expect(db.revisions.isInTransaction()).toBe(false);
1044
+ callOrder.push('after-1');
1045
+ }),
1046
+ vi.fn(async () => {
1047
+ callOrder.push('after-2');
1048
+ }),
1049
+ ],
1050
+ };
1051
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
1052
+ getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
1053
+ createDocumentVersion.mockImplementation(async () => {
1054
+ callOrder.push('persist');
1055
+ return { document: { id: 'ver-1', document_id: 'doc-1' }, fieldCount: 1 };
1056
+ });
1057
+ const definition = { ...minimalCollection, hooks };
1058
+ const ctx = buildCtx(db, definition);
1059
+ await updateDocument(ctx, {
1060
+ expectedRevision: 1,
1061
+ documentId: 'doc-1',
1062
+ data: { title: 'New' },
1063
+ availableLocales: ['en', 'fr'],
1064
+ });
1065
+ expect(callOrder).toEqual([
1066
+ 'before-1',
1067
+ 'before-2',
1068
+ 'persist',
1069
+ 'after-system',
1070
+ 'after-1',
1071
+ 'after-2',
1072
+ ]);
1073
+ });
1074
+ });
1075
+ // -----------------------------------------------------------------------
1076
+ // updateDocumentWithPatches
1077
+ // -----------------------------------------------------------------------
1078
+ describe('updateDocumentWithPatches', () => {
1079
+ it('throws ERR_NOT_FOUND when document is missing', async () => {
1080
+ const { db, getDocumentById } = createMockDb();
1081
+ getDocumentById.mockResolvedValue(null);
1082
+ const ctx = buildCtx(db);
1083
+ await expect(updateDocumentWithPatches(ctx, {
1084
+ expectedRevision: 1,
1085
+ documentId: 'doc-missing',
1086
+ patches: [],
1087
+ })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
1088
+ });
1089
+ it('classifies a post-patch afterUpdate failure without replaying the version write', async () => {
1090
+ const afterUpdate = vi
1091
+ .fn()
1092
+ .mockRejectedValue(Object.assign(new Error('storage unavailable'), { code: ErrorCodes.STORAGE }));
1093
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
1094
+ getDocumentById.mockResolvedValue({
1095
+ document_version_id: 'previous-version',
1096
+ fields: { title: 'Old' },
1097
+ });
1098
+ const definition = { ...minimalCollection, hooks: { afterUpdate } };
1099
+ await expect(updateDocumentWithPatches(buildCtx(db, definition), {
1100
+ expectedRevision: 1,
1101
+ documentId: 'doc-1',
1102
+ patches: [{ kind: 'field.set', path: 'title', value: 'New' }],
1103
+ })).rejects.toMatchObject({
1104
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
1105
+ details: {
1106
+ phase: 'afterUpdate',
1107
+ documentId: 'doc-1',
1108
+ documentVersionId: 'ver-1',
1109
+ sideEffectCode: ErrorCodes.STORAGE,
1110
+ },
1111
+ });
1112
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
1113
+ });
1114
+ it('throws ERR_DOCUMENT_STALE on revision mismatch', async () => {
1115
+ const { db, getDocumentById } = createMockDb();
1116
+ getDocumentById.mockResolvedValue({
1117
+ document_version_id: 'ver-current',
1118
+ fields: { title: 'Old' },
1119
+ });
1120
+ const ctx = buildCtx(db);
1121
+ await expect(updateDocumentWithPatches(ctx, { expectedRevision: 2, documentId: 'doc-1', patches: [] })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.DOCUMENT_STALE);
1122
+ });
1123
+ it('throws ERR_PATCH_FAILED when applyPatches returns errors', async () => {
1124
+ const { db, getDocumentById } = createMockDb();
1125
+ getDocumentById.mockResolvedValue({ fields: { title: 'Old' } });
1126
+ const ctx = buildCtx(db);
1127
+ // array.move on a top-level (non-array) field should produce an error
1128
+ await expect(updateDocumentWithPatches(ctx, {
1129
+ expectedRevision: 1,
1130
+ documentId: 'doc-1',
1131
+ patches: [{ kind: 'array.move', path: 'title', itemId: 'x', toIndex: 0 }],
1132
+ })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.PATCH_FAILED);
1133
+ });
1134
+ it('persists patched data and invokes hooks', async () => {
1135
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
1136
+ getDocumentById.mockResolvedValue({ fields: { title: 'Old' } });
1137
+ const afterUpdate = vi.fn();
1138
+ const definition = { ...minimalCollection, hooks: { afterUpdate } };
1139
+ const ctx = buildCtx(db, definition);
1140
+ await updateDocumentWithPatches(ctx, {
1141
+ expectedRevision: 1,
1142
+ documentId: 'doc-1',
1143
+ patches: [{ kind: 'field.set', path: 'title', value: 'Patched' }],
1144
+ });
1145
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
1146
+ const persistedData = createDocumentVersion.mock.calls[0]?.[0].documentData;
1147
+ expect(persistedData.title).toBe('Patched');
1148
+ expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({
1149
+ documentId: 'doc-1',
1150
+ documentVersionId: 'ver-1',
1151
+ }));
1152
+ });
1153
+ it('describes a patched path conflict as ownership by a live document', async () => {
1154
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
1155
+ getDocumentById.mockResolvedValue({
1156
+ document_version_id: 'ver-current',
1157
+ path: 'old-slug',
1158
+ source_locale: 'de',
1159
+ fields: { title: 'Old' },
1160
+ });
1161
+ createDocumentVersion.mockRejectedValueOnce(pathUniqueViolation());
1162
+ const ctx = buildCtx(db);
1163
+ await expect(updateDocumentWithPatches(ctx, {
1164
+ expectedRevision: 1,
1165
+ documentId: 'doc-1',
1166
+ patches: [],
1167
+ path: 'new-slug',
1168
+ locale: 'de',
1169
+ })).rejects.toMatchObject({
1170
+ code: ErrorCodes.PATH_CONFLICT,
1171
+ message: 'cannot update this document to path "new-slug" because a live document already uses it in this collection (locale: de)',
1172
+ details: {
1173
+ path: 'new-slug',
1174
+ locale: 'de',
1175
+ constraint: 'idx_document_paths_collection_locale_path',
1176
+ },
1177
+ });
1178
+ });
1179
+ it('normalizes patched and hook-produced numeric values before persistence', async () => {
1180
+ const { db, getDocumentById, createDocumentVersion } = createMockDb();
1181
+ getDocumentById.mockResolvedValue({
1182
+ fields: { quantity: 1, score: 1, price: '1.00' },
1183
+ });
1184
+ const beforeUpdate = vi.fn(({ data }) => {
1185
+ expect(data.quantity).toBe(7);
1186
+ data.price = ' 8.500 ';
1187
+ });
1188
+ const definition = { ...numericCollection, hooks: { beforeUpdate } };
1189
+ await updateDocumentWithPatches(buildCtx(db, definition), {
1190
+ expectedRevision: 1,
1191
+ documentId: 'doc-1',
1192
+ patches: [
1193
+ { kind: 'field.set', path: 'quantity', value: '7.0' },
1194
+ { kind: 'field.set', path: 'score', value: '2.75' },
1195
+ ],
1196
+ });
1197
+ expect(createDocumentVersion.mock.calls[0]?.[0].documentData).toEqual({
1198
+ quantity: 7,
1199
+ score: 2.75,
1200
+ price: '8.500',
1201
+ });
1202
+ });
1203
+ });
1204
+ // -----------------------------------------------------------------------
1205
+ // scheduled publication lifecycle
1206
+ // -----------------------------------------------------------------------
1207
+ describe('scheduled publication lifecycle', () => {
1208
+ const metadataRow = {
1209
+ document_version_id: 'ver-1',
1210
+ document_id: 'doc-1',
1211
+ collection_id: 'col-1',
1212
+ status: 'draft',
1213
+ created_at: new Date(),
1214
+ updated_at: new Date(),
1215
+ };
1216
+ it('requires both workflow abilities and records an atomic schedule audit', async () => {
1217
+ const { db, getCurrentVersionMetadata, publishSchedule, auditAppend } = createMockDb();
1218
+ const schedule = publishScheduleRow();
1219
+ getCurrentVersionMetadata.mockResolvedValue(metadataRow);
1220
+ publishSchedule.mockResolvedValue({ status: 'scheduled', schedule, previous: null });
1221
+ const ctx = buildCtx(db);
1222
+ await expect(scheduleDocumentPublish(ctx, {
1223
+ expectedRevision: 1,
1224
+ documentId: 'doc-1',
1225
+ expectedVersionId: 'ver-1',
1226
+ publishAt: schedule.publishAt.toISOString(),
1227
+ })).resolves.toEqual({ ...schedule, revision: 2 });
1228
+ expect(publishSchedule).toHaveBeenCalledWith(expect.objectContaining({
1229
+ documentId: 'doc-1',
1230
+ collectionId: 'col-1',
1231
+ expectedVersionId: 'ver-1',
1232
+ actorId: TEST_ACTOR_ID,
1233
+ publishAt: schedule.publishAt,
1234
+ }));
1235
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.scheduled' }));
1236
+ const restricted = buildCtx(db);
1237
+ restricted.requestContext = createRequestContext({
1238
+ actor: new AdminAuth({
1239
+ id: TEST_ACTOR_ID,
1240
+ abilities: ['collections.articles.changeStatus'],
1241
+ }),
1242
+ });
1243
+ await expect(scheduleDocumentPublish(restricted, {
1244
+ expectedRevision: 1,
1245
+ documentId: 'doc-1',
1246
+ expectedVersionId: 'ver-1',
1247
+ publishAt: schedule.publishAt.toISOString(),
1248
+ })).rejects.toMatchObject({ code: AuthErrorCodes.FORBIDDEN });
1249
+ });
1250
+ it('maps a database-time past instant and a stale version to domain errors', async () => {
1251
+ const { db, getCurrentVersionMetadata, publishSchedule } = createMockDb();
1252
+ getCurrentVersionMetadata.mockResolvedValue(metadataRow);
1253
+ publishSchedule.mockResolvedValue({ status: 'publish_at_not_future' });
1254
+ await expect(scheduleDocumentPublish(buildCtx(db), {
1255
+ expectedRevision: 1,
1256
+ documentId: 'doc-1',
1257
+ expectedVersionId: 'ver-1',
1258
+ publishAt: new Date(Date.now() + 60_000).toISOString(),
1259
+ })).rejects.toMatchObject({ code: ErrorCodes.VALIDATION });
1260
+ getCurrentVersionMetadata.mockResolvedValue({
1261
+ ...metadataRow,
1262
+ document_version_id: 'ver-2',
1263
+ });
1264
+ await expect(scheduleDocumentPublish(buildCtx(db), {
1265
+ expectedRevision: 1,
1266
+ documentId: 'doc-1',
1267
+ expectedVersionId: 'ver-1',
1268
+ publishAt: new Date(Date.now() + 60_000).toISOString(),
1269
+ })).rejects.toMatchObject({
1270
+ code: ErrorCodes.DOCUMENT_STALE,
1271
+ details: { reason: 'version_parent_mismatch' },
1272
+ });
1273
+ });
1274
+ it('rejects date strings that are valid to JavaScript but are not ISO instants', async () => {
1275
+ const { db, getCurrentVersionMetadata, publishSchedule } = createMockDb();
1276
+ getCurrentVersionMetadata.mockResolvedValue(metadataRow);
1277
+ await expect(scheduleDocumentPublish(buildCtx(db), {
1278
+ expectedRevision: 1,
1279
+ documentId: 'doc-1',
1280
+ expectedVersionId: 'ver-1',
1281
+ publishAt: 'August 23, 2026 12:00:00 UTC',
1282
+ })).rejects.toMatchObject({ code: ErrorCodes.VALIDATION });
1283
+ expect(publishSchedule).not.toHaveBeenCalled();
1284
+ });
1285
+ it('re-confirms and explicitly cancels through audited lifecycle operations', async () => {
1286
+ const { db, getCurrentVersionMetadata, confirmPublishSchedule, cancelPublishSchedule, auditAppend, } = createMockDb();
1287
+ const suspended = publishScheduleRow({
1288
+ state: 'needs_reconfirm',
1289
+ suspendedAt: new Date(),
1290
+ suspendedReason: 'content_edited',
1291
+ });
1292
+ const confirmed = publishScheduleRow();
1293
+ getCurrentVersionMetadata.mockResolvedValue(metadataRow);
1294
+ confirmPublishSchedule.mockResolvedValue({
1295
+ status: 'confirmed',
1296
+ schedule: confirmed,
1297
+ previousTargetVersionId: 'ver-0',
1298
+ });
1299
+ cancelPublishSchedule.mockResolvedValue(confirmed);
1300
+ await expect(confirmDocumentScheduledPublish(buildCtx(db), {
1301
+ expectedRevision: 1,
1302
+ documentId: 'doc-1',
1303
+ expectedVersionId: 'ver-1',
1304
+ })).resolves.toEqual({ ...confirmed, revision: 2 });
1305
+ await expect(cancelDocumentScheduledPublish(buildCtx(db), { expectedRevision: 2, documentId: 'doc-1' })).resolves.toEqual({ schedule: confirmed, revision: 3 });
1306
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.reconfirmed' }));
1307
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.cancelled' }));
1308
+ expect(suspended.state).toBe('needs_reconfirm');
1309
+ });
1310
+ it('suspends an armed schedule atomically when an update creates a version', async () => {
1311
+ const { db, getDocumentById, suspendPublishSchedule, auditAppend } = createMockDb();
1312
+ const schedule = publishScheduleRow({
1313
+ state: 'needs_reconfirm',
1314
+ suspendedAt: new Date(),
1315
+ suspendedReason: 'content_edited',
1316
+ });
1317
+ getDocumentById.mockResolvedValue({
1318
+ document_version_id: 'ver-0',
1319
+ document_id: 'doc-1',
1320
+ path: 'current-path',
1321
+ source_locale: 'en',
1322
+ fields: { title: 'Before' },
1323
+ });
1324
+ suspendPublishSchedule.mockResolvedValue({ status: 'suspended', schedule });
1325
+ await updateDocument(buildCtx(db), {
1326
+ expectedRevision: 1,
1327
+ documentId: 'doc-1',
1328
+ data: { title: 'After' },
1329
+ });
1330
+ expect(suspendPublishSchedule).toHaveBeenCalledWith({
1331
+ reason: 'content_edited',
1332
+ documentId: 'doc-1',
1333
+ collectionId: 'col-1',
1334
+ });
1335
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.suspended' }));
1336
+ });
1337
+ it('treats deleting a locale as a version-creating edit that suspends the schedule', async () => {
1338
+ const { db, deleteDocumentLocale, getDocumentById, suspendPublishSchedule, auditAppend } = createMockDb();
1339
+ getDocumentById.mockResolvedValue({
1340
+ document_version_id: 'ver-1',
1341
+ document_id: 'doc-1',
1342
+ path: 'current-path',
1343
+ _availableVersionLocales: ['en', 'fr'],
1344
+ fields: { title: 'Bonjour' },
1345
+ });
1346
+ suspendPublishSchedule.mockResolvedValue({
1347
+ status: 'suspended',
1348
+ schedule: publishScheduleRow({
1349
+ state: 'needs_reconfirm',
1350
+ suspendedAt: new Date(),
1351
+ suspendedReason: 'content_edited',
1352
+ }),
1353
+ });
1354
+ await deleteLocale(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1', locale: 'fr' });
1355
+ expect(deleteDocumentLocale).toHaveBeenCalledOnce();
1356
+ expect(suspendPublishSchedule).toHaveBeenCalledWith({
1357
+ reason: 'content_edited',
1358
+ documentId: 'doc-1',
1359
+ collectionId: 'col-1',
1360
+ });
1361
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.suspended' }));
1362
+ });
1363
+ it('classifies a delete-locale afterUpdate failure after the new version commits', async () => {
1364
+ const { db, deleteDocumentLocale, getDocumentById } = createMockDb();
1365
+ getDocumentById.mockResolvedValue({
1366
+ document_version_id: 'ver-1',
1367
+ document_id: 'doc-1',
1368
+ path: 'current-path',
1369
+ _availableVersionLocales: ['en', 'fr'],
1370
+ fields: { title: 'Bonjour' },
1371
+ });
1372
+ const definition = {
1373
+ ...minimalCollection,
1374
+ hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
1375
+ };
1376
+ await expect(deleteLocale(buildCtx(db, definition), {
1377
+ expectedRevision: 1,
1378
+ documentId: 'doc-1',
1379
+ locale: 'fr',
1380
+ })).rejects.toMatchObject({
1381
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
1382
+ details: {
1383
+ phase: 'afterUpdate',
1384
+ documentId: 'doc-1',
1385
+ documentVersionId: 'ver-2',
1386
+ sideEffectCode: ErrorCodes.UNHANDLED,
1387
+ },
1388
+ });
1389
+ expect(deleteDocumentLocale).toHaveBeenCalledOnce();
1390
+ });
1391
+ it('cancels an active schedule as an effect of an ordinary status transition', async () => {
1392
+ const { db, getCurrentVersionMetadata, cancelPublishSchedule, auditAppend } = createMockDb();
1393
+ getCurrentVersionMetadata.mockResolvedValue(metadataRow);
1394
+ cancelPublishSchedule.mockResolvedValue(publishScheduleRow());
1395
+ await changeDocumentStatus(buildCtx(db), {
1396
+ expectedRevision: 1,
1397
+ documentId: 'doc-1',
1398
+ nextStatus: 'published',
1399
+ });
1400
+ expect(cancelPublishSchedule).toHaveBeenCalledWith({
1401
+ documentId: 'doc-1',
1402
+ collectionId: 'col-1',
1403
+ });
1404
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.cancelled' }));
1405
+ });
1406
+ it('resolves cross-collection visibility above storage into a collection-id allowlist', async () => {
1407
+ const { db, listPublishSchedules } = createMockDb();
1408
+ const secret = {
1409
+ ...minimalCollection,
1410
+ path: 'secret',
1411
+ labels: { singular: 'Secret', plural: 'Secrets' },
1412
+ };
1413
+ const core = {
1414
+ collections: [minimalCollection, secret],
1415
+ db,
1416
+ getCollectionRecord: (path) => ({
1417
+ collectionId: path === 'articles' ? 'col-1' : 'col-2',
1418
+ version: 1,
1419
+ schemaHash: 'test',
1420
+ }),
1421
+ };
1422
+ const requestContext = createRequestContext({
1423
+ actor: new AdminAuth({
1424
+ id: TEST_ACTOR_ID,
1425
+ abilities: [
1426
+ 'collections.articles.changeStatus',
1427
+ 'collections.articles.publish',
1428
+ 'collections.secret.changeStatus',
1429
+ ],
1430
+ }),
1431
+ });
1432
+ await listDocumentPublishSchedules(core, requestContext, { page: 1, pageSize: 20 });
1433
+ expect(listPublishSchedules).toHaveBeenCalledWith({
1434
+ collectionIds: ['col-1'],
1435
+ states: undefined,
1436
+ lastAuthorizedBy: undefined,
1437
+ page: 1,
1438
+ pageSize: 20,
1439
+ });
1440
+ });
1441
+ });
1442
+ // -----------------------------------------------------------------------
1443
+ // changeDocumentStatus
1444
+ // -----------------------------------------------------------------------
1445
+ describe('changeDocumentStatus', () => {
1446
+ const metadataRow = {
1447
+ document_version_id: 'ver-1',
1448
+ document_id: 'doc-1',
1449
+ collection_id: 'col-1',
1450
+ path: 'hello',
1451
+ status: 'draft',
1452
+ created_at: new Date(),
1453
+ updated_at: new Date(),
1454
+ };
1455
+ it('validates and applies a valid transition', async () => {
1456
+ const { db, getCurrentVersionMetadata, setDocumentStatus } = createMockDb();
1457
+ getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1458
+ const ctx = buildCtx(db);
1459
+ const result = await changeDocumentStatus(ctx, {
1460
+ expectedRevision: 1,
1461
+ documentId: 'doc-1',
1462
+ nextStatus: 'published',
1463
+ });
1464
+ expect(setDocumentStatus).toHaveBeenCalledWith({
1465
+ document_version_id: 'ver-1',
1466
+ status: 'published',
1467
+ });
1468
+ expect(result.previousStatus).toBe('draft');
1469
+ expect(result.newStatus).toBe('published');
1470
+ });
1471
+ it('records a document.status.changed audit row atomically (from → to)', async () => {
1472
+ const { db, getCurrentVersionMetadata, auditAppend, withTransaction } = createMockDb();
1473
+ getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1474
+ const ctx = buildCtx(db);
1475
+ await changeDocumentStatus(ctx, {
1476
+ expectedRevision: 1,
1477
+ documentId: 'doc-1',
1478
+ nextStatus: 'published',
1479
+ });
1480
+ // The guarded transaction contains a nested status savepoint (docs/07-auth-and-security/02-auditability.md).
1481
+ expect(withTransaction).toHaveBeenCalledTimes(2);
1482
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1483
+ documentId: 'doc-1',
1484
+ collectionId: 'col-1',
1485
+ actorId: TEST_ACTOR_ID,
1486
+ actorRealm: 'admin',
1487
+ action: 'document.status.changed',
1488
+ field: 'status',
1489
+ before: 'draft',
1490
+ after: 'published',
1491
+ }));
1492
+ });
1493
+ it('throws ERR_NOT_FOUND when document is missing', async () => {
1494
+ const { db, getCurrentVersionMetadata } = createMockDb();
1495
+ getCurrentVersionMetadata.mockResolvedValue(null);
1496
+ const ctx = buildCtx(db);
1497
+ await expect(changeDocumentStatus(ctx, {
1498
+ expectedRevision: 1,
1499
+ documentId: 'doc-1',
1500
+ nextStatus: 'published',
1501
+ })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
1502
+ });
1503
+ it('throws ERR_INVALID_TRANSITION for an invalid transition', async () => {
1504
+ const { db, getCurrentVersionMetadata } = createMockDb();
1505
+ getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1506
+ const ctx = buildCtx(db);
1507
+ // draft → archived skips 'published', which is not ±1
1508
+ await expect(changeDocumentStatus(ctx, {
1509
+ expectedRevision: 1,
1510
+ documentId: 'doc-1',
1511
+ nextStatus: 'archived',
1512
+ })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.INVALID_TRANSITION);
1513
+ });
1514
+ it('invokes beforeStatusChange and afterStatusChange hooks', async () => {
1515
+ const callOrder = [];
1516
+ const hooks = {
1517
+ beforeStatusChange: vi.fn(async () => {
1518
+ callOrder.push('before');
1519
+ }),
1520
+ afterStatusChange: vi.fn(async () => {
1521
+ callOrder.push('after');
1522
+ }),
1523
+ };
1524
+ const { db, getCurrentVersionMetadata, setDocumentStatus } = createMockDb();
1525
+ getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1526
+ setDocumentStatus.mockImplementation(async () => {
1527
+ callOrder.push('persist');
1528
+ });
1529
+ const definition = { ...minimalCollection, hooks };
1530
+ const ctx = buildCtx(db, definition);
1531
+ await changeDocumentStatus(ctx, {
1532
+ expectedRevision: 1,
1533
+ documentId: 'doc-1',
1534
+ nextStatus: 'published',
1535
+ });
1536
+ expect(callOrder).toEqual(['before', 'persist', 'after']);
1537
+ expect(hooks.beforeStatusChange).toHaveBeenCalledWith(expect.objectContaining({
1538
+ previousStatus: 'draft',
1539
+ nextStatus: 'published',
1540
+ documentId: 'doc-1',
1541
+ documentVersionId: 'ver-1',
1542
+ path: 'current-path',
1543
+ }));
1544
+ expect(hooks.afterStatusChange).toHaveBeenCalledWith(expect.objectContaining({ path: 'current-path' }));
1545
+ });
1546
+ it('does not invoke hooks when transition is invalid', async () => {
1547
+ const hooks = {
1548
+ beforeStatusChange: vi.fn(),
1549
+ afterStatusChange: vi.fn(),
1550
+ };
1551
+ const { db, getCurrentVersionMetadata } = createMockDb();
1552
+ getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1553
+ const definition = { ...minimalCollection, hooks };
1554
+ const ctx = buildCtx(db, definition);
1555
+ await expect(changeDocumentStatus(ctx, {
1556
+ expectedRevision: 1,
1557
+ documentId: 'doc-1',
1558
+ nextStatus: 'archived',
1559
+ })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.INVALID_TRANSITION);
1560
+ expect(hooks.beforeStatusChange).not.toHaveBeenCalled();
1561
+ expect(hooks.afterStatusChange).not.toHaveBeenCalled();
1562
+ });
1563
+ it('auto-archives other published versions when publishing', async () => {
1564
+ const { db, getCurrentVersionMetadata, archivePublishedVersions } = createMockDb();
1565
+ getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1566
+ const ctx = buildCtx(db);
1567
+ await changeDocumentStatus(ctx, {
1568
+ expectedRevision: 1,
1569
+ documentId: 'doc-1',
1570
+ nextStatus: 'published',
1571
+ });
1572
+ expect(archivePublishedVersions).toHaveBeenCalledWith({
1573
+ document_id: 'doc-1',
1574
+ excludeVersionId: 'ver-1',
1575
+ });
1576
+ });
1577
+ it('supports an array of beforeStatusChange and afterStatusChange hooks', async () => {
1578
+ const callOrder = [];
1579
+ const hooks = {
1580
+ beforeStatusChange: [
1581
+ vi.fn(async () => {
1582
+ callOrder.push('before-1');
1583
+ }),
1584
+ vi.fn(async () => {
1585
+ callOrder.push('before-2');
1586
+ }),
1587
+ ],
1588
+ afterStatusChange: [
1589
+ vi.fn(async () => {
1590
+ callOrder.push('after-1');
1591
+ }),
1592
+ vi.fn(async () => {
1593
+ callOrder.push('after-2');
1594
+ }),
1595
+ ],
1596
+ };
1597
+ const { db, getCurrentVersionMetadata, setDocumentStatus } = createMockDb();
1598
+ getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
1599
+ setDocumentStatus.mockImplementation(async () => {
1600
+ callOrder.push('persist');
1601
+ });
1602
+ const definition = { ...minimalCollection, hooks };
1603
+ const ctx = buildCtx(db, definition);
1604
+ await changeDocumentStatus(ctx, {
1605
+ expectedRevision: 1,
1606
+ documentId: 'doc-1',
1607
+ nextStatus: 'published',
1608
+ });
1609
+ expect(callOrder).toEqual(['before-1', 'before-2', 'persist', 'after-1', 'after-2']);
1610
+ });
1611
+ });
1612
+ // -----------------------------------------------------------------------
1613
+ // unpublishDocument
1614
+ // -----------------------------------------------------------------------
1615
+ describe('unpublishDocument', () => {
1616
+ it('calls archivePublishedVersions and returns count', async () => {
1617
+ const { db, archivePublishedVersions } = createMockDb();
1618
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1619
+ document_id: 'doc-1',
1620
+ document_version_id: 'ver-1',
1621
+ status: 'published',
1622
+ path: 'current-path',
1623
+ fields: {},
1624
+ });
1625
+ archivePublishedVersions.mockResolvedValue(1);
1626
+ const ctx = buildCtx(db);
1627
+ const result = await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1628
+ expect(result.archivedCount).toBe(1);
1629
+ expect(archivePublishedVersions).toHaveBeenCalledWith({ document_id: 'doc-1' });
1630
+ });
1631
+ it('records an atomic published to archived status audit when versions change', async () => {
1632
+ const { db, archivePublishedVersions, auditAppend, withTransaction } = createMockDb();
1633
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1634
+ document_id: 'doc-1',
1635
+ document_version_id: 'ver-1',
1636
+ status: 'published',
1637
+ path: 'current-path',
1638
+ fields: {},
1639
+ });
1640
+ archivePublishedVersions.mockResolvedValue(2);
1641
+ await unpublishDocument(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1' });
1642
+ expect(withTransaction).toHaveBeenCalledOnce();
1643
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1644
+ documentId: 'doc-1',
1645
+ collectionId: 'col-1',
1646
+ actorId: TEST_ACTOR_ID,
1647
+ actorRealm: 'admin',
1648
+ action: 'document.status.changed',
1649
+ field: 'status',
1650
+ before: 'published',
1651
+ after: 'archived',
1652
+ }));
1653
+ });
1654
+ it('does not record an audit row when no published version changes', async () => {
1655
+ const { db, archivePublishedVersions, auditAppend } = createMockDb();
1656
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1657
+ document_id: 'doc-1',
1658
+ document_version_id: 'ver-1',
1659
+ status: 'published',
1660
+ path: 'current-path',
1661
+ fields: {},
1662
+ });
1663
+ archivePublishedVersions.mockResolvedValue(0);
1664
+ await unpublishDocument(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1' });
1665
+ expect(auditAppend).not.toHaveBeenCalled();
1666
+ });
1667
+ it('does not run afterUnpublish when the atomic audit unit fails', async () => {
1668
+ const afterUnpublish = vi.fn();
1669
+ const { db, archivePublishedVersions, auditAppend } = createMockDb();
1670
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1671
+ document_id: 'doc-1',
1672
+ document_version_id: 'ver-1',
1673
+ status: 'published',
1674
+ path: 'current-path',
1675
+ fields: {},
1676
+ });
1677
+ archivePublishedVersions.mockResolvedValue(1);
1678
+ auditAppend.mockRejectedValue(new Error('audit failed'));
1679
+ await expect(unpublishDocument(buildCtx(db, { ...minimalCollection, hooks: { afterUnpublish } }), {
1680
+ expectedRevision: 1,
1681
+ documentId: 'doc-1',
1682
+ })).rejects.toThrow('audit failed');
1683
+ expect(afterUnpublish).not.toHaveBeenCalled();
1684
+ });
1685
+ it('invokes beforeUnpublish and afterUnpublish hooks', async () => {
1686
+ const callOrder = [];
1687
+ const hooks = {
1688
+ beforeUnpublish: vi.fn(async () => {
1689
+ callOrder.push('before');
1690
+ }),
1691
+ afterUnpublish: vi.fn(async () => {
1692
+ callOrder.push('after');
1693
+ }),
1694
+ };
1695
+ const { db, archivePublishedVersions } = createMockDb();
1696
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1697
+ document_id: 'doc-1',
1698
+ document_version_id: 'ver-1',
1699
+ status: 'published',
1700
+ path: 'current-path',
1701
+ fields: {},
1702
+ });
1703
+ archivePublishedVersions.mockImplementation(async () => {
1704
+ callOrder.push('archive');
1705
+ return 2;
1706
+ });
1707
+ const definition = { ...minimalCollection, hooks };
1708
+ const ctx = buildCtx(db, definition);
1709
+ await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1710
+ expect(callOrder).toEqual(['before', 'archive', 'after']);
1711
+ expect(hooks.beforeUnpublish).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'current-path' }));
1712
+ expect(hooks.afterUnpublish).toHaveBeenCalledWith(expect.objectContaining({
1713
+ documentId: 'doc-1',
1714
+ archivedCount: 2,
1715
+ path: 'current-path',
1716
+ }));
1717
+ });
1718
+ it('works when no hooks are defined', async () => {
1719
+ const { db, archivePublishedVersions } = createMockDb();
1720
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1721
+ document_id: 'doc-1',
1722
+ document_version_id: 'ver-1',
1723
+ status: 'published',
1724
+ path: 'current-path',
1725
+ fields: {},
1726
+ });
1727
+ archivePublishedVersions.mockResolvedValue(0);
1728
+ const ctx = buildCtx(db);
1729
+ const result = await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1730
+ expect(result.archivedCount).toBe(0);
1731
+ });
1732
+ it('supports an array of beforeUnpublish and afterUnpublish hooks', async () => {
1733
+ const callOrder = [];
1734
+ const hooks = {
1735
+ beforeUnpublish: [
1736
+ vi.fn(async () => {
1737
+ callOrder.push('before-1');
1738
+ }),
1739
+ vi.fn(async () => {
1740
+ callOrder.push('before-2');
1741
+ }),
1742
+ ],
1743
+ afterUnpublish: [
1744
+ vi.fn(async () => {
1745
+ callOrder.push('after-1');
1746
+ }),
1747
+ vi.fn(async () => {
1748
+ callOrder.push('after-2');
1749
+ }),
1750
+ ],
1751
+ };
1752
+ const { db, archivePublishedVersions } = createMockDb();
1753
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
1754
+ document_id: 'doc-1',
1755
+ document_version_id: 'ver-1',
1756
+ status: 'published',
1757
+ path: 'current-path',
1758
+ fields: {},
1759
+ });
1760
+ archivePublishedVersions.mockImplementation(async () => {
1761
+ callOrder.push('archive');
1762
+ return 2;
1763
+ });
1764
+ const definition = { ...minimalCollection, hooks };
1765
+ const ctx = buildCtx(db, definition);
1766
+ await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1767
+ expect(callOrder).toEqual(['before-1', 'before-2', 'archive', 'after-1', 'after-2']);
1768
+ });
1769
+ });
1770
+ // -----------------------------------------------------------------------
1771
+ // deleteDocument
1772
+ // -----------------------------------------------------------------------
1773
+ describe('deleteDocument', () => {
1774
+ it('invokes beforeDelete / afterDelete with the document path', async () => {
1775
+ const beforeDelete = vi.fn();
1776
+ const afterDelete = vi.fn();
1777
+ const { db, getDocumentById } = createMockDb();
1778
+ getDocumentById.mockResolvedValue({
1779
+ document_version_id: 'ver-1',
1780
+ document_id: 'doc-1',
1781
+ path: 'doc-to-delete',
1782
+ fields: {},
1783
+ });
1784
+ const definition = { ...minimalCollection, hooks: { beforeDelete, afterDelete } };
1785
+ const ctx = buildCtx(db, definition);
1786
+ await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1787
+ expect(beforeDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
1788
+ expect(afterDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
1789
+ });
1790
+ it('records a document.deleted audit row atomically with the soft-delete', async () => {
1791
+ const { db, getDocumentById, softDeleteDocument, auditAppend, withTransaction } = createMockDb();
1792
+ getDocumentById.mockResolvedValue({
1793
+ document_version_id: 'ver-1',
1794
+ document_id: 'doc-1',
1795
+ path: 'doc-to-delete',
1796
+ fields: {},
1797
+ });
1798
+ const ctx = buildCtx(db);
1799
+ await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1800
+ expect(withTransaction).toHaveBeenCalledOnce();
1801
+ expect(softDeleteDocument).toHaveBeenCalledWith({ document_id: 'doc-1' });
1802
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1803
+ documentId: 'doc-1',
1804
+ collectionId: 'col-1',
1805
+ actorRealm: 'admin',
1806
+ action: 'document.deleted',
1807
+ }));
1808
+ });
1809
+ it('rejects a beforeDelete failure without soft-deleting the document', async () => {
1810
+ const beforeDelete = vi.fn(async () => {
1811
+ throw new Error('pre-commit hook failed');
1812
+ });
1813
+ const afterDelete = vi.fn();
1814
+ const { db, getDocumentById, softDeleteDocument } = createMockDb();
1815
+ getDocumentById.mockResolvedValue({
1816
+ document_version_id: 'ver-1',
1817
+ document_id: 'doc-1',
1818
+ path: 'doc-to-delete',
1819
+ fields: {},
1820
+ });
1821
+ const definition = { ...minimalCollection, hooks: { beforeDelete, afterDelete } };
1822
+ await expect(deleteDocument(buildCtx(db, definition), { expectedRevision: 1, documentId: 'doc-1' })).rejects.toThrow('pre-commit hook failed');
1823
+ expect(softDeleteDocument).not.toHaveBeenCalled();
1824
+ expect(afterDelete).not.toHaveBeenCalled();
1825
+ });
1826
+ it('retains stored files when soft-deleting an upload-capable document', async () => {
1827
+ const { db, getDocumentById } = createMockDb();
1828
+ const upload = { mimeTypes: ['application/pdf'], maxFileSize: 1024 };
1829
+ const definition = {
1830
+ ...minimalCollection,
1831
+ fields: [
1832
+ { name: 'cover', label: 'Cover', type: 'image', upload },
1833
+ {
1834
+ name: 'files',
1835
+ label: 'Files',
1836
+ type: 'array',
1837
+ fields: [
1838
+ {
1839
+ name: 'filesGroup',
1840
+ type: 'group',
1841
+ fields: [{ name: 'publicationFile', label: 'File', type: 'file', upload }],
1842
+ },
1843
+ ],
1844
+ },
1845
+ ],
1846
+ };
1847
+ getDocumentById.mockResolvedValue({
1848
+ document_version_id: 'ver-1',
1849
+ document_id: 'doc-1',
1850
+ path: 'doc-to-delete',
1851
+ fields: {
1852
+ cover: {
1853
+ storagePath: 'covers/original.jpg',
1854
+ variants: [{ storagePath: 'covers/thumb.avif' }],
1855
+ },
1856
+ files: [
1857
+ { filesGroup: { publicationFile: { storagePath: 'files/a.pdf' } } },
1858
+ { filesGroup: { publicationFile: null } },
1859
+ { filesGroup: { publicationFile: { storagePath: 'files/b.pdf' } } },
1860
+ ],
1861
+ },
1862
+ });
1863
+ const storageDelete = vi.fn().mockResolvedValue(undefined);
1864
+ const ctx = {
1865
+ ...buildCtx(db, definition),
1866
+ storage: { delete: storageDelete },
1867
+ };
1868
+ await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1869
+ expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct: true }));
1870
+ expect(storageDelete).not.toHaveBeenCalled();
1871
+ });
1872
+ it('returns only allowlisted failures and keeps raw details in internal logs', async () => {
1873
+ const { db, getDocumentById } = createMockDb();
1874
+ const upload = { mimeTypes: ['application/pdf'], maxFileSize: 1024 };
1875
+ const hookError = Object.assign(new Error('hook leaked private/hooks/search.ts'), {
1876
+ code: 'ERR_SEARCH',
1877
+ });
1878
+ const definition = {
1879
+ ...minimalCollection,
1880
+ fields: [{ name: 'file', label: 'File', type: 'file', upload }],
1881
+ hooks: {
1882
+ afterDelete: async () => {
1883
+ throw hookError;
1884
+ },
1885
+ },
1886
+ };
1887
+ getDocumentById.mockResolvedValue({
1888
+ document_version_id: 'ver-1',
1889
+ document_id: 'doc-1',
1890
+ path: 'doc-to-delete',
1891
+ fields: {
1892
+ file: {
1893
+ storagePath: 'private/original.pdf',
1894
+ variants: [
1895
+ { storagePath: 'private/preview.pdf' },
1896
+ { storagePath: 'private/thumbnail.pdf' },
1897
+ ],
1898
+ },
1899
+ },
1900
+ });
1901
+ const storageDelete = vi.fn(async (storagePath) => {
1902
+ if (storagePath === 'private/original.pdf') {
1903
+ throw Object.assign(new Error('storage unavailable'), { code: ErrorCodes.STORAGE });
1904
+ }
1905
+ if (storagePath === 'private/thumbnail.pdf')
1906
+ throw new Error('cleanup failed');
1907
+ });
1908
+ const ctx = {
1909
+ ...buildCtx(db, definition),
1910
+ storage: { delete: storageDelete },
1911
+ logger: { ...noopLogger, error: vi.fn() },
1912
+ };
1913
+ const result = await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
1914
+ expect(storageDelete).not.toHaveBeenCalled();
1915
+ expect(result).toEqual({
1916
+ affectedDocuments: [{ documentId: 'doc-1', revision: 2 }],
1917
+ scheduledPublicationsNeedReconfirmation: false,
1918
+ documentId: 'doc-1',
1919
+ revision: 2,
1920
+ deletedVersionCount: 1,
1921
+ outcome: 'committed-with-side-effect-failures',
1922
+ sideEffectFailures: [{ phase: 'afterDelete', code: 'ERR_UNHANDLED' }],
1923
+ });
1924
+ const serializedResult = JSON.stringify(result);
1925
+ expect(serializedResult).not.toContain('hook leaked');
1926
+ expect(serializedResult).not.toContain('private/');
1927
+ expect(serializedResult).not.toContain('ERR_SEARCH');
1928
+ expect(ctx.logger.error).toHaveBeenCalledTimes(2);
1929
+ expect(ctx.logger.error).toHaveBeenNthCalledWith(1, expect.objectContaining({ err: hookError, documentId: 'doc-1' }), 'afterDelete hook failed after document delete');
1930
+ expect(ctx.logger.error).toHaveBeenNthCalledWith(2, expect.objectContaining({
1931
+ documentId: 'doc-1',
1932
+ sideEffectFailures: [{ phase: 'afterDelete', code: 'ERR_UNHANDLED' }],
1933
+ }), 'post-commit delete side effects failed');
1934
+ });
1935
+ });
1936
+ // -----------------------------------------------------------------------
1937
+ // updateDocumentSystemFields (audited, non-versioned)
1938
+ // -----------------------------------------------------------------------
1939
+ describe('updateDocumentSystemFields', () => {
1940
+ function setupDoc(getDocumentSystemFieldsForUpdate, overrides) {
1941
+ getDocumentSystemFieldsForUpdate.mockResolvedValue({
1942
+ path: 'old-slug',
1943
+ source_locale: 'en',
1944
+ availableLocales: ['en'],
1945
+ ...overrides,
1946
+ });
1947
+ }
1948
+ it('records document.path.changed when the path actually changes', async () => {
1949
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
1950
+ setupDoc(getDocumentSystemFieldsForUpdate);
1951
+ const ctx = buildCtx(db);
1952
+ const result = await updateDocumentSystemFields(ctx, {
1953
+ expectedRevision: 1,
1954
+ documentId: 'doc-1',
1955
+ path: 'new-slug',
1956
+ });
1957
+ expect(withTransaction).toHaveBeenCalledOnce();
1958
+ expect(result).toMatchObject({
1959
+ changed: true,
1960
+ pathChanged: true,
1961
+ availableLocalesChanged: false,
1962
+ path: 'new-slug',
1963
+ });
1964
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
1965
+ action: 'document.path.changed',
1966
+ field: 'path',
1967
+ before: 'old-slug',
1968
+ after: 'new-slug',
1969
+ }));
1970
+ });
1971
+ it('describes a system-field path conflict as ownership by a live document', async () => {
1972
+ const { db, getDocumentSystemFieldsForUpdate } = createMockDb();
1973
+ setupDoc(getDocumentSystemFieldsForUpdate);
1974
+ db.commands.documents.updateDocumentPath.mockRejectedValueOnce(pathUniqueViolation());
1975
+ const ctx = buildCtx(db);
1976
+ await expect(updateDocumentSystemFields(ctx, {
1977
+ expectedRevision: 1,
1978
+ documentId: 'doc-1',
1979
+ path: 'new-slug',
1980
+ })).rejects.toMatchObject({
1981
+ code: ErrorCodes.PATH_CONFLICT,
1982
+ message: 'cannot update this document to path "new-slug" because a live document already uses it in this collection (locale: en)',
1983
+ details: {
1984
+ path: 'new-slug',
1985
+ locale: 'en',
1986
+ constraint: 'idx_document_paths_collection_locale_path',
1987
+ },
1988
+ });
1989
+ });
1990
+ it('does not write, audit, or fire the hook when the path is unchanged', async () => {
1991
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
1992
+ setupDoc(getDocumentSystemFieldsForUpdate);
1993
+ const afterSystemFieldsChange = vi.fn();
1994
+ const ctx = buildCtx(db, {
1995
+ ...minimalCollection,
1996
+ hooks: { afterSystemFieldsChange },
1997
+ });
1998
+ const result = await updateDocumentSystemFields(ctx, {
1999
+ expectedRevision: 1,
2000
+ documentId: 'doc-1',
2001
+ path: 'old-slug',
2002
+ });
2003
+ expect(result).toMatchObject({ changed: false, pathChanged: false });
2004
+ expect(db.commands.documents.updateDocumentPath).not.toHaveBeenCalled();
2005
+ expect(withTransaction).toHaveBeenCalledOnce();
2006
+ expect(auditAppend).not.toHaveBeenCalled();
2007
+ expect(afterSystemFieldsChange).not.toHaveBeenCalled();
2008
+ });
2009
+ it('records document.locales.changed with before/after sets', async () => {
2010
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend } = createMockDb();
2011
+ setupDoc(getDocumentSystemFieldsForUpdate);
2012
+ const ctx = buildCtx(db);
2013
+ const result = await updateDocumentSystemFields(ctx, {
2014
+ expectedRevision: 1,
2015
+ documentId: 'doc-1',
2016
+ availableLocales: ['en', 'fr'],
2017
+ });
2018
+ expect(result).toMatchObject({
2019
+ changed: true,
2020
+ pathChanged: false,
2021
+ availableLocalesChanged: true,
2022
+ availableLocalesWritten: true,
2023
+ });
2024
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
2025
+ action: 'document.locales.changed',
2026
+ field: 'availableLocales',
2027
+ before: ['en'],
2028
+ after: ['en', 'fr'],
2029
+ }));
2030
+ });
2031
+ it('treats reordered and duplicated locale values as a no-op set', async () => {
2032
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
2033
+ setupDoc(getDocumentSystemFieldsForUpdate, { availableLocales: ['en', 'fr'] });
2034
+ const afterSystemFieldsChange = vi.fn();
2035
+ const ctx = buildCtx(db, {
2036
+ ...minimalCollection,
2037
+ hooks: { afterSystemFieldsChange },
2038
+ });
2039
+ const result = await updateDocumentSystemFields(ctx, {
2040
+ expectedRevision: 1,
2041
+ documentId: 'doc-1',
2042
+ availableLocales: ['fr', 'en', 'fr'],
2043
+ });
2044
+ expect(result).toMatchObject({ changed: false, availableLocalesChanged: false });
2045
+ expect(db.commands.documents.setDocumentAvailableLocales).not.toHaveBeenCalled();
2046
+ expect(withTransaction).toHaveBeenCalledOnce();
2047
+ expect(auditAppend).not.toHaveBeenCalled();
2048
+ expect(afterSystemFieldsChange).not.toHaveBeenCalled();
2049
+ });
2050
+ it('fires ordered hooks after commit with previous and current field snapshots', async () => {
2051
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
2052
+ setupDoc(getDocumentSystemFieldsForUpdate);
2053
+ const order = [];
2054
+ vi.mocked(db.commands.documents.updateDocumentPath).mockImplementation(async () => {
2055
+ order.push('path-write');
2056
+ });
2057
+ vi.mocked(db.commands.documents.setDocumentAvailableLocales).mockImplementation(async () => {
2058
+ order.push('locales-write');
2059
+ });
2060
+ auditAppend.mockImplementation(async () => {
2061
+ order.push('audit');
2062
+ return { id: 'audit-1' };
2063
+ });
2064
+ withTransaction.mockImplementation(async (fn) => {
2065
+ order.push('transaction:start');
2066
+ const value = await fn();
2067
+ order.push('transaction:commit');
2068
+ return value;
2069
+ });
2070
+ const firstHook = vi.fn(async () => {
2071
+ order.push('hook:first');
2072
+ });
2073
+ const secondHook = vi.fn(async () => {
2074
+ order.push('hook:second');
2075
+ });
2076
+ const ctx = buildCtx(db, {
2077
+ ...minimalCollection,
2078
+ hooks: { afterSystemFieldsChange: [firstHook, secondHook] },
2079
+ });
2080
+ await updateDocumentSystemFields(ctx, {
2081
+ expectedRevision: 1,
2082
+ documentId: 'doc-1',
2083
+ path: 'new-slug',
2084
+ availableLocales: ['fr', 'en', 'fr'],
2085
+ });
2086
+ expect(order).toEqual([
2087
+ 'transaction:start',
2088
+ 'path-write',
2089
+ 'audit',
2090
+ 'locales-write',
2091
+ 'audit',
2092
+ 'transaction:commit',
2093
+ 'hook:first',
2094
+ 'hook:second',
2095
+ ]);
2096
+ expect(firstHook).toHaveBeenCalledWith({
2097
+ documentId: 'doc-1',
2098
+ collectionPath: 'articles',
2099
+ requested: { path: true, availableLocales: true },
2100
+ changed: { path: true, availableLocales: true },
2101
+ reconciliation: false,
2102
+ previousPath: 'old-slug',
2103
+ currentPath: 'new-slug',
2104
+ previousAvailableLocales: ['en'],
2105
+ currentAvailableLocales: ['fr', 'en'],
2106
+ });
2107
+ });
2108
+ it('does not fire side-effect hooks when the audited transaction fails', async () => {
2109
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend } = createMockDb();
2110
+ setupDoc(getDocumentSystemFieldsForUpdate);
2111
+ const afterSystemFieldsChange = vi.fn();
2112
+ auditAppend.mockRejectedValue(new Error('audit failed'));
2113
+ const ctx = buildCtx(db, {
2114
+ ...minimalCollection,
2115
+ hooks: { afterSystemFieldsChange },
2116
+ });
2117
+ await expect(updateDocumentSystemFields(ctx, {
2118
+ expectedRevision: 1,
2119
+ documentId: 'doc-1',
2120
+ path: 'new-slug',
2121
+ })).rejects.toThrow('audit failed');
2122
+ expect(db.commands.documents.updateDocumentPath).toHaveBeenCalledOnce();
2123
+ expect(afterSystemFieldsChange).not.toHaveBeenCalled();
2124
+ });
2125
+ it('propagates an after-hook failure without folding it into the committed transaction', async () => {
2126
+ const { db, getDocumentSystemFieldsForUpdate, withTransaction } = createMockDb();
2127
+ setupDoc(getDocumentSystemFieldsForUpdate);
2128
+ let committed = false;
2129
+ withTransaction.mockImplementation(async (fn) => {
2130
+ const value = await fn();
2131
+ committed = true;
2132
+ return value;
2133
+ });
2134
+ const afterSystemFieldsChange = vi.fn(async () => {
2135
+ expect(committed).toBe(true);
2136
+ throw new Error('cache unavailable');
2137
+ });
2138
+ const ctx = buildCtx(db, {
2139
+ ...minimalCollection,
2140
+ hooks: { afterSystemFieldsChange },
2141
+ });
2142
+ await expect(updateDocumentSystemFields(ctx, {
2143
+ expectedRevision: 1,
2144
+ documentId: 'doc-1',
2145
+ availableLocales: ['en', 'fr'],
2146
+ })).rejects.toMatchObject({
2147
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
2148
+ details: { revision: 2 },
2149
+ });
2150
+ expect(committed).toBe(true);
2151
+ expect(db.commands.documents.setDocumentAvailableLocales).toHaveBeenCalledOnce();
2152
+ expect(afterSystemFieldsChange).toHaveBeenCalledOnce();
2153
+ });
2154
+ it('re-runs reconciliation on an explicit no-op retry after a committed hook failure', async () => {
2155
+ const { db, getDocumentSystemFieldsForUpdate, auditAppend } = createMockDb();
2156
+ setupDoc(getDocumentSystemFieldsForUpdate);
2157
+ const afterSystemFieldsChange = vi
2158
+ .fn()
2159
+ .mockRejectedValueOnce(new Error('search unavailable'))
2160
+ .mockResolvedValue(undefined);
2161
+ const ctx = buildCtx(db, {
2162
+ ...minimalCollection,
2163
+ hooks: { afterSystemFieldsChange },
2164
+ });
2165
+ await expect(updateDocumentSystemFields(ctx, {
2166
+ expectedRevision: 1,
2167
+ documentId: 'doc-1',
2168
+ path: 'new-slug',
2169
+ })).rejects.toMatchObject({
2170
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
2171
+ details: { revision: 2 },
2172
+ });
2173
+ setupDoc(getDocumentSystemFieldsForUpdate, { path: 'new-slug' });
2174
+ const retry = await updateDocumentSystemFields(ctx, {
2175
+ expectedRevision: 2,
2176
+ documentId: 'doc-1',
2177
+ path: 'new-slug',
2178
+ reconcile: true,
2179
+ });
2180
+ expect(retry).toMatchObject({ changed: false, reconciliation: true });
2181
+ expect(db.commands.documents.updateDocumentPath).toHaveBeenCalledOnce();
2182
+ expect(auditAppend).toHaveBeenCalledOnce();
2183
+ expect(afterSystemFieldsChange).toHaveBeenCalledTimes(2);
2184
+ expect(afterSystemFieldsChange).toHaveBeenLastCalledWith(expect.objectContaining({
2185
+ requested: { path: true, availableLocales: false },
2186
+ changed: { path: false, availableLocales: false },
2187
+ reconciliation: true,
2188
+ previousPath: 'new-slug',
2189
+ currentPath: 'new-slug',
2190
+ }));
2191
+ });
2192
+ it('uses the locked transaction snapshot for audit and invalidation payloads', async () => {
2193
+ const { db, getDocumentById, getDocumentSystemFieldsForUpdate, auditAppend } = createMockDb();
2194
+ getDocumentById.mockResolvedValue({
2195
+ path: 'stale-path',
2196
+ source_locale: 'en',
2197
+ availableLocales: ['en'],
2198
+ });
2199
+ setupDoc(getDocumentSystemFieldsForUpdate, {
2200
+ path: 'concurrent-path',
2201
+ availableLocales: ['en', 'fr'],
2202
+ });
2203
+ const afterSystemFieldsChange = vi.fn();
2204
+ const ctx = buildCtx(db, {
2205
+ ...minimalCollection,
2206
+ hooks: { afterSystemFieldsChange },
2207
+ });
2208
+ await updateDocumentSystemFields(ctx, {
2209
+ expectedRevision: 1,
2210
+ documentId: 'doc-1',
2211
+ path: 'final-path',
2212
+ availableLocales: ['de'],
2213
+ });
2214
+ expect(getDocumentById).not.toHaveBeenCalled();
2215
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ field: 'path', before: 'concurrent-path', after: 'final-path' }));
2216
+ expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
2217
+ field: 'availableLocales',
2218
+ before: ['en', 'fr'],
2219
+ after: ['de'],
2220
+ }));
2221
+ expect(afterSystemFieldsChange).toHaveBeenCalledWith(expect.objectContaining({
2222
+ previousPath: 'concurrent-path',
2223
+ currentPath: 'final-path',
2224
+ previousAvailableLocales: ['en', 'fr'],
2225
+ currentAvailableLocales: ['de'],
2226
+ }));
2227
+ });
2228
+ });
2229
+ // -----------------------------------------------------------------------
2230
+ // restoreDocumentVersion
2231
+ // -----------------------------------------------------------------------
2232
+ describe('restoreDocumentVersion', () => {
2233
+ function setupRestore(opts) {
2234
+ const sourceDocumentId = opts?.sourceDocumentId ?? 'doc-1';
2235
+ const currentVersionId = opts?.currentVersionId ?? 'ver-current';
2236
+ const sourceVersionId = 'ver-source';
2237
+ const currentPath = opts?.currentPath ?? 'sticky-path';
2238
+ const sourceFields = opts?.sourceFields ?? {
2239
+ title: { en: 'Old EN', fr: 'Vieux FR' },
2240
+ };
2241
+ const mocks = createMockDb();
2242
+ const { db } = mocks;
2243
+ vi.mocked(db.queries.documents.getDocumentByVersion).mockResolvedValue({
2244
+ document_version_id: sourceVersionId,
2245
+ document_id: sourceDocumentId,
2246
+ path: 'long-ago-path',
2247
+ status: 'archived',
2248
+ fields: sourceFields,
2249
+ });
2250
+ mocks.getCurrentVersionMetadata.mockResolvedValue({
2251
+ document_version_id: currentVersionId,
2252
+ document_id: 'doc-1',
2253
+ collection_id: 'col-1',
2254
+ path: currentPath,
2255
+ status: 'published',
2256
+ created_at: new Date(),
2257
+ updated_at: new Date(),
2258
+ });
2259
+ mocks.getDocumentById.mockResolvedValue({
2260
+ document_version_id: currentVersionId,
2261
+ path: currentPath,
2262
+ status: 'published',
2263
+ fields: { title: 'Currently Published' },
2264
+ });
2265
+ mocks.createDocumentVersion.mockResolvedValue({
2266
+ document: { id: 'ver-restored', document_id: 'doc-1' },
2267
+ fieldCount: 5,
2268
+ });
2269
+ return { ...mocks, sourceVersionId, currentVersionId, sourceFields, currentPath };
2270
+ }
2271
+ it('reads the source with locale: "all" and re-emits via createDocumentVersion', async () => {
2272
+ const { db, createDocumentVersion, sourceVersionId, sourceFields, currentVersionId } = setupRestore();
2273
+ const ctx = buildCtx(db, {
2274
+ ...minimalCollection,
2275
+ fields: [{ name: 'title', type: 'text', localized: true }],
2276
+ });
2277
+ const result = await restoreDocumentVersion(ctx, {
2278
+ expectedRevision: 1,
2279
+ documentId: 'doc-1',
2280
+ sourceVersionId,
2281
+ });
2282
+ expect(db.queries.documents.getDocumentByVersion).toHaveBeenCalledWith({
2283
+ document_version_id: sourceVersionId,
2284
+ locale: 'all',
2285
+ });
2286
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
2287
+ const call = createDocumentVersion.mock.calls[0]?.[0];
2288
+ expect(call.action).toBe('restore');
2289
+ expect(call.locale).toBe('all');
2290
+ expect(call.documentData).toEqual(sourceFields);
2291
+ expect(call.previousVersionId).toBe(currentVersionId);
2292
+ expect(result).toEqual({
2293
+ documentId: 'doc-1',
2294
+ documentVersionId: 'ver-restored',
2295
+ revision: 2,
2296
+ sourceVersionId,
2297
+ });
2298
+ });
2299
+ describe('exemption from field validation', () => {
2300
+ // A historical version cannot be corrected through ordinary editing
2301
+ // before it is restored, and both the schema and the validation rules
2302
+ // may have moved since it was written. Enforcing today's rules would
2303
+ // make that content permanently unrecoverable.
2304
+ const tightened = {
2305
+ ...minimalCollection,
2306
+ fields: [
2307
+ { name: 'title', type: 'text', localized: true },
2308
+ { name: 'summary', type: 'text', label: 'Summary' },
2309
+ ],
2310
+ };
2311
+ it('review probe: advisory validator exceptions must not block recovery', async () => {
2312
+ const { db, createDocumentVersion, sourceVersionId } = setupRestore();
2313
+ const definition = {
2314
+ ...minimalCollection,
2315
+ fields: [{ name: 'summary', type: 'text', validate: (value) => value.trim() ? undefined : 'Required' }],
2316
+ };
2317
+ await expect(restoreDocumentVersion(buildCtx(db, definition), {
2318
+ expectedRevision: 1, documentId: 'doc-1', sourceVersionId,
2319
+ })).resolves.toMatchObject({ documentId: 'doc-1' });
2320
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
2321
+ });
2322
+ it('restores a version that predates a required field and reports it', async () => {
2323
+ const { db, createDocumentVersion, sourceVersionId } = setupRestore();
2324
+ const result = await restoreDocumentVersion(buildCtx(db, tightened), {
2325
+ expectedRevision: 1,
2326
+ documentId: 'doc-1',
2327
+ sourceVersionId,
2328
+ });
2329
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
2330
+ expect(result.validationIssues).toEqual([
2331
+ { field: 'summary', message: 'Summary is required', kind: 'required' },
2332
+ ]);
2333
+ });
2334
+ it('restores content that fails a rule the schema never changed', async () => {
2335
+ // The corrected email rule is the concrete case: a version written
2336
+ // when the rule was inert must stay restorable.
2337
+ const emailCollection = {
2338
+ ...minimalCollection,
2339
+ fields: [
2340
+ { name: 'title', type: 'text', localized: true },
2341
+ {
2342
+ name: 'contact',
2343
+ type: 'text',
2344
+ validation: { rules: [{ type: 'email', value: true }] },
2345
+ },
2346
+ ],
2347
+ };
2348
+ const { db, createDocumentVersion, sourceVersionId } = setupRestore({
2349
+ sourceFields: { title: { en: 'Old EN' }, contact: 'not-an-email' },
2350
+ });
2351
+ const result = await restoreDocumentVersion(buildCtx(db, emailCollection), {
2352
+ expectedRevision: 1,
2353
+ documentId: 'doc-1',
2354
+ sourceVersionId,
2355
+ });
2356
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
2357
+ expect(result.validationIssues?.map((issue) => issue.field)).toEqual(['contact']);
2358
+ expect(result.validationIssues?.[0]?.kind).toBe('invalid');
2359
+ });
2360
+ it('restores into a published default status, republishing invalid content', async () => {
2361
+ // Accepted recovery semantics, not a guarantee about restored content.
2362
+ const publishedOnly = {
2363
+ ...tightened,
2364
+ workflow: {
2365
+ statuses: [
2366
+ { name: 'draft', label: 'Draft' },
2367
+ { name: 'published', label: 'Published' },
2368
+ { name: 'archived', label: 'Archived' },
2369
+ ],
2370
+ defaultStatus: 'published',
2371
+ },
2372
+ };
2373
+ const { db, createDocumentVersion, sourceVersionId } = setupRestore();
2374
+ const result = await restoreDocumentVersion(buildCtx(db, publishedOnly), {
2375
+ expectedRevision: 1,
2376
+ documentId: 'doc-1',
2377
+ sourceVersionId,
2378
+ });
2379
+ expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('published');
2380
+ expect(result.validationIssues?.map((issue) => issue.field)).toEqual(['summary']);
2381
+ });
2382
+ it('omits validationIssues when the source satisfies the current rules', async () => {
2383
+ const { db, sourceVersionId } = setupRestore({
2384
+ sourceFields: { title: { en: 'Old EN' }, summary: 'Present' },
2385
+ });
2386
+ const result = await restoreDocumentVersion(buildCtx(db, tightened), {
2387
+ expectedRevision: 1,
2388
+ documentId: 'doc-1',
2389
+ sourceVersionId,
2390
+ });
2391
+ expect(result.validationIssues).toBeUndefined();
2392
+ });
2393
+ it('does not extend the exemption to duplication', async () => {
2394
+ // Duplicate reads the current version, which the editor can open and
2395
+ // correct, so it keeps the gate.
2396
+ const { db } = setupRestore();
2397
+ vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
2398
+ document_version_id: 'ver-current',
2399
+ document_id: 'doc-1',
2400
+ path: 'sticky-path',
2401
+ status: 'published',
2402
+ fields: { title: { en: 'Current' } },
2403
+ });
2404
+ await expect(duplicateDocument(buildCtx(db, tightened), {
2405
+ expectedRevision: 1,
2406
+ sourceDocumentId: 'doc-1',
2407
+ })).rejects.toThrow('Some document fields are invalid');
2408
+ });
2409
+ });
2410
+ it('hard-defaults the new version status to the workflow default (never inherits source status)', async () => {
2411
+ const { db, createDocumentVersion, sourceVersionId } = setupRestore();
2412
+ const ctx = buildCtx(db, {
2413
+ ...minimalCollection,
2414
+ fields: [{ name: 'title', type: 'text', localized: true }],
2415
+ });
2416
+ await restoreDocumentVersion(ctx, {
2417
+ expectedRevision: 1,
2418
+ documentId: 'doc-1',
2419
+ sourceVersionId,
2420
+ });
2421
+ // Source version was 'archived'; default status for minimalCollection is 'draft'.
2422
+ expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('draft');
2423
+ });
2424
+ it('does not pass path on restore — the existing path row is sticky', async () => {
2425
+ const { db, createDocumentVersion, sourceVersionId } = setupRestore({
2426
+ currentPath: 'sticky-path',
2427
+ });
2428
+ const ctx = buildCtx(db, {
2429
+ ...minimalCollection,
2430
+ fields: [{ name: 'title', type: 'text', localized: true }],
2431
+ });
2432
+ await restoreDocumentVersion(ctx, {
2433
+ expectedRevision: 1,
2434
+ documentId: 'doc-1',
2435
+ sourceVersionId,
2436
+ });
2437
+ // Restore never changes a document's path: the existing
2438
+ // byline_document_paths row carries forward unchanged. The storage
2439
+ // primitive only writes to document_paths when `path` is supplied.
2440
+ expect(createDocumentVersion.mock.calls[0]?.[0].path).toBeUndefined();
2441
+ });
2442
+ it('rejects when the source version belongs to a different document', async () => {
2443
+ const { db, sourceVersionId, createDocumentVersion } = setupRestore({
2444
+ sourceDocumentId: 'doc-OTHER',
2445
+ });
2446
+ const ctx = buildCtx(db, {
2447
+ ...minimalCollection,
2448
+ fields: [{ name: 'title', type: 'text', localized: true }],
2449
+ });
2450
+ try {
2451
+ await restoreDocumentVersion(ctx, {
2452
+ expectedRevision: 1,
2453
+ documentId: 'doc-1',
2454
+ sourceVersionId,
2455
+ });
2456
+ expect.fail('expected ERR_VALIDATION');
2457
+ }
2458
+ catch (err) {
2459
+ expect(err).toBeInstanceOf(BylineError);
2460
+ expect(err.code).toBe(ErrorCodes.VALIDATION);
2461
+ }
2462
+ expect(createDocumentVersion).not.toHaveBeenCalled();
2463
+ });
2464
+ it('rejects when the source is already the current version', async () => {
2465
+ const sourceVersionId = 'ver-source';
2466
+ const mocks = createMockDb();
2467
+ const { db } = mocks;
2468
+ vi.mocked(db.queries.documents.getDocumentByVersion).mockResolvedValue({
2469
+ document_version_id: sourceVersionId,
2470
+ document_id: 'doc-1',
2471
+ path: 'p',
2472
+ status: 'draft',
2473
+ fields: {},
2474
+ });
2475
+ mocks.getCurrentVersionMetadata.mockResolvedValue({
2476
+ document_version_id: sourceVersionId, // SAME as source
2477
+ document_id: 'doc-1',
2478
+ collection_id: 'col-1',
2479
+ path: 'p',
2480
+ status: 'draft',
2481
+ created_at: new Date(),
2482
+ updated_at: new Date(),
2483
+ });
2484
+ const ctx = buildCtx(db, {
2485
+ ...minimalCollection,
2486
+ fields: [{ name: 'title', type: 'text', localized: true }],
2487
+ });
2488
+ try {
2489
+ await restoreDocumentVersion(ctx, {
2490
+ expectedRevision: 1,
2491
+ documentId: 'doc-1',
2492
+ sourceVersionId,
2493
+ });
2494
+ expect.fail('expected ERR_INVALID_TRANSITION');
2495
+ }
2496
+ catch (err) {
2497
+ expect(err.code).toBe(ErrorCodes.INVALID_TRANSITION);
2498
+ }
2499
+ expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
2500
+ });
2501
+ it('fires beforeUpdate / afterUpdate with restore: { sourceVersionId } context', async () => {
2502
+ const beforeUpdate = vi.fn();
2503
+ const afterUpdate = vi.fn();
2504
+ const { db, sourceVersionId } = setupRestore();
2505
+ const definition = {
2506
+ ...minimalCollection,
2507
+ fields: [{ name: 'title', type: 'text', localized: true }],
2508
+ hooks: { beforeUpdate, afterUpdate },
2509
+ };
2510
+ const ctx = buildCtx(db, definition);
2511
+ await restoreDocumentVersion(ctx, {
2512
+ expectedRevision: 1,
2513
+ documentId: 'doc-1',
2514
+ sourceVersionId,
2515
+ });
2516
+ expect(beforeUpdate).toHaveBeenCalledWith(expect.objectContaining({
2517
+ collectionPath: 'articles',
2518
+ restore: { sourceVersionId },
2519
+ originalData: expect.objectContaining({
2520
+ fields: expect.objectContaining({ title: 'Currently Published' }),
2521
+ }),
2522
+ }));
2523
+ expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({
2524
+ documentId: 'doc-1',
2525
+ documentVersionId: 'ver-restored',
2526
+ // Sticky path comes from the current version's envelope, not the source.
2527
+ path: 'sticky-path',
2528
+ restore: { sourceVersionId },
2529
+ }));
2530
+ });
2531
+ it('classifies a restore afterUpdate failure after the restored version commits', async () => {
2532
+ const { db, sourceVersionId, createDocumentVersion } = setupRestore();
2533
+ const definition = {
2534
+ ...minimalCollection,
2535
+ fields: [{ name: 'title', type: 'text', localized: true }],
2536
+ hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
2537
+ };
2538
+ await expect(restoreDocumentVersion(buildCtx(db, definition), {
2539
+ expectedRevision: 1,
2540
+ documentId: 'doc-1',
2541
+ sourceVersionId,
2542
+ })).rejects.toMatchObject({
2543
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
2544
+ details: {
2545
+ phase: 'afterUpdate',
2546
+ documentId: 'doc-1',
2547
+ documentVersionId: 'ver-restored',
2548
+ sideEffectCode: ErrorCodes.UNHANDLED,
2549
+ },
2550
+ });
2551
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
2552
+ });
2553
+ it('throws ERR_FORBIDDEN when actor lacks collections.<path>.update', async () => {
2554
+ const { db, sourceVersionId } = setupRestore();
2555
+ const ctx = buildCtx(db, {
2556
+ ...minimalCollection,
2557
+ fields: [{ name: 'title', type: 'text', localized: true }],
2558
+ });
2559
+ const actor = new AdminAuth({
2560
+ id: 'editor',
2561
+ abilities: ['collections.articles.read'],
2562
+ });
2563
+ ctx.requestContext = createRequestContext({ actor });
2564
+ try {
2565
+ await restoreDocumentVersion(ctx, {
2566
+ expectedRevision: 1,
2567
+ documentId: 'doc-1',
2568
+ sourceVersionId,
2569
+ });
2570
+ expect.fail('expected ERR_FORBIDDEN');
2571
+ }
2572
+ catch (err) {
2573
+ expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
2574
+ expect(err.message).toContain('collections.articles.update');
2575
+ }
2576
+ });
2577
+ it('permits restore when actor holds only collections.<path>.update (no separate restore verb needed)', async () => {
2578
+ const { db, createDocumentVersion, sourceVersionId } = setupRestore();
2579
+ const ctx = buildCtx(db, {
2580
+ ...minimalCollection,
2581
+ fields: [{ name: 'title', type: 'text', localized: true }],
2582
+ });
2583
+ const actor = new AdminAuth({
2584
+ id: 'editor',
2585
+ abilities: ['collections.articles.update', 'collections.articles.read'],
2586
+ });
2587
+ ctx.requestContext = createRequestContext({ actor });
2588
+ await restoreDocumentVersion(ctx, {
2589
+ expectedRevision: 1,
2590
+ documentId: 'doc-1',
2591
+ sourceVersionId,
2592
+ });
2593
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
2594
+ });
2595
+ });
2596
+ // -----------------------------------------------------------------------
2597
+ // duplicateDocument
2598
+ // -----------------------------------------------------------------------
2599
+ describe('duplicateDocument', () => {
2600
+ /** Collection with a localized title (drives the per-locale suffix path). */
2601
+ const localizedCollection = {
2602
+ path: 'articles',
2603
+ labels: { singular: 'Article', plural: 'Articles' },
2604
+ useAsTitle: 'title',
2605
+ useAsPath: 'title',
2606
+ fields: [
2607
+ { name: 'title', type: 'text', localized: true },
2608
+ { name: 'tagline', type: 'text' },
2609
+ ],
2610
+ };
2611
+ /** Helper: seed the source read with `locale: 'all'`. */
2612
+ function setupSource(mocks, opts) {
2613
+ const sourceDocumentId = opts?.sourceDocumentId ?? 'doc-source';
2614
+ const sourceFields = opts?.sourceFields ?? {
2615
+ title: { en: 'Hello', fr: 'Bonjour' },
2616
+ tagline: 'A tagline',
2617
+ };
2618
+ mocks.getDocumentById.mockResolvedValue({
2619
+ document_version_id: 'ver-source',
2620
+ document_id: sourceDocumentId,
2621
+ path: 'hello',
2622
+ status: 'draft',
2623
+ created_at: new Date(),
2624
+ updated_at: new Date(),
2625
+ fields: sourceFields,
2626
+ });
2627
+ mocks.createDocumentVersion.mockResolvedValue({
2628
+ document: { id: 'ver-new', document_id: 'doc-new' },
2629
+ fieldCount: 5,
2630
+ });
2631
+ return { sourceDocumentId, sourceFields };
2632
+ }
2633
+ it('reads the source with locale: "all" and writes via createDocumentVersion with locale: "all", action: "create", no documentId', async () => {
2634
+ const mocks = createMockDb();
2635
+ const { sourceDocumentId } = setupSource(mocks);
2636
+ const ctx = buildCtx(mocks.db, localizedCollection);
2637
+ const result = await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId });
2638
+ expect(mocks.getDocumentById).toHaveBeenCalledWith(expect.objectContaining({
2639
+ collection_id: 'col-1',
2640
+ document_id: sourceDocumentId,
2641
+ locale: 'all',
2642
+ reconstruct: true,
2643
+ lenient: true,
2644
+ }));
2645
+ expect(mocks.createDocumentVersion).toHaveBeenCalledOnce();
2646
+ const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2647
+ expect(call.action).toBe('create');
2648
+ expect(call.locale).toBe('all');
2649
+ expect(call.documentId).toBeUndefined();
2650
+ expect(result.documentId).toBe('doc-new');
2651
+ expect(result.documentVersionId).toBe('ver-new');
2652
+ expect(result.sourceDocumentId).toBe(sourceDocumentId);
2653
+ expect(result.pathRetried).toBe(false);
2654
+ });
2655
+ it('appends " (copy)" to every locale of a localized useAsTitle field', async () => {
2656
+ const mocks = createMockDb();
2657
+ setupSource(mocks, {
2658
+ sourceFields: {
2659
+ title: { en: 'Hello', fr: 'Bonjour' },
2660
+ tagline: 'A tagline',
2661
+ },
2662
+ });
2663
+ const ctx = buildCtx(mocks.db, localizedCollection);
2664
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2665
+ const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2666
+ expect(call.documentData.title).toEqual({
2667
+ en: 'Hello (copy)',
2668
+ fr: 'Bonjour (copy)',
2669
+ });
2670
+ // Non-title fields pass through untouched.
2671
+ expect(call.documentData.tagline).toBe('A tagline');
2672
+ });
2673
+ it('appends " (copy)" once to a non-localized useAsTitle field', async () => {
2674
+ const nonLocalizedCollection = {
2675
+ ...localizedCollection,
2676
+ fields: [
2677
+ { name: 'title', type: 'text' },
2678
+ { name: 'tagline', type: 'text' },
2679
+ ],
2680
+ };
2681
+ const mocks = createMockDb();
2682
+ setupSource(mocks, {
2683
+ sourceFields: {
2684
+ title: 'Hello',
2685
+ tagline: 'A tagline',
2686
+ },
2687
+ });
2688
+ const ctx = buildCtx(mocks.db, nonLocalizedCollection);
2689
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2690
+ const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2691
+ expect(call.documentData.title).toBe('Hello (copy)');
2692
+ });
2693
+ it('derives the candidate path from the default-locale suffixed title', async () => {
2694
+ const mocks = createMockDb();
2695
+ setupSource(mocks, {
2696
+ sourceFields: {
2697
+ title: { en: 'Hello World', fr: 'Bonjour Monde' },
2698
+ tagline: 'A tagline',
2699
+ },
2700
+ });
2701
+ const ctx = buildCtx(mocks.db, localizedCollection);
2702
+ const result = await duplicateDocument(ctx, {
2703
+ expectedRevision: 1,
2704
+ sourceDocumentId: 'doc-source',
2705
+ });
2706
+ const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2707
+ // Default locale is 'en'; "Hello World (copy)" slugifies to "hello-world-copy".
2708
+ expect(call.path).toBe('hello-world-copy');
2709
+ expect(result.newPath).toBe('hello-world-copy');
2710
+ });
2711
+ it('strips _id and _type metadata from blocks and array items so the new doc gets fresh identities', async () => {
2712
+ const mocks = createMockDb();
2713
+ setupSource(mocks, {
2714
+ sourceFields: {
2715
+ title: { en: 'Hello', fr: 'Bonjour' },
2716
+ tagline: 'A tagline',
2717
+ sections: [
2718
+ {
2719
+ _id: 'section-id-1',
2720
+ _type: 'section',
2721
+ heading: 'Intro',
2722
+ blocks: [
2723
+ { _id: 'block-id-1', _type: 'photoBlock', display: 'wide' },
2724
+ { _id: 'block-id-2', _type: 'textBlock', body: 'inner' },
2725
+ ],
2726
+ },
2727
+ ],
2728
+ },
2729
+ });
2730
+ const ctx = buildCtx(mocks.db, localizedCollection);
2731
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2732
+ const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
2733
+ const sections = call.documentData.sections;
2734
+ expect(sections[0]._id).toBeUndefined();
2735
+ expect(sections[0]._type).toBeUndefined();
2736
+ expect(sections[0].blocks[0]._id).toBeUndefined();
2737
+ expect(sections[0].blocks[1]._id).toBeUndefined();
2738
+ // Content survives — only meta is stripped.
2739
+ expect(sections[0].heading).toBe('Intro');
2740
+ expect(sections[0].blocks[0].display).toBe('wide');
2741
+ });
2742
+ it('does not mutate the source object returned by getDocumentById (deep-clones before suffix / strip)', async () => {
2743
+ const originalFields = {
2744
+ title: { en: 'Hello', fr: 'Bonjour' },
2745
+ tagline: 'A tagline',
2746
+ sections: [{ _id: 'sec-1', heading: 'Intro' }],
2747
+ };
2748
+ const mocks = createMockDb();
2749
+ mocks.getDocumentById.mockResolvedValue({
2750
+ document_version_id: 'ver-source',
2751
+ document_id: 'doc-source',
2752
+ path: 'hello',
2753
+ status: 'draft',
2754
+ created_at: new Date(),
2755
+ updated_at: new Date(),
2756
+ fields: originalFields,
2757
+ });
2758
+ mocks.createDocumentVersion.mockResolvedValue({
2759
+ document: { id: 'ver-new', document_id: 'doc-new' },
2760
+ fieldCount: 5,
2761
+ });
2762
+ const ctx = buildCtx(mocks.db, localizedCollection);
2763
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2764
+ // Source title should be unchanged in memory.
2765
+ expect(originalFields.title).toEqual({ en: 'Hello', fr: 'Bonjour' });
2766
+ expect(originalFields.sections[0]?._id).toBe('sec-1');
2767
+ });
2768
+ it('retries once with a short-UUID suffix when the candidate path collides', async () => {
2769
+ const mocks = createMockDb();
2770
+ setupSource(mocks);
2771
+ const ctx = buildCtx(mocks.db, localizedCollection);
2772
+ // First call: throw a raw path unique violation. The lifecycle maps it
2773
+ // to ERR_PATH_CONFLICT, then the duplicate retry classifier handles it.
2774
+ let attempt = 0;
2775
+ mocks.createDocumentVersion.mockImplementation(() => {
2776
+ attempt += 1;
2777
+ if (attempt === 1) {
2778
+ return Promise.reject(pathUniqueViolation());
2779
+ }
2780
+ return Promise.resolve({
2781
+ document: { id: 'ver-new', document_id: 'doc-new' },
2782
+ fieldCount: 5,
2783
+ });
2784
+ });
2785
+ const result = await duplicateDocument(ctx, {
2786
+ expectedRevision: 1,
2787
+ sourceDocumentId: 'doc-source',
2788
+ });
2789
+ expect(mocks.createDocumentVersion).toHaveBeenCalledTimes(2);
2790
+ const firstPath = mocks.createDocumentVersion.mock.calls[0]?.[0].path;
2791
+ const retryPath = mocks.createDocumentVersion.mock.calls[1]?.[0].path;
2792
+ expect(retryPath.startsWith(`${firstPath}-`)).toBe(true);
2793
+ // 4-char UUID slice
2794
+ expect(retryPath.length).toBe(firstPath.length + 5);
2795
+ expect(result.pathRetried).toBe(true);
2796
+ expect(result.newPath).toBe(retryPath);
2797
+ });
2798
+ it('only retries once — a second conflict propagates to the caller', async () => {
2799
+ const mocks = createMockDb();
2800
+ setupSource(mocks);
2801
+ const ctx = buildCtx(mocks.db, localizedCollection);
2802
+ // Both attempts throw raw path unique violations, exercising both
2803
+ // duplicateDocument conflict-mapping calls.
2804
+ mocks.createDocumentVersion.mockImplementation(() => {
2805
+ return Promise.reject(pathUniqueViolation());
2806
+ });
2807
+ let thrown;
2808
+ try {
2809
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2810
+ }
2811
+ catch (err) {
2812
+ thrown = err;
2813
+ }
2814
+ const retryPath = mocks.createDocumentVersion.mock.calls[1]?.[0].path;
2815
+ expect(thrown).toMatchObject({
2816
+ code: ErrorCodes.PATH_CONFLICT,
2817
+ message: `cannot duplicate this document to path "${retryPath}" because a live document already uses it in this collection (locale: en)`,
2818
+ details: {
2819
+ path: retryPath,
2820
+ locale: 'en',
2821
+ constraint: 'idx_document_paths_collection_locale_path',
2822
+ },
2823
+ });
2824
+ // Bounded to exactly two attempts.
2825
+ expect(mocks.createDocumentVersion).toHaveBeenCalledTimes(2);
2826
+ });
2827
+ it('throws ERR_NOT_FOUND when the source document does not exist', async () => {
2828
+ const mocks = createMockDb();
2829
+ mocks.getDocumentById.mockResolvedValue(null);
2830
+ const ctx = buildCtx(mocks.db, localizedCollection);
2831
+ try {
2832
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-missing' });
2833
+ expect.fail('expected ERR_NOT_FOUND');
2834
+ }
2835
+ catch (err) {
2836
+ expect(err.code).toBe(ErrorCodes.NOT_FOUND);
2837
+ }
2838
+ expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
2839
+ });
2840
+ it('fires beforeCreate / afterCreate hooks with a duplicate marker', async () => {
2841
+ const beforeCreate = vi.fn();
2842
+ const afterCreate = vi.fn();
2843
+ const withHooks = {
2844
+ ...localizedCollection,
2845
+ hooks: {
2846
+ beforeCreate,
2847
+ afterCreate,
2848
+ },
2849
+ };
2850
+ const mocks = createMockDb();
2851
+ setupSource(mocks, { sourceDocumentId: 'doc-source' });
2852
+ const ctx = buildCtx(mocks.db, withHooks);
2853
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2854
+ expect(beforeCreate).toHaveBeenCalledOnce();
2855
+ const beforeCtx = beforeCreate.mock.calls[0]?.[0];
2856
+ expect(beforeCtx.duplicate).toEqual({ sourceDocumentId: 'doc-source' });
2857
+ expect(beforeCtx.collectionPath).toBe('articles');
2858
+ // Hook sees the multi-locale shape (mirrors restoreDocumentVersion's
2859
+ // multi-locale-data precedent on beforeUpdate).
2860
+ expect(beforeCtx.data.title).toEqual({ en: 'Hello (copy)', fr: 'Bonjour (copy)' });
2861
+ expect(afterCreate).toHaveBeenCalledOnce();
2862
+ const afterCtx = afterCreate.mock.calls[0]?.[0];
2863
+ expect(afterCtx.duplicate).toEqual({ sourceDocumentId: 'doc-source' });
2864
+ expect(afterCtx.documentId).toBe('doc-new');
2865
+ expect(afterCtx.documentVersionId).toBe('ver-new');
2866
+ // afterCreate carries the final path written for the duplicate — the
2867
+ // same value handed to createDocumentVersion.
2868
+ const writtenPath = mocks.createDocumentVersion.mock.calls[0]?.[0]?.path;
2869
+ expect(typeof afterCtx.path).toBe('string');
2870
+ expect(afterCtx.path).toBe(writtenPath);
2871
+ });
2872
+ it('classifies a duplicate afterCreate failure with the committed document ids', async () => {
2873
+ const withHooks = {
2874
+ ...localizedCollection,
2875
+ hooks: { afterCreate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
2876
+ };
2877
+ const mocks = createMockDb();
2878
+ setupSource(mocks);
2879
+ await expect(duplicateDocument(buildCtx(mocks.db, withHooks), {
2880
+ expectedRevision: 1,
2881
+ sourceDocumentId: 'doc-source',
2882
+ })).rejects.toMatchObject({
2883
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
2884
+ details: {
2885
+ phase: 'afterCreate',
2886
+ documentId: 'doc-new',
2887
+ documentVersionId: 'ver-new',
2888
+ sideEffectCode: ErrorCodes.UNHANDLED,
2889
+ },
2890
+ });
2891
+ expect(mocks.createDocumentVersion).toHaveBeenCalledOnce();
2892
+ });
2893
+ it('enforces collections.<path>.create — rejects an admin actor missing the ability', async () => {
2894
+ const mocks = createMockDb();
2895
+ setupSource(mocks);
2896
+ const ctx = buildCtx(mocks.db, localizedCollection);
2897
+ // Replace super-admin with an admin who only has read.
2898
+ const actor = new AdminAuth({
2899
+ id: 'reader',
2900
+ abilities: ['collections.articles.read'],
2901
+ });
2902
+ ctx.requestContext = createRequestContext({ actor });
2903
+ try {
2904
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2905
+ expect.fail('expected ERR_FORBIDDEN');
2906
+ }
2907
+ catch (err) {
2908
+ expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
2909
+ expect(err.message).toContain('collections.articles.create');
2910
+ }
2911
+ expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
2912
+ });
2913
+ it('rejects when requestContext is absent (ERR_UNAUTHENTICATED)', async () => {
2914
+ const mocks = createMockDb();
2915
+ setupSource(mocks);
2916
+ const ctx = buildCtx(mocks.db, localizedCollection);
2917
+ ctx.requestContext = undefined;
2918
+ try {
2919
+ await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
2920
+ expect.fail('expected ERR_UNAUTHENTICATED');
2921
+ }
2922
+ catch (err) {
2923
+ expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
2924
+ }
2925
+ expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
2926
+ });
2927
+ });
2928
+ // -----------------------------------------------------------------------
2929
+ // copyToLocale
2930
+ // -----------------------------------------------------------------------
2931
+ describe('copyToLocale', () => {
2932
+ /** Collection with mixed localized / non-localized fields, including
2933
+ * nested structure (array of groups, blocks). */
2934
+ const mixedCollection = {
2935
+ path: 'articles',
2936
+ labels: { singular: 'Article', plural: 'Articles' },
2937
+ useAsTitle: 'title',
2938
+ fields: [
2939
+ { name: 'title', type: 'text', localized: true },
2940
+ { name: 'tagline', type: 'text', optional: true, localized: true },
2941
+ { name: 'sku', type: 'text' /* non-localized */ },
2942
+ {
2943
+ name: 'sections',
2944
+ type: 'array',
2945
+ fields: [
2946
+ { name: 'heading', type: 'text', localized: true },
2947
+ { name: 'order', type: 'integer' /* non-localized */ },
2948
+ ],
2949
+ },
2950
+ ],
2951
+ };
2952
+ function setupSourceTarget(opts) {
2953
+ const mocks = createMockDb();
2954
+ const sourceFields = opts?.sourceFields ?? {
2955
+ title: 'Hello',
2956
+ tagline: 'World',
2957
+ sku: 'SKU-1',
2958
+ sections: [{ _id: 'sec-1', heading: 'Intro', order: 1 }],
2959
+ };
2960
+ const targetFields = opts?.targetFields ?? {
2961
+ title: '',
2962
+ tagline: 'Already translated',
2963
+ sku: 'SKU-1',
2964
+ sections: [{ _id: 'sec-1', heading: '', order: 1 }],
2965
+ };
2966
+ const currentVersionId = opts?.currentVersionId ?? 'ver-current';
2967
+ mocks.getDocumentById.mockImplementation(async (params) => {
2968
+ if (params.locale === 'en') {
2969
+ return {
2970
+ document_version_id: currentVersionId,
2971
+ document_id: 'doc-1',
2972
+ path: 'hello',
2973
+ status: 'draft',
2974
+ fields: sourceFields,
2975
+ };
2976
+ }
2977
+ if (params.locale === 'fr') {
2978
+ return {
2979
+ document_version_id: currentVersionId,
2980
+ document_id: 'doc-1',
2981
+ path: 'hello',
2982
+ status: 'draft',
2983
+ fields: targetFields,
2984
+ };
2985
+ }
2986
+ return null;
2987
+ });
2988
+ mocks.createDocumentVersion.mockResolvedValue({
2989
+ document: { id: 'ver-new', document_id: 'doc-1' },
2990
+ fieldCount: 5,
2991
+ });
2992
+ return { mocks, sourceFields, targetFields, currentVersionId };
2993
+ }
2994
+ it('reads both source and target locales, writes target with action="copy_to_locale", locale=target, previousVersionId threaded', async () => {
2995
+ const { mocks, currentVersionId } = setupSourceTarget();
2996
+ const ctx = buildCtx(mocks.db, mixedCollection);
2997
+ const result = await copyToLocale(ctx, {
2998
+ expectedRevision: 1,
2999
+ documentId: 'doc-1',
3000
+ sourceLocale: 'en',
3001
+ targetLocale: 'fr',
3002
+ overwrite: false,
3003
+ });
3004
+ // Source + target read.
3005
+ const readCalls = mocks.getDocumentById.mock.calls.map((c) => c[0]);
3006
+ expect(readCalls.some((p) => p.locale === 'en')).toBe(true);
3007
+ expect(readCalls.some((p) => p.locale === 'fr')).toBe(true);
3008
+ // Single write to target locale.
3009
+ expect(mocks.createDocumentVersion).toHaveBeenCalledOnce();
3010
+ const writeCall = mocks.createDocumentVersion.mock.calls[0]?.[0];
3011
+ expect(writeCall.action).toBe('copy_to_locale');
3012
+ expect(writeCall.locale).toBe('fr');
3013
+ expect(writeCall.documentId).toBe('doc-1');
3014
+ expect(writeCall.previousVersionId).toBe(currentVersionId);
3015
+ expect(writeCall.path).toBeUndefined();
3016
+ // Result envelope.
3017
+ expect(result.documentId).toBe('doc-1');
3018
+ expect(result.sourceLocale).toBe('en');
3019
+ expect(result.targetLocale).toBe('fr');
3020
+ });
3021
+ it('overwrite=false: fills empty target slots from source, preserves populated target slots, never touches non-localized fields', async () => {
3022
+ const { mocks } = setupSourceTarget({
3023
+ sourceFields: {
3024
+ title: 'EN Title',
3025
+ tagline: 'EN Tagline',
3026
+ sku: 'SKU-EN',
3027
+ sections: [{ _id: 'sec-1', heading: 'EN Heading', order: 5 }],
3028
+ },
3029
+ targetFields: {
3030
+ title: '', // empty → should be filled
3031
+ tagline: 'FR Tagline Already', // populated → should be kept
3032
+ sku: 'SKU-FR', // non-localized, target value preserved
3033
+ sections: [{ _id: 'sec-1', heading: '', order: 9 }],
3034
+ },
3035
+ });
3036
+ const ctx = buildCtx(mocks.db, mixedCollection);
3037
+ const result = await copyToLocale(ctx, {
3038
+ expectedRevision: 1,
3039
+ documentId: 'doc-1',
3040
+ sourceLocale: 'en',
3041
+ targetLocale: 'fr',
3042
+ overwrite: false,
3043
+ });
3044
+ const data = mocks.createDocumentVersion.mock.calls[0]?.[0].documentData;
3045
+ expect(data.title).toBe('EN Title'); // filled
3046
+ expect(data.tagline).toBe('FR Tagline Already'); // kept
3047
+ expect(data.sku).toBe('SKU-FR'); // non-localized: target preserved
3048
+ expect(data.sections[0].heading).toBe('EN Heading'); // filled
3049
+ expect(data.sections[0].order).toBe(9); // non-localized: target preserved
3050
+ expect(data.sections[0]._id).toBe('sec-1'); // identity preserved
3051
+ expect(result.fieldsUpdated).toBe(2); // title + sections[0].heading
3052
+ });
3053
+ it('overwrite=true: replaces every localized leaf with source value, even when source is empty', async () => {
3054
+ const { mocks } = setupSourceTarget({
3055
+ sourceFields: {
3056
+ title: 'EN Title',
3057
+ tagline: '', // empty source
3058
+ sku: 'SKU-EN',
3059
+ sections: [{ _id: 'sec-1', heading: 'EN Heading', order: 5 }],
3060
+ },
3061
+ targetFields: {
3062
+ title: 'FR Title',
3063
+ tagline: 'FR Tagline',
3064
+ sku: 'SKU-FR',
3065
+ sections: [{ _id: 'sec-1', heading: 'FR Heading', order: 9 }],
3066
+ },
3067
+ });
3068
+ const ctx = buildCtx(mocks.db, mixedCollection);
3069
+ await copyToLocale(ctx, {
3070
+ expectedRevision: 1,
3071
+ documentId: 'doc-1',
3072
+ sourceLocale: 'en',
3073
+ targetLocale: 'fr',
3074
+ overwrite: true,
3075
+ });
3076
+ const data = mocks.createDocumentVersion.mock.calls[0]?.[0].documentData;
3077
+ expect(data.title).toBe('EN Title'); // overwritten
3078
+ expect(data.tagline).toBe(''); // overwritten even though source is empty
3079
+ expect(data.sku).toBe('SKU-FR'); // non-localized: still target preserved
3080
+ expect(data.sections[0].heading).toBe('EN Heading'); // overwritten
3081
+ expect(data.sections[0].order).toBe(9); // non-localized preserved
3082
+ });
3083
+ it('does not pass `path` — path is sticky on copy-to-locale', async () => {
3084
+ const { mocks } = setupSourceTarget();
3085
+ const ctx = buildCtx(mocks.db, mixedCollection);
3086
+ await copyToLocale(ctx, {
3087
+ expectedRevision: 1,
3088
+ documentId: 'doc-1',
3089
+ sourceLocale: 'en',
3090
+ targetLocale: 'fr',
3091
+ overwrite: false,
3092
+ });
3093
+ expect(mocks.createDocumentVersion.mock.calls[0]?.[0].path).toBeUndefined();
3094
+ });
3095
+ it('rejects when sourceLocale === targetLocale (ERR_VALIDATION)', async () => {
3096
+ const { mocks } = setupSourceTarget();
3097
+ const ctx = buildCtx(mocks.db, mixedCollection);
3098
+ try {
3099
+ await copyToLocale(ctx, {
3100
+ expectedRevision: 1,
3101
+ documentId: 'doc-1',
3102
+ sourceLocale: 'en',
3103
+ targetLocale: 'en',
3104
+ overwrite: false,
3105
+ });
3106
+ expect.fail('expected ERR_VALIDATION');
3107
+ }
3108
+ catch (err) {
3109
+ expect(err.code).toBe(ErrorCodes.VALIDATION);
3110
+ }
3111
+ expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
3112
+ });
3113
+ it('throws ERR_NOT_FOUND when the source-locale read returns null', async () => {
3114
+ const mocks = createMockDb();
3115
+ mocks.getDocumentById.mockResolvedValue(null); // both reads fail
3116
+ const ctx = buildCtx(mocks.db, mixedCollection);
3117
+ try {
3118
+ await copyToLocale(ctx, {
3119
+ expectedRevision: 1,
3120
+ documentId: 'doc-1',
3121
+ sourceLocale: 'en',
3122
+ targetLocale: 'fr',
3123
+ overwrite: false,
3124
+ });
3125
+ expect.fail('expected ERR_NOT_FOUND');
3126
+ }
3127
+ catch (err) {
3128
+ expect(err.code).toBe(ErrorCodes.NOT_FOUND);
3129
+ }
3130
+ expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
3131
+ });
3132
+ it('throws ERR_NOT_FOUND when the target-locale read returns null', async () => {
3133
+ const mocks = createMockDb();
3134
+ // Source returns a doc; target returns null.
3135
+ mocks.getDocumentById.mockImplementation(async (params) => {
3136
+ if (params.locale === 'en') {
3137
+ return {
3138
+ document_version_id: 'ver-current',
3139
+ document_id: 'doc-1',
3140
+ path: 'hello',
3141
+ status: 'draft',
3142
+ fields: { title: 'EN Title' },
3143
+ };
3144
+ }
3145
+ return null;
3146
+ });
3147
+ const ctx = buildCtx(mocks.db, mixedCollection);
3148
+ try {
3149
+ await copyToLocale(ctx, {
3150
+ expectedRevision: 1,
3151
+ documentId: 'doc-1',
3152
+ sourceLocale: 'en',
3153
+ targetLocale: 'fr',
3154
+ overwrite: false,
3155
+ });
3156
+ expect.fail('expected ERR_NOT_FOUND');
3157
+ }
3158
+ catch (err) {
3159
+ expect(err.code).toBe(ErrorCodes.NOT_FOUND);
3160
+ }
3161
+ expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
3162
+ });
3163
+ it('fires beforeUpdate / afterUpdate with the copyToLocale discriminator', async () => {
3164
+ const beforeUpdate = vi.fn();
3165
+ const afterUpdate = vi.fn();
3166
+ const withHooks = {
3167
+ ...mixedCollection,
3168
+ hooks: { beforeUpdate, afterUpdate },
3169
+ };
3170
+ const { mocks } = setupSourceTarget();
3171
+ const ctx = buildCtx(mocks.db, withHooks);
3172
+ await copyToLocale(ctx, {
3173
+ expectedRevision: 1,
3174
+ documentId: 'doc-1',
3175
+ sourceLocale: 'en',
3176
+ targetLocale: 'fr',
3177
+ overwrite: false,
3178
+ });
3179
+ expect(beforeUpdate).toHaveBeenCalledOnce();
3180
+ expect(beforeUpdate.mock.calls[0]?.[0].copyToLocale).toEqual({
3181
+ sourceLocale: 'en',
3182
+ targetLocale: 'fr',
3183
+ });
3184
+ expect(afterUpdate).toHaveBeenCalledOnce();
3185
+ expect(afterUpdate.mock.calls[0]?.[0].copyToLocale).toEqual({
3186
+ sourceLocale: 'en',
3187
+ targetLocale: 'fr',
3188
+ });
3189
+ // Sticky path read off the target-locale envelope.
3190
+ expect(afterUpdate.mock.calls[0]?.[0].path).toBe('hello');
3191
+ });
3192
+ it('classifies a copy-to-locale afterUpdate failure after the version commits', async () => {
3193
+ const withHooks = {
3194
+ ...mixedCollection,
3195
+ hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
3196
+ };
3197
+ const { mocks } = setupSourceTarget();
3198
+ await expect(copyToLocale(buildCtx(mocks.db, withHooks), {
3199
+ expectedRevision: 1,
3200
+ documentId: 'doc-1',
3201
+ sourceLocale: 'en',
3202
+ targetLocale: 'fr',
3203
+ overwrite: false,
3204
+ })).rejects.toMatchObject({
3205
+ code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
3206
+ details: {
3207
+ phase: 'afterUpdate',
3208
+ documentId: 'doc-1',
3209
+ documentVersionId: 'ver-new',
3210
+ sideEffectCode: ErrorCodes.UNHANDLED,
3211
+ },
3212
+ });
3213
+ expect(mocks.createDocumentVersion).toHaveBeenCalledOnce();
3214
+ });
3215
+ it('enforces collections.<path>.update — rejects an admin actor missing the ability', async () => {
3216
+ const { mocks } = setupSourceTarget();
3217
+ const ctx = buildCtx(mocks.db, mixedCollection);
3218
+ const actor = new AdminAuth({
3219
+ id: 'reader',
3220
+ abilities: ['collections.articles.read'],
3221
+ });
3222
+ ctx.requestContext = createRequestContext({ actor });
3223
+ try {
3224
+ await copyToLocale(ctx, {
3225
+ expectedRevision: 1,
3226
+ documentId: 'doc-1',
3227
+ sourceLocale: 'en',
3228
+ targetLocale: 'fr',
3229
+ overwrite: false,
3230
+ });
3231
+ expect.fail('expected ERR_FORBIDDEN');
3232
+ }
3233
+ catch (err) {
3234
+ expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
3235
+ expect(err.message).toContain('collections.articles.update');
3236
+ }
3237
+ expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
3238
+ });
3239
+ it('rejects when requestContext is absent (ERR_UNAUTHENTICATED)', async () => {
3240
+ const { mocks } = setupSourceTarget();
3241
+ const ctx = buildCtx(mocks.db, mixedCollection);
3242
+ ctx.requestContext = undefined;
3243
+ try {
3244
+ await copyToLocale(ctx, {
3245
+ expectedRevision: 1,
3246
+ documentId: 'doc-1',
3247
+ sourceLocale: 'en',
3248
+ targetLocale: 'fr',
3249
+ overwrite: false,
3250
+ });
3251
+ expect.fail('expected ERR_UNAUTHENTICATED');
3252
+ }
3253
+ catch (err) {
3254
+ expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
3255
+ }
3256
+ expect(mocks.createDocumentVersion).not.toHaveBeenCalled();
3257
+ });
3258
+ });
3259
+ // -----------------------------------------------------------------------
3260
+ // Ability enforcement (Phase 4)
3261
+ // -----------------------------------------------------------------------
3262
+ describe('ability enforcement', () => {
3263
+ it('throws ERR_UNAUTHENTICATED when requestContext is absent', async () => {
3264
+ const { db } = createMockDb();
3265
+ const ctx = buildCtx(db);
3266
+ ctx.requestContext = undefined;
3267
+ try {
3268
+ await createDocument(ctx, { data: { title: 'Oops' } });
3269
+ expect.fail('expected ERR_UNAUTHENTICATED');
3270
+ }
3271
+ catch (err) {
3272
+ expect(err).toBeInstanceOf(AuthError);
3273
+ expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
3274
+ }
3275
+ });
3276
+ it('throws ERR_FORBIDDEN when actor lacks collections.<path>.create', async () => {
3277
+ const { db } = createMockDb();
3278
+ const ctx = buildCtx(db);
3279
+ const actor = new AdminAuth({ id: 'editor', abilities: ['collections.articles.read'] });
3280
+ ctx.requestContext = createRequestContext({ actor });
3281
+ try {
3282
+ await createDocument(ctx, { data: { title: 'Nope' } });
3283
+ expect.fail('expected ERR_FORBIDDEN');
3284
+ }
3285
+ catch (err) {
3286
+ expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
3287
+ }
3288
+ });
3289
+ it('permits create when actor holds collections.<path>.create', async () => {
3290
+ const { db, createDocumentVersion } = createMockDb();
3291
+ const ctx = buildCtx(db);
3292
+ const actor = new AdminAuth({
3293
+ id: 'editor',
3294
+ abilities: ['collections.articles.create'],
3295
+ });
3296
+ ctx.requestContext = createRequestContext({ actor });
3297
+ await createDocument(ctx, { data: { title: 'Yes' } });
3298
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
3299
+ });
3300
+ it('requires both changeStatus and publish when transitioning to published', async () => {
3301
+ const { db, getCurrentVersionMetadata } = createMockDb();
3302
+ getCurrentVersionMetadata.mockResolvedValue({
3303
+ document_version_id: 'ver-1',
3304
+ document_id: 'doc-1',
3305
+ collection_id: 'col-1',
3306
+ path: 'x',
3307
+ status: 'draft',
3308
+ created_at: new Date(),
3309
+ updated_at: new Date(),
3310
+ });
3311
+ const ctx = buildCtx(db);
3312
+ const actor = new AdminAuth({
3313
+ id: 'editor',
3314
+ // Has changeStatus but NOT publish — the publish transition should fail.
3315
+ abilities: ['collections.articles.changeStatus'],
3316
+ });
3317
+ ctx.requestContext = createRequestContext({ actor });
3318
+ try {
3319
+ await changeDocumentStatus(ctx, {
3320
+ expectedRevision: 1,
3321
+ documentId: 'doc-1',
3322
+ nextStatus: 'published',
3323
+ });
3324
+ expect.fail('expected ERR_FORBIDDEN');
3325
+ }
3326
+ catch (err) {
3327
+ expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
3328
+ expect(err.message).toContain('collections.articles.publish');
3329
+ }
3330
+ });
3331
+ it('permits a non-publish transition with only the changeStatus ability', async () => {
3332
+ const { db, getCurrentVersionMetadata, setDocumentStatus } = createMockDb();
3333
+ getCurrentVersionMetadata.mockResolvedValue({
3334
+ document_version_id: 'ver-1',
3335
+ document_id: 'doc-1',
3336
+ collection_id: 'col-1',
3337
+ path: 'x',
3338
+ status: 'draft',
3339
+ created_at: new Date(),
3340
+ updated_at: new Date(),
3341
+ });
3342
+ const definition = {
3343
+ ...minimalCollection,
3344
+ workflow: {
3345
+ statuses: [
3346
+ { name: 'draft' },
3347
+ { name: 'in_review' },
3348
+ { name: 'published' },
3349
+ { name: 'archived' },
3350
+ ],
3351
+ },
3352
+ };
3353
+ const ctx = buildCtx(db, definition);
3354
+ const actor = new AdminAuth({
3355
+ id: 'editor',
3356
+ abilities: ['collections.articles.changeStatus'],
3357
+ });
3358
+ ctx.requestContext = createRequestContext({ actor });
3359
+ await changeDocumentStatus(ctx, {
3360
+ expectedRevision: 1,
3361
+ documentId: 'doc-1',
3362
+ nextStatus: 'in_review',
3363
+ });
3364
+ expect(setDocumentStatus).toHaveBeenCalledOnce();
3365
+ });
3366
+ it('super-admin bypasses every check', async () => {
3367
+ const { db, createDocumentVersion } = createMockDb();
3368
+ const ctx = buildCtx(db);
3369
+ // buildCtx already defaults to super-admin; confirm no ability grants
3370
+ // are actually needed.
3371
+ await createDocument(ctx, { data: { title: 'X' } });
3372
+ expect(createDocumentVersion).toHaveBeenCalledOnce();
3373
+ });
3374
+ it('enforces delete against collections.<path>.delete', async () => {
3375
+ const { db, getDocumentById } = createMockDb();
3376
+ getDocumentById.mockResolvedValue({
3377
+ document_version_id: 'ver-1',
3378
+ document_id: 'doc-1',
3379
+ fields: {},
3380
+ });
3381
+ const ctx = buildCtx(db);
3382
+ const actor = new AdminAuth({
3383
+ id: 'editor',
3384
+ abilities: ['collections.articles.read', 'collections.articles.update'],
3385
+ });
3386
+ ctx.requestContext = createRequestContext({ actor });
3387
+ try {
3388
+ await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
3389
+ expect.fail('expected ERR_FORBIDDEN');
3390
+ }
3391
+ catch (err) {
3392
+ expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
3393
+ expect(err.message).toContain('collections.articles.delete');
3394
+ }
3395
+ });
3396
+ });
3397
+ });