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