@byline/core 4.14.1 → 4.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/@types/db-types.d.ts +222 -0
- package/dist/@types/site-config.d.ts +24 -0
- package/dist/codegen/index.test.node.js +1 -1
- package/dist/core.d.ts +8 -0
- package/dist/core.js +39 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +8 -11
- package/dist/scheduler/define-recurring-task.d.ts +19 -0
- package/dist/scheduler/define-recurring-task.js +20 -0
- package/dist/scheduler/index.d.ts +19 -0
- package/dist/scheduler/index.js +18 -0
- package/dist/scheduler/run-due-tasks.d.ts +39 -0
- package/dist/scheduler/run-due-tasks.js +324 -0
- package/dist/scheduler/run-due-tasks.test.node.d.ts +8 -0
- package/dist/scheduler/run-due-tasks.test.node.js +396 -0
- package/dist/scheduler/scheduled-publication-constants.d.ts +11 -0
- package/dist/scheduler/scheduled-publication-constants.js +11 -0
- package/dist/scheduler/scheduled-publication.d.ts +31 -0
- package/dist/scheduler/scheduled-publication.js +198 -0
- package/dist/scheduler/scheduled-publication.test.node.d.ts +8 -0
- package/dist/scheduler/scheduled-publication.test.node.js +109 -0
- package/dist/scheduler/scheduler-boot.test.node.d.ts +8 -0
- package/dist/scheduler/scheduler-boot.test.node.js +103 -0
- package/dist/scheduler/ticker.d.ts +34 -0
- package/dist/scheduler/ticker.js +144 -0
- package/dist/scheduler/ticker.test.node.d.ts +8 -0
- package/dist/scheduler/ticker.test.node.js +249 -0
- package/dist/scheduler/types.d.ts +241 -0
- package/dist/scheduler/types.js +8 -0
- package/dist/scheduler/validate-scheduler-config.d.ts +19 -0
- package/dist/scheduler/validate-scheduler-config.js +25 -0
- package/dist/scheduler/validate-scheduler-config.test.node.d.ts +8 -0
- package/dist/scheduler/validate-scheduler-config.test.node.js +38 -0
- package/dist/scheduler/validate-tasks.d.ts +14 -0
- package/dist/scheduler/validate-tasks.js +40 -0
- package/dist/scheduler/validate-tasks.test.node.d.ts +8 -0
- package/dist/scheduler/validate-tasks.test.node.js +49 -0
- package/dist/services/collection-bootstrap.test.node.js +2 -0
- package/dist/services/discover-counter-groups.test.node.js +2 -0
- package/dist/services/document-lifecycle/audit.d.ts +6 -0
- package/dist/services/document-lifecycle/audit.js +6 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +15 -10
- package/dist/services/document-lifecycle/delete-locale.js +18 -10
- package/dist/services/document-lifecycle/delete.js +8 -0
- package/dist/services/document-lifecycle/index.d.ts +1 -0
- package/dist/services/document-lifecycle/index.js +1 -0
- package/dist/services/document-lifecycle/publish-schedule-consistency.d.ts +36 -0
- package/dist/services/document-lifecycle/publish-schedule-consistency.js +80 -0
- package/dist/services/document-lifecycle/restore.js +15 -10
- package/dist/services/document-lifecycle/scheduled-publish.d.ts +51 -0
- package/dist/services/document-lifecycle/scheduled-publish.js +351 -0
- package/dist/services/document-lifecycle/status-transition.d.ts +42 -0
- package/dist/services/document-lifecycle/status-transition.js +41 -0
- package/dist/services/document-lifecycle/status-transition.test.node.d.ts +8 -0
- package/dist/services/document-lifecycle/status-transition.test.node.js +145 -0
- package/dist/services/document-lifecycle/status.js +30 -23
- package/dist/services/document-lifecycle/tree.test.node.js +3 -0
- package/dist/services/document-lifecycle/update.js +35 -30
- package/dist/services/document-lifecycle.test.node.js +262 -2
- package/dist/services/field-upload.test.node.js +2 -0
- package/dist/services/populate.test.node.js +2 -0
- package/package.json +7 -2
|
@@ -0,0 +1,145 @@
|
|
|
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 { describe, expect, it } from 'vitest';
|
|
9
|
+
import { commitDocumentStatusTransition } from './status-transition.js';
|
|
10
|
+
function createTransitionHarness() {
|
|
11
|
+
const order = [];
|
|
12
|
+
const auditRows = [];
|
|
13
|
+
let status = 'draft';
|
|
14
|
+
let archived = false;
|
|
15
|
+
const db = {
|
|
16
|
+
commands: {
|
|
17
|
+
documents: {
|
|
18
|
+
setDocumentStatus: async ({ status: nextStatus }) => {
|
|
19
|
+
order.push('status-write');
|
|
20
|
+
status = nextStatus;
|
|
21
|
+
},
|
|
22
|
+
archivePublishedVersions: async () => {
|
|
23
|
+
order.push('auto-archive');
|
|
24
|
+
archived = true;
|
|
25
|
+
return 1;
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
audit: {
|
|
29
|
+
append: async (input) => {
|
|
30
|
+
order.push('audit-append');
|
|
31
|
+
auditRows.push(input);
|
|
32
|
+
return { id: 'audit-1' };
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
withTransaction: async (fn) => {
|
|
37
|
+
const snapshot = {
|
|
38
|
+
status,
|
|
39
|
+
archived,
|
|
40
|
+
auditLength: auditRows.length,
|
|
41
|
+
};
|
|
42
|
+
order.push('transaction-start');
|
|
43
|
+
try {
|
|
44
|
+
const result = await fn();
|
|
45
|
+
order.push('transaction-commit');
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
status = snapshot.status;
|
|
50
|
+
archived = snapshot.archived;
|
|
51
|
+
auditRows.length = snapshot.auditLength;
|
|
52
|
+
order.push('transaction-rollback');
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
db,
|
|
59
|
+
order,
|
|
60
|
+
auditRows,
|
|
61
|
+
get status() {
|
|
62
|
+
return status;
|
|
63
|
+
},
|
|
64
|
+
get archived() {
|
|
65
|
+
return archived;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function transitionParams(db) {
|
|
70
|
+
return {
|
|
71
|
+
db,
|
|
72
|
+
documentId: 'doc-1',
|
|
73
|
+
documentVersionId: 'ver-1',
|
|
74
|
+
collectionId: 'col-1',
|
|
75
|
+
previousStatus: 'draft',
|
|
76
|
+
nextStatus: 'published',
|
|
77
|
+
actor: {
|
|
78
|
+
actorId: '01901234-0000-7000-8000-000000000001',
|
|
79
|
+
actorRealm: 'admin',
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
describe('commitDocumentStatusTransition', () => {
|
|
84
|
+
it('runs contributions inside the transaction at the specified boundaries', async () => {
|
|
85
|
+
const harness = createTransitionHarness();
|
|
86
|
+
await commitDocumentStatusTransition({
|
|
87
|
+
...transitionParams(harness.db),
|
|
88
|
+
contributions: {
|
|
89
|
+
beforeStatusWrite: () => {
|
|
90
|
+
harness.order.push('before-contribution');
|
|
91
|
+
},
|
|
92
|
+
afterAuditAppend: () => {
|
|
93
|
+
harness.order.push('after-contribution');
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
expect(harness.order).toEqual([
|
|
98
|
+
'transaction-start',
|
|
99
|
+
'before-contribution',
|
|
100
|
+
'status-write',
|
|
101
|
+
'auto-archive',
|
|
102
|
+
'audit-append',
|
|
103
|
+
'after-contribution',
|
|
104
|
+
'transaction-commit',
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
it('aborts before any mutation or audit when the before contribution throws', async () => {
|
|
108
|
+
const harness = createTransitionHarness();
|
|
109
|
+
const error = new Error('guard rejected');
|
|
110
|
+
await expect(commitDocumentStatusTransition({
|
|
111
|
+
...transitionParams(harness.db),
|
|
112
|
+
contributions: {
|
|
113
|
+
beforeStatusWrite: () => {
|
|
114
|
+
throw error;
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
})).rejects.toBe(error);
|
|
118
|
+
expect(harness.status).toBe('draft');
|
|
119
|
+
expect(harness.archived).toBe(false);
|
|
120
|
+
expect(harness.auditRows).toEqual([]);
|
|
121
|
+
expect(harness.order).toEqual(['transaction-start', 'transaction-rollback']);
|
|
122
|
+
});
|
|
123
|
+
it('propagates an after-contribution failure so the transaction rolls back', async () => {
|
|
124
|
+
const harness = createTransitionHarness();
|
|
125
|
+
const error = new Error('schedule deletion failed');
|
|
126
|
+
await expect(commitDocumentStatusTransition({
|
|
127
|
+
...transitionParams(harness.db),
|
|
128
|
+
contributions: {
|
|
129
|
+
afterAuditAppend: () => {
|
|
130
|
+
throw error;
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
})).rejects.toBe(error);
|
|
134
|
+
expect(harness.status).toBe('draft');
|
|
135
|
+
expect(harness.archived).toBe(false);
|
|
136
|
+
expect(harness.auditRows).toEqual([]);
|
|
137
|
+
expect(harness.order).toEqual([
|
|
138
|
+
'transaction-start',
|
|
139
|
+
'status-write',
|
|
140
|
+
'auto-archive',
|
|
141
|
+
'audit-append',
|
|
142
|
+
'transaction-rollback',
|
|
143
|
+
]);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -12,6 +12,8 @@ import { withLogContext } from '../../lib/logger.js';
|
|
|
12
12
|
import { getWorkflow, validateStatusTransition } from '../../workflow/workflow.js';
|
|
13
13
|
import { AUDIT_ACTIONS, auditActor, requireAuditCapability } from './audit.js';
|
|
14
14
|
import { invokeHook } from './internals.js';
|
|
15
|
+
import { appendPublishScheduleCancellationAudit, cancelPublishScheduleInTransaction, } from './publish-schedule-consistency.js';
|
|
16
|
+
import { commitDocumentStatusTransition } from './status-transition.js';
|
|
15
17
|
/**
|
|
16
18
|
* Change a document's workflow status.
|
|
17
19
|
*
|
|
@@ -90,29 +92,27 @@ export async function changeDocumentStatus(ctx, params) {
|
|
|
90
92
|
// record. Status mutates the version row rather than minting a new
|
|
91
93
|
// version, so the version stream never captures *who* changed it —
|
|
92
94
|
// the audit log is its only accountability home (docs/07-auth-and-security/02-auditability.md).
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
await
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
after: params.nextStatus,
|
|
115
|
-
});
|
|
95
|
+
const transitionAudit = requireAuditCapability(db);
|
|
96
|
+
let cancelledSchedule = null;
|
|
97
|
+
await commitDocumentStatusTransition({
|
|
98
|
+
db,
|
|
99
|
+
documentId: params.documentId,
|
|
100
|
+
documentVersionId,
|
|
101
|
+
collectionId,
|
|
102
|
+
previousStatus: currentStatus,
|
|
103
|
+
nextStatus: params.nextStatus,
|
|
104
|
+
actor: auditActor(ctx),
|
|
105
|
+
contributions: {
|
|
106
|
+
beforeStatusWrite: async () => {
|
|
107
|
+
cancelledSchedule = await cancelPublishScheduleInTransaction(ctx, params.documentId);
|
|
108
|
+
},
|
|
109
|
+
afterAuditAppend: () => appendPublishScheduleCancellationAudit({
|
|
110
|
+
ctx,
|
|
111
|
+
audit: transitionAudit,
|
|
112
|
+
schedule: cancelledSchedule,
|
|
113
|
+
reason: 'status_changed',
|
|
114
|
+
}),
|
|
115
|
+
},
|
|
116
116
|
});
|
|
117
117
|
// 6. afterStatusChange hook.
|
|
118
118
|
await invokeHook(hooks?.afterStatusChange, hookCtx);
|
|
@@ -156,6 +156,7 @@ export async function unpublishDocument(ctx, params) {
|
|
|
156
156
|
const audit = requireAuditCapability(db);
|
|
157
157
|
const actor = auditActor(ctx);
|
|
158
158
|
const archivedCount = await audit.withTransaction(async () => {
|
|
159
|
+
const cancelledSchedule = await cancelPublishScheduleInTransaction(ctx, params.documentId);
|
|
159
160
|
const count = await db.commands.documents.archivePublishedVersions({
|
|
160
161
|
document_id: params.documentId,
|
|
161
162
|
});
|
|
@@ -171,6 +172,12 @@ export async function unpublishDocument(ctx, params) {
|
|
|
171
172
|
after: 'archived',
|
|
172
173
|
});
|
|
173
174
|
}
|
|
175
|
+
await appendPublishScheduleCancellationAudit({
|
|
176
|
+
ctx,
|
|
177
|
+
audit,
|
|
178
|
+
schedule: cancelledSchedule,
|
|
179
|
+
reason: 'unpublished',
|
|
180
|
+
});
|
|
174
181
|
return count;
|
|
175
182
|
});
|
|
176
183
|
await invokeHook(hooks?.afterUnpublish, {
|
|
@@ -213,6 +213,9 @@ function createHarness(options = {}) {
|
|
|
213
213
|
const db = {
|
|
214
214
|
commands: {
|
|
215
215
|
documents: {
|
|
216
|
+
publishSchedules: {
|
|
217
|
+
cancel: vi.fn(async () => null),
|
|
218
|
+
},
|
|
216
219
|
placeTreeNode: place,
|
|
217
220
|
removeFromTree: remove,
|
|
218
221
|
promoteChildrenAndRemoveFromTree: promote,
|
|
@@ -15,6 +15,7 @@ import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
|
15
15
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
16
|
import { normalizeNumericFields } from '../normalize-numeric-fields.js';
|
|
17
17
|
import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
|
|
18
|
+
import { commitContentVersionWithScheduleSuspension } from './publish-schedule-consistency.js';
|
|
18
19
|
import { selfHealTreePlacement } from './tree.js';
|
|
19
20
|
/**
|
|
20
21
|
* Update a document via full replacement (PUT semantics).
|
|
@@ -76,22 +77,24 @@ export async function updateDocument(ctx, params) {
|
|
|
76
77
|
logger: ctx.logger,
|
|
77
78
|
});
|
|
78
79
|
await applyRichTextEmbed(ctx, data);
|
|
79
|
-
const result = await
|
|
80
|
-
|
|
80
|
+
const result = await commitContentVersionWithScheduleSuspension({
|
|
81
|
+
ctx,
|
|
81
82
|
documentId: params.documentId,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
83
|
+
write: () => db.commands.documents.createDocumentVersion({
|
|
84
|
+
documentId: params.documentId,
|
|
85
|
+
collectionId,
|
|
86
|
+
collectionVersion: ctx.collectionVersion,
|
|
87
|
+
collectionConfig: definition,
|
|
88
|
+
action: 'update',
|
|
89
|
+
documentData: data,
|
|
90
|
+
path: pathForCommand,
|
|
91
|
+
availableLocales: params.availableLocales,
|
|
92
|
+
status: defaultStatus,
|
|
93
|
+
locale: requestLocale,
|
|
94
|
+
previousVersionId: originalData.document_version_id,
|
|
95
|
+
createdBy: actorId(ctx),
|
|
96
|
+
}),
|
|
97
|
+
}).catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
|
|
95
98
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
96
99
|
const documentVersionId = extractVersionId(result.document);
|
|
97
100
|
// Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
|
|
@@ -195,22 +198,24 @@ export async function updateDocumentWithPatches(ctx, params) {
|
|
|
195
198
|
logger: ctx.logger,
|
|
196
199
|
});
|
|
197
200
|
await applyRichTextEmbed(ctx, nextData);
|
|
198
|
-
const result = await
|
|
199
|
-
|
|
201
|
+
const result = await commitContentVersionWithScheduleSuspension({
|
|
202
|
+
ctx,
|
|
200
203
|
documentId: params.documentId,
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
204
|
+
write: () => db.commands.documents.createDocumentVersion({
|
|
205
|
+
documentId: params.documentId,
|
|
206
|
+
collectionId,
|
|
207
|
+
collectionVersion: ctx.collectionVersion,
|
|
208
|
+
collectionConfig: definition,
|
|
209
|
+
action: 'update',
|
|
210
|
+
documentData: nextData,
|
|
211
|
+
path: pathForCommand,
|
|
212
|
+
availableLocales: params.availableLocales,
|
|
213
|
+
status: defaultStatus,
|
|
214
|
+
locale: requestLocale,
|
|
215
|
+
previousVersionId: originalData.document_version_id,
|
|
216
|
+
createdBy: actorId(ctx),
|
|
217
|
+
}),
|
|
218
|
+
}).catch((err) => rethrowPathConflict(db, err, pathForCommand ?? '', sourceLocale, 'update'));
|
|
214
219
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
215
220
|
const documentVersionId = extractVersionId(result.document);
|
|
216
221
|
// Self-heal: re-root a genuinely-unplaced doc in a tree collection so any
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { AdminAuth, AuthError, AuthErrorCodes, createRequestContext, createSuperAdminContext, } from '@byline/auth';
|
|
9
9
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
10
|
import { BylineError, DbErrorCodes, ErrorCodes } from '../lib/errors.js';
|
|
11
|
-
import { changeDocumentStatus, copyToLocale, createDocument, deleteDocument, duplicateDocument, restoreDocumentVersion, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
|
|
11
|
+
import { cancelDocumentScheduledPublish, changeDocumentStatus, confirmDocumentScheduledPublish, copyToLocale, createDocument, deleteDocument, deleteLocale, duplicateDocument, listDocumentPublishSchedules, restoreDocumentVersion, scheduleDocumentPublish, unpublishDocument, updateDocument, updateDocumentSystemFields, updateDocumentWithPatches, } from './document-lifecycle/index.js';
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
// Fixtures / Helpers
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
@@ -32,6 +32,30 @@ const numericCollection = {
|
|
|
32
32
|
{ name: 'price', type: 'decimal' },
|
|
33
33
|
],
|
|
34
34
|
};
|
|
35
|
+
function publishScheduleRow(overrides = {}) {
|
|
36
|
+
const now = new Date('2026-08-22T12:00:00.000Z');
|
|
37
|
+
return {
|
|
38
|
+
documentId: 'doc-1',
|
|
39
|
+
collectionId: 'col-1',
|
|
40
|
+
targetVersionId: 'ver-1',
|
|
41
|
+
publishAt: new Date('2026-08-23T12:00:00.000Z'),
|
|
42
|
+
state: 'armed',
|
|
43
|
+
suspendedAt: null,
|
|
44
|
+
suspendedReason: null,
|
|
45
|
+
scheduledBy: TEST_ACTOR_ID,
|
|
46
|
+
lastAuthorizedBy: TEST_ACTOR_ID,
|
|
47
|
+
lastAuthorizedAt: now,
|
|
48
|
+
scheduledAt: now,
|
|
49
|
+
updatedAt: now,
|
|
50
|
+
executionToken: null,
|
|
51
|
+
executionExpiresAt: null,
|
|
52
|
+
lastAttemptAt: null,
|
|
53
|
+
nextAttemptAt: new Date('2026-08-23T12:00:00.000Z'),
|
|
54
|
+
attemptCount: 0,
|
|
55
|
+
lastError: null,
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
35
59
|
/** Build a mock IDbAdapter. Returns the adapter plus individual mock fns. */
|
|
36
60
|
function createMockDb() {
|
|
37
61
|
const createDocumentVersion = vi.fn().mockResolvedValue({
|
|
@@ -41,10 +65,22 @@ function createMockDb() {
|
|
|
41
65
|
const setDocumentStatus = vi.fn().mockResolvedValue(undefined);
|
|
42
66
|
const archivePublishedVersions = vi.fn().mockResolvedValue(0);
|
|
43
67
|
const softDeleteDocument = vi.fn().mockResolvedValue(1);
|
|
68
|
+
const deleteDocumentLocale = vi.fn().mockResolvedValue({ newVersionId: 'ver-2' });
|
|
44
69
|
const getDocumentById = vi.fn().mockResolvedValue(null);
|
|
45
70
|
const getDocumentSystemFieldsForUpdate = vi.fn().mockResolvedValue(null);
|
|
46
71
|
const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
|
|
47
72
|
const getCurrentPath = vi.fn().mockResolvedValue('current-path');
|
|
73
|
+
const publishSchedule = vi.fn().mockResolvedValue({ status: 'document_not_found' });
|
|
74
|
+
const confirmPublishSchedule = vi.fn().mockResolvedValue({ status: 'schedule_not_found' });
|
|
75
|
+
const cancelPublishSchedule = vi.fn().mockResolvedValue(null);
|
|
76
|
+
const suspendPublishSchedule = vi.fn().mockResolvedValue({ status: 'schedule_not_found' });
|
|
77
|
+
const claimDuePublishSchedules = vi.fn().mockResolvedValue([]);
|
|
78
|
+
const lockPublishScheduleClaim = vi.fn().mockResolvedValue(null);
|
|
79
|
+
const deletePublishScheduleClaim = vi.fn().mockResolvedValue(false);
|
|
80
|
+
const suspendPublishScheduleClaim = vi.fn().mockResolvedValue(false);
|
|
81
|
+
const releasePublishScheduleClaim = vi.fn().mockResolvedValue(false);
|
|
82
|
+
const getPublishSchedule = vi.fn().mockResolvedValue(null);
|
|
83
|
+
const listPublishSchedules = vi.fn().mockResolvedValue({ schedules: [], total: 0 });
|
|
48
84
|
// Audit capability (docs/07-auth-and-security/02-auditability.md — W2). `withTransaction` is a passthrough
|
|
49
85
|
// in unit tests (runs the unit of work immediately, no real tx); `append`
|
|
50
86
|
// records the calls so write-point tests can assert the audit rows emitted.
|
|
@@ -71,6 +107,17 @@ function createMockDb() {
|
|
|
71
107
|
delete: vi.fn(),
|
|
72
108
|
},
|
|
73
109
|
documents: {
|
|
110
|
+
publishSchedules: {
|
|
111
|
+
schedule: publishSchedule,
|
|
112
|
+
confirm: confirmPublishSchedule,
|
|
113
|
+
cancel: cancelPublishSchedule,
|
|
114
|
+
suspendForContentEdit: suspendPublishSchedule,
|
|
115
|
+
claimDue: claimDuePublishSchedules,
|
|
116
|
+
lockClaim: lockPublishScheduleClaim,
|
|
117
|
+
deleteClaim: deletePublishScheduleClaim,
|
|
118
|
+
suspendClaimForContentEdit: suspendPublishScheduleClaim,
|
|
119
|
+
releaseClaim: releasePublishScheduleClaim,
|
|
120
|
+
},
|
|
74
121
|
createDocumentVersion,
|
|
75
122
|
updateDocumentPath: vi.fn().mockResolvedValue(undefined),
|
|
76
123
|
setDocumentAvailableLocales: vi.fn().mockResolvedValue(undefined),
|
|
@@ -78,7 +125,7 @@ function createMockDb() {
|
|
|
78
125
|
archivePublishedVersions,
|
|
79
126
|
softDeleteDocument,
|
|
80
127
|
restoreSoftDeletedDocument: vi.fn(),
|
|
81
|
-
deleteDocumentLocale:
|
|
128
|
+
deleteDocumentLocale: deleteDocumentLocale,
|
|
82
129
|
setOrderKey: vi.fn(),
|
|
83
130
|
placeTreeNode: vi.fn(),
|
|
84
131
|
removeFromTree: vi.fn(),
|
|
@@ -108,6 +155,10 @@ function createMockDb() {
|
|
|
108
155
|
getCollectionById: vi.fn(),
|
|
109
156
|
},
|
|
110
157
|
documents: {
|
|
158
|
+
publishSchedules: {
|
|
159
|
+
get: getPublishSchedule,
|
|
160
|
+
list: listPublishSchedules,
|
|
161
|
+
},
|
|
111
162
|
getDocumentSystemFieldsForUpdate,
|
|
112
163
|
getDocumentById,
|
|
113
164
|
getCurrentVersionMetadata,
|
|
@@ -147,10 +198,22 @@ function createMockDb() {
|
|
|
147
198
|
setDocumentStatus,
|
|
148
199
|
archivePublishedVersions,
|
|
149
200
|
softDeleteDocument,
|
|
201
|
+
deleteDocumentLocale,
|
|
150
202
|
getDocumentById,
|
|
151
203
|
getDocumentSystemFieldsForUpdate,
|
|
152
204
|
getCurrentVersionMetadata,
|
|
153
205
|
getCurrentPath,
|
|
206
|
+
publishSchedule,
|
|
207
|
+
confirmPublishSchedule,
|
|
208
|
+
cancelPublishSchedule,
|
|
209
|
+
suspendPublishSchedule,
|
|
210
|
+
claimDuePublishSchedules,
|
|
211
|
+
lockPublishScheduleClaim,
|
|
212
|
+
deletePublishScheduleClaim,
|
|
213
|
+
suspendPublishScheduleClaim,
|
|
214
|
+
releasePublishScheduleClaim,
|
|
215
|
+
getPublishSchedule,
|
|
216
|
+
listPublishSchedules,
|
|
154
217
|
auditAppend,
|
|
155
218
|
withTransaction,
|
|
156
219
|
};
|
|
@@ -825,6 +888,203 @@ describe('Document lifecycle service', () => {
|
|
|
825
888
|
});
|
|
826
889
|
});
|
|
827
890
|
// -----------------------------------------------------------------------
|
|
891
|
+
// scheduled publication lifecycle
|
|
892
|
+
// -----------------------------------------------------------------------
|
|
893
|
+
describe('scheduled publication lifecycle', () => {
|
|
894
|
+
const metadataRow = {
|
|
895
|
+
document_version_id: 'ver-1',
|
|
896
|
+
document_id: 'doc-1',
|
|
897
|
+
collection_id: 'col-1',
|
|
898
|
+
status: 'draft',
|
|
899
|
+
created_at: new Date(),
|
|
900
|
+
updated_at: new Date(),
|
|
901
|
+
};
|
|
902
|
+
it('requires both workflow abilities and records an atomic schedule audit', async () => {
|
|
903
|
+
const { db, getCurrentVersionMetadata, publishSchedule, auditAppend } = createMockDb();
|
|
904
|
+
const schedule = publishScheduleRow();
|
|
905
|
+
getCurrentVersionMetadata.mockResolvedValue(metadataRow);
|
|
906
|
+
publishSchedule.mockResolvedValue({ status: 'scheduled', schedule, previous: null });
|
|
907
|
+
const ctx = buildCtx(db);
|
|
908
|
+
await expect(scheduleDocumentPublish(ctx, {
|
|
909
|
+
documentId: 'doc-1',
|
|
910
|
+
expectedVersionId: 'ver-1',
|
|
911
|
+
publishAt: schedule.publishAt.toISOString(),
|
|
912
|
+
})).resolves.toEqual(schedule);
|
|
913
|
+
expect(publishSchedule).toHaveBeenCalledWith(expect.objectContaining({
|
|
914
|
+
documentId: 'doc-1',
|
|
915
|
+
collectionId: 'col-1',
|
|
916
|
+
expectedVersionId: 'ver-1',
|
|
917
|
+
actorId: TEST_ACTOR_ID,
|
|
918
|
+
publishAt: schedule.publishAt,
|
|
919
|
+
}));
|
|
920
|
+
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.scheduled' }));
|
|
921
|
+
const restricted = buildCtx(db);
|
|
922
|
+
restricted.requestContext = createRequestContext({
|
|
923
|
+
actor: new AdminAuth({
|
|
924
|
+
id: TEST_ACTOR_ID,
|
|
925
|
+
abilities: ['collections.articles.changeStatus'],
|
|
926
|
+
}),
|
|
927
|
+
});
|
|
928
|
+
await expect(scheduleDocumentPublish(restricted, {
|
|
929
|
+
documentId: 'doc-1',
|
|
930
|
+
expectedVersionId: 'ver-1',
|
|
931
|
+
publishAt: schedule.publishAt.toISOString(),
|
|
932
|
+
})).rejects.toMatchObject({ code: AuthErrorCodes.FORBIDDEN });
|
|
933
|
+
});
|
|
934
|
+
it('maps a database-time past instant and a stale version to domain errors', async () => {
|
|
935
|
+
const { db, getCurrentVersionMetadata, publishSchedule } = createMockDb();
|
|
936
|
+
getCurrentVersionMetadata.mockResolvedValue(metadataRow);
|
|
937
|
+
publishSchedule.mockResolvedValue({ status: 'publish_at_not_future' });
|
|
938
|
+
await expect(scheduleDocumentPublish(buildCtx(db), {
|
|
939
|
+
documentId: 'doc-1',
|
|
940
|
+
expectedVersionId: 'ver-1',
|
|
941
|
+
publishAt: new Date(Date.now() + 60_000).toISOString(),
|
|
942
|
+
})).rejects.toMatchObject({ code: ErrorCodes.VALIDATION });
|
|
943
|
+
getCurrentVersionMetadata.mockResolvedValue({
|
|
944
|
+
...metadataRow,
|
|
945
|
+
document_version_id: 'ver-2',
|
|
946
|
+
});
|
|
947
|
+
await expect(scheduleDocumentPublish(buildCtx(db), {
|
|
948
|
+
documentId: 'doc-1',
|
|
949
|
+
expectedVersionId: 'ver-1',
|
|
950
|
+
publishAt: new Date(Date.now() + 60_000).toISOString(),
|
|
951
|
+
})).rejects.toMatchObject({ code: ErrorCodes.CONFLICT });
|
|
952
|
+
});
|
|
953
|
+
it('rejects date strings that are valid to JavaScript but are not ISO instants', async () => {
|
|
954
|
+
const { db, getCurrentVersionMetadata, publishSchedule } = createMockDb();
|
|
955
|
+
getCurrentVersionMetadata.mockResolvedValue(metadataRow);
|
|
956
|
+
await expect(scheduleDocumentPublish(buildCtx(db), {
|
|
957
|
+
documentId: 'doc-1',
|
|
958
|
+
expectedVersionId: 'ver-1',
|
|
959
|
+
publishAt: 'August 23, 2026 12:00:00 UTC',
|
|
960
|
+
})).rejects.toMatchObject({ code: ErrorCodes.VALIDATION });
|
|
961
|
+
expect(publishSchedule).not.toHaveBeenCalled();
|
|
962
|
+
});
|
|
963
|
+
it('re-confirms and explicitly cancels through audited lifecycle operations', async () => {
|
|
964
|
+
const { db, getCurrentVersionMetadata, confirmPublishSchedule, cancelPublishSchedule, auditAppend, } = createMockDb();
|
|
965
|
+
const suspended = publishScheduleRow({
|
|
966
|
+
state: 'needs_reconfirm',
|
|
967
|
+
suspendedAt: new Date(),
|
|
968
|
+
suspendedReason: 'content_edited',
|
|
969
|
+
});
|
|
970
|
+
const confirmed = publishScheduleRow();
|
|
971
|
+
getCurrentVersionMetadata.mockResolvedValue(metadataRow);
|
|
972
|
+
confirmPublishSchedule.mockResolvedValue({
|
|
973
|
+
status: 'confirmed',
|
|
974
|
+
schedule: confirmed,
|
|
975
|
+
previousTargetVersionId: 'ver-0',
|
|
976
|
+
});
|
|
977
|
+
cancelPublishSchedule.mockResolvedValue(confirmed);
|
|
978
|
+
await expect(confirmDocumentScheduledPublish(buildCtx(db), {
|
|
979
|
+
documentId: 'doc-1',
|
|
980
|
+
expectedVersionId: 'ver-1',
|
|
981
|
+
})).resolves.toEqual(confirmed);
|
|
982
|
+
await expect(cancelDocumentScheduledPublish(buildCtx(db), { documentId: 'doc-1' })).resolves.toEqual(confirmed);
|
|
983
|
+
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.reconfirmed' }));
|
|
984
|
+
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.cancelled' }));
|
|
985
|
+
expect(suspended.state).toBe('needs_reconfirm');
|
|
986
|
+
});
|
|
987
|
+
it('suspends an armed schedule atomically when an update creates a version', async () => {
|
|
988
|
+
const { db, getDocumentById, suspendPublishSchedule, auditAppend } = createMockDb();
|
|
989
|
+
const schedule = publishScheduleRow({
|
|
990
|
+
state: 'needs_reconfirm',
|
|
991
|
+
suspendedAt: new Date(),
|
|
992
|
+
suspendedReason: 'content_edited',
|
|
993
|
+
});
|
|
994
|
+
getDocumentById.mockResolvedValue({
|
|
995
|
+
document_version_id: 'ver-0',
|
|
996
|
+
document_id: 'doc-1',
|
|
997
|
+
path: 'current-path',
|
|
998
|
+
source_locale: 'en',
|
|
999
|
+
fields: { title: 'Before' },
|
|
1000
|
+
});
|
|
1001
|
+
suspendPublishSchedule.mockResolvedValue({ status: 'suspended', schedule });
|
|
1002
|
+
await updateDocument(buildCtx(db), {
|
|
1003
|
+
documentId: 'doc-1',
|
|
1004
|
+
data: { title: 'After' },
|
|
1005
|
+
});
|
|
1006
|
+
expect(suspendPublishSchedule).toHaveBeenCalledWith({
|
|
1007
|
+
documentId: 'doc-1',
|
|
1008
|
+
collectionId: 'col-1',
|
|
1009
|
+
});
|
|
1010
|
+
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.suspended' }));
|
|
1011
|
+
});
|
|
1012
|
+
it('treats deleting a locale as a version-creating edit that suspends the schedule', async () => {
|
|
1013
|
+
const { db, deleteDocumentLocale, getDocumentById, suspendPublishSchedule, auditAppend } = createMockDb();
|
|
1014
|
+
getDocumentById.mockResolvedValue({
|
|
1015
|
+
document_version_id: 'ver-1',
|
|
1016
|
+
document_id: 'doc-1',
|
|
1017
|
+
path: 'current-path',
|
|
1018
|
+
_availableVersionLocales: ['en', 'fr'],
|
|
1019
|
+
fields: { title: 'Bonjour' },
|
|
1020
|
+
});
|
|
1021
|
+
suspendPublishSchedule.mockResolvedValue({
|
|
1022
|
+
status: 'suspended',
|
|
1023
|
+
schedule: publishScheduleRow({
|
|
1024
|
+
state: 'needs_reconfirm',
|
|
1025
|
+
suspendedAt: new Date(),
|
|
1026
|
+
suspendedReason: 'content_edited',
|
|
1027
|
+
}),
|
|
1028
|
+
});
|
|
1029
|
+
await deleteLocale(buildCtx(db), { documentId: 'doc-1', locale: 'fr' });
|
|
1030
|
+
expect(deleteDocumentLocale).toHaveBeenCalledOnce();
|
|
1031
|
+
expect(suspendPublishSchedule).toHaveBeenCalledWith({
|
|
1032
|
+
documentId: 'doc-1',
|
|
1033
|
+
collectionId: 'col-1',
|
|
1034
|
+
});
|
|
1035
|
+
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.suspended' }));
|
|
1036
|
+
});
|
|
1037
|
+
it('cancels an active schedule as an effect of an ordinary status transition', async () => {
|
|
1038
|
+
const { db, getCurrentVersionMetadata, cancelPublishSchedule, auditAppend } = createMockDb();
|
|
1039
|
+
getCurrentVersionMetadata.mockResolvedValue(metadataRow);
|
|
1040
|
+
cancelPublishSchedule.mockResolvedValue(publishScheduleRow());
|
|
1041
|
+
await changeDocumentStatus(buildCtx(db), {
|
|
1042
|
+
documentId: 'doc-1',
|
|
1043
|
+
nextStatus: 'published',
|
|
1044
|
+
});
|
|
1045
|
+
expect(cancelPublishSchedule).toHaveBeenCalledWith({
|
|
1046
|
+
documentId: 'doc-1',
|
|
1047
|
+
collectionId: 'col-1',
|
|
1048
|
+
});
|
|
1049
|
+
expect(auditAppend).toHaveBeenCalledWith(expect.objectContaining({ action: 'document.publish.schedule.cancelled' }));
|
|
1050
|
+
});
|
|
1051
|
+
it('resolves cross-collection visibility above storage into a collection-id allowlist', async () => {
|
|
1052
|
+
const { db, listPublishSchedules } = createMockDb();
|
|
1053
|
+
const secret = {
|
|
1054
|
+
...minimalCollection,
|
|
1055
|
+
path: 'secret',
|
|
1056
|
+
labels: { singular: 'Secret', plural: 'Secrets' },
|
|
1057
|
+
};
|
|
1058
|
+
const core = {
|
|
1059
|
+
collections: [minimalCollection, secret],
|
|
1060
|
+
db,
|
|
1061
|
+
getCollectionRecord: (path) => ({
|
|
1062
|
+
collectionId: path === 'articles' ? 'col-1' : 'col-2',
|
|
1063
|
+
version: 1,
|
|
1064
|
+
schemaHash: 'test',
|
|
1065
|
+
}),
|
|
1066
|
+
};
|
|
1067
|
+
const requestContext = createRequestContext({
|
|
1068
|
+
actor: new AdminAuth({
|
|
1069
|
+
id: TEST_ACTOR_ID,
|
|
1070
|
+
abilities: [
|
|
1071
|
+
'collections.articles.changeStatus',
|
|
1072
|
+
'collections.articles.publish',
|
|
1073
|
+
'collections.secret.changeStatus',
|
|
1074
|
+
],
|
|
1075
|
+
}),
|
|
1076
|
+
});
|
|
1077
|
+
await listDocumentPublishSchedules(core, requestContext, { page: 1, pageSize: 20 });
|
|
1078
|
+
expect(listPublishSchedules).toHaveBeenCalledWith({
|
|
1079
|
+
collectionIds: ['col-1'],
|
|
1080
|
+
states: undefined,
|
|
1081
|
+
lastAuthorizedBy: undefined,
|
|
1082
|
+
page: 1,
|
|
1083
|
+
pageSize: 20,
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
});
|
|
1087
|
+
// -----------------------------------------------------------------------
|
|
828
1088
|
// changeDocumentStatus
|
|
829
1089
|
// -----------------------------------------------------------------------
|
|
830
1090
|
describe('changeDocumentStatus', () => {
|
|
@@ -53,6 +53,7 @@ function createMockDb() {
|
|
|
53
53
|
delete: vi.fn(),
|
|
54
54
|
},
|
|
55
55
|
documents: {
|
|
56
|
+
publishSchedules: {},
|
|
56
57
|
createDocumentVersion,
|
|
57
58
|
updateDocumentPath: vi.fn(),
|
|
58
59
|
setDocumentAvailableLocales: vi.fn(),
|
|
@@ -89,6 +90,7 @@ function createMockDb() {
|
|
|
89
90
|
getCollectionById: vi.fn(),
|
|
90
91
|
},
|
|
91
92
|
documents: {
|
|
93
|
+
publishSchedules: {},
|
|
92
94
|
getDocumentSystemFieldsForUpdate: vi.fn(async () => null),
|
|
93
95
|
getDocumentById: vi.fn(),
|
|
94
96
|
getCurrentVersionMetadata: vi.fn(),
|