@byline/core 4.19.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 +32 -1
- 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 +15 -0
- package/dist/lib/errors.js +32 -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 +2 -1
- package/dist/services/document-lifecycle/committed-hook.js +19 -2
- package/dist/services/document-lifecycle/copy-to-locale.d.ts +2 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +28 -43
- package/dist/services/document-lifecycle/create.d.ts +1 -0
- package/dist/services/document-lifecycle/create.js +4 -20
- package/dist/services/document-lifecycle/delete-locale.d.ts +4 -2
- package/dist/services/document-lifecycle/delete-locale.js +35 -37
- 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 +48 -41
- package/dist/services/document-lifecycle/index.d.ts +3 -1
- package/dist/services/document-lifecycle/index.js +3 -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 +19 -25
- 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 +101 -80
- package/dist/services/document-lifecycle.test.node.js +401 -73
- 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 +74 -77
- 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 +67 -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/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,
|
|
@@ -559,11 +625,48 @@ describe('Document lifecycle service', () => {
|
|
|
559
625
|
// updateDocument (PUT)
|
|
560
626
|
// -----------------------------------------------------------------------
|
|
561
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
|
+
});
|
|
562
664
|
it('passes the acting user id as createdBy for the audit trail', async () => {
|
|
563
665
|
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
564
666
|
getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
|
|
565
667
|
const ctx = buildCtx(db);
|
|
566
668
|
await updateDocument(ctx, {
|
|
669
|
+
expectedRevision: 1,
|
|
567
670
|
documentId: 'doc-1',
|
|
568
671
|
data: { title: 'New' },
|
|
569
672
|
});
|
|
@@ -576,6 +679,7 @@ describe('Document lifecycle service', () => {
|
|
|
576
679
|
const definition = { ...minimalCollection, hooks: { beforeUpdate } };
|
|
577
680
|
const ctx = buildCtx(db, definition);
|
|
578
681
|
await updateDocument(ctx, {
|
|
682
|
+
expectedRevision: 1,
|
|
579
683
|
documentId: 'doc-1',
|
|
580
684
|
data: { title: 'New' },
|
|
581
685
|
});
|
|
@@ -597,6 +701,7 @@ describe('Document lifecycle service', () => {
|
|
|
597
701
|
});
|
|
598
702
|
const definition = { ...numericCollection, hooks: { beforeUpdate } };
|
|
599
703
|
await updateDocument(buildCtx(db, definition), {
|
|
704
|
+
expectedRevision: 1,
|
|
600
705
|
documentId: 'doc-1',
|
|
601
706
|
data: { quantity: '2', score: '2.5e1', price: 4.25 },
|
|
602
707
|
});
|
|
@@ -613,6 +718,7 @@ describe('Document lifecycle service', () => {
|
|
|
613
718
|
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
614
719
|
const ctx = buildCtx(db, definition);
|
|
615
720
|
await updateDocument(ctx, {
|
|
721
|
+
expectedRevision: 1,
|
|
616
722
|
documentId: 'doc-1',
|
|
617
723
|
data: { title: 'New' },
|
|
618
724
|
});
|
|
@@ -627,6 +733,7 @@ describe('Document lifecycle service', () => {
|
|
|
627
733
|
getDocumentById.mockResolvedValue({ fields: { title: 'Old' } });
|
|
628
734
|
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
629
735
|
await expect(updateDocument(buildCtx(db, definition), {
|
|
736
|
+
expectedRevision: 1,
|
|
630
737
|
documentId: 'doc-1',
|
|
631
738
|
data: { title: 'New' },
|
|
632
739
|
})).rejects.toMatchObject({
|
|
@@ -650,7 +757,11 @@ describe('Document lifecycle service', () => {
|
|
|
650
757
|
});
|
|
651
758
|
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
652
759
|
const ctx = buildCtx(db, definition);
|
|
653
|
-
await updateDocument(ctx, {
|
|
760
|
+
await updateDocument(ctx, {
|
|
761
|
+
expectedRevision: 1,
|
|
762
|
+
documentId: 'doc-1',
|
|
763
|
+
data: { title: 'New' },
|
|
764
|
+
});
|
|
654
765
|
expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({ path: 'sticky-path' }));
|
|
655
766
|
});
|
|
656
767
|
it('afterUpdate surfaces an explicitly-supplied path', async () => {
|
|
@@ -664,6 +775,7 @@ describe('Document lifecycle service', () => {
|
|
|
664
775
|
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
665
776
|
const ctx = buildCtx(db, definition);
|
|
666
777
|
await updateDocument(ctx, {
|
|
778
|
+
expectedRevision: 1,
|
|
667
779
|
documentId: 'doc-1',
|
|
668
780
|
data: { title: 'New' },
|
|
669
781
|
path: 'new-path',
|
|
@@ -681,6 +793,7 @@ describe('Document lifecycle service', () => {
|
|
|
681
793
|
const definition = { ...minimalCollection, useAsPath: 'title' };
|
|
682
794
|
const ctx = buildCtx(db, definition);
|
|
683
795
|
await updateDocument(ctx, {
|
|
796
|
+
expectedRevision: 1,
|
|
684
797
|
documentId: 'doc-1',
|
|
685
798
|
data: { title: 'Brand New Title' },
|
|
686
799
|
});
|
|
@@ -698,6 +811,7 @@ describe('Document lifecycle service', () => {
|
|
|
698
811
|
});
|
|
699
812
|
const ctx = buildCtx(db);
|
|
700
813
|
await updateDocument(ctx, {
|
|
814
|
+
expectedRevision: 1,
|
|
701
815
|
documentId: 'doc-1',
|
|
702
816
|
data: { title: 'New' },
|
|
703
817
|
path: 'manually-set',
|
|
@@ -712,6 +826,7 @@ describe('Document lifecycle service', () => {
|
|
|
712
826
|
});
|
|
713
827
|
const ctx = buildCtx(db);
|
|
714
828
|
await updateDocument(ctx, {
|
|
829
|
+
expectedRevision: 1,
|
|
715
830
|
documentId: 'doc-1',
|
|
716
831
|
data: { title: 'Updated' },
|
|
717
832
|
});
|
|
@@ -730,6 +845,7 @@ describe('Document lifecycle service', () => {
|
|
|
730
845
|
const warn = ctx.logger?.warn;
|
|
731
846
|
warn.mockClear();
|
|
732
847
|
await updateDocument(ctx, {
|
|
848
|
+
expectedRevision: 1,
|
|
733
849
|
documentId: 'doc-1',
|
|
734
850
|
data: { title: 'À propos' },
|
|
735
851
|
locale: 'fr',
|
|
@@ -763,6 +879,7 @@ describe('Document lifecycle service', () => {
|
|
|
763
879
|
});
|
|
764
880
|
const ctx = buildCtx(db);
|
|
765
881
|
await updateDocument(ctx, {
|
|
882
|
+
expectedRevision: 1,
|
|
766
883
|
documentId: 'doc-1',
|
|
767
884
|
data: { title: 'Über uns — neu' },
|
|
768
885
|
locale: 'de',
|
|
@@ -782,6 +899,7 @@ describe('Document lifecycle service', () => {
|
|
|
782
899
|
const warn = ctx.logger?.warn;
|
|
783
900
|
warn.mockClear();
|
|
784
901
|
await updateDocument(ctx, {
|
|
902
|
+
expectedRevision: 1,
|
|
785
903
|
documentId: 'doc-1',
|
|
786
904
|
data: { title: 'À propos' },
|
|
787
905
|
locale: 'fr',
|
|
@@ -804,6 +922,7 @@ describe('Document lifecycle service', () => {
|
|
|
804
922
|
const ctx = buildCtx(db);
|
|
805
923
|
try {
|
|
806
924
|
await updateDocument(ctx, {
|
|
925
|
+
expectedRevision: 1,
|
|
807
926
|
documentId: 'doc-1',
|
|
808
927
|
data: { title: 'About' },
|
|
809
928
|
path: 'home', // collides
|
|
@@ -834,21 +953,32 @@ describe('Document lifecycle service', () => {
|
|
|
834
953
|
const original = new Error('connection refused');
|
|
835
954
|
createDocumentVersion.mockRejectedValueOnce(original);
|
|
836
955
|
const ctx = buildCtx(db);
|
|
837
|
-
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);
|
|
838
962
|
});
|
|
839
963
|
it('supports an array of beforeUpdate and afterUpdate hooks', async () => {
|
|
840
964
|
const callOrder = [];
|
|
841
965
|
const hooks = {
|
|
842
966
|
beforeUpdate: [
|
|
843
967
|
vi.fn(async () => {
|
|
968
|
+
expect(db.revisions.isInTransaction()).toBe(false);
|
|
844
969
|
callOrder.push('before-1');
|
|
845
970
|
}),
|
|
846
971
|
vi.fn(async () => {
|
|
847
972
|
callOrder.push('before-2');
|
|
848
973
|
}),
|
|
849
974
|
],
|
|
975
|
+
afterSystemFieldsChange: () => {
|
|
976
|
+
expect(db.revisions.isInTransaction()).toBe(false);
|
|
977
|
+
callOrder.push('after-system');
|
|
978
|
+
},
|
|
850
979
|
afterUpdate: [
|
|
851
980
|
vi.fn(async () => {
|
|
981
|
+
expect(db.revisions.isInTransaction()).toBe(false);
|
|
852
982
|
callOrder.push('after-1');
|
|
853
983
|
}),
|
|
854
984
|
vi.fn(async () => {
|
|
@@ -864,8 +994,20 @@ describe('Document lifecycle service', () => {
|
|
|
864
994
|
});
|
|
865
995
|
const definition = { ...minimalCollection, hooks };
|
|
866
996
|
const ctx = buildCtx(db, definition);
|
|
867
|
-
await updateDocument(ctx, {
|
|
868
|
-
|
|
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
|
+
]);
|
|
869
1011
|
});
|
|
870
1012
|
});
|
|
871
1013
|
// -----------------------------------------------------------------------
|
|
@@ -877,6 +1019,7 @@ describe('Document lifecycle service', () => {
|
|
|
877
1019
|
getDocumentById.mockResolvedValue(null);
|
|
878
1020
|
const ctx = buildCtx(db);
|
|
879
1021
|
await expect(updateDocumentWithPatches(ctx, {
|
|
1022
|
+
expectedRevision: 1,
|
|
880
1023
|
documentId: 'doc-missing',
|
|
881
1024
|
patches: [],
|
|
882
1025
|
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
|
|
@@ -892,8 +1035,8 @@ describe('Document lifecycle service', () => {
|
|
|
892
1035
|
});
|
|
893
1036
|
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
894
1037
|
await expect(updateDocumentWithPatches(buildCtx(db, definition), {
|
|
1038
|
+
expectedRevision: 1,
|
|
895
1039
|
documentId: 'doc-1',
|
|
896
|
-
documentVersionId: 'previous-version',
|
|
897
1040
|
patches: [{ kind: 'field.set', path: 'title', value: 'New' }],
|
|
898
1041
|
})).rejects.toMatchObject({
|
|
899
1042
|
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
@@ -906,18 +1049,14 @@ describe('Document lifecycle service', () => {
|
|
|
906
1049
|
});
|
|
907
1050
|
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
908
1051
|
});
|
|
909
|
-
it('throws
|
|
1052
|
+
it('throws ERR_DOCUMENT_STALE on revision mismatch', async () => {
|
|
910
1053
|
const { db, getDocumentById } = createMockDb();
|
|
911
1054
|
getDocumentById.mockResolvedValue({
|
|
912
1055
|
document_version_id: 'ver-current',
|
|
913
1056
|
fields: { title: 'Old' },
|
|
914
1057
|
});
|
|
915
1058
|
const ctx = buildCtx(db);
|
|
916
|
-
await expect(updateDocumentWithPatches(ctx, {
|
|
917
|
-
documentId: 'doc-1',
|
|
918
|
-
patches: [],
|
|
919
|
-
documentVersionId: 'ver-stale',
|
|
920
|
-
})).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);
|
|
921
1060
|
});
|
|
922
1061
|
it('throws ERR_PATCH_FAILED when applyPatches returns errors', async () => {
|
|
923
1062
|
const { db, getDocumentById } = createMockDb();
|
|
@@ -925,6 +1064,7 @@ describe('Document lifecycle service', () => {
|
|
|
925
1064
|
const ctx = buildCtx(db);
|
|
926
1065
|
// array.move on a top-level (non-array) field should produce an error
|
|
927
1066
|
await expect(updateDocumentWithPatches(ctx, {
|
|
1067
|
+
expectedRevision: 1,
|
|
928
1068
|
documentId: 'doc-1',
|
|
929
1069
|
patches: [{ kind: 'array.move', path: 'title', itemId: 'x', toIndex: 0 }],
|
|
930
1070
|
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.PATCH_FAILED);
|
|
@@ -936,6 +1076,7 @@ describe('Document lifecycle service', () => {
|
|
|
936
1076
|
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
937
1077
|
const ctx = buildCtx(db, definition);
|
|
938
1078
|
await updateDocumentWithPatches(ctx, {
|
|
1079
|
+
expectedRevision: 1,
|
|
939
1080
|
documentId: 'doc-1',
|
|
940
1081
|
patches: [{ kind: 'field.set', path: 'title', value: 'Patched' }],
|
|
941
1082
|
});
|
|
@@ -958,6 +1099,7 @@ describe('Document lifecycle service', () => {
|
|
|
958
1099
|
createDocumentVersion.mockRejectedValueOnce(pathUniqueViolation());
|
|
959
1100
|
const ctx = buildCtx(db);
|
|
960
1101
|
await expect(updateDocumentWithPatches(ctx, {
|
|
1102
|
+
expectedRevision: 1,
|
|
961
1103
|
documentId: 'doc-1',
|
|
962
1104
|
patches: [],
|
|
963
1105
|
path: 'new-slug',
|
|
@@ -983,6 +1125,7 @@ describe('Document lifecycle service', () => {
|
|
|
983
1125
|
});
|
|
984
1126
|
const definition = { ...numericCollection, hooks: { beforeUpdate } };
|
|
985
1127
|
await updateDocumentWithPatches(buildCtx(db, definition), {
|
|
1128
|
+
expectedRevision: 1,
|
|
986
1129
|
documentId: 'doc-1',
|
|
987
1130
|
patches: [
|
|
988
1131
|
{ kind: 'field.set', path: 'quantity', value: '7.0' },
|
|
@@ -1015,10 +1158,11 @@ describe('Document lifecycle service', () => {
|
|
|
1015
1158
|
publishSchedule.mockResolvedValue({ status: 'scheduled', schedule, previous: null });
|
|
1016
1159
|
const ctx = buildCtx(db);
|
|
1017
1160
|
await expect(scheduleDocumentPublish(ctx, {
|
|
1161
|
+
expectedRevision: 1,
|
|
1018
1162
|
documentId: 'doc-1',
|
|
1019
1163
|
expectedVersionId: 'ver-1',
|
|
1020
1164
|
publishAt: schedule.publishAt.toISOString(),
|
|
1021
|
-
})).resolves.toEqual(schedule);
|
|
1165
|
+
})).resolves.toEqual({ ...schedule, revision: 2 });
|
|
1022
1166
|
expect(publishSchedule).toHaveBeenCalledWith(expect.objectContaining({
|
|
1023
1167
|
documentId: 'doc-1',
|
|
1024
1168
|
collectionId: 'col-1',
|
|
@@ -1035,6 +1179,7 @@ describe('Document lifecycle service', () => {
|
|
|
1035
1179
|
}),
|
|
1036
1180
|
});
|
|
1037
1181
|
await expect(scheduleDocumentPublish(restricted, {
|
|
1182
|
+
expectedRevision: 1,
|
|
1038
1183
|
documentId: 'doc-1',
|
|
1039
1184
|
expectedVersionId: 'ver-1',
|
|
1040
1185
|
publishAt: schedule.publishAt.toISOString(),
|
|
@@ -1045,6 +1190,7 @@ describe('Document lifecycle service', () => {
|
|
|
1045
1190
|
getCurrentVersionMetadata.mockResolvedValue(metadataRow);
|
|
1046
1191
|
publishSchedule.mockResolvedValue({ status: 'publish_at_not_future' });
|
|
1047
1192
|
await expect(scheduleDocumentPublish(buildCtx(db), {
|
|
1193
|
+
expectedRevision: 1,
|
|
1048
1194
|
documentId: 'doc-1',
|
|
1049
1195
|
expectedVersionId: 'ver-1',
|
|
1050
1196
|
publishAt: new Date(Date.now() + 60_000).toISOString(),
|
|
@@ -1054,15 +1200,20 @@ describe('Document lifecycle service', () => {
|
|
|
1054
1200
|
document_version_id: 'ver-2',
|
|
1055
1201
|
});
|
|
1056
1202
|
await expect(scheduleDocumentPublish(buildCtx(db), {
|
|
1203
|
+
expectedRevision: 1,
|
|
1057
1204
|
documentId: 'doc-1',
|
|
1058
1205
|
expectedVersionId: 'ver-1',
|
|
1059
1206
|
publishAt: new Date(Date.now() + 60_000).toISOString(),
|
|
1060
|
-
})).rejects.toMatchObject({
|
|
1207
|
+
})).rejects.toMatchObject({
|
|
1208
|
+
code: ErrorCodes.DOCUMENT_STALE,
|
|
1209
|
+
details: { reason: 'version_parent_mismatch' },
|
|
1210
|
+
});
|
|
1061
1211
|
});
|
|
1062
1212
|
it('rejects date strings that are valid to JavaScript but are not ISO instants', async () => {
|
|
1063
1213
|
const { db, getCurrentVersionMetadata, publishSchedule } = createMockDb();
|
|
1064
1214
|
getCurrentVersionMetadata.mockResolvedValue(metadataRow);
|
|
1065
1215
|
await expect(scheduleDocumentPublish(buildCtx(db), {
|
|
1216
|
+
expectedRevision: 1,
|
|
1066
1217
|
documentId: 'doc-1',
|
|
1067
1218
|
expectedVersionId: 'ver-1',
|
|
1068
1219
|
publishAt: 'August 23, 2026 12:00:00 UTC',
|
|
@@ -1085,10 +1236,11 @@ describe('Document lifecycle service', () => {
|
|
|
1085
1236
|
});
|
|
1086
1237
|
cancelPublishSchedule.mockResolvedValue(confirmed);
|
|
1087
1238
|
await expect(confirmDocumentScheduledPublish(buildCtx(db), {
|
|
1239
|
+
expectedRevision: 1,
|
|
1088
1240
|
documentId: 'doc-1',
|
|
1089
1241
|
expectedVersionId: 'ver-1',
|
|
1090
|
-
})).resolves.toEqual(confirmed);
|
|
1091
|
-
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 });
|
|
1092
1244
|
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.reconfirmed' }));
|
|
1093
1245
|
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.cancelled' }));
|
|
1094
1246
|
expect(suspended.state).toBe('needs_reconfirm');
|
|
@@ -1109,10 +1261,12 @@ describe('Document lifecycle service', () => {
|
|
|
1109
1261
|
});
|
|
1110
1262
|
suspendPublishSchedule.mockResolvedValue({ status: 'suspended', schedule });
|
|
1111
1263
|
await updateDocument(buildCtx(db), {
|
|
1264
|
+
expectedRevision: 1,
|
|
1112
1265
|
documentId: 'doc-1',
|
|
1113
1266
|
data: { title: 'After' },
|
|
1114
1267
|
});
|
|
1115
1268
|
expect(suspendPublishSchedule).toHaveBeenCalledWith({
|
|
1269
|
+
reason: 'content_edited',
|
|
1116
1270
|
documentId: 'doc-1',
|
|
1117
1271
|
collectionId: 'col-1',
|
|
1118
1272
|
});
|
|
@@ -1135,9 +1289,10 @@ describe('Document lifecycle service', () => {
|
|
|
1135
1289
|
suspendedReason: 'content_edited',
|
|
1136
1290
|
}),
|
|
1137
1291
|
});
|
|
1138
|
-
await deleteLocale(buildCtx(db), { documentId: 'doc-1', locale: 'fr' });
|
|
1292
|
+
await deleteLocale(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1', locale: 'fr' });
|
|
1139
1293
|
expect(deleteDocumentLocale).toHaveBeenCalledOnce();
|
|
1140
1294
|
expect(suspendPublishSchedule).toHaveBeenCalledWith({
|
|
1295
|
+
reason: 'content_edited',
|
|
1141
1296
|
documentId: 'doc-1',
|
|
1142
1297
|
collectionId: 'col-1',
|
|
1143
1298
|
});
|
|
@@ -1156,7 +1311,11 @@ describe('Document lifecycle service', () => {
|
|
|
1156
1311
|
...minimalCollection,
|
|
1157
1312
|
hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
|
|
1158
1313
|
};
|
|
1159
|
-
await expect(deleteLocale(buildCtx(db, definition), {
|
|
1314
|
+
await expect(deleteLocale(buildCtx(db, definition), {
|
|
1315
|
+
expectedRevision: 1,
|
|
1316
|
+
documentId: 'doc-1',
|
|
1317
|
+
locale: 'fr',
|
|
1318
|
+
})).rejects.toMatchObject({
|
|
1160
1319
|
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
1161
1320
|
details: {
|
|
1162
1321
|
phase: 'afterUpdate',
|
|
@@ -1172,6 +1331,7 @@ describe('Document lifecycle service', () => {
|
|
|
1172
1331
|
getCurrentVersionMetadata.mockResolvedValue(metadataRow);
|
|
1173
1332
|
cancelPublishSchedule.mockResolvedValue(publishScheduleRow());
|
|
1174
1333
|
await changeDocumentStatus(buildCtx(db), {
|
|
1334
|
+
expectedRevision: 1,
|
|
1175
1335
|
documentId: 'doc-1',
|
|
1176
1336
|
nextStatus: 'published',
|
|
1177
1337
|
});
|
|
@@ -1235,6 +1395,7 @@ describe('Document lifecycle service', () => {
|
|
|
1235
1395
|
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
1236
1396
|
const ctx = buildCtx(db);
|
|
1237
1397
|
const result = await changeDocumentStatus(ctx, {
|
|
1398
|
+
expectedRevision: 1,
|
|
1238
1399
|
documentId: 'doc-1',
|
|
1239
1400
|
nextStatus: 'published',
|
|
1240
1401
|
});
|
|
@@ -1249,9 +1410,13 @@ describe('Document lifecycle service', () => {
|
|
|
1249
1410
|
const { db, getCurrentVersionMetadata, auditAppend, withTransaction } = createMockDb();
|
|
1250
1411
|
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
1251
1412
|
const ctx = buildCtx(db);
|
|
1252
|
-
await changeDocumentStatus(ctx, {
|
|
1253
|
-
|
|
1254
|
-
|
|
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);
|
|
1255
1420
|
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
|
|
1256
1421
|
documentId: 'doc-1',
|
|
1257
1422
|
collectionId: 'col-1',
|
|
@@ -1267,14 +1432,22 @@ describe('Document lifecycle service', () => {
|
|
|
1267
1432
|
const { db, getCurrentVersionMetadata } = createMockDb();
|
|
1268
1433
|
getCurrentVersionMetadata.mockResolvedValue(null);
|
|
1269
1434
|
const ctx = buildCtx(db);
|
|
1270
|
-
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);
|
|
1271
1440
|
});
|
|
1272
1441
|
it('throws ERR_INVALID_TRANSITION for an invalid transition', async () => {
|
|
1273
1442
|
const { db, getCurrentVersionMetadata } = createMockDb();
|
|
1274
1443
|
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
1275
1444
|
const ctx = buildCtx(db);
|
|
1276
1445
|
// draft → archived skips 'published', which is not ±1
|
|
1277
|
-
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);
|
|
1278
1451
|
});
|
|
1279
1452
|
it('invokes beforeStatusChange and afterStatusChange hooks', async () => {
|
|
1280
1453
|
const callOrder = [];
|
|
@@ -1293,7 +1466,11 @@ describe('Document lifecycle service', () => {
|
|
|
1293
1466
|
});
|
|
1294
1467
|
const definition = { ...minimalCollection, hooks };
|
|
1295
1468
|
const ctx = buildCtx(db, definition);
|
|
1296
|
-
await changeDocumentStatus(ctx, {
|
|
1469
|
+
await changeDocumentStatus(ctx, {
|
|
1470
|
+
expectedRevision: 1,
|
|
1471
|
+
documentId: 'doc-1',
|
|
1472
|
+
nextStatus: 'published',
|
|
1473
|
+
});
|
|
1297
1474
|
expect(callOrder).toEqual(['before', 'persist', 'after']);
|
|
1298
1475
|
expect(hooks.beforeStatusChange).toHaveBeenCalledWith(expect.objectContaining({
|
|
1299
1476
|
previousStatus: 'draft',
|
|
@@ -1313,7 +1490,11 @@ describe('Document lifecycle service', () => {
|
|
|
1313
1490
|
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
1314
1491
|
const definition = { ...minimalCollection, hooks };
|
|
1315
1492
|
const ctx = buildCtx(db, definition);
|
|
1316
|
-
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);
|
|
1317
1498
|
expect(hooks.beforeStatusChange).not.toHaveBeenCalled();
|
|
1318
1499
|
expect(hooks.afterStatusChange).not.toHaveBeenCalled();
|
|
1319
1500
|
});
|
|
@@ -1321,7 +1502,11 @@ describe('Document lifecycle service', () => {
|
|
|
1321
1502
|
const { db, getCurrentVersionMetadata, archivePublishedVersions } = createMockDb();
|
|
1322
1503
|
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
1323
1504
|
const ctx = buildCtx(db);
|
|
1324
|
-
await changeDocumentStatus(ctx, {
|
|
1505
|
+
await changeDocumentStatus(ctx, {
|
|
1506
|
+
expectedRevision: 1,
|
|
1507
|
+
documentId: 'doc-1',
|
|
1508
|
+
nextStatus: 'published',
|
|
1509
|
+
});
|
|
1325
1510
|
expect(archivePublishedVersions).toHaveBeenCalledWith({
|
|
1326
1511
|
document_id: 'doc-1',
|
|
1327
1512
|
excludeVersionId: 'ver-1',
|
|
@@ -1354,7 +1539,11 @@ describe('Document lifecycle service', () => {
|
|
|
1354
1539
|
});
|
|
1355
1540
|
const definition = { ...minimalCollection, hooks };
|
|
1356
1541
|
const ctx = buildCtx(db, definition);
|
|
1357
|
-
await changeDocumentStatus(ctx, {
|
|
1542
|
+
await changeDocumentStatus(ctx, {
|
|
1543
|
+
expectedRevision: 1,
|
|
1544
|
+
documentId: 'doc-1',
|
|
1545
|
+
nextStatus: 'published',
|
|
1546
|
+
});
|
|
1358
1547
|
expect(callOrder).toEqual(['before-1', 'before-2', 'persist', 'after-1', 'after-2']);
|
|
1359
1548
|
});
|
|
1360
1549
|
});
|
|
@@ -1364,16 +1553,30 @@ describe('Document lifecycle service', () => {
|
|
|
1364
1553
|
describe('unpublishDocument', () => {
|
|
1365
1554
|
it('calls archivePublishedVersions and returns count', async () => {
|
|
1366
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
|
+
});
|
|
1367
1563
|
archivePublishedVersions.mockResolvedValue(1);
|
|
1368
1564
|
const ctx = buildCtx(db);
|
|
1369
|
-
const result = await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
1565
|
+
const result = await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1370
1566
|
expect(result.archivedCount).toBe(1);
|
|
1371
1567
|
expect(archivePublishedVersions).toHaveBeenCalledWith({ document_id: 'doc-1' });
|
|
1372
1568
|
});
|
|
1373
1569
|
it('records an atomic published to archived status audit when versions change', async () => {
|
|
1374
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
|
+
});
|
|
1375
1578
|
archivePublishedVersions.mockResolvedValue(2);
|
|
1376
|
-
await unpublishDocument(buildCtx(db), { documentId: 'doc-1' });
|
|
1579
|
+
await unpublishDocument(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1' });
|
|
1377
1580
|
expect(withTransaction).toHaveBeenCalledOnce();
|
|
1378
1581
|
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
|
|
1379
1582
|
documentId: 'doc-1',
|
|
@@ -1388,16 +1591,31 @@ describe('Document lifecycle service', () => {
|
|
|
1388
1591
|
});
|
|
1389
1592
|
it('does not record an audit row when no published version changes', async () => {
|
|
1390
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
|
+
});
|
|
1391
1601
|
archivePublishedVersions.mockResolvedValue(0);
|
|
1392
|
-
await unpublishDocument(buildCtx(db), { documentId: 'doc-1' });
|
|
1602
|
+
await unpublishDocument(buildCtx(db), { expectedRevision: 1, documentId: 'doc-1' });
|
|
1393
1603
|
expect(auditAppend).not.toHaveBeenCalled();
|
|
1394
1604
|
});
|
|
1395
1605
|
it('does not run afterUnpublish when the atomic audit unit fails', async () => {
|
|
1396
1606
|
const afterUnpublish = vi.fn();
|
|
1397
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
|
+
});
|
|
1398
1615
|
archivePublishedVersions.mockResolvedValue(1);
|
|
1399
1616
|
auditAppend.mockRejectedValue(new Error('audit failed'));
|
|
1400
1617
|
await expect(unpublishDocument(buildCtx(db, { ...minimalCollection, hooks: { afterUnpublish } }), {
|
|
1618
|
+
expectedRevision: 1,
|
|
1401
1619
|
documentId: 'doc-1',
|
|
1402
1620
|
})).rejects.toThrow('audit failed');
|
|
1403
1621
|
expect(afterUnpublish).not.toHaveBeenCalled();
|
|
@@ -1413,13 +1631,20 @@ describe('Document lifecycle service', () => {
|
|
|
1413
1631
|
}),
|
|
1414
1632
|
};
|
|
1415
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
|
+
});
|
|
1416
1641
|
archivePublishedVersions.mockImplementation(async () => {
|
|
1417
1642
|
callOrder.push('archive');
|
|
1418
1643
|
return 2;
|
|
1419
1644
|
});
|
|
1420
1645
|
const definition = { ...minimalCollection, hooks };
|
|
1421
1646
|
const ctx = buildCtx(db, definition);
|
|
1422
|
-
await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
1647
|
+
await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1423
1648
|
expect(callOrder).toEqual(['before', 'archive', 'after']);
|
|
1424
1649
|
expect(hooks.beforeUnpublish).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'current-path' }));
|
|
1425
1650
|
expect(hooks.afterUnpublish).toHaveBeenCalledWith(expect.objectContaining({
|
|
@@ -1430,9 +1655,16 @@ describe('Document lifecycle service', () => {
|
|
|
1430
1655
|
});
|
|
1431
1656
|
it('works when no hooks are defined', async () => {
|
|
1432
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
|
+
});
|
|
1433
1665
|
archivePublishedVersions.mockResolvedValue(0);
|
|
1434
1666
|
const ctx = buildCtx(db);
|
|
1435
|
-
const result = await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
1667
|
+
const result = await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1436
1668
|
expect(result.archivedCount).toBe(0);
|
|
1437
1669
|
});
|
|
1438
1670
|
it('supports an array of beforeUnpublish and afterUnpublish hooks', async () => {
|
|
@@ -1456,13 +1688,20 @@ describe('Document lifecycle service', () => {
|
|
|
1456
1688
|
],
|
|
1457
1689
|
};
|
|
1458
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
|
+
});
|
|
1459
1698
|
archivePublishedVersions.mockImplementation(async () => {
|
|
1460
1699
|
callOrder.push('archive');
|
|
1461
1700
|
return 2;
|
|
1462
1701
|
});
|
|
1463
1702
|
const definition = { ...minimalCollection, hooks };
|
|
1464
1703
|
const ctx = buildCtx(db, definition);
|
|
1465
|
-
await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
1704
|
+
await unpublishDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1466
1705
|
expect(callOrder).toEqual(['before-1', 'before-2', 'archive', 'after-1', 'after-2']);
|
|
1467
1706
|
});
|
|
1468
1707
|
});
|
|
@@ -1482,7 +1721,7 @@ describe('Document lifecycle service', () => {
|
|
|
1482
1721
|
});
|
|
1483
1722
|
const definition = { ...minimalCollection, hooks: { beforeDelete, afterDelete } };
|
|
1484
1723
|
const ctx = buildCtx(db, definition);
|
|
1485
|
-
await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
1724
|
+
await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1486
1725
|
expect(beforeDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
|
|
1487
1726
|
expect(afterDelete).toHaveBeenCalledWith(expect.objectContaining({ documentId: 'doc-1', path: 'doc-to-delete' }));
|
|
1488
1727
|
});
|
|
@@ -1495,7 +1734,7 @@ describe('Document lifecycle service', () => {
|
|
|
1495
1734
|
fields: {},
|
|
1496
1735
|
});
|
|
1497
1736
|
const ctx = buildCtx(db);
|
|
1498
|
-
await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
1737
|
+
await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1499
1738
|
expect(withTransaction).toHaveBeenCalledOnce();
|
|
1500
1739
|
expect(softDeleteDocument).toHaveBeenCalledWith({ document_id: 'doc-1' });
|
|
1501
1740
|
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({
|
|
@@ -1518,7 +1757,7 @@ describe('Document lifecycle service', () => {
|
|
|
1518
1757
|
fields: {},
|
|
1519
1758
|
});
|
|
1520
1759
|
const definition = { ...minimalCollection, hooks: { beforeDelete, afterDelete } };
|
|
1521
|
-
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');
|
|
1522
1761
|
expect(softDeleteDocument).not.toHaveBeenCalled();
|
|
1523
1762
|
expect(afterDelete).not.toHaveBeenCalled();
|
|
1524
1763
|
});
|
|
@@ -1564,8 +1803,8 @@ describe('Document lifecycle service', () => {
|
|
|
1564
1803
|
...buildCtx(db, definition),
|
|
1565
1804
|
storage: { delete: storageDelete },
|
|
1566
1805
|
};
|
|
1567
|
-
await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
1568
|
-
expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct:
|
|
1806
|
+
await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1807
|
+
expect(getDocumentById).toHaveBeenCalledWith(expect.objectContaining({ reconstruct: true }));
|
|
1569
1808
|
expect(storageDelete).not.toHaveBeenCalled();
|
|
1570
1809
|
});
|
|
1571
1810
|
it('returns only allowlisted failures and keeps raw details in internal logs', async () => {
|
|
@@ -1609,9 +1848,13 @@ describe('Document lifecycle service', () => {
|
|
|
1609
1848
|
storage: { delete: storageDelete },
|
|
1610
1849
|
logger: { ...noopLogger, error: vi.fn() },
|
|
1611
1850
|
};
|
|
1612
|
-
const result = await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
1851
|
+
const result = await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
1613
1852
|
expect(storageDelete).not.toHaveBeenCalled();
|
|
1614
1853
|
expect(result).toEqual({
|
|
1854
|
+
affectedDocuments: [{ documentId: 'doc-1', revision: 2 }],
|
|
1855
|
+
scheduledPublicationsNeedReconfirmation: false,
|
|
1856
|
+
documentId: 'doc-1',
|
|
1857
|
+
revision: 2,
|
|
1615
1858
|
deletedVersionCount: 1,
|
|
1616
1859
|
outcome: 'committed-with-side-effect-failures',
|
|
1617
1860
|
sideEffectFailures: [{ phase: 'afterDelete', code: 'ERR_UNHANDLED' }],
|
|
@@ -1645,6 +1888,7 @@ describe('Document lifecycle service', () => {
|
|
|
1645
1888
|
setupDoc(getDocumentSystemFieldsForUpdate);
|
|
1646
1889
|
const ctx = buildCtx(db);
|
|
1647
1890
|
const result = await updateDocumentSystemFields(ctx, {
|
|
1891
|
+
expectedRevision: 1,
|
|
1648
1892
|
documentId: 'doc-1',
|
|
1649
1893
|
path: 'new-slug',
|
|
1650
1894
|
});
|
|
@@ -1668,6 +1912,7 @@ describe('Document lifecycle service', () => {
|
|
|
1668
1912
|
db.commands.documents.updateDocumentPath.mockRejectedValueOnce(pathUniqueViolation());
|
|
1669
1913
|
const ctx = buildCtx(db);
|
|
1670
1914
|
await expect(updateDocumentSystemFields(ctx, {
|
|
1915
|
+
expectedRevision: 1,
|
|
1671
1916
|
documentId: 'doc-1',
|
|
1672
1917
|
path: 'new-slug',
|
|
1673
1918
|
})).rejects.toMatchObject({
|
|
@@ -1689,6 +1934,7 @@ describe('Document lifecycle service', () => {
|
|
|
1689
1934
|
hooks: { afterSystemFieldsChange },
|
|
1690
1935
|
});
|
|
1691
1936
|
const result = await updateDocumentSystemFields(ctx, {
|
|
1937
|
+
expectedRevision: 1,
|
|
1692
1938
|
documentId: 'doc-1',
|
|
1693
1939
|
path: 'old-slug',
|
|
1694
1940
|
});
|
|
@@ -1703,6 +1949,7 @@ describe('Document lifecycle service', () => {
|
|
|
1703
1949
|
setupDoc(getDocumentSystemFieldsForUpdate);
|
|
1704
1950
|
const ctx = buildCtx(db);
|
|
1705
1951
|
const result = await updateDocumentSystemFields(ctx, {
|
|
1952
|
+
expectedRevision: 1,
|
|
1706
1953
|
documentId: 'doc-1',
|
|
1707
1954
|
availableLocales: ['en', 'fr'],
|
|
1708
1955
|
});
|
|
@@ -1728,6 +1975,7 @@ describe('Document lifecycle service', () => {
|
|
|
1728
1975
|
hooks: { afterSystemFieldsChange },
|
|
1729
1976
|
});
|
|
1730
1977
|
const result = await updateDocumentSystemFields(ctx, {
|
|
1978
|
+
expectedRevision: 1,
|
|
1731
1979
|
documentId: 'doc-1',
|
|
1732
1980
|
availableLocales: ['fr', 'en', 'fr'],
|
|
1733
1981
|
});
|
|
@@ -1741,10 +1989,10 @@ describe('Document lifecycle service', () => {
|
|
|
1741
1989
|
const { db, getDocumentSystemFieldsForUpdate, auditAppend, withTransaction } = createMockDb();
|
|
1742
1990
|
setupDoc(getDocumentSystemFieldsForUpdate);
|
|
1743
1991
|
const order = [];
|
|
1744
|
-
db.commands.documents.updateDocumentPath.mockImplementation(async () => {
|
|
1992
|
+
vi.mocked(db.commands.documents.updateDocumentPath).mockImplementation(async () => {
|
|
1745
1993
|
order.push('path-write');
|
|
1746
1994
|
});
|
|
1747
|
-
db.commands.documents.setDocumentAvailableLocales.mockImplementation(async () => {
|
|
1995
|
+
vi.mocked(db.commands.documents.setDocumentAvailableLocales).mockImplementation(async () => {
|
|
1748
1996
|
order.push('locales-write');
|
|
1749
1997
|
});
|
|
1750
1998
|
auditAppend.mockImplementation(async () => {
|
|
@@ -1768,6 +2016,7 @@ describe('Document lifecycle service', () => {
|
|
|
1768
2016
|
hooks: { afterSystemFieldsChange: [firstHook, secondHook] },
|
|
1769
2017
|
});
|
|
1770
2018
|
await updateDocumentSystemFields(ctx, {
|
|
2019
|
+
expectedRevision: 1,
|
|
1771
2020
|
documentId: 'doc-1',
|
|
1772
2021
|
path: 'new-slug',
|
|
1773
2022
|
availableLocales: ['fr', 'en', 'fr'],
|
|
@@ -1803,7 +2052,11 @@ describe('Document lifecycle service', () => {
|
|
|
1803
2052
|
...minimalCollection,
|
|
1804
2053
|
hooks: { afterSystemFieldsChange },
|
|
1805
2054
|
});
|
|
1806
|
-
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');
|
|
1807
2060
|
expect(db.commands.documents.updateDocumentPath).toHaveBeenCalledOnce();
|
|
1808
2061
|
expect(afterSystemFieldsChange).not.toHaveBeenCalled();
|
|
1809
2062
|
});
|
|
@@ -1824,7 +2077,14 @@ describe('Document lifecycle service', () => {
|
|
|
1824
2077
|
...minimalCollection,
|
|
1825
2078
|
hooks: { afterSystemFieldsChange },
|
|
1826
2079
|
});
|
|
1827
|
-
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
|
+
});
|
|
1828
2088
|
expect(committed).toBe(true);
|
|
1829
2089
|
expect(db.commands.documents.setDocumentAvailableLocales).toHaveBeenCalledOnce();
|
|
1830
2090
|
expect(afterSystemFieldsChange).toHaveBeenCalledOnce();
|
|
@@ -1840,9 +2100,17 @@ describe('Document lifecycle service', () => {
|
|
|
1840
2100
|
...minimalCollection,
|
|
1841
2101
|
hooks: { afterSystemFieldsChange },
|
|
1842
2102
|
});
|
|
1843
|
-
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
|
+
});
|
|
1844
2111
|
setupDoc(getDocumentSystemFieldsForUpdate, { path: 'new-slug' });
|
|
1845
2112
|
const retry = await updateDocumentSystemFields(ctx, {
|
|
2113
|
+
expectedRevision: 2,
|
|
1846
2114
|
documentId: 'doc-1',
|
|
1847
2115
|
path: 'new-slug',
|
|
1848
2116
|
reconcile: true,
|
|
@@ -1876,6 +2144,7 @@ describe('Document lifecycle service', () => {
|
|
|
1876
2144
|
hooks: { afterSystemFieldsChange },
|
|
1877
2145
|
});
|
|
1878
2146
|
await updateDocumentSystemFields(ctx, {
|
|
2147
|
+
expectedRevision: 1,
|
|
1879
2148
|
documentId: 'doc-1',
|
|
1880
2149
|
path: 'final-path',
|
|
1881
2150
|
availableLocales: ['de'],
|
|
@@ -1909,7 +2178,7 @@ describe('Document lifecycle service', () => {
|
|
|
1909
2178
|
};
|
|
1910
2179
|
const mocks = createMockDb();
|
|
1911
2180
|
const { db } = mocks;
|
|
1912
|
-
db.queries.documents.getDocumentByVersion.mockResolvedValue({
|
|
2181
|
+
vi.mocked(db.queries.documents.getDocumentByVersion).mockResolvedValue({
|
|
1913
2182
|
document_version_id: sourceVersionId,
|
|
1914
2183
|
document_id: sourceDocumentId,
|
|
1915
2184
|
path: 'long-ago-path',
|
|
@@ -1941,6 +2210,7 @@ describe('Document lifecycle service', () => {
|
|
|
1941
2210
|
const { db, createDocumentVersion, sourceVersionId, sourceFields, currentVersionId } = setupRestore();
|
|
1942
2211
|
const ctx = buildCtx(db);
|
|
1943
2212
|
const result = await restoreDocumentVersion(ctx, {
|
|
2213
|
+
expectedRevision: 1,
|
|
1944
2214
|
documentId: 'doc-1',
|
|
1945
2215
|
sourceVersionId,
|
|
1946
2216
|
});
|
|
@@ -1957,13 +2227,18 @@ describe('Document lifecycle service', () => {
|
|
|
1957
2227
|
expect(result).toEqual({
|
|
1958
2228
|
documentId: 'doc-1',
|
|
1959
2229
|
documentVersionId: 'ver-restored',
|
|
2230
|
+
revision: 2,
|
|
1960
2231
|
sourceVersionId,
|
|
1961
2232
|
});
|
|
1962
2233
|
});
|
|
1963
2234
|
it('hard-defaults the new version status to the workflow default (never inherits source status)', async () => {
|
|
1964
2235
|
const { db, createDocumentVersion, sourceVersionId } = setupRestore();
|
|
1965
2236
|
const ctx = buildCtx(db);
|
|
1966
|
-
await restoreDocumentVersion(ctx, {
|
|
2237
|
+
await restoreDocumentVersion(ctx, {
|
|
2238
|
+
expectedRevision: 1,
|
|
2239
|
+
documentId: 'doc-1',
|
|
2240
|
+
sourceVersionId,
|
|
2241
|
+
});
|
|
1967
2242
|
// Source version was 'archived'; default status for minimalCollection is 'draft'.
|
|
1968
2243
|
expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('draft');
|
|
1969
2244
|
});
|
|
@@ -1972,7 +2247,11 @@ describe('Document lifecycle service', () => {
|
|
|
1972
2247
|
currentPath: 'sticky-path',
|
|
1973
2248
|
});
|
|
1974
2249
|
const ctx = buildCtx(db);
|
|
1975
|
-
await restoreDocumentVersion(ctx, {
|
|
2250
|
+
await restoreDocumentVersion(ctx, {
|
|
2251
|
+
expectedRevision: 1,
|
|
2252
|
+
documentId: 'doc-1',
|
|
2253
|
+
sourceVersionId,
|
|
2254
|
+
});
|
|
1976
2255
|
// Restore never changes a document's path: the existing
|
|
1977
2256
|
// byline_document_paths row carries forward unchanged. The storage
|
|
1978
2257
|
// primitive only writes to document_paths when `path` is supplied.
|
|
@@ -1984,7 +2263,11 @@ describe('Document lifecycle service', () => {
|
|
|
1984
2263
|
});
|
|
1985
2264
|
const ctx = buildCtx(db);
|
|
1986
2265
|
try {
|
|
1987
|
-
await restoreDocumentVersion(ctx, {
|
|
2266
|
+
await restoreDocumentVersion(ctx, {
|
|
2267
|
+
expectedRevision: 1,
|
|
2268
|
+
documentId: 'doc-1',
|
|
2269
|
+
sourceVersionId,
|
|
2270
|
+
});
|
|
1988
2271
|
expect.fail('expected ERR_VALIDATION');
|
|
1989
2272
|
}
|
|
1990
2273
|
catch (err) {
|
|
@@ -1997,7 +2280,7 @@ describe('Document lifecycle service', () => {
|
|
|
1997
2280
|
const sourceVersionId = 'ver-source';
|
|
1998
2281
|
const mocks = createMockDb();
|
|
1999
2282
|
const { db } = mocks;
|
|
2000
|
-
db.queries.documents.getDocumentByVersion.mockResolvedValue({
|
|
2283
|
+
vi.mocked(db.queries.documents.getDocumentByVersion).mockResolvedValue({
|
|
2001
2284
|
document_version_id: sourceVersionId,
|
|
2002
2285
|
document_id: 'doc-1',
|
|
2003
2286
|
path: 'p',
|
|
@@ -2015,7 +2298,11 @@ describe('Document lifecycle service', () => {
|
|
|
2015
2298
|
});
|
|
2016
2299
|
const ctx = buildCtx(db);
|
|
2017
2300
|
try {
|
|
2018
|
-
await restoreDocumentVersion(ctx, {
|
|
2301
|
+
await restoreDocumentVersion(ctx, {
|
|
2302
|
+
expectedRevision: 1,
|
|
2303
|
+
documentId: 'doc-1',
|
|
2304
|
+
sourceVersionId,
|
|
2305
|
+
});
|
|
2019
2306
|
expect.fail('expected ERR_INVALID_TRANSITION');
|
|
2020
2307
|
}
|
|
2021
2308
|
catch (err) {
|
|
@@ -2029,7 +2316,11 @@ describe('Document lifecycle service', () => {
|
|
|
2029
2316
|
const { db, sourceVersionId } = setupRestore();
|
|
2030
2317
|
const definition = { ...minimalCollection, hooks: { beforeUpdate, afterUpdate } };
|
|
2031
2318
|
const ctx = buildCtx(db, definition);
|
|
2032
|
-
await restoreDocumentVersion(ctx, {
|
|
2319
|
+
await restoreDocumentVersion(ctx, {
|
|
2320
|
+
expectedRevision: 1,
|
|
2321
|
+
documentId: 'doc-1',
|
|
2322
|
+
sourceVersionId,
|
|
2323
|
+
});
|
|
2033
2324
|
expect(beforeUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
|
2034
2325
|
collectionPath: 'articles',
|
|
2035
2326
|
restore: { sourceVersionId },
|
|
@@ -2052,6 +2343,7 @@ describe('Document lifecycle service', () => {
|
|
|
2052
2343
|
hooks: { afterUpdate: vi.fn().mockRejectedValue(new Error('search unavailable')) },
|
|
2053
2344
|
};
|
|
2054
2345
|
await expect(restoreDocumentVersion(buildCtx(db, definition), {
|
|
2346
|
+
expectedRevision: 1,
|
|
2055
2347
|
documentId: 'doc-1',
|
|
2056
2348
|
sourceVersionId,
|
|
2057
2349
|
})).rejects.toMatchObject({
|
|
@@ -2074,7 +2366,11 @@ describe('Document lifecycle service', () => {
|
|
|
2074
2366
|
});
|
|
2075
2367
|
ctx.requestContext = createRequestContext({ actor });
|
|
2076
2368
|
try {
|
|
2077
|
-
await restoreDocumentVersion(ctx, {
|
|
2369
|
+
await restoreDocumentVersion(ctx, {
|
|
2370
|
+
expectedRevision: 1,
|
|
2371
|
+
documentId: 'doc-1',
|
|
2372
|
+
sourceVersionId,
|
|
2373
|
+
});
|
|
2078
2374
|
expect.fail('expected ERR_FORBIDDEN');
|
|
2079
2375
|
}
|
|
2080
2376
|
catch (err) {
|
|
@@ -2090,7 +2386,11 @@ describe('Document lifecycle service', () => {
|
|
|
2090
2386
|
abilities: ['collections.articles.update', 'collections.articles.read'],
|
|
2091
2387
|
});
|
|
2092
2388
|
ctx.requestContext = createRequestContext({ actor });
|
|
2093
|
-
await restoreDocumentVersion(ctx, {
|
|
2389
|
+
await restoreDocumentVersion(ctx, {
|
|
2390
|
+
expectedRevision: 1,
|
|
2391
|
+
documentId: 'doc-1',
|
|
2392
|
+
sourceVersionId,
|
|
2393
|
+
});
|
|
2094
2394
|
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
2095
2395
|
});
|
|
2096
2396
|
});
|
|
@@ -2135,7 +2435,7 @@ describe('Document lifecycle service', () => {
|
|
|
2135
2435
|
const mocks = createMockDb();
|
|
2136
2436
|
const { sourceDocumentId } = setupSource(mocks);
|
|
2137
2437
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2138
|
-
const result = await duplicateDocument(ctx, { sourceDocumentId });
|
|
2438
|
+
const result = await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId });
|
|
2139
2439
|
expect(mocks.getDocumentById).toHaveBeenCalledWith(expect.objectContaining({
|
|
2140
2440
|
collection_id: 'col-1',
|
|
2141
2441
|
document_id: sourceDocumentId,
|
|
@@ -2162,7 +2462,7 @@ describe('Document lifecycle service', () => {
|
|
|
2162
2462
|
},
|
|
2163
2463
|
});
|
|
2164
2464
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2165
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2465
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2166
2466
|
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
2167
2467
|
expect(call.documentData.title).toEqual({
|
|
2168
2468
|
en: 'Hello (copy)',
|
|
@@ -2187,7 +2487,7 @@ describe('Document lifecycle service', () => {
|
|
|
2187
2487
|
},
|
|
2188
2488
|
});
|
|
2189
2489
|
const ctx = buildCtx(mocks.db, nonLocalizedCollection);
|
|
2190
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2490
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2191
2491
|
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
2192
2492
|
expect(call.documentData.title).toBe('Hello (copy)');
|
|
2193
2493
|
});
|
|
@@ -2200,7 +2500,10 @@ describe('Document lifecycle service', () => {
|
|
|
2200
2500
|
},
|
|
2201
2501
|
});
|
|
2202
2502
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2203
|
-
const result = await duplicateDocument(ctx, {
|
|
2503
|
+
const result = await duplicateDocument(ctx, {
|
|
2504
|
+
expectedRevision: 1,
|
|
2505
|
+
sourceDocumentId: 'doc-source',
|
|
2506
|
+
});
|
|
2204
2507
|
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
2205
2508
|
// Default locale is 'en'; "Hello World (copy)" slugifies to "hello-world-copy".
|
|
2206
2509
|
expect(call.path).toBe('hello-world-copy');
|
|
@@ -2226,7 +2529,7 @@ describe('Document lifecycle service', () => {
|
|
|
2226
2529
|
},
|
|
2227
2530
|
});
|
|
2228
2531
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2229
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2532
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2230
2533
|
const call = mocks.createDocumentVersion.mock.calls[0]?.[0];
|
|
2231
2534
|
const sections = call.documentData.sections;
|
|
2232
2535
|
expect(sections[0]._id).toBeUndefined();
|
|
@@ -2258,7 +2561,7 @@ describe('Document lifecycle service', () => {
|
|
|
2258
2561
|
fieldCount: 5,
|
|
2259
2562
|
});
|
|
2260
2563
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2261
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2564
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2262
2565
|
// Source title should be unchanged in memory.
|
|
2263
2566
|
expect(originalFields.title).toEqual({ en: 'Hello', fr: 'Bonjour' });
|
|
2264
2567
|
expect(originalFields.sections[0]?._id).toBe('sec-1');
|
|
@@ -2280,7 +2583,10 @@ describe('Document lifecycle service', () => {
|
|
|
2280
2583
|
fieldCount: 5,
|
|
2281
2584
|
});
|
|
2282
2585
|
});
|
|
2283
|
-
const result = await duplicateDocument(ctx, {
|
|
2586
|
+
const result = await duplicateDocument(ctx, {
|
|
2587
|
+
expectedRevision: 1,
|
|
2588
|
+
sourceDocumentId: 'doc-source',
|
|
2589
|
+
});
|
|
2284
2590
|
expect(mocks.createDocumentVersion).toHaveBeenCalledTimes(2);
|
|
2285
2591
|
const firstPath = mocks.createDocumentVersion.mock.calls[0]?.[0].path;
|
|
2286
2592
|
const retryPath = mocks.createDocumentVersion.mock.calls[1]?.[0].path;
|
|
@@ -2301,7 +2607,7 @@ describe('Document lifecycle service', () => {
|
|
|
2301
2607
|
});
|
|
2302
2608
|
let thrown;
|
|
2303
2609
|
try {
|
|
2304
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2610
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2305
2611
|
}
|
|
2306
2612
|
catch (err) {
|
|
2307
2613
|
thrown = err;
|
|
@@ -2324,7 +2630,7 @@ describe('Document lifecycle service', () => {
|
|
|
2324
2630
|
mocks.getDocumentById.mockResolvedValue(null);
|
|
2325
2631
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2326
2632
|
try {
|
|
2327
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-missing' });
|
|
2633
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-missing' });
|
|
2328
2634
|
expect.fail('expected ERR_NOT_FOUND');
|
|
2329
2635
|
}
|
|
2330
2636
|
catch (err) {
|
|
@@ -2345,7 +2651,7 @@ describe('Document lifecycle service', () => {
|
|
|
2345
2651
|
const mocks = createMockDb();
|
|
2346
2652
|
setupSource(mocks, { sourceDocumentId: 'doc-source' });
|
|
2347
2653
|
const ctx = buildCtx(mocks.db, withHooks);
|
|
2348
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2654
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2349
2655
|
expect(beforeCreate).toHaveBeenCalledOnce();
|
|
2350
2656
|
const beforeCtx = beforeCreate.mock.calls[0]?.[0];
|
|
2351
2657
|
expect(beforeCtx.duplicate).toEqual({ sourceDocumentId: 'doc-source' });
|
|
@@ -2371,7 +2677,10 @@ describe('Document lifecycle service', () => {
|
|
|
2371
2677
|
};
|
|
2372
2678
|
const mocks = createMockDb();
|
|
2373
2679
|
setupSource(mocks);
|
|
2374
|
-
await expect(duplicateDocument(buildCtx(mocks.db, withHooks), {
|
|
2680
|
+
await expect(duplicateDocument(buildCtx(mocks.db, withHooks), {
|
|
2681
|
+
expectedRevision: 1,
|
|
2682
|
+
sourceDocumentId: 'doc-source',
|
|
2683
|
+
})).rejects.toMatchObject({
|
|
2375
2684
|
code: ErrorCodes.DOCUMENT_HOOK_COMMITTED,
|
|
2376
2685
|
details: {
|
|
2377
2686
|
phase: 'afterCreate',
|
|
@@ -2393,7 +2702,7 @@ describe('Document lifecycle service', () => {
|
|
|
2393
2702
|
});
|
|
2394
2703
|
ctx.requestContext = createRequestContext({ actor });
|
|
2395
2704
|
try {
|
|
2396
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2705
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2397
2706
|
expect.fail('expected ERR_FORBIDDEN');
|
|
2398
2707
|
}
|
|
2399
2708
|
catch (err) {
|
|
@@ -2408,7 +2717,7 @@ describe('Document lifecycle service', () => {
|
|
|
2408
2717
|
const ctx = buildCtx(mocks.db, localizedCollection);
|
|
2409
2718
|
ctx.requestContext = undefined;
|
|
2410
2719
|
try {
|
|
2411
|
-
await duplicateDocument(ctx, { sourceDocumentId: 'doc-source' });
|
|
2720
|
+
await duplicateDocument(ctx, { expectedRevision: 1, sourceDocumentId: 'doc-source' });
|
|
2412
2721
|
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
2413
2722
|
}
|
|
2414
2723
|
catch (err) {
|
|
@@ -2487,6 +2796,7 @@ describe('Document lifecycle service', () => {
|
|
|
2487
2796
|
const { mocks, currentVersionId } = setupSourceTarget();
|
|
2488
2797
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2489
2798
|
const result = await copyToLocale(ctx, {
|
|
2799
|
+
expectedRevision: 1,
|
|
2490
2800
|
documentId: 'doc-1',
|
|
2491
2801
|
sourceLocale: 'en',
|
|
2492
2802
|
targetLocale: 'fr',
|
|
@@ -2526,6 +2836,7 @@ describe('Document lifecycle service', () => {
|
|
|
2526
2836
|
});
|
|
2527
2837
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2528
2838
|
const result = await copyToLocale(ctx, {
|
|
2839
|
+
expectedRevision: 1,
|
|
2529
2840
|
documentId: 'doc-1',
|
|
2530
2841
|
sourceLocale: 'en',
|
|
2531
2842
|
targetLocale: 'fr',
|
|
@@ -2557,6 +2868,7 @@ describe('Document lifecycle service', () => {
|
|
|
2557
2868
|
});
|
|
2558
2869
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2559
2870
|
await copyToLocale(ctx, {
|
|
2871
|
+
expectedRevision: 1,
|
|
2560
2872
|
documentId: 'doc-1',
|
|
2561
2873
|
sourceLocale: 'en',
|
|
2562
2874
|
targetLocale: 'fr',
|
|
@@ -2573,6 +2885,7 @@ describe('Document lifecycle service', () => {
|
|
|
2573
2885
|
const { mocks } = setupSourceTarget();
|
|
2574
2886
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2575
2887
|
await copyToLocale(ctx, {
|
|
2888
|
+
expectedRevision: 1,
|
|
2576
2889
|
documentId: 'doc-1',
|
|
2577
2890
|
sourceLocale: 'en',
|
|
2578
2891
|
targetLocale: 'fr',
|
|
@@ -2585,6 +2898,7 @@ describe('Document lifecycle service', () => {
|
|
|
2585
2898
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2586
2899
|
try {
|
|
2587
2900
|
await copyToLocale(ctx, {
|
|
2901
|
+
expectedRevision: 1,
|
|
2588
2902
|
documentId: 'doc-1',
|
|
2589
2903
|
sourceLocale: 'en',
|
|
2590
2904
|
targetLocale: 'en',
|
|
@@ -2603,6 +2917,7 @@ describe('Document lifecycle service', () => {
|
|
|
2603
2917
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2604
2918
|
try {
|
|
2605
2919
|
await copyToLocale(ctx, {
|
|
2920
|
+
expectedRevision: 1,
|
|
2606
2921
|
documentId: 'doc-1',
|
|
2607
2922
|
sourceLocale: 'en',
|
|
2608
2923
|
targetLocale: 'fr',
|
|
@@ -2633,6 +2948,7 @@ describe('Document lifecycle service', () => {
|
|
|
2633
2948
|
const ctx = buildCtx(mocks.db, mixedCollection);
|
|
2634
2949
|
try {
|
|
2635
2950
|
await copyToLocale(ctx, {
|
|
2951
|
+
expectedRevision: 1,
|
|
2636
2952
|
documentId: 'doc-1',
|
|
2637
2953
|
sourceLocale: 'en',
|
|
2638
2954
|
targetLocale: 'fr',
|
|
@@ -2655,6 +2971,7 @@ describe('Document lifecycle service', () => {
|
|
|
2655
2971
|
const { mocks } = setupSourceTarget();
|
|
2656
2972
|
const ctx = buildCtx(mocks.db, withHooks);
|
|
2657
2973
|
await copyToLocale(ctx, {
|
|
2974
|
+
expectedRevision: 1,
|
|
2658
2975
|
documentId: 'doc-1',
|
|
2659
2976
|
sourceLocale: 'en',
|
|
2660
2977
|
targetLocale: 'fr',
|
|
@@ -2680,6 +2997,7 @@ describe('Document lifecycle service', () => {
|
|
|
2680
2997
|
};
|
|
2681
2998
|
const { mocks } = setupSourceTarget();
|
|
2682
2999
|
await expect(copyToLocale(buildCtx(mocks.db, withHooks), {
|
|
3000
|
+
expectedRevision: 1,
|
|
2683
3001
|
documentId: 'doc-1',
|
|
2684
3002
|
sourceLocale: 'en',
|
|
2685
3003
|
targetLocale: 'fr',
|
|
@@ -2705,6 +3023,7 @@ describe('Document lifecycle service', () => {
|
|
|
2705
3023
|
ctx.requestContext = createRequestContext({ actor });
|
|
2706
3024
|
try {
|
|
2707
3025
|
await copyToLocale(ctx, {
|
|
3026
|
+
expectedRevision: 1,
|
|
2708
3027
|
documentId: 'doc-1',
|
|
2709
3028
|
sourceLocale: 'en',
|
|
2710
3029
|
targetLocale: 'fr',
|
|
@@ -2724,6 +3043,7 @@ describe('Document lifecycle service', () => {
|
|
|
2724
3043
|
ctx.requestContext = undefined;
|
|
2725
3044
|
try {
|
|
2726
3045
|
await copyToLocale(ctx, {
|
|
3046
|
+
expectedRevision: 1,
|
|
2727
3047
|
documentId: 'doc-1',
|
|
2728
3048
|
sourceLocale: 'en',
|
|
2729
3049
|
targetLocale: 'fr',
|
|
@@ -2797,7 +3117,11 @@ describe('Document lifecycle service', () => {
|
|
|
2797
3117
|
});
|
|
2798
3118
|
ctx.requestContext = createRequestContext({ actor });
|
|
2799
3119
|
try {
|
|
2800
|
-
await changeDocumentStatus(ctx, {
|
|
3120
|
+
await changeDocumentStatus(ctx, {
|
|
3121
|
+
expectedRevision: 1,
|
|
3122
|
+
documentId: 'doc-1',
|
|
3123
|
+
nextStatus: 'published',
|
|
3124
|
+
});
|
|
2801
3125
|
expect.fail('expected ERR_FORBIDDEN');
|
|
2802
3126
|
}
|
|
2803
3127
|
catch (err) {
|
|
@@ -2833,7 +3157,11 @@ describe('Document lifecycle service', () => {
|
|
|
2833
3157
|
abilities: ['collections.articles.changeStatus'],
|
|
2834
3158
|
});
|
|
2835
3159
|
ctx.requestContext = createRequestContext({ actor });
|
|
2836
|
-
await changeDocumentStatus(ctx, {
|
|
3160
|
+
await changeDocumentStatus(ctx, {
|
|
3161
|
+
expectedRevision: 1,
|
|
3162
|
+
documentId: 'doc-1',
|
|
3163
|
+
nextStatus: 'in_review',
|
|
3164
|
+
});
|
|
2837
3165
|
expect(setDocumentStatus).toHaveBeenCalledOnce();
|
|
2838
3166
|
});
|
|
2839
3167
|
it('super-admin bypasses every check', async () => {
|
|
@@ -2858,7 +3186,7 @@ describe('Document lifecycle service', () => {
|
|
|
2858
3186
|
});
|
|
2859
3187
|
ctx.requestContext = createRequestContext({ actor });
|
|
2860
3188
|
try {
|
|
2861
|
-
await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
3189
|
+
await deleteDocument(ctx, { expectedRevision: 1, documentId: 'doc-1' });
|
|
2862
3190
|
expect.fail('expected ERR_FORBIDDEN');
|
|
2863
3191
|
}
|
|
2864
3192
|
catch (err) {
|