@byline/core 4.18.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/@types/db-types.d.ts +38 -4
- package/dist/@types/document-revision.d.ts +92 -0
- package/dist/@types/document-revision.js +8 -0
- package/dist/@types/index.d.ts +1 -0
- package/dist/@types/index.js +1 -0
- package/dist/config/attach-hooks.test.node.js +2 -1
- package/dist/config/config-hooks.test.node.js +2 -1
- package/dist/core.js +8 -0
- package/dist/core.test.node.js +27 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/lib/errors.d.ts +25 -0
- package/dist/lib/errors.js +42 -0
- package/dist/lib/lock-conflict.test.node.d.ts +1 -0
- package/dist/lib/lock-conflict.test.node.js +31 -0
- package/dist/scheduler/run-due-tasks.test.node.d.ts +0 -7
- package/dist/scheduler/run-due-tasks.test.node.js +3 -2
- package/dist/scheduler/scheduled-publication.d.ts +16 -1
- package/dist/scheduler/scheduled-publication.js +27 -1
- package/dist/scheduler/scheduled-publication.test.node.d.ts +0 -7
- package/dist/scheduler/scheduled-publication.test.node.js +18 -3
- package/dist/scheduler/scheduler-boot.test.node.d.ts +0 -7
- package/dist/scheduler/scheduler-boot.test.node.js +40 -12
- package/dist/scheduler/ticker.test.node.d.ts +0 -7
- package/dist/scheduler/ticker.test.node.js +3 -2
- package/dist/services/collection-bootstrap.test.node.d.ts +0 -7
- package/dist/services/collection-bootstrap.test.node.js +12 -3
- package/dist/services/discover-counter-groups.test.node.d.ts +0 -7
- package/dist/services/discover-counter-groups.test.node.js +19 -3
- package/dist/services/document-lifecycle/audit.d.ts +1 -0
- package/dist/services/document-lifecycle/audit.js +1 -0
- package/dist/services/document-lifecycle/committed-hook.d.ts +25 -0
- package/dist/services/document-lifecycle/committed-hook.js +96 -0
- package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +30 -44
- package/dist/services/document-lifecycle/create.d.ts +1 -0
- package/dist/services/document-lifecycle/create.js +6 -21
- package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
- package/dist/services/document-lifecycle/delete-locale.js +42 -39
- package/dist/services/document-lifecycle/delete.d.ts +8 -2
- package/dist/services/document-lifecycle/delete.js +24 -15
- package/dist/services/document-lifecycle/duplicate.d.ts +3 -0
- package/dist/services/document-lifecycle/duplicate.js +55 -43
- package/dist/services/document-lifecycle/index.d.ts +5 -1
- package/dist/services/document-lifecycle/index.js +4 -1
- package/dist/services/document-lifecycle/persistence.js +18 -7
- package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +2 -0
- package/dist/services/document-lifecycle/publish-schedule-consistency.js +28 -24
- package/dist/services/document-lifecycle/reorder.d.ts +11 -0
- package/dist/services/document-lifecycle/reorder.js +83 -0
- package/dist/services/document-lifecycle/restore.d.ts +2 -0
- package/dist/services/document-lifecycle/restore.js +21 -26
- package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/rethrow-path-conflict.test.node.js +2 -1
- package/dist/services/document-lifecycle/revision-guard.d.ts +34 -0
- package/dist/services/document-lifecycle/revision-guard.js +154 -0
- package/dist/services/document-lifecycle/same-document-data.d.ts +2 -0
- package/dist/services/document-lifecycle/same-document-data.js +17 -0
- package/dist/services/document-lifecycle/scheduled-publish.d.ts +20 -4
- package/dist/services/document-lifecycle/scheduled-publish.js +146 -84
- package/dist/services/document-lifecycle/stale-document.d.ts +13 -0
- package/dist/services/document-lifecycle/stale-document.js +95 -0
- package/dist/services/document-lifecycle/stale-document.test.node.d.ts +1 -0
- package/dist/services/document-lifecycle/stale-document.test.node.js +121 -0
- package/dist/services/document-lifecycle/status-transition.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/status-transition.test.node.js +3 -2
- package/dist/services/document-lifecycle/status.d.ts +5 -21
- package/dist/services/document-lifecycle/status.js +90 -133
- package/dist/services/document-lifecycle/system-fields.d.ts +21 -0
- package/dist/services/document-lifecycle/system-fields.js +118 -116
- package/dist/services/document-lifecycle/tree.d.ts +9 -6
- package/dist/services/document-lifecycle/tree.js +65 -39
- package/dist/services/document-lifecycle/tree.test.node.d.ts +0 -7
- package/dist/services/document-lifecycle/tree.test.node.js +152 -35
- package/dist/services/document-lifecycle/update.d.ts +14 -6
- package/dist/services/document-lifecycle/update.js +104 -82
- package/dist/services/document-lifecycle.test.node.js +556 -70
- package/dist/services/field-upload.d.ts +1 -0
- package/dist/services/field-upload.js +5 -1
- package/dist/services/field-upload.test.node.d.ts +0 -7
- package/dist/services/field-upload.test.node.js +38 -2
- package/dist/services/populate.test.node.d.ts +0 -7
- package/dist/services/populate.test.node.js +19 -3
- package/dist/services/richtext-populate.test.node.d.ts +0 -7
- package/dist/services/richtext-populate.test.node.js +3 -2
- package/dist/services/singleton-lifecycle/copy-to-locale.d.ts +1 -0
- package/dist/services/singleton-lifecycle/copy-to-locale.js +9 -2
- package/dist/services/singleton-lifecycle/index.d.ts +1 -1
- package/dist/services/singleton-lifecycle/internals.d.ts +4 -6
- package/dist/services/singleton-lifecycle/internals.js +75 -73
- package/dist/services/singleton-lifecycle/restore.d.ts +1 -0
- package/dist/services/singleton-lifecycle/restore.js +3 -1
- package/dist/services/singleton-lifecycle/update.d.ts +6 -5
- package/dist/services/singleton-lifecycle/update.js +158 -91
- package/dist/services/singleton-lifecycle.test.node.d.ts +0 -7
- package/dist/services/singleton-lifecycle.test.node.js +75 -23
- package/dist/storage/db-adapter.test-helper.d.ts +26 -0
- package/dist/storage/db-adapter.test-helper.js +117 -0
- package/dist/storage/document-revision-capability.d.ts +3 -0
- package/dist/storage/document-revision-capability.js +15 -0
- package/dist/storage/document-revision-capability.test.node.d.ts +1 -0
- package/dist/storage/document-revision-capability.test.node.js +53 -0
- package/dist/storage/document-revision.d.ts +12 -0
- package/dist/storage/document-revision.js +37 -0
- package/dist/storage/document-revision.test.node.d.ts +1 -0
- package/dist/storage/document-revision.test.node.js +48 -0
- package/dist/storage/document-version-parent.d.ts +20 -0
- package/dist/storage/document-version-parent.js +34 -0
- package/dist/storage/document-version-parent.test.node.d.ts +8 -0
- package/dist/storage/document-version-parent.test.node.js +60 -0
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/read-snapshot.d.ts +3 -0
- package/dist/storage/read-snapshot.js +55 -0
- package/dist/storage/revision-store.test-helper.d.ts +3 -0
- package/dist/storage/revision-store.test-helper.js +16 -0
- package/package.json +2 -2
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
|
|
9
9
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
-
import { BylineError, DbErrorCodes, ErrorCodes } from '../lib/errors.js';
|
|
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';
|
|
11
13
|
import { cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, copyToLocale, createDocument, deleteDocument, deleteLocale, duplicateDocument, listDocumentPublishSchedules, restoreDocumentVersion, scheduleDocumentPublish, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
13
15
|
// Fixtures / Helpers
|
|
@@ -38,6 +40,7 @@ function publishScheduleRow(overrides = {}) {
|
|
|
38
40
|
documentId: 'doc-1',
|
|
39
41
|
collectionId: 'col-1',
|
|
40
42
|
targetVersionId: 'ver-1',
|
|
43
|
+
authorizedRevision: 1,
|
|
41
44
|
publishAt: new Date('2026-08-23T12:00:00.000Z'),
|
|
42
45
|
state: 'armed',
|
|
43
46
|
suspendedAt: null,
|
|
@@ -66,7 +69,10 @@ function createMockDb() {
|
|
|
66
69
|
const archivePublishedVersions = vi.fn().mockResolvedValue(0);
|
|
67
70
|
const softDeleteDocument = vi.fn().mockResolvedValue(1);
|
|
68
71
|
const deleteDocumentLocale = vi.fn().mockResolvedValue({ newVersionId: 'ver-2' });
|
|
69
|
-
const getDocumentById = vi.fn().
|
|
72
|
+
const getDocumentById = vi.fn().mockImplementation(async () => {
|
|
73
|
+
const metadata = await getCurrentVersionMetadata();
|
|
74
|
+
return metadata == null ? null : { ...metadata, fields: {}, path: await getCurrentPath() };
|
|
75
|
+
});
|
|
70
76
|
const getDocumentSystemFieldsForUpdate = vi.fn().mockResolvedValue(null);
|
|
71
77
|
const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
|
|
72
78
|
const getCurrentPath = vi.fn().mockResolvedValue('current-path');
|
|
@@ -85,7 +91,20 @@ function createMockDb() {
|
|
|
85
91
|
// in unit tests (runs the unit of work immediately, no real tx); `append`
|
|
86
92
|
// records the calls so write-point tests can assert the audit rows emitted.
|
|
87
93
|
const auditAppend = vi.fn().mockResolvedValue({ id: 'audit-1' });
|
|
88
|
-
|
|
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');
|
|
89
108
|
const fail = () => {
|
|
90
109
|
throw new Error('unexpected singleton mapping call');
|
|
91
110
|
};
|
|
@@ -103,14 +122,60 @@ function createMockDb() {
|
|
|
103
122
|
});
|
|
104
123
|
const db = {
|
|
105
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),
|
|
106
169
|
commands: {
|
|
107
170
|
collections: {
|
|
171
|
+
lockCollectionRegistration: vi.fn(async () => { }),
|
|
108
172
|
create: vi.fn(),
|
|
109
173
|
update: vi.fn(),
|
|
110
174
|
delete: vi.fn(),
|
|
111
175
|
},
|
|
112
176
|
documents: {
|
|
113
177
|
publishSchedules: {
|
|
178
|
+
lockDocuments: vi.fn(async () => { }),
|
|
114
179
|
schedule: publishSchedule,
|
|
115
180
|
confirm: confirmPublishSchedule,
|
|
116
181
|
cancel: cancelPublishSchedule,
|
|
@@ -150,12 +215,12 @@ function createMockDb() {
|
|
|
150
215
|
},
|
|
151
216
|
audit: { append: auditAppend },
|
|
152
217
|
singletons: {
|
|
153
|
-
lockSlot: vi.fn(
|
|
218
|
+
lockSlot: vi.fn(async () => { }),
|
|
154
219
|
setMapping: vi.fn(fail),
|
|
155
220
|
clearMapping: vi.fn(fail),
|
|
156
221
|
},
|
|
157
222
|
},
|
|
158
|
-
withTransaction:
|
|
223
|
+
withTransaction: transaction.run,
|
|
159
224
|
queries: {
|
|
160
225
|
collections: {
|
|
161
226
|
getAllCollections: vi.fn(),
|
|
@@ -167,6 +232,7 @@ function createMockDb() {
|
|
|
167
232
|
get: getPublishSchedule,
|
|
168
233
|
list: listPublishSchedules,
|
|
169
234
|
},
|
|
235
|
+
getDocumentRevision: async ({ document_id }) => revisions.get(document_id) ?? 1,
|
|
170
236
|
getDocumentSystemFieldsForUpdate,
|
|
171
237
|
getDocumentById,
|
|
172
238
|
getCurrentVersionMetadata,
|
|
@@ -335,6 +401,36 @@ describe('Document lifecycle service', () => {
|
|
|
335
401
|
documentVersionId: 'ver-1',
|
|
336
402
|
}));
|
|
337
403
|
});
|
|
404
|
+
it('classifies an afterCreate failure as post-commit and retains committed ids', async () => {
|
|
405
|
+
const afterCreate = vi.fn().mockRejectedValue(new Error('cache unavailable'));
|
|
406
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
407
|
+
const definition = { ...minimalCollection, hooks: { afterCreate } };
|
|
408
|
+
await expect(createDocument(buildCtx(db, definition), { data: { title: 'X' } })).rejects.toMatchObject({
|
|
409
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
410
|
+
details: {
|
|
411
|
+
phase: 'afterCreate',
|
|
412
|
+
documentId: 'doc-1',
|
|
413
|
+
documentVersionId: 'ver-1',
|
|
414
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
418
|
+
});
|
|
419
|
+
it('preserves committed classification when diagnostic logging also fails', async () => {
|
|
420
|
+
const afterCreate = vi.fn().mockRejectedValue(new Error('cache unavailable'));
|
|
421
|
+
const { db } = createMockDb();
|
|
422
|
+
const definition = { ...minimalCollection, hooks: { afterCreate } };
|
|
423
|
+
const ctx = buildCtx(db, definition);
|
|
424
|
+
ctx.logger = {
|
|
425
|
+
...noopLogger,
|
|
426
|
+
log: vi.fn(() => {
|
|
427
|
+
throw new Error('logger failed');
|
|
428
|
+
}),
|
|
429
|
+
};
|
|
430
|
+
await expect(createDocument(ctx, { data: { title: 'X' } })).rejects.toMatchObject({
|
|
431
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
432
|
+
});
|
|
433
|
+
});
|
|
338
434
|
it('afterCreate receives the resolved canonical path', async () => {
|
|
339
435
|
const afterCreate = vi.fn();
|
|
340
436
|
const { db } = createMockDb();
|
|
@@ -529,11 +625,48 @@ describe('Document lifecycle service', () => {
|
|
|
529
625
|
// updateDocument (PUT)
|
|
530
626
|
// -----------------------------------------------------------------------
|
|
531
627
|
describe('updateDocument', () => {
|
|
628
|
+
it('normalizes a stale parent from persistence without firing success hooks', async () => {
|
|
629
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
630
|
+
getDocumentById.mockResolvedValue({
|
|
631
|
+
document_version_id: 'observed',
|
|
632
|
+
status: 'draft',
|
|
633
|
+
fields: { title: 'Old' },
|
|
634
|
+
});
|
|
635
|
+
const raw = new BylineError(ErrorCodes.CONFLICT, {
|
|
636
|
+
message: 'previous document version is stale',
|
|
637
|
+
details: {
|
|
638
|
+
reason: 'stale',
|
|
639
|
+
documentId: 'doc-1',
|
|
640
|
+
previousVersionId: 'observed',
|
|
641
|
+
currentVersionId: 'concurrent',
|
|
642
|
+
},
|
|
643
|
+
});
|
|
644
|
+
createDocumentVersion.mockRejectedValue(raw);
|
|
645
|
+
const afterUpdate = vi.fn();
|
|
646
|
+
await expect(updateDocument(buildCtx(db, { ...minimalCollection, hooks: { afterUpdate } }), {
|
|
647
|
+
expectedRevision: 1,
|
|
648
|
+
documentId: 'doc-1',
|
|
649
|
+
data: { title: 'New' },
|
|
650
|
+
})).rejects.toMatchObject({
|
|
651
|
+
code: ErrorCodes.DOCUMENT_STALE,
|
|
652
|
+
cause: raw,
|
|
653
|
+
details: {
|
|
654
|
+
reason: 'version_parent_mismatch',
|
|
655
|
+
documentId: 'doc-1',
|
|
656
|
+
previousVersionId: 'observed',
|
|
657
|
+
currentVersionId: 'concurrent',
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
661
|
+
expect(afterUpdate).not.toHaveBeenCalled();
|
|
662
|
+
expect(db.commands.documents.publishSchedules.suspendForContentEdit).not.toHaveBeenCalled();
|
|
663
|
+
});
|
|
532
664
|
it('passes the acting user id as createdBy for the audit trail', async () => {
|
|
533
665
|
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
534
666
|
getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
|
|
535
667
|
const ctx = buildCtx(db);
|
|
536
668
|
await updateDocument(ctx, {
|
|
669
|
+
expectedRevision: 1,
|
|
537
670
|
documentId: 'doc-1',
|
|
538
671
|
data: { title: 'New' },
|
|
539
672
|
});
|
|
@@ -546,6 +679,7 @@ describe('Document lifecycle service', () => {
|
|
|
546
679
|
const definition = { ...minimalCollection, hooks: { beforeUpdate } };
|
|
547
680
|
const ctx = buildCtx(db, definition);
|
|
548
681
|
await updateDocument(ctx, {
|
|
682
|
+
expectedRevision: 1,
|
|
549
683
|
documentId: 'doc-1',
|
|
550
684
|
data: { title: 'New' },
|
|
551
685
|
});
|
|
@@ -567,6 +701,7 @@ describe('Document lifecycle service', () => {
|
|
|
567
701
|
});
|
|
568
702
|
const definition = { ...numericCollection, hooks: { beforeUpdate } };
|
|
569
703
|
await updateDocument(buildCtx(db, definition), {
|
|
704
|
+
expectedRevision: 1,
|
|
570
705
|
documentId: 'doc-1',
|
|
571
706
|
data: { quantity: '2', score: '2.5e1', price: 4.25 },
|
|
572
707
|
});
|
|
@@ -583,6 +718,7 @@ describe('Document lifecycle service', () => {
|
|
|
583
718
|
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
584
719
|
const ctx = buildCtx(db, definition);
|
|
585
720
|
await updateDocument(ctx, {
|
|
721
|
+
expectedRevision: 1,
|
|
586
722
|
documentId: 'doc-1',
|
|
587
723
|
data: { title: 'New' },
|
|
588
724
|
});
|
|
@@ -591,6 +727,26 @@ describe('Document lifecycle service', () => {
|
|
|
591
727
|
documentVersionId: 'ver-1',
|
|
592
728
|
}));
|
|
593
729
|
});
|
|
730
|
+
it('classifies an afterUpdate failure as post-commit and retains committed ids', async () => {
|
|
731
|
+
const afterUpdate = vi.fn().mockRejectedValue(new Error('search unavailable'));
|
|
732
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
733
|
+
getDocumentById.mockResolvedValue({ fields: { title: 'Old' } });
|
|
734
|
+
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
735
|
+
await expect(updateDocument(buildCtx(db, definition), {
|
|
736
|
+
expectedRevision: 1,
|
|
737
|
+
documentId: 'doc-1',
|
|
738
|
+
data: { title: 'New' },
|
|
739
|
+
})).rejects.toMatchObject({
|
|
740
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
741
|
+
details: {
|
|
742
|
+
phase: 'afterUpdate',
|
|
743
|
+
documentId: 'doc-1',
|
|
744
|
+
documentVersionId: 'ver-1',
|
|
745
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
749
|
+
});
|
|
594
750
|
it('afterUpdate carries the sticky path forward when none is supplied', async () => {
|
|
595
751
|
const afterUpdate = vi.fn();
|
|
596
752
|
const { db, getDocumentById } = createMockDb();
|
|
@@ -601,7 +757,11 @@ describe('Document lifecycle service', () => {
|
|
|
601
757
|
});
|
|
602
758
|
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
603
759
|
const ctx = buildCtx(db, definition);
|
|
604
|
-
await updateDocument(ctx, {
|
|
760
|
+
await updateDocument(ctx, {
|
|
761
|
+
expectedRevision: 1,
|
|
762
|
+
documentId: 'doc-1',
|
|
763
|
+
data: { title: 'New' },
|
|
764
|
+
});
|
|
605
765
|
expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({ path: 'sticky-path' }));
|
|
606
766
|
});
|
|
607
767
|
it('afterUpdate surfaces an explicitly-supplied path', async () => {
|
|
@@ -615,6 +775,7 @@ describe('Document lifecycle service', () => {
|
|
|
615
775
|
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
616
776
|
const ctx = buildCtx(db, definition);
|
|
617
777
|
await updateDocument(ctx, {
|
|
778
|
+
expectedRevision: 1,
|
|
618
779
|
documentId: 'doc-1',
|
|
619
780
|
data: { title: 'New' },
|
|
620
781
|
path: 'new-path',
|
|
@@ -632,6 +793,7 @@ describe('Document lifecycle service', () => {
|
|
|
632
793
|
const definition = { ...minimalCollection, useAsPath: 'title' };
|
|
633
794
|
const ctx = buildCtx(db, definition);
|
|
634
795
|
await updateDocument(ctx, {
|
|
796
|
+
expectedRevision: 1,
|
|
635
797
|
documentId: 'doc-1',
|
|
636
798
|
data: { title: 'Brand New Title' },
|
|
637
799
|
});
|
|
@@ -649,6 +811,7 @@ describe('Document lifecycle service', () => {
|
|
|
649
811
|
});
|
|
650
812
|
const ctx = buildCtx(db);
|
|
651
813
|
await updateDocument(ctx, {
|
|
814
|
+
expectedRevision: 1,
|
|
652
815
|
documentId: 'doc-1',
|
|
653
816
|
data: { title: 'New' },
|
|
654
817
|
path: 'manually-set',
|
|
@@ -663,6 +826,7 @@ describe('Document lifecycle service', () => {
|
|
|
663
826
|
});
|
|
664
827
|
const ctx = buildCtx(db);
|
|
665
828
|
await updateDocument(ctx, {
|
|
829
|
+
expectedRevision: 1,
|
|
666
830
|
documentId: 'doc-1',
|
|
667
831
|
data: { title: 'Updated' },
|
|
668
832
|
});
|
|
@@ -681,6 +845,7 @@ describe('Document lifecycle service', () => {
|
|
|
681
845
|
const warn = ctx.logger?.warn;
|
|
682
846
|
warn.mockClear();
|
|
683
847
|
await updateDocument(ctx, {
|
|
848
|
+
expectedRevision: 1,
|
|
684
849
|
documentId: 'doc-1',
|
|
685
850
|
data: { title: 'À propos' },
|
|
686
851
|
locale: 'fr',
|
|
@@ -714,6 +879,7 @@ describe('Document lifecycle service', () => {
|
|
|
714
879
|
});
|
|
715
880
|
const ctx = buildCtx(db);
|
|
716
881
|
await updateDocument(ctx, {
|
|
882
|
+
expectedRevision: 1,
|
|
717
883
|
documentId: 'doc-1',
|
|
718
884
|
data: { title: 'Über uns — neu' },
|
|
719
885
|
locale: 'de',
|
|
@@ -733,6 +899,7 @@ describe('Document lifecycle service', () => {
|
|
|
733
899
|
const warn = ctx.logger?.warn;
|
|
734
900
|
warn.mockClear();
|
|
735
901
|
await updateDocument(ctx, {
|
|
902
|
+
expectedRevision: 1,
|
|
736
903
|
documentId: 'doc-1',
|
|
737
904
|
data: { title: 'À propos' },
|
|
738
905
|
locale: 'fr',
|
|
@@ -755,6 +922,7 @@ describe('Document lifecycle service', () => {
|
|
|
755
922
|
const ctx = buildCtx(db);
|
|
756
923
|
try {
|
|
757
924
|
await updateDocument(ctx, {
|
|
925
|
+
expectedRevision: 1,
|
|
758
926
|
documentId: 'doc-1',
|
|
759
927
|
data: { title: 'About' },
|
|
760
928
|
path: 'home', // collides
|
|
@@ -785,21 +953,32 @@ describe('Document lifecycle service', () => {
|
|
|
785
953
|
const original = new Error('connection refused');
|
|
786
954
|
createDocumentVersion.mockRejectedValueOnce(original);
|
|
787
955
|
const ctx = buildCtx(db);
|
|
788
|
-
await expect(updateDocument(ctx, {
|
|
956
|
+
await expect(updateDocument(ctx, {
|
|
957
|
+
expectedRevision: 1,
|
|
958
|
+
documentId: 'doc-1',
|
|
959
|
+
data: { title: 'X' },
|
|
960
|
+
path: 'home',
|
|
961
|
+
})).rejects.toBe(original);
|
|
789
962
|
});
|
|
790
963
|
it('supports an array of beforeUpdate and afterUpdate hooks', async () => {
|
|
791
964
|
const callOrder = [];
|
|
792
965
|
const hooks = {
|
|
793
966
|
beforeUpdate: [
|
|
794
967
|
vi.fn(async () => {
|
|
968
|
+
expect(db.revisions.isInTransaction()).toBe(false);
|
|
795
969
|
callOrder.push('before-1');
|
|
796
970
|
}),
|
|
797
971
|
vi.fn(async () => {
|
|
798
972
|
callOrder.push('before-2');
|
|
799
973
|
}),
|
|
800
974
|
],
|
|
975
|
+
afterSystemFieldsChange: () => {
|
|
976
|
+
expect(db.revisions.isInTransaction()).toBe(false);
|
|
977
|
+
callOrder.push('after-system');
|
|
978
|
+
},
|
|
801
979
|
afterUpdate: [
|
|
802
980
|
vi.fn(async () => {
|
|
981
|
+
expect(db.revisions.isInTransaction()).toBe(false);
|
|
803
982
|
callOrder.push('after-1');
|
|
804
983
|
}),
|
|
805
984
|
vi.fn(async () => {
|
|
@@ -815,8 +994,20 @@ describe('Document lifecycle service', () => {
|
|
|
815
994
|
});
|
|
816
995
|
const definition = { ...minimalCollection, hooks };
|
|
817
996
|
const ctx = buildCtx(db, definition);
|
|
818
|
-
await updateDocument(ctx, {
|
|
819
|
-
|
|
997
|
+
await updateDocument(ctx, {
|
|
998
|
+
expectedRevision: 1,
|
|
999
|
+
documentId: 'doc-1',
|
|
1000
|
+
data: { title: 'New' },
|
|
1001
|
+
availableLocales: ['en', 'fr'],
|
|
1002
|
+
});
|
|
1003
|
+
expect(callOrder).toEqual([
|
|
1004
|
+
'before-1',
|
|
1005
|
+
'before-2',
|
|
1006
|
+
'persist',
|
|
1007
|
+
'after-system',
|
|
1008
|
+
'after-1',
|
|
1009
|
+
'after-2',
|
|
1010
|
+
]);
|
|
820
1011
|
});
|
|
821
1012
|
});
|
|
822
1013
|
// -----------------------------------------------------------------------
|
|
@@ -828,22 +1019,44 @@ describe('Document lifecycle service', () => {
|
|
|
828
1019
|
getDocumentById.mockResolvedValue(null);
|
|
829
1020
|
const ctx = buildCtx(db);
|
|
830
1021
|
await expect(updateDocumentWithPatches(ctx, {
|
|
1022
|
+
expectedRevision: 1,
|
|
831
1023
|
documentId: 'doc-missing',
|
|
832
1024
|
patches: [],
|
|
833
1025
|
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
|
|
834
1026
|
});
|
|
835
|
-
it('
|
|
1027
|
+
it('classifies a post-patch afterUpdate failure without replaying the version write', async () => {
|
|
1028
|
+
const afterUpdate = vi
|
|
1029
|
+
.fn()
|
|
1030
|
+
.mockRejectedValue(Object.assign(new Error('storage unavailable'), { code: ErrorCodes.STORAGE }));
|
|
1031
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
1032
|
+
getDocumentById.mockResolvedValue({
|
|
1033
|
+
document_version_id: 'previous-version',
|
|
1034
|
+
fields: { title: 'Old' },
|
|
1035
|
+
});
|
|
1036
|
+
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
1037
|
+
await expect(updateDocumentWithPatches(buildCtx(db, definition), {
|
|
1038
|
+
expectedRevision: 1,
|
|
1039
|
+
documentId: 'doc-1',
|
|
1040
|
+
patches: [{ kind: 'field.set', path: 'title', value: 'New' }],
|
|
1041
|
+
})).rejects.toMatchObject({
|
|
1042
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
1043
|
+
details: {
|
|
1044
|
+
phase: 'afterUpdate',
|
|
1045
|
+
documentId: 'doc-1',
|
|
1046
|
+
documentVersionId: 'ver-1',
|
|
1047
|
+
sideEffectCode: ErrorCodes.STORAGE,
|
|
1048
|
+
},
|
|
1049
|
+
});
|
|
1050
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
1051
|
+
});
|
|
1052
|
+
it('throws ERR_DOCUMENT_STALE on revision mismatch', async () => {
|
|
836
1053
|
const { db, getDocumentById } = createMockDb();
|
|
837
1054
|
getDocumentById.mockResolvedValue({
|
|
838
1055
|
document_version_id: 'ver-current',
|
|
839
1056
|
fields: { title: 'Old' },
|
|
840
1057
|
});
|
|
841
1058
|
const ctx = buildCtx(db);
|
|
842
|
-
await expect(updateDocumentWithPatches(ctx, {
|
|
843
|
-
documentId: 'doc-1',
|
|
844
|
-
patches: [],
|
|
845
|
-
documentVersionId: 'ver-stale',
|
|
846
|
-
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.CONFLICT);
|
|
1059
|
+
await expect(updateDocumentWithPatches(ctx, { expectedRevision: 2, documentId: 'doc-1', patches: [] })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.DOCUMENT_STALE);
|
|
847
1060
|
});
|
|
848
1061
|
it('throws ERR_PATCH_FAILED when applyPatches returns errors', async () => {
|
|
849
1062
|
const { db, getDocumentById } = createMockDb();
|
|
@@ -851,6 +1064,7 @@ describe('Document lifecycle service', () => {
|
|
|
851
1064
|
const ctx = buildCtx(db);
|
|
852
1065
|
// array.move on a top-level (non-array) field should produce an error
|
|
853
1066
|
await expect(updateDocumentWithPatches(ctx, {
|
|
1067
|
+
expectedRevision: 1,
|
|
854
1068
|
documentId: 'doc-1',
|
|
855
1069
|
patches: [{ kind: 'array.move', path: 'title', itemId: 'x', toIndex: 0 }],
|
|
856
1070
|
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.PATCH_FAILED);
|
|
@@ -862,6 +1076,7 @@ describe('Document lifecycle service', () => {
|
|
|
862
1076
|
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
863
1077
|
const ctx = buildCtx(db, definition);
|
|
864
1078
|
await updateDocumentWithPatches(ctx, {
|
|
1079
|
+
expectedRevision: 1,
|
|
865
1080
|
documentId: 'doc-1',
|
|
866
1081
|
patches: [{ kind: 'field.set', path: 'title', value: 'Patched' }],
|
|
867
1082
|
});
|
|
@@ -884,6 +1099,7 @@ describe('Document lifecycle service', () => {
|
|
|
884
1099
|
createDocumentVersion.mockRejectedValueOnce(pathUniqueViolation());
|
|
885
1100
|
const ctx = buildCtx(db);
|
|
886
1101
|
await expect(updateDocumentWithPatches(ctx, {
|
|
1102
|
+
expectedRevision: 1,
|
|
887
1103
|
documentId: 'doc-1',
|
|
888
1104
|
patches: [],
|
|
889
1105
|
path: 'new-slug',
|
|
@@ -909,6 +1125,7 @@ describe('Document lifecycle service', () => {
|
|
|
909
1125
|
});
|
|
910
1126
|
const definition = { ...numericCollection, hooks: { beforeUpdate } };
|
|
911
1127
|
await updateDocumentWithPatches(buildCtx(db, definition), {
|
|
1128
|
+
expectedRevision: 1,
|
|
912
1129
|
documentId: 'doc-1',
|
|
913
1130
|
patches: [
|
|
914
1131
|
{ kind: 'field.set', path: 'quantity', value: '7.0' },
|
|
@@ -941,10 +1158,11 @@ describe('Document lifecycle service', () => {
|
|
|
941
1158
|
publishSchedule.mockResolvedValue({ status: 'scheduled', schedule, previous: null });
|
|
942
1159
|
const ctx = buildCtx(db);
|
|
943
1160
|
await expect(scheduleDocumentPublish(ctx, {
|
|
1161
|
+
expectedRevision: 1,
|
|
944
1162
|
documentId: 'doc-1',
|
|
945
1163
|
expectedVersionId: 'ver-1',
|
|
946
1164
|
publishAt: schedule.publishAt.toISOString(),
|
|
947
|
-
})).resolves.toEqual(schedule);
|
|
1165
|
+
})).resolves.toEqual({ ...schedule, revision: 2 });
|
|
948
1166
|
expect(publishSchedule).toHaveBeenCalledWith(expect.objectContaining({
|
|
949
1167
|
documentId: 'doc-1',
|
|
950
1168
|
collectionId: 'col-1',
|
|
@@ -961,6 +1179,7 @@ describe('Document lifecycle service', () => {
|
|
|
961
1179
|
}),
|
|
962
1180
|
});
|
|
963
1181
|
await expect(scheduleDocumentPublish(restricted, {
|
|
1182
|
+
expectedRevision: 1,
|
|
964
1183
|
documentId: 'doc-1',
|
|
965
1184
|
expectedVersionId: 'ver-1',
|
|
966
1185
|
publishAt: schedule.publishAt.toISOString(),
|
|
@@ -971,6 +1190,7 @@ describe('Document lifecycle service', () => {
|
|
|
971
1190
|
getCurrentVersionMetadata.mockResolvedValue(metadataRow);
|
|
972
1191
|
publishSchedule.mockResolvedValue({ status: 'publish_at_not_future' });
|
|
973
1192
|
await expect(scheduleDocumentPublish(buildCtx(db), {
|
|
1193
|
+
expectedRevision: 1,
|
|
974
1194
|
documentId: 'doc-1',
|
|
975
1195
|
expectedVersionId: 'ver-1',
|
|
976
1196
|
publishAt: new Date(Date.now() + 60_000).toISOString(),
|
|
@@ -980,15 +1200,20 @@ describe('Document lifecycle service', () => {
|
|
|
980
1200
|
document_version_id: 'ver-2',
|
|
981
1201
|
});
|
|
982
1202
|
await expect(scheduleDocumentPublish(buildCtx(db), {
|
|
1203
|
+
expectedRevision: 1,
|
|
983
1204
|
documentId: 'doc-1',
|
|
984
1205
|
expectedVersionId: 'ver-1',
|
|
985
1206
|
publishAt: new Date(Date.now() + 60_000).toISOString(),
|
|
986
|
-
})).rejects.toMatchObject({
|
|
1207
|
+
})).rejects.toMatchObject({
|
|
1208
|
+
code: ErrorCodes.DOCUMENT_STALE,
|
|
1209
|
+
details: { reason: 'version_parent_mismatch' },
|
|
1210
|
+
});
|
|
987
1211
|
});
|
|
988
1212
|
it('rejects date strings that are valid to JavaScript but are not ISO instants', async () => {
|
|
989
1213
|
const { db, getCurrentVersionMetadata, publishSchedule } = createMockDb();
|
|
990
1214
|
getCurrentVersionMetadata.mockResolvedValue(metadataRow);
|
|
991
1215
|
await expect(scheduleDocumentPublish(buildCtx(db), {
|
|
1216
|
+
expectedRevision: 1,
|
|
992
1217
|
documentId: 'doc-1',
|
|
993
1218
|
expectedVersionId: 'ver-1',
|
|
994
1219
|
publishAt: 'August 23, 2026 12:00:00 UTC',
|
|
@@ -1011,10 +1236,11 @@ describe('Document lifecycle service', () => {
|
|
|
1011
1236
|
});
|
|
1012
1237
|
cancelPublishSchedule.mockResolvedValue(confirmed);
|
|
1013
1238
|
await expect(confirmDocumentScheduledPublish(buildCtx(db), {
|
|
1239
|
+
expectedRevision: 1,
|
|
1014
1240
|
documentId: 'doc-1',
|
|
1015
1241
|
expectedVersionId: 'ver-1',
|
|
1016
|
-
})).resolves.toEqual(confirmed);
|
|
1017
|
-
await expect(cancelDocumentScheduledPublish(buildCtx(db), { documentId: 'doc-1' })).resolves.toEqual(confirmed);
|
|
1242
|
+
})).resolves.toEqual({ ...confirmed, revision: 2 });
|
|
1243
|
+
await expect(cancelDocumentScheduledPublish(buildCtx(db), { expectedRevision: 2, documentId: 'doc-1' })).resolves.toEqual({ schedule: confirmed, revision: 3 });
|
|
1018
1244
|
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.reconfirmed' }));
|
|
1019
1245
|
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.cancelled' }));
|
|
1020
1246
|
expect(suspended.state).toBe('needs_reconfirm');
|
|
@@ -1035,10 +1261,12 @@ describe('Document lifecycle service', () => {
|
|
|
1035
1261
|
});
|
|
1036
1262
|
suspendPublishSchedule.mockResolvedValue({ status: 'suspended', schedule });
|
|
1037
1263
|
await updateDocument(buildCtx(db), {
|
|
1264
|
+
expectedRevision: 1,
|
|
1038
1265
|
documentId: 'doc-1',
|
|
1039
1266
|
data: { title: 'After' },
|
|
1040
1267
|
});
|
|
1041
1268
|
expect(suspendPublishSchedule).toHaveBeenCalledWith({
|
|
1269
|
+
reason: 'content_edited',
|
|
1042
1270
|
documentId: 'doc-1',
|
|
1043
1271
|
collectionId: 'col-1',
|
|
1044
1272
|
});
|
|
@@ -1061,19 +1289,49 @@ describe('Document lifecycle service', () => {
|
|
|
1061
1289
|
suspendedReason: 'content_edited',
|
|
1062
1290
|
}),
|
|
1063
1291
|
});
|
|
1064
|
-
await deleteLocale(buildCtx(db), { documentId: 'doc-1', locale: 'fr' });
|
|
1292
|
+
await deleteLocale(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1', locale: 'fr' });
|
|
1065
1293
|
expect(deleteDocumentLocale).toHaveBeenCalledOnce();
|
|
1066
1294
|
expect(suspendPublishSchedule).toHaveBeenCalledWith({
|
|
1295
|
+
reason: 'content_edited',
|
|
1067
1296
|
documentId: 'doc-1',
|
|
1068
1297
|
collectionId: 'col-1',
|
|
1069
1298
|
});
|
|
1070
1299
|
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.suspended' }));
|
|
1071
1300
|
});
|
|
1301
|
+
it('classifies a delete-locale afterUpdate failure after the new version commits', async () => {
|
|
1302
|
+
const { db, deleteDocumentLocale, getDocumentById } = createMockDb();
|
|
1303
|
+
getDocumentById.mockResolvedValue({
|
|
1304
|
+
document_version_id: 'ver-1',
|
|
1305
|
+
document_id: 'doc-1',
|
|
1306
|
+
path: 'current-path',
|
|
1307
|
+
_availableVersionLocales: ['en', 'fr'],
|
|
1308
|
+
fields: { title: 'Bonjour' },
|
|
1309
|
+
});
|
|
1310
|
+
const definition = {
|
|
1311
|
+
...minimalCollection,
|
|
1312
|
+
hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
|
|
1313
|
+
};
|
|
1314
|
+
await expect(deleteLocale(buildCtx(db, definition), {
|
|
1315
|
+
expectedRevision: 1,
|
|
1316
|
+
documentId: 'doc-1',
|
|
1317
|
+
locale: 'fr',
|
|
1318
|
+
})).rejects.toMatchObject({
|
|
1319
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
1320
|
+
details: {
|
|
1321
|
+
phase: 'afterUpdate',
|
|
1322
|
+
documentId: 'doc-1',
|
|
1323
|
+
documentVersionId: 'ver-2',
|
|
1324
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
1325
|
+
},
|
|
1326
|
+
});
|
|
1327
|
+
expect(deleteDocumentLocale).toHaveBeenCalledOnce();
|
|
1328
|
+
});
|
|
1072
1329
|
it('cancels an active schedule as an effect of an ordinary status transition', async () => {
|
|
1073
1330
|
const { db, getCurrentVersionMetadata, cancelPublishSchedule, auditAppend } = createMockDb();
|
|
1074
1331
|
getCurrentVersionMetadata.mockResolvedValue(metadataRow);
|
|
1075
1332
|
cancelPublishSchedule.mockResolvedValue(publishScheduleRow());
|
|
1076
1333
|
await changeDocumentStatus(buildCtx(db), {
|
|
1334
|
+
expectedRevision: 1,
|
|
1077
1335
|
documentId: 'doc-1',
|
|
1078
1336
|
nextStatus: 'published',
|
|
1079
1337
|
});
|
|
@@ -1137,6 +1395,7 @@ describe('Document lifecycle service', () => {
|
|
|
1137
1395
|
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
1138
1396
|
const ctx = buildCtx(db);
|
|
1139
1397
|
const result = await changeDocumentStatus(ctx, {
|
|
1398
|
+
expectedRevision: 1,
|
|
1140
1399
|
documentId: 'doc-1',
|
|
1141
1400
|
nextStatus: 'published',
|
|
1142
1401
|
});
|
|
@@ -1151,9 +1410,13 @@ describe('Document lifecycle service', () => {
|
|
|
1151
1410
|
const { db, getCurrentVersionMetadata, auditAppend, withTransaction } = createMockDb();
|
|
1152
1411
|
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
1153
1412
|
const ctx = buildCtx(db);
|
|
1154
|
-
await changeDocumentStatus(ctx, {
|
|
1155
|
-
|
|
1156
|
-
|
|
1413
|
+
await changeDocumentStatus(ctx, {
|
|
1414
|
+
expectedRevision: 1,
|
|
1415
|
+
documentId: 'doc-1',
|
|
1416
|
+
nextStatus: 'published',
|
|
1417
|
+
});
|
|
1418
|
+
// The guarded transaction contains a nested status savepoint (docs/07-auth-and-security/02-auditability.md).
|
|
1419
|
+
expect(withTransaction).toHaveBeenCalledTimes(2);
|
|
1157
1420
|
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
|
|
1158
1421
|
documentId: 'doc-1',
|
|
1159
1422
|
collectionId: 'col-1',
|
|
@@ -1169,14 +1432,22 @@ describe('Document lifecycle service', () => {
|
|
|
1169
1432
|
const { db, getCurrentVersionMetadata } = createMockDb();
|
|
1170
1433
|
getCurrentVersionMetadata.mockResolvedValue(null);
|
|
1171
1434
|
const ctx = buildCtx(db);
|
|
1172
|
-
await expect(changeDocumentStatus(ctx, {
|
|
1435
|
+
await expect(changeDocumentStatus(ctx, {
|
|
1436
|
+
expectedRevision: 1,
|
|
1437
|
+
documentId: 'doc-1',
|
|
1438
|
+
nextStatus: 'published',
|
|
1439
|
+
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
|
|
1173
1440
|
});
|
|
1174
1441
|
it('throws ERR_INVALID_TRANSITION for an invalid transition', async () => {
|
|
1175
1442
|
const { db, getCurrentVersionMetadata } = createMockDb();
|
|
1176
1443
|
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
1177
1444
|
const ctx = buildCtx(db);
|
|
1178
1445
|
// draft → archived skips 'published', which is not ±1
|
|
1179
|
-
await expect(changeDocumentStatus(ctx, {
|
|
1446
|
+
await expect(changeDocumentStatus(ctx, {
|
|
1447
|
+
expectedRevision: 1,
|
|
1448
|
+
documentId: 'doc-1',
|
|
1449
|
+
nextStatus: 'archived',
|
|
1450
|
+
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.INVALID_TRANSITION);
|
|
1180
1451
|
});
|
|
1181
1452
|
it('invokes beforeStatusChange and afterStatusChange hooks', async () => {
|
|
1182
1453
|
const callOrder = [];
|
|
@@ -1195,7 +1466,11 @@ describe('Document lifecycle service', () => {
|
|
|
1195
1466
|
});
|
|
1196
1467
|
const definition = { ...minimalCollection, hooks };
|
|
1197
1468
|
const ctx = buildCtx(db, definition);
|
|
1198
|
-
await changeDocumentStatus(ctx, {
|
|
1469
|
+
await changeDocumentStatus(ctx, {
|
|
1470
|
+
expectedRevision: 1,
|
|
1471
|
+
documentId: 'doc-1',
|
|
1472
|
+
nextStatus: 'published',
|
|
1473
|
+
});
|
|
1199
1474
|
expect(callOrder).toEqual(['before', 'persist', 'after']);
|
|
1200
1475
|
expect(hooks.beforeStatusChange).toHaveBeenCalledWith(expect.objectContaining({
|
|
1201
1476
|
previousStatus: 'draft',
|
|
@@ -1215,7 +1490,11 @@ describe('Document lifecycle service', () => {
|
|
|
1215
1490
|
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
1216
1491
|
const definition = { ...minimalCollection, hooks };
|
|
1217
1492
|
const ctx = buildCtx(db, definition);
|
|
1218
|
-
await expect(changeDocumentStatus(ctx, {
|
|
1493
|
+
await expect(changeDocumentStatus(ctx, {
|
|
1494
|
+
expectedRevision: 1,
|
|
1495
|
+
documentId: 'doc-1',
|
|
1496
|
+
nextStatus: 'archived',
|
|
1497
|
+
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.INVALID_TRANSITION);
|
|
1219
1498
|
expect(hooks.beforeStatusChange).not.toHaveBeenCalled();
|
|
1220
1499
|
expect(hooks.afterStatusChange).not.toHaveBeenCalled();
|
|
1221
1500
|
});
|
|
@@ -1223,7 +1502,11 @@ describe('Document lifecycle service', () => {
|
|
|
1223
1502
|
const { db, getCurrentVersionMetadata, archivePublishedVersions } = createMockDb();
|
|
1224
1503
|
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
1225
1504
|
const ctx = buildCtx(db);
|
|
1226
|
-
await changeDocumentStatus(ctx, {
|
|
1505
|
+
await changeDocumentStatus(ctx, {
|
|
1506
|
+
expectedRevision: 1,
|
|
1507
|
+
documentId: 'doc-1',
|
|
1508
|
+
nextStatus: 'published',
|
|
1509
|
+
});
|
|
1227
1510
|
expect(archivePublishedVersions).toHaveBeenCalledWith({
|
|
1228
1511
|
document_id: 'doc-1',
|
|
1229
1512
|
excludeVersionId: 'ver-1',
|
|
@@ -1256,7 +1539,11 @@ describe('Document lifecycle service', () => {
|
|
|
1256
1539
|
});
|
|
1257
1540
|
const definition = { ...minimalCollection, hooks };
|
|
1258
1541
|
const ctx = buildCtx(db, definition);
|
|
1259
|
-
await changeDocumentStatus(ctx, {
|
|
1542
|
+
await changeDocumentStatus(ctx, {
|
|
1543
|
+
expectedRevision: 1,
|
|
1544
|
+
documentId: 'doc-1',
|
|
1545
|
+
nextStatus: 'published',
|
|
1546
|
+
});
|
|
1260
1547
|
expect(callOrder).toEqual(['before-1', 'before-2', 'persist', 'after-1', 'after-2']);
|
|
1261
1548
|
});
|
|
1262
1549
|
});
|
|
@@ -1266,16 +1553,30 @@ describe('Document lifecycle service', () => {
|
|
|
1266
1553
|
describe('unpublishDocument', () => {
|
|
1267
1554
|
it('calls archivePublishedVersions and returns count', async () => {
|
|
1268
1555
|
const { db, archivePublishedVersions } = createMockDb();
|
|
1556
|
+
vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
|
|
1557
|
+
document_id: 'doc-1',
|
|
1558
|
+
document_version_id: 'ver-1',
|
|
1559
|
+
status: 'published',
|
|
1560
|
+
path: 'current-path',
|
|
1561
|
+
fields: {},
|
|
1562
|
+
});
|
|
1269
1563
|
archivePublishedVersions.mockResolvedValue(1);
|
|
1270
1564
|
const ctx = buildCtx(db);
|
|
1271
|
-
const result = await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
1565
|
+
const result = await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1272
1566
|
expect(result.archivedCount).toBe(1);
|
|
1273
1567
|
expect(archivePublishedVersions).toHaveBeenCalledWith({ document_id: 'doc-1' });
|
|
1274
1568
|
});
|
|
1275
1569
|
it('records an atomic published to archived status audit when versions change', async () => {
|
|
1276
1570
|
const { db, archivePublishedVersions, auditAppend, withTransaction } = createMockDb();
|
|
1571
|
+
vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
|
|
1572
|
+
document_id: 'doc-1',
|
|
1573
|
+
document_version_id: 'ver-1',
|
|
1574
|
+
status: 'published',
|
|
1575
|
+
path: 'current-path',
|
|
1576
|
+
fields: {},
|
|
1577
|
+
});
|
|
1277
1578
|
archivePublishedVersions.mockResolvedValue(2);
|
|
1278
|
-
await unpublishDocument(buildCtx(db), { documentId: 'doc-1' });
|
|
1579
|
+
await unpublishDocument(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1' });
|
|
1279
1580
|
expect(withTransaction).toHaveBeenCalledOnce();
|
|
1280
1581
|
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
|
|
1281
1582
|
documentId: 'doc-1',
|
|
@@ -1290,16 +1591,31 @@ describe('Document lifecycle service', () => {
|
|
|
1290
1591
|
});
|
|
1291
1592
|
it('does not record an audit row when no published version changes', async () => {
|
|
1292
1593
|
const { db, archivePublishedVersions, auditAppend } = createMockDb();
|
|
1594
|
+
vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
|
|
1595
|
+
document_id: 'doc-1',
|
|
1596
|
+
document_version_id: 'ver-1',
|
|
1597
|
+
status: 'published',
|
|
1598
|
+
path: 'current-path',
|
|
1599
|
+
fields: {},
|
|
1600
|
+
});
|
|
1293
1601
|
archivePublishedVersions.mockResolvedValue(0);
|
|
1294
|
-
await unpublishDocument(buildCtx(db), { documentId: 'doc-1' });
|
|
1602
|
+
await unpublishDocument(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1' });
|
|
1295
1603
|
expect(auditAppend).not.toHaveBeenCalled();
|
|
1296
1604
|
});
|
|
1297
1605
|
it('does not run afterUnpublish when the atomic audit unit fails', async () => {
|
|
1298
1606
|
const afterUnpublish = vi.fn();
|
|
1299
1607
|
const { db, archivePublishedVersions, auditAppend } = createMockDb();
|
|
1608
|
+
vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
|
|
1609
|
+
document_id: 'doc-1',
|
|
1610
|
+
document_version_id: 'ver-1',
|
|
1611
|
+
status: 'published',
|
|
1612
|
+
path: 'current-path',
|
|
1613
|
+
fields: {},
|
|
1614
|
+
});
|
|
1300
1615
|
archivePublishedVersions.mockResolvedValue(1);
|
|
1301
1616
|
auditAppend.mockRejectedValue(new Error('audit failed'));
|
|
1302
1617
|
await expect(unpublishDocument(buildCtx(db, { ...minimalCollection, hooks: { afterUnpublish } }), {
|
|
1618
|
+
expectedRevision: 1,
|
|
1303
1619
|
documentId: 'doc-1',
|
|
1304
1620
|
})).rejects.toThrow('audit failed');
|
|
1305
1621
|
expect(afterUnpublish).not.toHaveBeenCalled();
|
|
@@ -1315,13 +1631,20 @@ describe('Document lifecycle service', () => {
|
|
|
1315
1631
|
}),
|
|
1316
1632
|
};
|
|
1317
1633
|
const { db, archivePublishedVersions } = createMockDb();
|
|
1634
|
+
vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
|
|
1635
|
+
document_id: 'doc-1',
|
|
1636
|
+
document_version_id: 'ver-1',
|
|
1637
|
+
status: 'published',
|
|
1638
|
+
path: 'current-path',
|
|
1639
|
+
fields: {},
|
|
1640
|
+
});
|
|
1318
1641
|
archivePublishedVersions.mockImplementation(async () => {
|
|
1319
1642
|
callOrder.push('archive');
|
|
1320
1643
|
return 2;
|
|
1321
1644
|
});
|
|
1322
1645
|
const definition = { ...minimalCollection, hooks };
|
|
1323
1646
|
const ctx = buildCtx(db, definition);
|
|
1324
|
-
await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
1647
|
+
await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1325
1648
|
expect(callOrder).toEqual(['before', 'archive', 'after']);
|
|
1326
1649
|
expect(hooks.beforeUnpublish).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'current-path' }));
|
|
1327
1650
|
expect(hooks.afterUnpublish).toHaveBeenCalledWith(expect.objectContaining({
|
|
@@ -1332,9 +1655,16 @@ describe('Document lifecycle service', () => {
|
|
|
1332
1655
|
});
|
|
1333
1656
|
it('works when no hooks are defined', async () => {
|
|
1334
1657
|
const { db, archivePublishedVersions } = createMockDb();
|
|
1658
|
+
vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
|
|
1659
|
+
document_id: 'doc-1',
|
|
1660
|
+
document_version_id: 'ver-1',
|
|
1661
|
+
status: 'published',
|
|
1662
|
+
path: 'current-path',
|
|
1663
|
+
fields: {},
|
|
1664
|
+
});
|
|
1335
1665
|
archivePublishedVersions.mockResolvedValue(0);
|
|
1336
1666
|
const ctx = buildCtx(db);
|
|
1337
|
-
const result = await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
1667
|
+
const result = await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1338
1668
|
expect(result.archivedCount).toBe(0);
|
|
1339
1669
|
});
|
|
1340
1670
|
it('supports an array of beforeUnpublish and afterUnpublish hooks', async () => {
|
|
@@ -1358,13 +1688,20 @@ describe('Document lifecycle service', () => {
|
|
|
1358
1688
|
],
|
|
1359
1689
|
};
|
|
1360
1690
|
const { db, archivePublishedVersions } = createMockDb();
|
|
1691
|
+
vi.mocked(db.queries.documents.getDocumentById).mockResolvedValue({
|
|
1692
|
+
document_id: 'doc-1',
|
|
1693
|
+
document_version_id: 'ver-1',
|
|
1694
|
+
status: 'published',
|
|
1695
|
+
path: 'current-path',
|
|
1696
|
+
fields: {},
|
|
1697
|
+
});
|
|
1361
1698
|
archivePublishedVersions.mockImplementation(async () => {
|
|
1362
1699
|
callOrder.push('archive');
|
|
1363
1700
|
return 2;
|
|
1364
1701
|
});
|
|
1365
1702
|
const definition = { ...minimalCollection, hooks };
|
|
1366
1703
|
const ctx = buildCtx(db, definition);
|
|
1367
|
-
await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
1704
|
+
await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1368
1705
|
expect(callOrder).toEqual(['before-1', 'before-2', 'archive', 'after-1', 'after-2']);
|
|
1369
1706
|
});
|
|
1370
1707
|
});
|
|
@@ -1384,7 +1721,7 @@ describe('Document lifecycle service', () => {
|
|
|
1384
1721
|
});
|
|
1385
1722
|
const definition = { ...minimalCollection, hooks: { beforeDelete, afterDelete } };
|
|
1386
1723
|
const ctx = buildCtx(db, definition);
|
|
1387
|
-
await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
1724
|
+
await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1388
1725
|
expect(beforeDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
|
|
1389
1726
|
expect(afterDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
|
|
1390
1727
|
});
|
|
@@ -1397,7 +1734,7 @@ describe('Document lifecycle service', () => {
|
|
|
1397
1734
|
fields: {},
|
|
1398
1735
|
});
|
|
1399
1736
|
const ctx = buildCtx(db);
|
|
1400
|
-
await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
1737
|
+
await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1401
1738
|
expect(withTransaction).toHaveBeenCalledOnce();
|
|
1402
1739
|
expect(softDeleteDocument).toHaveBeenCalledWith({ document_id: 'doc-1' });
|
|
1403
1740
|
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
|
|
@@ -1420,7 +1757,7 @@ describe('Document lifecycle service', () => {
|
|
|
1420
1757
|
fields: {},
|
|
1421
1758
|
});
|
|
1422
1759
|
const definition = { ...minimalCollection, hooks: { beforeDelete, afterDelete } };
|
|
1423
|
-
await expect(deleteDocument(buildCtx(db, definition), { documentId: 'doc-1' })).rejects.toThrow('pre-commit hook failed');
|
|
1760
|
+
await expect(deleteDocument(buildCtx(db, definition), { expectedRevision: 1, documentId: 'doc-1' })).rejects.toThrow('pre-commit hook failed');
|
|
1424
1761
|
expect(softDeleteDocument).not.toHaveBeenCalled();
|
|
1425
1762
|
expect(afterDelete).not.toHaveBeenCalled();
|
|
1426
1763
|
});
|
|
@@ -1466,8 +1803,8 @@ describe('Document lifecycle service', () => {
|
|
|
1466
1803
|
...buildCtx(db, definition),
|
|
1467
1804
|
storage: { delete: storageDelete },
|
|
1468
1805
|
};
|
|
1469
|
-
await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
1470
|
-
expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct:
|
|
1806
|
+
await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1807
|
+
expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct: true }));
|
|
1471
1808
|
expect(storageDelete).not.toHaveBeenCalled();
|
|
1472
1809
|
});
|
|
1473
1810
|
it('returns only allowlisted failures and keeps raw details in internal logs', async () => {
|
|
@@ -1511,9 +1848,13 @@ describe('Document lifecycle service', () => {
|
|
|
1511
1848
|
storage: { delete: storageDelete },
|
|
1512
1849
|
logger: { ...noopLogger, error: vi.fn() },
|
|
1513
1850
|
};
|
|
1514
|
-
const result = await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
1851
|
+
const result = await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1515
1852
|
expect(storageDelete).not.toHaveBeenCalled();
|
|
1516
1853
|
expect(result).toEqual({
|
|
1854
|
+
affectedDocuments: [{ documentId: 'doc-1', revision: 2 }],
|
|
1855
|
+
scheduledPublicationsNeedReconfirmation: false,
|
|
1856
|
+
documentId: 'doc-1',
|
|
1857
|
+
revision: 2,
|
|
1517
1858
|
deletedVersionCount: 1,
|
|
1518
1859
|
outcome: 'committed-with-side-effect-failures',
|
|
1519
1860
|
sideEffectFailures: [{ phase: 'afterDelete', code: 'ERR_UNHANDLED' }],
|
|
@@ -1547,6 +1888,7 @@ describe('Document lifecycle service', () => {
|
|
|
1547
1888
|
setupDoc(getDocumentSystemFieldsForUpdate);
|
|
1548
1889
|
const ctx = buildCtx(db);
|
|
1549
1890
|
const result = await updateDocumentSystemFields(ctx, {
|
|
1891
|
+
expectedRevision: 1,
|
|
1550
1892
|
documentId: 'doc-1',
|
|
1551
1893
|
path: 'new-slug',
|
|
1552
1894
|
});
|
|
@@ -1570,6 +1912,7 @@ describe('Document lifecycle service', () => {
|
|
|
1570
1912
|
db.commands.documents.updateDocumentPath.mockRejectedValueOnce(pathUniqueViolation());
|
|
1571
1913
|
const ctx = buildCtx(db);
|
|
1572
1914
|
await expect(updateDocumentSystemFields(ctx, {
|
|
1915
|
+
expectedRevision: 1,
|
|
1573
1916
|
documentId: 'doc-1',
|
|
1574
1917
|
path: 'new-slug',
|
|
1575
1918
|
})).rejects.toMatchObject({
|
|
@@ -1591,6 +1934,7 @@ describe('Document lifecycle service', () => {
|
|
|
1591
1934
|
hooks: { afterSystemFieldsChange },
|
|
1592
1935
|
});
|
|
1593
1936
|
const result = await updateDocumentSystemFields(ctx, {
|
|
1937
|
+
expectedRevision: 1,
|
|
1594
1938
|
documentId: 'doc-1',
|
|
1595
1939
|
path: 'old-slug',
|
|
1596
1940
|
});
|
|
@@ -1605,6 +1949,7 @@ describe('Document lifecycle service', () => {
|
|
|
1605
1949
|
setupDoc(getDocumentSystemFieldsForUpdate);
|
|
1606
1950
|
const ctx = buildCtx(db);
|
|
1607
1951
|
const result = await updateDocumentSystemFields(ctx, {
|
|
1952
|
+
expectedRevision: 1,
|
|
1608
1953
|
documentId: 'doc-1',
|
|
1609
1954
|
availableLocales: ['en', 'fr'],
|
|
1610
1955
|
});
|
|
@@ -1630,6 +1975,7 @@ describe('Document lifecycle service', () => {
|
|
|
1630
1975
|
hooks: { afterSystemFieldsChange },
|
|
1631
1976
|
});
|
|
1632
1977
|
const result = await updateDocumentSystemFields(ctx, {
|
|
1978
|
+
expectedRevision: 1,
|
|
1633
1979
|
documentId: 'doc-1',
|
|
1634
1980
|
availableLocales: ['fr', 'en', 'fr'],
|
|
1635
1981
|
});
|
|
@@ -1643,10 +1989,10 @@ describe('Document lifecycle service', () => {
|
|
|
1643
1989
|
const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
|
|
1644
1990
|
setupDoc(getDocumentSystemFieldsForUpdate);
|
|
1645
1991
|
const order = [];
|
|
1646
|
-
db.commands.documents.updateDocumentPath.mockImplementation(async () => {
|
|
1992
|
+
vi.mocked(db.commands.documents.updateDocumentPath).mockImplementation(async () => {
|
|
1647
1993
|
order.push('path-write');
|
|
1648
1994
|
});
|
|
1649
|
-
db.commands.documents.setDocumentAvailableLocales.mockImplementation(async () => {
|
|
1995
|
+
vi.mocked(db.commands.documents.setDocumentAvailableLocales).mockImplementation(async () => {
|
|
1650
1996
|
order.push('locales-write');
|
|
1651
1997
|
});
|
|
1652
1998
|
auditAppend.mockImplementation(async () => {
|
|
@@ -1670,6 +2016,7 @@ describe('Document lifecycle service', () => {
|
|
|
1670
2016
|
hooks: { afterSystemFieldsChange: [firstHook, secondHook] },
|
|
1671
2017
|
});
|
|
1672
2018
|
await updateDocumentSystemFields(ctx, {
|
|
2019
|
+
expectedRevision: 1,
|
|
1673
2020
|
documentId: 'doc-1',
|
|
1674
2021
|
path: 'new-slug',
|
|
1675
2022
|
availableLocales: ['fr', 'en', 'fr'],
|
|
@@ -1705,7 +2052,11 @@ describe('Document lifecycle service', () => {
|
|
|
1705
2052
|
...minimalCollection,
|
|
1706
2053
|
hooks: { afterSystemFieldsChange },
|
|
1707
2054
|
});
|
|
1708
|
-
await expect(updateDocumentSystemFields(ctx, {
|
|
2055
|
+
await expect(updateDocumentSystemFields(ctx, {
|
|
2056
|
+
expectedRevision: 1,
|
|
2057
|
+
documentId: 'doc-1',
|
|
2058
|
+
path: 'new-slug',
|
|
2059
|
+
})).rejects.toThrow('audit failed');
|
|
1709
2060
|
expect(db.commands.documents.updateDocumentPath).toHaveBeenCalledOnce();
|
|
1710
2061
|
expect(afterSystemFieldsChange).not.toHaveBeenCalled();
|
|
1711
2062
|
});
|
|
@@ -1726,7 +2077,14 @@ describe('Document lifecycle service', () => {
|
|
|
1726
2077
|
...minimalCollection,
|
|
1727
2078
|
hooks: { afterSystemFieldsChange },
|
|
1728
2079
|
});
|
|
1729
|
-
await expect(updateDocumentSystemFields(ctx, {
|
|
2080
|
+
await expect(updateDocumentSystemFields(ctx, {
|
|
2081
|
+
expectedRevision: 1,
|
|
2082
|
+
documentId: 'doc-1',
|
|
2083
|
+
availableLocales: ['en', 'fr'],
|
|
2084
|
+
})).rejects.toMatchObject({
|
|
2085
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
2086
|
+
details: { revision: 2 },
|
|
2087
|
+
});
|
|
1730
2088
|
expect(committed).toBe(true);
|
|
1731
2089
|
expect(db.commands.documents.setDocumentAvailableLocales).toHaveBeenCalledOnce();
|
|
1732
2090
|
expect(afterSystemFieldsChange).toHaveBeenCalledOnce();
|
|
@@ -1742,9 +2100,17 @@ describe('Document lifecycle service', () => {
|
|
|
1742
2100
|
...minimalCollection,
|
|
1743
2101
|
hooks: { afterSystemFieldsChange },
|
|
1744
2102
|
});
|
|
1745
|
-
await expect(updateDocumentSystemFields(ctx, {
|
|
2103
|
+
await expect(updateDocumentSystemFields(ctx, {
|
|
2104
|
+
expectedRevision: 1,
|
|
2105
|
+
documentId: 'doc-1',
|
|
2106
|
+
path: 'new-slug',
|
|
2107
|
+
})).rejects.toMatchObject({
|
|
2108
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
2109
|
+
details: { revision: 2 },
|
|
2110
|
+
});
|
|
1746
2111
|
setupDoc(getDocumentSystemFieldsForUpdate, { path: 'new-slug' });
|
|
1747
2112
|
const retry = await updateDocumentSystemFields(ctx, {
|
|
2113
|
+
expectedRevision: 2,
|
|
1748
2114
|
documentId: 'doc-1',
|
|
1749
2115
|
path: 'new-slug',
|
|
1750
2116
|
reconcile: true,
|
|
@@ -1778,6 +2144,7 @@ describe('Document lifecycle service', () => {
|
|
|
1778
2144
|
hooks: { afterSystemFieldsChange },
|
|
1779
2145
|
});
|
|
1780
2146
|
await updateDocumentSystemFields(ctx, {
|
|
2147
|
+
expectedRevision: 1,
|
|
1781
2148
|
documentId: 'doc-1',
|
|
1782
2149
|
path: 'final-path',
|
|
1783
2150
|
availableLocales: ['de'],
|
|
@@ -1811,7 +2178,7 @@ describe('Document lifecycle service', () => {
|
|
|
1811
2178
|
};
|
|
1812
2179
|
const mocks = createMockDb();
|
|
1813
2180
|
const { db } = mocks;
|
|
1814
|
-
db.queries.documents.getDocumentByVersion.mockResolvedValue({
|
|
2181
|
+
vi.mocked(db.queries.documents.getDocumentByVersion).mockResolvedValue({
|
|
1815
2182
|
document_version_id: sourceVersionId,
|
|
1816
2183
|
document_id: sourceDocumentId,
|
|
1817
2184
|
path: 'long-ago-path',
|
|
@@ -1843,6 +2210,7 @@ describe('Document lifecycle service', () => {
|
|
|
1843
2210
|
const { db, createDocumentVersion, sourceVersionId, sourceFields, currentVersionId } = setupRestore();
|
|
1844
2211
|
const ctx = buildCtx(db);
|
|
1845
2212
|
const result = await restoreDocumentVersion(ctx, {
|
|
2213
|
+
expectedRevision: 1,
|
|
1846
2214
|
documentId: 'doc-1',
|
|
1847
2215
|
sourceVersionId,
|
|
1848
2216
|
});
|
|
@@ -1859,13 +2227,18 @@ describe('Document lifecycle service', () => {
|
|
|
1859
2227
|
expect(result).toEqual({
|
|
1860
2228
|
documentId: 'doc-1',
|
|
1861
2229
|
documentVersionId: 'ver-restored',
|
|
2230
|
+
revision: 2,
|
|
1862
2231
|
sourceVersionId,
|
|
1863
2232
|
});
|
|
1864
2233
|
});
|
|
1865
2234
|
it('hard-defaults the new version status to the workflow default (never inherits source status)', async () => {
|
|
1866
2235
|
const { db, createDocumentVersion, sourceVersionId } = setupRestore();
|
|
1867
2236
|
const ctx = buildCtx(db);
|
|
1868
|
-
await restoreDocumentVersion(ctx, {
|
|
2237
|
+
await restoreDocumentVersion(ctx, {
|
|
2238
|
+
expectedRevision: 1,
|
|
2239
|
+
documentId: 'doc-1',
|
|
2240
|
+
sourceVersionId,
|
|
2241
|
+
});
|
|
1869
2242
|
// Source version was 'archived'; default status for minimalCollection is 'draft'.
|
|
1870
2243
|
expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('draft');
|
|
1871
2244
|
});
|
|
@@ -1874,7 +2247,11 @@ describe('Document lifecycle service', () => {
|
|
|
1874
2247
|
currentPath: 'sticky-path',
|
|
1875
2248
|
});
|
|
1876
2249
|
const ctx = buildCtx(db);
|
|
1877
|
-
await restoreDocumentVersion(ctx, {
|
|
2250
|
+
await restoreDocumentVersion(ctx, {
|
|
2251
|
+
expectedRevision: 1,
|
|
2252
|
+
documentId: 'doc-1',
|
|
2253
|
+
sourceVersionId,
|
|
2254
|
+
});
|
|
1878
2255
|
// Restore never changes a document's path: the existing
|
|
1879
2256
|
// byline_document_paths row carries forward unchanged. The storage
|
|
1880
2257
|
// primitive only writes to document_paths when `path` is supplied.
|
|
@@ -1886,7 +2263,11 @@ describe('Document lifecycle service', () => {
|
|
|
1886
2263
|
});
|
|
1887
2264
|
const ctx = buildCtx(db);
|
|
1888
2265
|
try {
|
|
1889
|
-
await restoreDocumentVersion(ctx, {
|
|
2266
|
+
await restoreDocumentVersion(ctx, {
|
|
2267
|
+
expectedRevision: 1,
|
|
2268
|
+
documentId: 'doc-1',
|
|
2269
|
+
sourceVersionId,
|
|
2270
|
+
});
|
|
1890
2271
|
expect.fail('expected ERR_VALIDATION');
|
|
1891
2272
|
}
|
|
1892
2273
|
catch (err) {
|
|
@@ -1899,7 +2280,7 @@ describe('Document lifecycle service', () => {
|
|
|
1899
2280
|
const sourceVersionId = 'ver-source';
|
|
1900
2281
|
const mocks = createMockDb();
|
|
1901
2282
|
const { db } = mocks;
|
|
1902
|
-
db.queries.documents.getDocumentByVersion.mockResolvedValue({
|
|
2283
|
+
vi.mocked(db.queries.documents.getDocumentByVersion).mockResolvedValue({
|
|
1903
2284
|
document_version_id: sourceVersionId,
|
|
1904
2285
|
document_id: 'doc-1',
|
|
1905
2286
|
path: 'p',
|
|
@@ -1917,7 +2298,11 @@ describe('Document lifecycle service', () => {
|
|
|
1917
2298
|
});
|
|
1918
2299
|
const ctx = buildCtx(db);
|
|
1919
2300
|
try {
|
|
1920
|
-
await restoreDocumentVersion(ctx, {
|
|
2301
|
+
await restoreDocumentVersion(ctx, {
|
|
2302
|
+
expectedRevision: 1,
|
|
2303
|
+
documentId: 'doc-1',
|
|
2304
|
+
sourceVersionId,
|
|
2305
|
+
});
|
|
1921
2306
|
expect.fail('expected ERR_INVALID_TRANSITION');
|
|
1922
2307
|
}
|
|
1923
2308
|
catch (err) {
|
|
@@ -1931,7 +2316,11 @@ describe('Document lifecycle service', () => {
|
|
|
1931
2316
|
const { db, sourceVersionId } = setupRestore();
|
|
1932
2317
|
const definition = { ...minimalCollection, hooks: { beforeUpdate, afterUpdate } };
|
|
1933
2318
|
const ctx = buildCtx(db, definition);
|
|
1934
|
-
await restoreDocumentVersion(ctx, {
|
|
2319
|
+
await restoreDocumentVersion(ctx, {
|
|
2320
|
+
expectedRevision: 1,
|
|
2321
|
+
documentId: 'doc-1',
|
|
2322
|
+
sourceVersionId,
|
|
2323
|
+
});
|
|
1935
2324
|
expect(beforeUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
|
1936
2325
|
collectionPath: 'articles',
|
|
1937
2326
|
restore: { sourceVersionId },
|
|
@@ -1947,6 +2336,27 @@ describe('Document lifecycle service', () => {
|
|
|
1947
2336
|
restore: { sourceVersionId },
|
|
1948
2337
|
}));
|
|
1949
2338
|
});
|
|
2339
|
+
it('classifies a restore afterUpdate failure after the restored version commits', async () => {
|
|
2340
|
+
const { db, sourceVersionId, createDocumentVersion } = setupRestore();
|
|
2341
|
+
const definition = {
|
|
2342
|
+
...minimalCollection,
|
|
2343
|
+
hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
|
|
2344
|
+
};
|
|
2345
|
+
await expect(restoreDocumentVersion(buildCtx(db, definition), {
|
|
2346
|
+
expectedRevision: 1,
|
|
2347
|
+
documentId: 'doc-1',
|
|
2348
|
+
sourceVersionId,
|
|
2349
|
+
})).rejects.toMatchObject({
|
|
2350
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
2351
|
+
details: {
|
|
2352
|
+
phase: 'afterUpdate',
|
|
2353
|
+
documentId: 'doc-1',
|
|
2354
|
+
documentVersionId: 'ver-restored',
|
|
2355
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
2356
|
+
},
|
|
2357
|
+
});
|
|
2358
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
2359
|
+
});
|
|
1950
2360
|
it('throws ERR_FORBIDDEN when actor lacks collections.<path>.update', async () => {
|
|
1951
2361
|
const { db, sourceVersionId } = setupRestore();
|
|
1952
2362
|
const ctx = buildCtx(db);
|
|
@@ -1956,7 +2366,11 @@ describe('Document lifecycle service', () => {
|
|
|
1956
2366
|
});
|
|
1957
2367
|
ctx.requestContext = createRequestContext({ actor });
|
|
1958
2368
|
try {
|
|
1959
|
-
await restoreDocumentVersion(ctx, {
|
|
2369
|
+
await restoreDocumentVersion(ctx, {
|
|
2370
|
+
expectedRevision: 1,
|
|
2371
|
+
documentId: 'doc-1',
|
|
2372
|
+
sourceVersionId,
|
|
2373
|
+
});
|
|
1960
2374
|
expect.fail('expected ERR_FORBIDDEN');
|
|
1961
2375
|
}
|
|
1962
2376
|
catch (err) {
|
|
@@ -1972,7 +2386,11 @@ describe('Document lifecycle service', () => {
|
|
|
1972
2386
|
abilities: ['collections.articles.update', 'collections.articles.read'],
|
|
1973
2387
|
});
|
|
1974
2388
|
ctx.requestContext = createRequestContext({ actor });
|
|
1975
|
-
await restoreDocumentVersion(ctx, {
|
|
2389
|
+
await restoreDocumentVersion(ctx, {
|
|
2390
|
+
expectedRevision: 1,
|
|
2391
|
+
documentId: 'doc-1',
|
|
2392
|
+
sourceVersionId,
|
|
2393
|
+
});
|
|
1976
2394
|
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
1977
2395
|
});
|
|
1978
2396
|
});
|
|
@@ -2017,7 +2435,7 @@ describe('Document lifecycle service', () => {
|
|
|
2017
2435
|
const mocks = createMockDb();
|
|
2018
2436
|
const { sourceDocumentId } = setupSource(mocks);
|
|
2019
2437
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2020
|
-
const result = await duplicateDocument(ctx, { sourceDocumentId });
|
|
2438
|
+
const result = await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId });
|
|
2021
2439
|
expect(mocks.getDocumentById).toHaveBeenCalledWith(expect.objectContaining({
|
|
2022
2440
|
collection_id: 'col-1',
|
|
2023
2441
|
document_id: sourceDocumentId,
|
|
@@ -2044,7 +2462,7 @@ describe('Document lifecycle service', () => {
|
|
|
2044
2462
|
},
|
|
2045
2463
|
});
|
|
2046
2464
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2047
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2465
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2048
2466
|
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
2049
2467
|
expect(call.documentData.title).toEqual({
|
|
2050
2468
|
en: 'Hello (copy)',
|
|
@@ -2069,7 +2487,7 @@ describe('Document lifecycle service', () => {
|
|
|
2069
2487
|
},
|
|
2070
2488
|
});
|
|
2071
2489
|
const ctx = buildCtx(mocks.db, nonLocalizedCollection);
|
|
2072
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2490
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2073
2491
|
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
2074
2492
|
expect(call.documentData.title).toBe('Hello (copy)');
|
|
2075
2493
|
});
|
|
@@ -2082,7 +2500,10 @@ describe('Document lifecycle service', () => {
|
|
|
2082
2500
|
},
|
|
2083
2501
|
});
|
|
2084
2502
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2085
|
-
const result = await duplicateDocument(ctx, {
|
|
2503
|
+
const result = await duplicateDocument(ctx, {
|
|
2504
|
+
expectedRevision: 1,
|
|
2505
|
+
sourceDocumentId: 'doc-source',
|
|
2506
|
+
});
|
|
2086
2507
|
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
2087
2508
|
// Default locale is 'en'; "Hello World (copy)" slugifies to "hello-world-copy".
|
|
2088
2509
|
expect(call.path).toBe('hello-world-copy');
|
|
@@ -2108,7 +2529,7 @@ describe('Document lifecycle service', () => {
|
|
|
2108
2529
|
},
|
|
2109
2530
|
});
|
|
2110
2531
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2111
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2532
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2112
2533
|
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
2113
2534
|
const sections = call.documentData.sections;
|
|
2114
2535
|
expect(sections[0]._id).toBeUndefined();
|
|
@@ -2140,7 +2561,7 @@ describe('Document lifecycle service', () => {
|
|
|
2140
2561
|
fieldCount: 5,
|
|
2141
2562
|
});
|
|
2142
2563
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2143
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2564
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2144
2565
|
// Source title should be unchanged in memory.
|
|
2145
2566
|
expect(originalFields.title).toEqual({ en: 'Hello', fr: 'Bonjour' });
|
|
2146
2567
|
expect(originalFields.sections[0]?._id).toBe('sec-1');
|
|
@@ -2162,7 +2583,10 @@ describe('Document lifecycle service', () => {
|
|
|
2162
2583
|
fieldCount: 5,
|
|
2163
2584
|
});
|
|
2164
2585
|
});
|
|
2165
|
-
const result = await duplicateDocument(ctx, {
|
|
2586
|
+
const result = await duplicateDocument(ctx, {
|
|
2587
|
+
expectedRevision: 1,
|
|
2588
|
+
sourceDocumentId: 'doc-source',
|
|
2589
|
+
});
|
|
2166
2590
|
expect(mocks.createDocumentVersion).toHaveBeenCalledTimes(2);
|
|
2167
2591
|
const firstPath = mocks.createDocumentVersion.mock.calls[0]?.[0].path;
|
|
2168
2592
|
const retryPath = mocks.createDocumentVersion.mock.calls[1]?.[0].path;
|
|
@@ -2183,7 +2607,7 @@ describe('Document lifecycle service', () => {
|
|
|
2183
2607
|
});
|
|
2184
2608
|
let thrown;
|
|
2185
2609
|
try {
|
|
2186
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2610
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2187
2611
|
}
|
|
2188
2612
|
catch (err) {
|
|
2189
2613
|
thrown = err;
|
|
@@ -2206,7 +2630,7 @@ describe('Document lifecycle service', () => {
|
|
|
2206
2630
|
mocks.getDocumentById.mockResolvedValue(null);
|
|
2207
2631
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2208
2632
|
try {
|
|
2209
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-missing' });
|
|
2633
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-missing' });
|
|
2210
2634
|
expect.fail('expected ERR_NOT_FOUND');
|
|
2211
2635
|
}
|
|
2212
2636
|
catch (err) {
|
|
@@ -2227,7 +2651,7 @@ describe('Document lifecycle service', () => {
|
|
|
2227
2651
|
const mocks = createMockDb();
|
|
2228
2652
|
setupSource(mocks, { sourceDocumentId: 'doc-source' });
|
|
2229
2653
|
const ctx = buildCtx(mocks.db, withHooks);
|
|
2230
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2654
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2231
2655
|
expect(beforeCreate).toHaveBeenCalledOnce();
|
|
2232
2656
|
const beforeCtx = beforeCreate.mock.calls[0]?.[0];
|
|
2233
2657
|
expect(beforeCtx.duplicate).toEqual({ sourceDocumentId: 'doc-source' });
|
|
@@ -2246,6 +2670,27 @@ describe('Document lifecycle service', () => {
|
|
|
2246
2670
|
expect(typeof afterCtx.path).toBe('string');
|
|
2247
2671
|
expect(afterCtx.path).toBe(writtenPath);
|
|
2248
2672
|
});
|
|
2673
|
+
it('classifies a duplicate afterCreate failure with the committed document ids', async () => {
|
|
2674
|
+
const withHooks = {
|
|
2675
|
+
...localizedCollection,
|
|
2676
|
+
hooks: { afterCreate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
|
|
2677
|
+
};
|
|
2678
|
+
const mocks = createMockDb();
|
|
2679
|
+
setupSource(mocks);
|
|
2680
|
+
await expect(duplicateDocument(buildCtx(mocks.db, withHooks), {
|
|
2681
|
+
expectedRevision: 1,
|
|
2682
|
+
sourceDocumentId: 'doc-source',
|
|
2683
|
+
})).rejects.toMatchObject({
|
|
2684
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
2685
|
+
details: {
|
|
2686
|
+
phase: 'afterCreate',
|
|
2687
|
+
documentId: 'doc-new',
|
|
2688
|
+
documentVersionId: 'ver-new',
|
|
2689
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
2690
|
+
},
|
|
2691
|
+
});
|
|
2692
|
+
expect(mocks.createDocumentVersion).toHaveBeenCalledOnce();
|
|
2693
|
+
});
|
|
2249
2694
|
it('enforces collections.<path>.create — rejects an admin actor missing the ability', async () => {
|
|
2250
2695
|
const mocks = createMockDb();
|
|
2251
2696
|
setupSource(mocks);
|
|
@@ -2257,7 +2702,7 @@ describe('Document lifecycle service', () => {
|
|
|
2257
2702
|
});
|
|
2258
2703
|
ctx.requestContext = createRequestContext({ actor });
|
|
2259
2704
|
try {
|
|
2260
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2705
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2261
2706
|
expect.fail('expected ERR_FORBIDDEN');
|
|
2262
2707
|
}
|
|
2263
2708
|
catch (err) {
|
|
@@ -2272,7 +2717,7 @@ describe('Document lifecycle service', () => {
|
|
|
2272
2717
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2273
2718
|
ctx.requestContext = undefined;
|
|
2274
2719
|
try {
|
|
2275
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2720
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2276
2721
|
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
2277
2722
|
}
|
|
2278
2723
|
catch (err) {
|
|
@@ -2351,6 +2796,7 @@ describe('Document lifecycle service', () => {
|
|
|
2351
2796
|
const { mocks, currentVersionId } = setupSourceTarget();
|
|
2352
2797
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2353
2798
|
const result = await copyToLocale(ctx, {
|
|
2799
|
+
expectedRevision: 1,
|
|
2354
2800
|
documentId: 'doc-1',
|
|
2355
2801
|
sourceLocale: 'en',
|
|
2356
2802
|
targetLocale: 'fr',
|
|
@@ -2390,6 +2836,7 @@ describe('Document lifecycle service', () => {
|
|
|
2390
2836
|
});
|
|
2391
2837
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2392
2838
|
const result = await copyToLocale(ctx, {
|
|
2839
|
+
expectedRevision: 1,
|
|
2393
2840
|
documentId: 'doc-1',
|
|
2394
2841
|
sourceLocale: 'en',
|
|
2395
2842
|
targetLocale: 'fr',
|
|
@@ -2421,6 +2868,7 @@ describe('Document lifecycle service', () => {
|
|
|
2421
2868
|
});
|
|
2422
2869
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2423
2870
|
await copyToLocale(ctx, {
|
|
2871
|
+
expectedRevision: 1,
|
|
2424
2872
|
documentId: 'doc-1',
|
|
2425
2873
|
sourceLocale: 'en',
|
|
2426
2874
|
targetLocale: 'fr',
|
|
@@ -2437,6 +2885,7 @@ describe('Document lifecycle service', () => {
|
|
|
2437
2885
|
const { mocks } = setupSourceTarget();
|
|
2438
2886
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2439
2887
|
await copyToLocale(ctx, {
|
|
2888
|
+
expectedRevision: 1,
|
|
2440
2889
|
documentId: 'doc-1',
|
|
2441
2890
|
sourceLocale: 'en',
|
|
2442
2891
|
targetLocale: 'fr',
|
|
@@ -2449,6 +2898,7 @@ describe('Document lifecycle service', () => {
|
|
|
2449
2898
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2450
2899
|
try {
|
|
2451
2900
|
await copyToLocale(ctx, {
|
|
2901
|
+
expectedRevision: 1,
|
|
2452
2902
|
documentId: 'doc-1',
|
|
2453
2903
|
sourceLocale: 'en',
|
|
2454
2904
|
targetLocale: 'en',
|
|
@@ -2467,6 +2917,7 @@ describe('Document lifecycle service', () => {
|
|
|
2467
2917
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2468
2918
|
try {
|
|
2469
2919
|
await copyToLocale(ctx, {
|
|
2920
|
+
expectedRevision: 1,
|
|
2470
2921
|
documentId: 'doc-1',
|
|
2471
2922
|
sourceLocale: 'en',
|
|
2472
2923
|
targetLocale: 'fr',
|
|
@@ -2497,6 +2948,7 @@ describe('Document lifecycle service', () => {
|
|
|
2497
2948
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2498
2949
|
try {
|
|
2499
2950
|
await copyToLocale(ctx, {
|
|
2951
|
+
expectedRevision: 1,
|
|
2500
2952
|
documentId: 'doc-1',
|
|
2501
2953
|
sourceLocale: 'en',
|
|
2502
2954
|
targetLocale: 'fr',
|
|
@@ -2519,6 +2971,7 @@ describe('Document lifecycle service', () => {
|
|
|
2519
2971
|
const { mocks } = setupSourceTarget();
|
|
2520
2972
|
const ctx = buildCtx(mocks.db, withHooks);
|
|
2521
2973
|
await copyToLocale(ctx, {
|
|
2974
|
+
expectedRevision: 1,
|
|
2522
2975
|
documentId: 'doc-1',
|
|
2523
2976
|
sourceLocale: 'en',
|
|
2524
2977
|
targetLocale: 'fr',
|
|
@@ -2537,6 +2990,29 @@ describe('Document lifecycle service', () => {
|
|
|
2537
2990
|
// Sticky path read off the target-locale envelope.
|
|
2538
2991
|
expect(afterUpdate.mock.calls[0]?.[0].path).toBe('hello');
|
|
2539
2992
|
});
|
|
2993
|
+
it('classifies a copy-to-locale afterUpdate failure after the version commits', async () => {
|
|
2994
|
+
const withHooks = {
|
|
2995
|
+
...mixedCollection,
|
|
2996
|
+
hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
|
|
2997
|
+
};
|
|
2998
|
+
const { mocks } = setupSourceTarget();
|
|
2999
|
+
await expect(copyToLocale(buildCtx(mocks.db, withHooks), {
|
|
3000
|
+
expectedRevision: 1,
|
|
3001
|
+
documentId: 'doc-1',
|
|
3002
|
+
sourceLocale: 'en',
|
|
3003
|
+
targetLocale: 'fr',
|
|
3004
|
+
overwrite: false,
|
|
3005
|
+
})).rejects.toMatchObject({
|
|
3006
|
+
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
3007
|
+
details: {
|
|
3008
|
+
phase: 'afterUpdate',
|
|
3009
|
+
documentId: 'doc-1',
|
|
3010
|
+
documentVersionId: 'ver-new',
|
|
3011
|
+
sideEffectCode: ErrorCodes.UNHANDLED,
|
|
3012
|
+
},
|
|
3013
|
+
});
|
|
3014
|
+
expect(mocks.createDocumentVersion).toHaveBeenCalledOnce();
|
|
3015
|
+
});
|
|
2540
3016
|
it('enforces collections.<path>.update — rejects an admin actor missing the ability', async () => {
|
|
2541
3017
|
const { mocks } = setupSourceTarget();
|
|
2542
3018
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
@@ -2547,6 +3023,7 @@ describe('Document lifecycle service', () => {
|
|
|
2547
3023
|
ctx.requestContext = createRequestContext({ actor });
|
|
2548
3024
|
try {
|
|
2549
3025
|
await copyToLocale(ctx, {
|
|
3026
|
+
expectedRevision: 1,
|
|
2550
3027
|
documentId: 'doc-1',
|
|
2551
3028
|
sourceLocale: 'en',
|
|
2552
3029
|
targetLocale: 'fr',
|
|
@@ -2566,6 +3043,7 @@ describe('Document lifecycle service', () => {
|
|
|
2566
3043
|
ctx.requestContext = undefined;
|
|
2567
3044
|
try {
|
|
2568
3045
|
await copyToLocale(ctx, {
|
|
3046
|
+
expectedRevision: 1,
|
|
2569
3047
|
documentId: 'doc-1',
|
|
2570
3048
|
sourceLocale: 'en',
|
|
2571
3049
|
targetLocale: 'fr',
|
|
@@ -2639,7 +3117,11 @@ describe('Document lifecycle service', () => {
|
|
|
2639
3117
|
});
|
|
2640
3118
|
ctx.requestContext = createRequestContext({ actor });
|
|
2641
3119
|
try {
|
|
2642
|
-
await changeDocumentStatus(ctx, {
|
|
3120
|
+
await changeDocumentStatus(ctx, {
|
|
3121
|
+
expectedRevision: 1,
|
|
3122
|
+
documentId: 'doc-1',
|
|
3123
|
+
nextStatus: 'published',
|
|
3124
|
+
});
|
|
2643
3125
|
expect.fail('expected ERR_FORBIDDEN');
|
|
2644
3126
|
}
|
|
2645
3127
|
catch (err) {
|
|
@@ -2675,7 +3157,11 @@ describe('Document lifecycle service', () => {
|
|
|
2675
3157
|
abilities: ['collections.articles.changeStatus'],
|
|
2676
3158
|
});
|
|
2677
3159
|
ctx.requestContext = createRequestContext({ actor });
|
|
2678
|
-
await changeDocumentStatus(ctx, {
|
|
3160
|
+
await changeDocumentStatus(ctx, {
|
|
3161
|
+
expectedRevision: 1,
|
|
3162
|
+
documentId: 'doc-1',
|
|
3163
|
+
nextStatus: 'in_review',
|
|
3164
|
+
});
|
|
2679
3165
|
expect(setDocumentStatus).toHaveBeenCalledOnce();
|
|
2680
3166
|
});
|
|
2681
3167
|
it('super-admin bypasses every check', async () => {
|
|
@@ -2700,7 +3186,7 @@ describe('Document lifecycle service', () => {
|
|
|
2700
3186
|
});
|
|
2701
3187
|
ctx.requestContext = createRequestContext({ actor });
|
|
2702
3188
|
try {
|
|
2703
|
-
await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
3189
|
+
await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
2704
3190
|
expect.fail('expected ERR_FORBIDDEN');
|
|
2705
3191
|
}
|
|
2706
3192
|
catch (err) {
|