@byline/core 0.9.3
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/LICENSE +373 -0
- package/README.md +17 -0
- package/dist/@types/admin-types.d.ts +275 -0
- package/dist/@types/admin-types.d.ts.map +1 -0
- package/dist/@types/admin-types.js +18 -0
- package/dist/@types/admin-types.js.map +1 -0
- package/dist/@types/collection-types.d.ts +816 -0
- package/dist/@types/collection-types.d.ts.map +1 -0
- package/dist/@types/collection-types.js +217 -0
- package/dist/@types/collection-types.js.map +1 -0
- package/dist/@types/db-types.d.ts +463 -0
- package/dist/@types/db-types.d.ts.map +1 -0
- package/dist/@types/db-types.js +2 -0
- package/dist/@types/db-types.js.map +1 -0
- package/dist/@types/field-data-types.d.ts +147 -0
- package/dist/@types/field-data-types.d.ts.map +1 -0
- package/dist/@types/field-data-types.js +38 -0
- package/dist/@types/field-data-types.js.map +1 -0
- package/dist/@types/field-types.d.ts +579 -0
- package/dist/@types/field-types.d.ts.map +1 -0
- package/dist/@types/field-types.js +32 -0
- package/dist/@types/field-types.js.map +1 -0
- package/dist/@types/index.d.ts +18 -0
- package/dist/@types/index.d.ts.map +1 -0
- package/dist/@types/index.js +18 -0
- package/dist/@types/index.js.map +1 -0
- package/dist/@types/populate-types.d.ts +54 -0
- package/dist/@types/populate-types.d.ts.map +1 -0
- package/dist/@types/populate-types.js +9 -0
- package/dist/@types/populate-types.js.map +1 -0
- package/dist/@types/query-predicate.d.ts +74 -0
- package/dist/@types/query-predicate.d.ts.map +1 -0
- package/dist/@types/query-predicate.js +9 -0
- package/dist/@types/query-predicate.js.map +1 -0
- package/dist/@types/site-config.d.ts +212 -0
- package/dist/@types/site-config.d.ts.map +1 -0
- package/dist/@types/site-config.js +9 -0
- package/dist/@types/site-config.js.map +1 -0
- package/dist/@types/storage-types.d.ts +86 -0
- package/dist/@types/storage-types.d.ts.map +1 -0
- package/dist/@types/storage-types.js +9 -0
- package/dist/@types/storage-types.js.map +1 -0
- package/dist/@types/store-types.d.ts +134 -0
- package/dist/@types/store-types.d.ts.map +1 -0
- package/dist/@types/store-types.js +24 -0
- package/dist/@types/store-types.js.map +1 -0
- package/dist/@types/type-utils.d.ts +17 -0
- package/dist/@types/type-utils.d.ts.map +1 -0
- package/dist/@types/type-utils.js +9 -0
- package/dist/@types/type-utils.js.map +1 -0
- package/dist/auth/apply-before-read.d.ts +36 -0
- package/dist/auth/apply-before-read.d.ts.map +1 -0
- package/dist/auth/apply-before-read.js +68 -0
- package/dist/auth/apply-before-read.js.map +1 -0
- package/dist/auth/apply-before-read.test.node.d.ts +9 -0
- package/dist/auth/apply-before-read.test.node.d.ts.map +1 -0
- package/dist/auth/apply-before-read.test.node.js +144 -0
- package/dist/auth/apply-before-read.test.node.js.map +1 -0
- package/dist/auth/assert-actor-can-perform.d.ts +39 -0
- package/dist/auth/assert-actor-can-perform.d.ts.map +1 -0
- package/dist/auth/assert-actor-can-perform.js +64 -0
- package/dist/auth/assert-actor-can-perform.js.map +1 -0
- package/dist/auth/assert-actor-can-perform.test.node.d.ts +9 -0
- package/dist/auth/assert-actor-can-perform.test.node.d.ts.map +1 -0
- package/dist/auth/assert-actor-can-perform.test.node.js +119 -0
- package/dist/auth/assert-actor-can-perform.test.node.js.map +1 -0
- package/dist/auth/index.d.ts +11 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +11 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/register-collection-abilities.d.ts +40 -0
- package/dist/auth/register-collection-abilities.d.ts.map +1 -0
- package/dist/auth/register-collection-abilities.js +87 -0
- package/dist/auth/register-collection-abilities.js.map +1 -0
- package/dist/auth/register-collection-abilities.test.node.d.ts +9 -0
- package/dist/auth/register-collection-abilities.test.node.d.ts.map +1 -0
- package/dist/auth/register-collection-abilities.test.node.js +124 -0
- package/dist/auth/register-collection-abilities.test.node.js.map +1 -0
- package/dist/config/config.d.ts +10 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +108 -0
- package/dist/config/config.js.map +1 -0
- package/dist/config/routes.d.ts +16 -0
- package/dist/config/routes.d.ts.map +1 -0
- package/dist/config/routes.js +26 -0
- package/dist/config/routes.js.map +1 -0
- package/dist/config/validate-admin-configs.d.ts +33 -0
- package/dist/config/validate-admin-configs.d.ts.map +1 -0
- package/dist/config/validate-admin-configs.js +250 -0
- package/dist/config/validate-admin-configs.js.map +1 -0
- package/dist/config/validate-admin-configs.test.node.d.ts +9 -0
- package/dist/config/validate-admin-configs.test.node.d.ts.map +1 -0
- package/dist/config/validate-admin-configs.test.node.js +224 -0
- package/dist/config/validate-admin-configs.test.node.js.map +1 -0
- package/dist/config/validate-collections.d.ts +33 -0
- package/dist/config/validate-collections.d.ts.map +1 -0
- package/dist/config/validate-collections.js +70 -0
- package/dist/config/validate-collections.js.map +1 -0
- package/dist/config/validate-collections.test.node.d.ts +9 -0
- package/dist/config/validate-collections.test.node.d.ts.map +1 -0
- package/dist/config/validate-collections.test.node.js +149 -0
- package/dist/config/validate-collections.test.node.js.map +1 -0
- package/dist/core.d.ts +89 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +99 -0
- package/dist/core.js.map +1 -0
- package/dist/defaults/default-values.d.ts +13 -0
- package/dist/defaults/default-values.d.ts.map +1 -0
- package/dist/defaults/default-values.js +60 -0
- package/dist/defaults/default-values.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/errors.d.ts +98 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +134 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/logger.d.ts +62 -0
- package/dist/lib/logger.d.ts.map +1 -0
- package/dist/lib/logger.js +120 -0
- package/dist/lib/logger.js.map +1 -0
- package/dist/lib/registry.d.ts +65 -0
- package/dist/lib/registry.d.ts.map +1 -0
- package/dist/lib/registry.js +133 -0
- package/dist/lib/registry.js.map +1 -0
- package/dist/logger/index.d.ts +3 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +3 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/patches/apply-patches.d.ts +21 -0
- package/dist/patches/apply-patches.d.ts.map +1 -0
- package/dist/patches/apply-patches.js +357 -0
- package/dist/patches/apply-patches.js.map +1 -0
- package/dist/patches/index.d.ts +3 -0
- package/dist/patches/index.d.ts.map +1 -0
- package/dist/patches/index.js +4 -0
- package/dist/patches/index.js.map +1 -0
- package/dist/patches/patch-types.d.ts +82 -0
- package/dist/patches/patch-types.d.ts.map +1 -0
- package/dist/patches/patch-types.js +3 -0
- package/dist/patches/patch-types.js.map +1 -0
- package/dist/patches/patch.test.node.d.ts +2 -0
- package/dist/patches/patch.test.node.d.ts.map +1 -0
- package/dist/patches/patch.test.node.js +193 -0
- package/dist/patches/patch.test.node.js.map +1 -0
- package/dist/query/parse-where.d.ts +100 -0
- package/dist/query/parse-where.d.ts.map +1 -0
- package/dist/query/parse-where.js +352 -0
- package/dist/query/parse-where.js.map +1 -0
- package/dist/query/parse-where.test.node.d.ts +9 -0
- package/dist/query/parse-where.test.node.d.ts.map +1 -0
- package/dist/query/parse-where.test.node.js +581 -0
- package/dist/query/parse-where.test.node.js.map +1 -0
- package/dist/schemas/zod/builder.d.ts +466 -0
- package/dist/schemas/zod/builder.d.ts.map +1 -0
- package/dist/schemas/zod/builder.js +276 -0
- package/dist/schemas/zod/builder.js.map +1 -0
- package/dist/schemas/zod/cache.d.ts +14 -0
- package/dist/schemas/zod/cache.d.ts.map +1 -0
- package/dist/schemas/zod/cache.js +40 -0
- package/dist/schemas/zod/cache.js.map +1 -0
- package/dist/schemas/zod/index.d.ts +4 -0
- package/dist/schemas/zod/index.d.ts.map +1 -0
- package/dist/schemas/zod/index.js +4 -0
- package/dist/schemas/zod/index.js.map +1 -0
- package/dist/schemas/zod/types.d.ts +13 -0
- package/dist/schemas/zod/types.d.ts.map +1 -0
- package/dist/schemas/zod/types.js +2 -0
- package/dist/schemas/zod/types.js.map +1 -0
- package/dist/services/collection-bootstrap.d.ts +46 -0
- package/dist/services/collection-bootstrap.d.ts.map +1 -0
- package/dist/services/collection-bootstrap.js +108 -0
- package/dist/services/collection-bootstrap.js.map +1 -0
- package/dist/services/collection-bootstrap.test.node.d.ts +9 -0
- package/dist/services/collection-bootstrap.test.node.d.ts.map +1 -0
- package/dist/services/collection-bootstrap.test.node.js +208 -0
- package/dist/services/collection-bootstrap.test.node.js.map +1 -0
- package/dist/services/document-lifecycle.d.ts +245 -0
- package/dist/services/document-lifecycle.d.ts.map +1 -0
- package/dist/services/document-lifecycle.js +481 -0
- package/dist/services/document-lifecycle.js.map +1 -0
- package/dist/services/document-lifecycle.test.node.d.ts +9 -0
- package/dist/services/document-lifecycle.test.node.d.ts.map +1 -0
- package/dist/services/document-lifecycle.test.node.js +781 -0
- package/dist/services/document-lifecycle.test.node.js.map +1 -0
- package/dist/services/document-read.d.ts +26 -0
- package/dist/services/document-read.d.ts.map +1 -0
- package/dist/services/document-read.js +60 -0
- package/dist/services/document-read.js.map +1 -0
- package/dist/services/field-upload.d.ts +100 -0
- package/dist/services/field-upload.d.ts.map +1 -0
- package/dist/services/field-upload.js +328 -0
- package/dist/services/field-upload.js.map +1 -0
- package/dist/services/field-upload.test.node.d.ts +9 -0
- package/dist/services/field-upload.test.node.d.ts.map +1 -0
- package/dist/services/field-upload.test.node.js +337 -0
- package/dist/services/field-upload.test.node.js.map +1 -0
- package/dist/services/index.d.ts +10 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +11 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/populate.d.ts +299 -0
- package/dist/services/populate.d.ts.map +1 -0
- package/dist/services/populate.js +484 -0
- package/dist/services/populate.js.map +1 -0
- package/dist/services/populate.test.node.d.ts +9 -0
- package/dist/services/populate.test.node.d.ts.map +1 -0
- package/dist/services/populate.test.node.js +910 -0
- package/dist/services/populate.test.node.js.map +1 -0
- package/dist/services/relation-projection.d.ts +52 -0
- package/dist/services/relation-projection.d.ts.map +1 -0
- package/dist/services/relation-projection.js +81 -0
- package/dist/services/relation-projection.js.map +1 -0
- package/dist/services/richtext-populate.d.ts +87 -0
- package/dist/services/richtext-populate.d.ts.map +1 -0
- package/dist/services/richtext-populate.js +189 -0
- package/dist/services/richtext-populate.js.map +1 -0
- package/dist/services/richtext-populate.test.node.d.ts +9 -0
- package/dist/services/richtext-populate.test.node.d.ts.map +1 -0
- package/dist/services/richtext-populate.test.node.js +197 -0
- package/dist/services/richtext-populate.test.node.js.map +1 -0
- package/dist/storage/collection-fingerprint.d.ts +21 -0
- package/dist/storage/collection-fingerprint.d.ts.map +1 -0
- package/dist/storage/collection-fingerprint.js +172 -0
- package/dist/storage/collection-fingerprint.js.map +1 -0
- package/dist/storage/collection-fingerprint.test.node.d.ts +9 -0
- package/dist/storage/collection-fingerprint.test.node.d.ts.map +1 -0
- package/dist/storage/collection-fingerprint.test.node.js +256 -0
- package/dist/storage/collection-fingerprint.test.node.js.map +1 -0
- package/dist/storage/field-store-map.d.ts +59 -0
- package/dist/storage/field-store-map.d.ts.map +1 -0
- package/dist/storage/field-store-map.js +75 -0
- package/dist/storage/field-store-map.js.map +1 -0
- package/dist/storage/field-store-map.test.node.d.ts +9 -0
- package/dist/storage/field-store-map.test.node.d.ts.map +1 -0
- package/dist/storage/field-store-map.test.node.js +117 -0
- package/dist/storage/field-store-map.test.node.js.map +1 -0
- package/dist/storage/index.d.ts +10 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +10 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/utils/normalise-dates.d.ts +15 -0
- package/dist/utils/normalise-dates.d.ts.map +1 -0
- package/dist/utils/normalise-dates.js +22 -0
- package/dist/utils/normalise-dates.js.map +1 -0
- package/dist/utils/slugify.d.ts +56 -0
- package/dist/utils/slugify.d.ts.map +1 -0
- package/dist/utils/slugify.js +91 -0
- package/dist/utils/slugify.js.map +1 -0
- package/dist/utils/slugify.test.node.d.ts +9 -0
- package/dist/utils/slugify.test.node.d.ts.map +1 -0
- package/dist/utils/slugify.test.node.js +86 -0
- package/dist/utils/slugify.test.node.js.map +1 -0
- package/dist/utils/storage-utils.d.ts +36 -0
- package/dist/utils/storage-utils.d.ts.map +1 -0
- package/dist/utils/storage-utils.js +38 -0
- package/dist/utils/storage-utils.js.map +1 -0
- package/dist/utils/utils.general.d.ts +64 -0
- package/dist/utils/utils.general.d.ts.map +1 -0
- package/dist/utils/utils.general.js +219 -0
- package/dist/utils/utils.general.js.map +1 -0
- package/dist/validation/index.d.ts +9 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +9 -0
- package/dist/validation/index.js.map +1 -0
- package/dist/validation/shared.d.ts +36 -0
- package/dist/validation/shared.d.ts.map +1 -0
- package/dist/validation/shared.js +42 -0
- package/dist/validation/shared.js.map +1 -0
- package/dist/workflow/index.d.ts +2 -0
- package/dist/workflow/index.d.ts.map +1 -0
- package/dist/workflow/index.js +3 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/workflow.d.ts +40 -0
- package/dist/workflow/workflow.d.ts.map +1 -0
- package/dist/workflow/workflow.js +96 -0
- package/dist/workflow/workflow.js.map +1 -0
- package/dist/workflow/workflow.test.node.d.ts +2 -0
- package/dist/workflow/workflow.test.node.d.ts.map +1 -0
- package/dist/workflow/workflow.test.node.js +198 -0
- package/dist/workflow/workflow.test.node.js.map +1 -0
- package/package.json +88 -0
|
@@ -0,0 +1,781 @@
|
|
|
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, ErrorCodes } from '../lib/errors.js';
|
|
11
|
+
import { changeDocumentStatus, createDocument, deleteDocument, unpublishDocument, updateDocument, updateDocumentWithPatches, } from './document-lifecycle.js';
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Fixtures / Helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const minimalCollection = {
|
|
16
|
+
path: 'articles',
|
|
17
|
+
labels: { singular: 'Article', plural: 'Articles' },
|
|
18
|
+
fields: [{ name: 'title', type: 'text', label: 'Title' }],
|
|
19
|
+
workflow: {
|
|
20
|
+
statuses: [
|
|
21
|
+
{ name: 'draft', label: 'Draft' },
|
|
22
|
+
{ name: 'published', label: 'Published' },
|
|
23
|
+
{ name: 'archived', label: 'Archived' },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
/** Build a mock IDbAdapter. Returns the adapter plus individual mock fns. */
|
|
28
|
+
function createMockDb() {
|
|
29
|
+
const createDocumentVersion = vi.fn().mockResolvedValue({
|
|
30
|
+
document: { id: 'ver-1', document_id: 'doc-1' },
|
|
31
|
+
fieldCount: 3,
|
|
32
|
+
});
|
|
33
|
+
const setDocumentStatus = vi.fn().mockResolvedValue(undefined);
|
|
34
|
+
const archivePublishedVersions = vi.fn().mockResolvedValue(0);
|
|
35
|
+
const softDeleteDocument = vi.fn().mockResolvedValue(1);
|
|
36
|
+
const getDocumentById = vi.fn().mockResolvedValue(null);
|
|
37
|
+
const getCurrentVersionMetadata = vi.fn().mockResolvedValue(null);
|
|
38
|
+
const db = {
|
|
39
|
+
commands: {
|
|
40
|
+
collections: {
|
|
41
|
+
create: vi.fn(),
|
|
42
|
+
update: vi.fn(),
|
|
43
|
+
delete: vi.fn(),
|
|
44
|
+
},
|
|
45
|
+
documents: {
|
|
46
|
+
createDocumentVersion,
|
|
47
|
+
setDocumentStatus,
|
|
48
|
+
archivePublishedVersions,
|
|
49
|
+
softDeleteDocument,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
queries: {
|
|
53
|
+
collections: {
|
|
54
|
+
getAllCollections: vi.fn(),
|
|
55
|
+
getCollectionByPath: vi.fn(),
|
|
56
|
+
getCollectionById: vi.fn(),
|
|
57
|
+
},
|
|
58
|
+
documents: {
|
|
59
|
+
getDocumentById,
|
|
60
|
+
getCurrentVersionMetadata,
|
|
61
|
+
getDocumentByPath: vi.fn(),
|
|
62
|
+
getDocumentByVersion: vi.fn(),
|
|
63
|
+
getDocumentsByVersionIds: vi.fn(),
|
|
64
|
+
getDocumentsByDocumentIds: vi.fn(),
|
|
65
|
+
getDocumentHistory: vi.fn(),
|
|
66
|
+
getPublishedVersion: vi.fn(),
|
|
67
|
+
getPublishedDocumentIds: vi.fn(),
|
|
68
|
+
getDocumentCountsByStatus: vi.fn(),
|
|
69
|
+
findDocuments: vi.fn(),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
db,
|
|
75
|
+
createDocumentVersion,
|
|
76
|
+
setDocumentStatus,
|
|
77
|
+
archivePublishedVersions,
|
|
78
|
+
softDeleteDocument,
|
|
79
|
+
getDocumentById,
|
|
80
|
+
getCurrentVersionMetadata,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const noopLogger = {
|
|
84
|
+
log: vi.fn(),
|
|
85
|
+
fatal: vi.fn(),
|
|
86
|
+
error: vi.fn(),
|
|
87
|
+
warn: vi.fn(),
|
|
88
|
+
info: vi.fn(),
|
|
89
|
+
debug: vi.fn(),
|
|
90
|
+
trace: vi.fn(),
|
|
91
|
+
silent: vi.fn(),
|
|
92
|
+
};
|
|
93
|
+
function buildCtx(db, definition = minimalCollection) {
|
|
94
|
+
return {
|
|
95
|
+
db,
|
|
96
|
+
definition,
|
|
97
|
+
collectionId: 'col-1',
|
|
98
|
+
collectionVersion: 1,
|
|
99
|
+
collectionPath: definition.path,
|
|
100
|
+
logger: noopLogger,
|
|
101
|
+
defaultLocale: 'en',
|
|
102
|
+
// Inject a super-admin context by default so the bulk of existing
|
|
103
|
+
// tests do not have to care about ability enforcement. The dedicated
|
|
104
|
+
// "enforcement" block below covers the missing-context / missing-ability
|
|
105
|
+
// negative cases.
|
|
106
|
+
requestContext: createSuperAdminContext({ id: 'test-super-admin' }),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Tests
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
describe('Document lifecycle service', () => {
|
|
113
|
+
// -----------------------------------------------------------------------
|
|
114
|
+
// createDocument
|
|
115
|
+
// -----------------------------------------------------------------------
|
|
116
|
+
describe('createDocument', () => {
|
|
117
|
+
it('calls createDocumentVersion and returns IDs', async () => {
|
|
118
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
119
|
+
const ctx = buildCtx(db);
|
|
120
|
+
const result = await createDocument(ctx, {
|
|
121
|
+
data: { title: 'Hello' },
|
|
122
|
+
locale: 'en',
|
|
123
|
+
});
|
|
124
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
125
|
+
expect(result.documentId).toBe('doc-1');
|
|
126
|
+
expect(result.documentVersionId).toBe('ver-1');
|
|
127
|
+
});
|
|
128
|
+
it('invokes beforeCreate and afterCreate hooks in order', async () => {
|
|
129
|
+
const callOrder = [];
|
|
130
|
+
const hooks = {
|
|
131
|
+
beforeCreate: vi.fn(async () => {
|
|
132
|
+
callOrder.push('before');
|
|
133
|
+
}),
|
|
134
|
+
afterCreate: vi.fn(async () => {
|
|
135
|
+
callOrder.push('after');
|
|
136
|
+
}),
|
|
137
|
+
};
|
|
138
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
139
|
+
createDocumentVersion.mockImplementation(async () => {
|
|
140
|
+
callOrder.push('persist');
|
|
141
|
+
return { document: { id: 'ver-1', document_id: 'doc-1' }, fieldCount: 1 };
|
|
142
|
+
});
|
|
143
|
+
const definition = { ...minimalCollection, hooks };
|
|
144
|
+
const ctx = buildCtx(db, definition);
|
|
145
|
+
await createDocument(ctx, { data: { title: 'Test' } });
|
|
146
|
+
expect(callOrder).toEqual(['before', 'persist', 'after']);
|
|
147
|
+
});
|
|
148
|
+
it('afterCreate receives documentId and documentVersionId', async () => {
|
|
149
|
+
const afterCreate = vi.fn();
|
|
150
|
+
const { db } = createMockDb();
|
|
151
|
+
const definition = { ...minimalCollection, hooks: { afterCreate } };
|
|
152
|
+
const ctx = buildCtx(db, definition);
|
|
153
|
+
await createDocument(ctx, { data: { title: 'X' } });
|
|
154
|
+
expect(afterCreate).toHaveBeenCalledWith(expect.objectContaining({
|
|
155
|
+
documentId: 'doc-1',
|
|
156
|
+
documentVersionId: 'ver-1',
|
|
157
|
+
}));
|
|
158
|
+
});
|
|
159
|
+
it('beforeCreate can mutate data before persistence', async () => {
|
|
160
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
161
|
+
const definition = {
|
|
162
|
+
...minimalCollection,
|
|
163
|
+
hooks: {
|
|
164
|
+
beforeCreate: vi.fn(({ data }) => {
|
|
165
|
+
data.title = 'Mutated';
|
|
166
|
+
}),
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
const ctx = buildCtx(db, definition);
|
|
170
|
+
await createDocument(ctx, { data: { title: 'Original' } });
|
|
171
|
+
const persistedData = createDocumentVersion.mock.calls[0]?.[0].documentData;
|
|
172
|
+
expect(persistedData.title).toBe('Mutated');
|
|
173
|
+
});
|
|
174
|
+
it('derives path from useAsPath source field via the slugifier', async () => {
|
|
175
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
176
|
+
const definition = { ...minimalCollection, useAsPath: 'title' };
|
|
177
|
+
const ctx = buildCtx(db, definition);
|
|
178
|
+
await createDocument(ctx, { data: { title: 'My Great Post' } });
|
|
179
|
+
const persistedPath = createDocumentVersion.mock.calls[0]?.[0].path;
|
|
180
|
+
expect(persistedPath).toBe('my-great-post');
|
|
181
|
+
});
|
|
182
|
+
it('uses an explicit params.path verbatim, bypassing derivation', async () => {
|
|
183
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
184
|
+
const definition = { ...minimalCollection, useAsPath: 'title' };
|
|
185
|
+
const ctx = buildCtx(db, definition);
|
|
186
|
+
await createDocument(ctx, {
|
|
187
|
+
data: { title: 'Will Be Ignored' },
|
|
188
|
+
path: 'custom/route',
|
|
189
|
+
});
|
|
190
|
+
const persistedPath = createDocumentVersion.mock.calls[0]?.[0].path;
|
|
191
|
+
expect(persistedPath).toBe('custom/route');
|
|
192
|
+
});
|
|
193
|
+
it('falls back to a UUID when no useAsPath and no explicit path', async () => {
|
|
194
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
195
|
+
const ctx = buildCtx(db); // minimalCollection has no useAsPath
|
|
196
|
+
await createDocument(ctx, { data: { title: 'Anything' } });
|
|
197
|
+
const persistedPath = createDocumentVersion.mock.calls[0]?.[0].path;
|
|
198
|
+
expect(persistedPath).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
199
|
+
});
|
|
200
|
+
it('rejects creates in any non-default locale', async () => {
|
|
201
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
202
|
+
const ctx = buildCtx(db);
|
|
203
|
+
await expect(createDocument(ctx, { data: { title: 'Hello' }, locale: 'fr' })).rejects.toMatchObject({ code: ErrorCodes.VALIDATION });
|
|
204
|
+
expect(createDocumentVersion).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
it('works when no hooks are defined', async () => {
|
|
207
|
+
const { db } = createMockDb();
|
|
208
|
+
const ctx = buildCtx(db);
|
|
209
|
+
// Should not throw
|
|
210
|
+
const result = await createDocument(ctx, { data: { title: 'OK' } });
|
|
211
|
+
expect(result.documentId).toBe('doc-1');
|
|
212
|
+
});
|
|
213
|
+
it('supports an array of beforeCreate hooks executed in order', async () => {
|
|
214
|
+
const callOrder = [];
|
|
215
|
+
const hooks = {
|
|
216
|
+
beforeCreate: [
|
|
217
|
+
vi.fn(async () => {
|
|
218
|
+
callOrder.push('hook-1');
|
|
219
|
+
}),
|
|
220
|
+
vi.fn(async () => {
|
|
221
|
+
callOrder.push('hook-2');
|
|
222
|
+
}),
|
|
223
|
+
vi.fn(async () => {
|
|
224
|
+
callOrder.push('hook-3');
|
|
225
|
+
}),
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
229
|
+
createDocumentVersion.mockImplementation(async () => {
|
|
230
|
+
callOrder.push('persist');
|
|
231
|
+
return { document: { id: 'ver-1', document_id: 'doc-1' }, fieldCount: 1 };
|
|
232
|
+
});
|
|
233
|
+
const definition = { ...minimalCollection, hooks };
|
|
234
|
+
const ctx = buildCtx(db, definition);
|
|
235
|
+
await createDocument(ctx, { data: { title: 'Test' } });
|
|
236
|
+
expect(callOrder).toEqual(['hook-1', 'hook-2', 'hook-3', 'persist']);
|
|
237
|
+
for (const fn of hooks.beforeCreate) {
|
|
238
|
+
expect(fn).toHaveBeenCalledOnce();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
it('supports an array of afterCreate hooks executed in order', async () => {
|
|
242
|
+
const callOrder = [];
|
|
243
|
+
const hooks = {
|
|
244
|
+
afterCreate: [
|
|
245
|
+
vi.fn(async () => {
|
|
246
|
+
callOrder.push('after-1');
|
|
247
|
+
}),
|
|
248
|
+
vi.fn(async () => {
|
|
249
|
+
callOrder.push('after-2');
|
|
250
|
+
}),
|
|
251
|
+
],
|
|
252
|
+
};
|
|
253
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
254
|
+
createDocumentVersion.mockImplementation(async () => {
|
|
255
|
+
callOrder.push('persist');
|
|
256
|
+
return { document: { id: 'ver-1', document_id: 'doc-1' }, fieldCount: 1 };
|
|
257
|
+
});
|
|
258
|
+
const definition = { ...minimalCollection, hooks };
|
|
259
|
+
const ctx = buildCtx(db, definition);
|
|
260
|
+
await createDocument(ctx, { data: { title: 'Test' } });
|
|
261
|
+
expect(callOrder).toEqual(['persist', 'after-1', 'after-2']);
|
|
262
|
+
});
|
|
263
|
+
it('array of beforeCreate hooks can each mutate data cumulatively', async () => {
|
|
264
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
265
|
+
const definition = {
|
|
266
|
+
...minimalCollection,
|
|
267
|
+
hooks: {
|
|
268
|
+
beforeCreate: [
|
|
269
|
+
vi.fn(({ data }) => {
|
|
270
|
+
data.title = `${data.title}-A`;
|
|
271
|
+
}),
|
|
272
|
+
vi.fn(({ data }) => {
|
|
273
|
+
data.title = `${data.title}-B`;
|
|
274
|
+
}),
|
|
275
|
+
],
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
const ctx = buildCtx(db, definition);
|
|
279
|
+
await createDocument(ctx, { data: { title: 'Original' } });
|
|
280
|
+
const persistedData = createDocumentVersion.mock.calls[0]?.[0].documentData;
|
|
281
|
+
expect(persistedData.title).toBe('Original-A-B');
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
// -----------------------------------------------------------------------
|
|
285
|
+
// updateDocument (PUT)
|
|
286
|
+
// -----------------------------------------------------------------------
|
|
287
|
+
describe('updateDocument', () => {
|
|
288
|
+
it('fetches the original before calling hooks', async () => {
|
|
289
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
290
|
+
getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
|
|
291
|
+
const beforeUpdate = vi.fn();
|
|
292
|
+
const definition = { ...minimalCollection, hooks: { beforeUpdate } };
|
|
293
|
+
const ctx = buildCtx(db, definition);
|
|
294
|
+
await updateDocument(ctx, {
|
|
295
|
+
documentId: 'doc-1',
|
|
296
|
+
data: { title: 'New' },
|
|
297
|
+
});
|
|
298
|
+
// The hook should receive the REAL original, not the incoming data
|
|
299
|
+
expect(beforeUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
|
300
|
+
originalData: expect.objectContaining({
|
|
301
|
+
fields: expect.objectContaining({ title: 'Old' }),
|
|
302
|
+
}),
|
|
303
|
+
data: expect.objectContaining({ title: 'New' }),
|
|
304
|
+
}));
|
|
305
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
306
|
+
});
|
|
307
|
+
it('afterUpdate receives documentId and documentVersionId', async () => {
|
|
308
|
+
const afterUpdate = vi.fn();
|
|
309
|
+
const { db, getDocumentById } = createMockDb();
|
|
310
|
+
getDocumentById.mockResolvedValue({ fields: { title: 'Old' } });
|
|
311
|
+
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
312
|
+
const ctx = buildCtx(db, definition);
|
|
313
|
+
await updateDocument(ctx, {
|
|
314
|
+
documentId: 'doc-1',
|
|
315
|
+
data: { title: 'New' },
|
|
316
|
+
});
|
|
317
|
+
expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
|
318
|
+
documentId: 'doc-1',
|
|
319
|
+
documentVersionId: 'ver-1',
|
|
320
|
+
}));
|
|
321
|
+
});
|
|
322
|
+
it('keeps path sticky from the previous version when no explicit path is supplied', async () => {
|
|
323
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
324
|
+
getDocumentById.mockResolvedValue({
|
|
325
|
+
document_version_id: 'prev-ver',
|
|
326
|
+
path: 'original-path',
|
|
327
|
+
status: 'draft',
|
|
328
|
+
fields: { title: 'Old' },
|
|
329
|
+
});
|
|
330
|
+
const definition = { ...minimalCollection, useAsPath: 'title' };
|
|
331
|
+
const ctx = buildCtx(db, definition);
|
|
332
|
+
await updateDocument(ctx, {
|
|
333
|
+
documentId: 'doc-1',
|
|
334
|
+
data: { title: 'Brand New Title' },
|
|
335
|
+
});
|
|
336
|
+
// Path is NOT re-derived from the now-changed title
|
|
337
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].path).toBe('original-path');
|
|
338
|
+
});
|
|
339
|
+
it('uses an explicit params.path verbatim on update, overriding the sticky value', async () => {
|
|
340
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
341
|
+
getDocumentById.mockResolvedValue({
|
|
342
|
+
document_version_id: 'prev-ver',
|
|
343
|
+
path: 'original-path',
|
|
344
|
+
status: 'draft',
|
|
345
|
+
fields: { title: 'Old' },
|
|
346
|
+
});
|
|
347
|
+
const ctx = buildCtx(db);
|
|
348
|
+
await updateDocument(ctx, {
|
|
349
|
+
documentId: 'doc-1',
|
|
350
|
+
data: { title: 'New' },
|
|
351
|
+
path: 'manually-set',
|
|
352
|
+
});
|
|
353
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].path).toBe('manually-set');
|
|
354
|
+
});
|
|
355
|
+
it('sets status to the default status (draft)', async () => {
|
|
356
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
357
|
+
getDocumentById.mockResolvedValue({
|
|
358
|
+
status: 'published',
|
|
359
|
+
fields: { title: 'Old' },
|
|
360
|
+
});
|
|
361
|
+
const ctx = buildCtx(db);
|
|
362
|
+
await updateDocument(ctx, {
|
|
363
|
+
documentId: 'doc-1',
|
|
364
|
+
data: { title: 'Updated' },
|
|
365
|
+
});
|
|
366
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].status).toBe('draft');
|
|
367
|
+
});
|
|
368
|
+
it('supports an array of beforeUpdate and afterUpdate hooks', async () => {
|
|
369
|
+
const callOrder = [];
|
|
370
|
+
const hooks = {
|
|
371
|
+
beforeUpdate: [
|
|
372
|
+
vi.fn(async () => {
|
|
373
|
+
callOrder.push('before-1');
|
|
374
|
+
}),
|
|
375
|
+
vi.fn(async () => {
|
|
376
|
+
callOrder.push('before-2');
|
|
377
|
+
}),
|
|
378
|
+
],
|
|
379
|
+
afterUpdate: [
|
|
380
|
+
vi.fn(async () => {
|
|
381
|
+
callOrder.push('after-1');
|
|
382
|
+
}),
|
|
383
|
+
vi.fn(async () => {
|
|
384
|
+
callOrder.push('after-2');
|
|
385
|
+
}),
|
|
386
|
+
],
|
|
387
|
+
};
|
|
388
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
389
|
+
getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
|
|
390
|
+
createDocumentVersion.mockImplementation(async () => {
|
|
391
|
+
callOrder.push('persist');
|
|
392
|
+
return { document: { id: 'ver-1', document_id: 'doc-1' }, fieldCount: 1 };
|
|
393
|
+
});
|
|
394
|
+
const definition = { ...minimalCollection, hooks };
|
|
395
|
+
const ctx = buildCtx(db, definition);
|
|
396
|
+
await updateDocument(ctx, { documentId: 'doc-1', data: { title: 'New' } });
|
|
397
|
+
expect(callOrder).toEqual(['before-1', 'before-2', 'persist', 'after-1', 'after-2']);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
// -----------------------------------------------------------------------
|
|
401
|
+
// updateDocumentWithPatches
|
|
402
|
+
// -----------------------------------------------------------------------
|
|
403
|
+
describe('updateDocumentWithPatches', () => {
|
|
404
|
+
it('throws ERR_NOT_FOUND when document is missing', async () => {
|
|
405
|
+
const { db, getDocumentById } = createMockDb();
|
|
406
|
+
getDocumentById.mockResolvedValue(null);
|
|
407
|
+
const ctx = buildCtx(db);
|
|
408
|
+
await expect(updateDocumentWithPatches(ctx, {
|
|
409
|
+
documentId: 'doc-missing',
|
|
410
|
+
patches: [],
|
|
411
|
+
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
|
|
412
|
+
});
|
|
413
|
+
it('throws ERR_CONFLICT on version mismatch', async () => {
|
|
414
|
+
const { db, getDocumentById } = createMockDb();
|
|
415
|
+
getDocumentById.mockResolvedValue({
|
|
416
|
+
document_version_id: 'ver-current',
|
|
417
|
+
fields: { title: 'Old' },
|
|
418
|
+
});
|
|
419
|
+
const ctx = buildCtx(db);
|
|
420
|
+
await expect(updateDocumentWithPatches(ctx, {
|
|
421
|
+
documentId: 'doc-1',
|
|
422
|
+
patches: [],
|
|
423
|
+
documentVersionId: 'ver-stale',
|
|
424
|
+
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.CONFLICT);
|
|
425
|
+
});
|
|
426
|
+
it('throws ERR_PATCH_FAILED when applyPatches returns errors', async () => {
|
|
427
|
+
const { db, getDocumentById } = createMockDb();
|
|
428
|
+
getDocumentById.mockResolvedValue({ fields: { title: 'Old' } });
|
|
429
|
+
const ctx = buildCtx(db);
|
|
430
|
+
// array.move on a top-level (non-array) field should produce an error
|
|
431
|
+
await expect(updateDocumentWithPatches(ctx, {
|
|
432
|
+
documentId: 'doc-1',
|
|
433
|
+
patches: [{ kind: 'array.move', path: 'title', itemId: 'x', toIndex: 0 }],
|
|
434
|
+
})).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.PATCH_FAILED);
|
|
435
|
+
});
|
|
436
|
+
it('persists patched data and invokes hooks', async () => {
|
|
437
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
438
|
+
getDocumentById.mockResolvedValue({ fields: { title: 'Old' } });
|
|
439
|
+
const afterUpdate = vi.fn();
|
|
440
|
+
const definition = { ...minimalCollection, hooks: { afterUpdate } };
|
|
441
|
+
const ctx = buildCtx(db, definition);
|
|
442
|
+
await updateDocumentWithPatches(ctx, {
|
|
443
|
+
documentId: 'doc-1',
|
|
444
|
+
patches: [{ kind: 'field.set', path: 'title', value: 'Patched' }],
|
|
445
|
+
});
|
|
446
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
447
|
+
const persistedData = createDocumentVersion.mock.calls[0]?.[0].documentData;
|
|
448
|
+
expect(persistedData.title).toBe('Patched');
|
|
449
|
+
expect(afterUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
|
450
|
+
documentId: 'doc-1',
|
|
451
|
+
documentVersionId: 'ver-1',
|
|
452
|
+
}));
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
// -----------------------------------------------------------------------
|
|
456
|
+
// changeDocumentStatus
|
|
457
|
+
// -----------------------------------------------------------------------
|
|
458
|
+
describe('changeDocumentStatus', () => {
|
|
459
|
+
const metadataRow = {
|
|
460
|
+
document_version_id: 'ver-1',
|
|
461
|
+
document_id: 'doc-1',
|
|
462
|
+
collection_id: 'col-1',
|
|
463
|
+
path: 'hello',
|
|
464
|
+
status: 'draft',
|
|
465
|
+
created_at: new Date(),
|
|
466
|
+
updated_at: new Date(),
|
|
467
|
+
};
|
|
468
|
+
it('validates and applies a valid transition', async () => {
|
|
469
|
+
const { db, getCurrentVersionMetadata, setDocumentStatus } = createMockDb();
|
|
470
|
+
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
471
|
+
const ctx = buildCtx(db);
|
|
472
|
+
const result = await changeDocumentStatus(ctx, {
|
|
473
|
+
documentId: 'doc-1',
|
|
474
|
+
nextStatus: 'published',
|
|
475
|
+
});
|
|
476
|
+
expect(setDocumentStatus).toHaveBeenCalledWith({
|
|
477
|
+
document_version_id: 'ver-1',
|
|
478
|
+
status: 'published',
|
|
479
|
+
});
|
|
480
|
+
expect(result.previousStatus).toBe('draft');
|
|
481
|
+
expect(result.newStatus).toBe('published');
|
|
482
|
+
});
|
|
483
|
+
it('throws ERR_NOT_FOUND when document is missing', async () => {
|
|
484
|
+
const { db, getCurrentVersionMetadata } = createMockDb();
|
|
485
|
+
getCurrentVersionMetadata.mockResolvedValue(null);
|
|
486
|
+
const ctx = buildCtx(db);
|
|
487
|
+
await expect(changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.NOT_FOUND);
|
|
488
|
+
});
|
|
489
|
+
it('throws ERR_INVALID_TRANSITION for an invalid transition', async () => {
|
|
490
|
+
const { db, getCurrentVersionMetadata } = createMockDb();
|
|
491
|
+
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
492
|
+
const ctx = buildCtx(db);
|
|
493
|
+
// draft → archived skips 'published', which is not ±1
|
|
494
|
+
await expect(changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'archived' })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.INVALID_TRANSITION);
|
|
495
|
+
});
|
|
496
|
+
it('invokes beforeStatusChange and afterStatusChange hooks', async () => {
|
|
497
|
+
const callOrder = [];
|
|
498
|
+
const hooks = {
|
|
499
|
+
beforeStatusChange: vi.fn(async () => {
|
|
500
|
+
callOrder.push('before');
|
|
501
|
+
}),
|
|
502
|
+
afterStatusChange: vi.fn(async () => {
|
|
503
|
+
callOrder.push('after');
|
|
504
|
+
}),
|
|
505
|
+
};
|
|
506
|
+
const { db, getCurrentVersionMetadata, setDocumentStatus } = createMockDb();
|
|
507
|
+
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
508
|
+
setDocumentStatus.mockImplementation(async () => {
|
|
509
|
+
callOrder.push('persist');
|
|
510
|
+
});
|
|
511
|
+
const definition = { ...minimalCollection, hooks };
|
|
512
|
+
const ctx = buildCtx(db, definition);
|
|
513
|
+
await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
|
|
514
|
+
expect(callOrder).toEqual(['before', 'persist', 'after']);
|
|
515
|
+
expect(hooks.beforeStatusChange).toHaveBeenCalledWith(expect.objectContaining({
|
|
516
|
+
previousStatus: 'draft',
|
|
517
|
+
nextStatus: 'published',
|
|
518
|
+
documentId: 'doc-1',
|
|
519
|
+
documentVersionId: 'ver-1',
|
|
520
|
+
}));
|
|
521
|
+
});
|
|
522
|
+
it('does not invoke hooks when transition is invalid', async () => {
|
|
523
|
+
const hooks = {
|
|
524
|
+
beforeStatusChange: vi.fn(),
|
|
525
|
+
afterStatusChange: vi.fn(),
|
|
526
|
+
};
|
|
527
|
+
const { db, getCurrentVersionMetadata } = createMockDb();
|
|
528
|
+
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
529
|
+
const definition = { ...minimalCollection, hooks };
|
|
530
|
+
const ctx = buildCtx(db, definition);
|
|
531
|
+
await expect(changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'archived' })).rejects.toSatisfy((err) => err instanceof BylineError && err.code === ErrorCodes.INVALID_TRANSITION);
|
|
532
|
+
expect(hooks.beforeStatusChange).not.toHaveBeenCalled();
|
|
533
|
+
expect(hooks.afterStatusChange).not.toHaveBeenCalled();
|
|
534
|
+
});
|
|
535
|
+
it('auto-archives other published versions when publishing', async () => {
|
|
536
|
+
const { db, getCurrentVersionMetadata, archivePublishedVersions } = createMockDb();
|
|
537
|
+
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
538
|
+
const ctx = buildCtx(db);
|
|
539
|
+
await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
|
|
540
|
+
expect(archivePublishedVersions).toHaveBeenCalledWith({
|
|
541
|
+
document_id: 'doc-1',
|
|
542
|
+
excludeVersionId: 'ver-1',
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
it('supports an array of beforeStatusChange and afterStatusChange hooks', async () => {
|
|
546
|
+
const callOrder = [];
|
|
547
|
+
const hooks = {
|
|
548
|
+
beforeStatusChange: [
|
|
549
|
+
vi.fn(async () => {
|
|
550
|
+
callOrder.push('before-1');
|
|
551
|
+
}),
|
|
552
|
+
vi.fn(async () => {
|
|
553
|
+
callOrder.push('before-2');
|
|
554
|
+
}),
|
|
555
|
+
],
|
|
556
|
+
afterStatusChange: [
|
|
557
|
+
vi.fn(async () => {
|
|
558
|
+
callOrder.push('after-1');
|
|
559
|
+
}),
|
|
560
|
+
vi.fn(async () => {
|
|
561
|
+
callOrder.push('after-2');
|
|
562
|
+
}),
|
|
563
|
+
],
|
|
564
|
+
};
|
|
565
|
+
const { db, getCurrentVersionMetadata, setDocumentStatus } = createMockDb();
|
|
566
|
+
getCurrentVersionMetadata.mockResolvedValue({ ...metadataRow });
|
|
567
|
+
setDocumentStatus.mockImplementation(async () => {
|
|
568
|
+
callOrder.push('persist');
|
|
569
|
+
});
|
|
570
|
+
const definition = { ...minimalCollection, hooks };
|
|
571
|
+
const ctx = buildCtx(db, definition);
|
|
572
|
+
await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
|
|
573
|
+
expect(callOrder).toEqual(['before-1', 'before-2', 'persist', 'after-1', 'after-2']);
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
// -----------------------------------------------------------------------
|
|
577
|
+
// unpublishDocument
|
|
578
|
+
// -----------------------------------------------------------------------
|
|
579
|
+
describe('unpublishDocument', () => {
|
|
580
|
+
it('calls archivePublishedVersions and returns count', async () => {
|
|
581
|
+
const { db, archivePublishedVersions } = createMockDb();
|
|
582
|
+
archivePublishedVersions.mockResolvedValue(1);
|
|
583
|
+
const ctx = buildCtx(db);
|
|
584
|
+
const result = await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
585
|
+
expect(result.archivedCount).toBe(1);
|
|
586
|
+
expect(archivePublishedVersions).toHaveBeenCalledWith({ document_id: 'doc-1' });
|
|
587
|
+
});
|
|
588
|
+
it('invokes beforeUnpublish and afterUnpublish hooks', async () => {
|
|
589
|
+
const callOrder = [];
|
|
590
|
+
const hooks = {
|
|
591
|
+
beforeUnpublish: vi.fn(async () => {
|
|
592
|
+
callOrder.push('before');
|
|
593
|
+
}),
|
|
594
|
+
afterUnpublish: vi.fn(async () => {
|
|
595
|
+
callOrder.push('after');
|
|
596
|
+
}),
|
|
597
|
+
};
|
|
598
|
+
const { db, archivePublishedVersions } = createMockDb();
|
|
599
|
+
archivePublishedVersions.mockImplementation(async () => {
|
|
600
|
+
callOrder.push('archive');
|
|
601
|
+
return 2;
|
|
602
|
+
});
|
|
603
|
+
const definition = { ...minimalCollection, hooks };
|
|
604
|
+
const ctx = buildCtx(db, definition);
|
|
605
|
+
await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
606
|
+
expect(callOrder).toEqual(['before', 'archive', 'after']);
|
|
607
|
+
expect(hooks.afterUnpublish).toHaveBeenCalledWith(expect.objectContaining({
|
|
608
|
+
documentId: 'doc-1',
|
|
609
|
+
archivedCount: 2,
|
|
610
|
+
}));
|
|
611
|
+
});
|
|
612
|
+
it('works when no hooks are defined', async () => {
|
|
613
|
+
const { db, archivePublishedVersions } = createMockDb();
|
|
614
|
+
archivePublishedVersions.mockResolvedValue(0);
|
|
615
|
+
const ctx = buildCtx(db);
|
|
616
|
+
const result = await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
617
|
+
expect(result.archivedCount).toBe(0);
|
|
618
|
+
});
|
|
619
|
+
it('supports an array of beforeUnpublish and afterUnpublish hooks', async () => {
|
|
620
|
+
const callOrder = [];
|
|
621
|
+
const hooks = {
|
|
622
|
+
beforeUnpublish: [
|
|
623
|
+
vi.fn(async () => {
|
|
624
|
+
callOrder.push('before-1');
|
|
625
|
+
}),
|
|
626
|
+
vi.fn(async () => {
|
|
627
|
+
callOrder.push('before-2');
|
|
628
|
+
}),
|
|
629
|
+
],
|
|
630
|
+
afterUnpublish: [
|
|
631
|
+
vi.fn(async () => {
|
|
632
|
+
callOrder.push('after-1');
|
|
633
|
+
}),
|
|
634
|
+
vi.fn(async () => {
|
|
635
|
+
callOrder.push('after-2');
|
|
636
|
+
}),
|
|
637
|
+
],
|
|
638
|
+
};
|
|
639
|
+
const { db, archivePublishedVersions } = createMockDb();
|
|
640
|
+
archivePublishedVersions.mockImplementation(async () => {
|
|
641
|
+
callOrder.push('archive');
|
|
642
|
+
return 2;
|
|
643
|
+
});
|
|
644
|
+
const definition = { ...minimalCollection, hooks };
|
|
645
|
+
const ctx = buildCtx(db, definition);
|
|
646
|
+
await unpublishDocument(ctx, { documentId: 'doc-1' });
|
|
647
|
+
expect(callOrder).toEqual(['before-1', 'before-2', 'archive', 'after-1', 'after-2']);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
// -----------------------------------------------------------------------
|
|
651
|
+
// Ability enforcement (Phase 4)
|
|
652
|
+
// -----------------------------------------------------------------------
|
|
653
|
+
describe('ability enforcement', () => {
|
|
654
|
+
it('throws ERR_UNAUTHENTICATED when requestContext is absent', async () => {
|
|
655
|
+
const { db } = createMockDb();
|
|
656
|
+
const ctx = buildCtx(db);
|
|
657
|
+
ctx.requestContext = undefined;
|
|
658
|
+
try {
|
|
659
|
+
await createDocument(ctx, { data: { title: 'Oops' } });
|
|
660
|
+
expect.fail('expected ERR_UNAUTHENTICATED');
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
expect(err).toBeInstanceOf(AuthError);
|
|
664
|
+
expect(err.code).toBe(AuthErrorCodes.UNAUTHENTICATED);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
it('throws ERR_FORBIDDEN when actor lacks collections.<path>.create', async () => {
|
|
668
|
+
const { db } = createMockDb();
|
|
669
|
+
const ctx = buildCtx(db);
|
|
670
|
+
const actor = new AdminAuth({ id: 'editor', abilities: ['collections.articles.read'] });
|
|
671
|
+
ctx.requestContext = createRequestContext({ actor });
|
|
672
|
+
try {
|
|
673
|
+
await createDocument(ctx, { data: { title: 'Nope' } });
|
|
674
|
+
expect.fail('expected ERR_FORBIDDEN');
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
it('permits create when actor holds collections.<path>.create', async () => {
|
|
681
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
682
|
+
const ctx = buildCtx(db);
|
|
683
|
+
const actor = new AdminAuth({
|
|
684
|
+
id: 'editor',
|
|
685
|
+
abilities: ['collections.articles.create'],
|
|
686
|
+
});
|
|
687
|
+
ctx.requestContext = createRequestContext({ actor });
|
|
688
|
+
await createDocument(ctx, { data: { title: 'Yes' } });
|
|
689
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
690
|
+
});
|
|
691
|
+
it('requires both changeStatus and publish when transitioning to published', async () => {
|
|
692
|
+
const { db, getCurrentVersionMetadata } = createMockDb();
|
|
693
|
+
getCurrentVersionMetadata.mockResolvedValue({
|
|
694
|
+
document_version_id: 'ver-1',
|
|
695
|
+
document_id: 'doc-1',
|
|
696
|
+
collection_id: 'col-1',
|
|
697
|
+
path: 'x',
|
|
698
|
+
status: 'draft',
|
|
699
|
+
created_at: new Date(),
|
|
700
|
+
updated_at: new Date(),
|
|
701
|
+
});
|
|
702
|
+
const ctx = buildCtx(db);
|
|
703
|
+
const actor = new AdminAuth({
|
|
704
|
+
id: 'editor',
|
|
705
|
+
// Has changeStatus but NOT publish — the publish transition should fail.
|
|
706
|
+
abilities: ['collections.articles.changeStatus'],
|
|
707
|
+
});
|
|
708
|
+
ctx.requestContext = createRequestContext({ actor });
|
|
709
|
+
try {
|
|
710
|
+
await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'published' });
|
|
711
|
+
expect.fail('expected ERR_FORBIDDEN');
|
|
712
|
+
}
|
|
713
|
+
catch (err) {
|
|
714
|
+
expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
|
|
715
|
+
expect(err.message).toContain('collections.articles.publish');
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
it('permits a non-publish transition with only the changeStatus ability', async () => {
|
|
719
|
+
const { db, getCurrentVersionMetadata, setDocumentStatus } = createMockDb();
|
|
720
|
+
getCurrentVersionMetadata.mockResolvedValue({
|
|
721
|
+
document_version_id: 'ver-1',
|
|
722
|
+
document_id: 'doc-1',
|
|
723
|
+
collection_id: 'col-1',
|
|
724
|
+
path: 'x',
|
|
725
|
+
status: 'draft',
|
|
726
|
+
created_at: new Date(),
|
|
727
|
+
updated_at: new Date(),
|
|
728
|
+
});
|
|
729
|
+
const definition = {
|
|
730
|
+
...minimalCollection,
|
|
731
|
+
workflow: {
|
|
732
|
+
statuses: [
|
|
733
|
+
{ name: 'draft' },
|
|
734
|
+
{ name: 'in_review' },
|
|
735
|
+
{ name: 'published' },
|
|
736
|
+
{ name: 'archived' },
|
|
737
|
+
],
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
const ctx = buildCtx(db, definition);
|
|
741
|
+
const actor = new AdminAuth({
|
|
742
|
+
id: 'editor',
|
|
743
|
+
abilities: ['collections.articles.changeStatus'],
|
|
744
|
+
});
|
|
745
|
+
ctx.requestContext = createRequestContext({ actor });
|
|
746
|
+
await changeDocumentStatus(ctx, { documentId: 'doc-1', nextStatus: 'in_review' });
|
|
747
|
+
expect(setDocumentStatus).toHaveBeenCalledOnce();
|
|
748
|
+
});
|
|
749
|
+
it('super-admin bypasses every check', async () => {
|
|
750
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
751
|
+
const ctx = buildCtx(db);
|
|
752
|
+
// buildCtx already defaults to super-admin; confirm no ability grants
|
|
753
|
+
// are actually needed.
|
|
754
|
+
await createDocument(ctx, { data: { title: 'X' } });
|
|
755
|
+
expect(createDocumentVersion).toHaveBeenCalledOnce();
|
|
756
|
+
});
|
|
757
|
+
it('enforces delete against collections.<path>.delete', async () => {
|
|
758
|
+
const { db, getDocumentById } = createMockDb();
|
|
759
|
+
getDocumentById.mockResolvedValue({
|
|
760
|
+
document_version_id: 'ver-1',
|
|
761
|
+
document_id: 'doc-1',
|
|
762
|
+
fields: {},
|
|
763
|
+
});
|
|
764
|
+
const ctx = buildCtx(db);
|
|
765
|
+
const actor = new AdminAuth({
|
|
766
|
+
id: 'editor',
|
|
767
|
+
abilities: ['collections.articles.read', 'collections.articles.update'],
|
|
768
|
+
});
|
|
769
|
+
ctx.requestContext = createRequestContext({ actor });
|
|
770
|
+
try {
|
|
771
|
+
await deleteDocument(ctx, { documentId: 'doc-1' });
|
|
772
|
+
expect.fail('expected ERR_FORBIDDEN');
|
|
773
|
+
}
|
|
774
|
+
catch (err) {
|
|
775
|
+
expect(err.code).toBe(AuthErrorCodes.FORBIDDEN);
|
|
776
|
+
expect(err.message).toContain('collections.articles.delete');
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
});
|
|
781
|
+
//# sourceMappingURL=document-lifecycle.test.node.js.map
|