@actuate-media/cms-admin 0.17.0 → 0.19.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/__tests__/lib/page-editor-service-api.test.d.ts +2 -0
- package/dist/__tests__/lib/page-editor-service-api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js +155 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -0
- package/dist/__tests__/lib/pages-service-fetch.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-service-fetch.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js +125 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -0
- package/dist/__tests__/lib/post-editor-service.test.js +75 -23
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/posts-service.test.js +132 -142
- package/dist/__tests__/lib/posts-service.test.js.map +1 -1
- package/dist/__tests__/views/page-section-editor.test.d.ts +2 -0
- package/dist/__tests__/views/page-section-editor.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-section-editor.test.js +56 -0
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.d.ts +2 -0
- package/dist/__tests__/views/pages-list-view.test.d.ts.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +102 -0
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -0
- package/dist/__tests__/views/posts-list-view.render.test.d.ts +2 -0
- package/dist/__tests__/views/posts-list-view.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/posts-list-view.render.test.js +73 -0
- package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +1 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +55 -0
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-service.d.ts +26 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +65 -38
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +6 -4
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +32 -37
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +91 -160
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +71 -46
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Posts/NewPostPicker.d.ts +5 -4
- package/dist/views/Posts/NewPostPicker.d.ts.map +1 -1
- package/dist/views/Posts/NewPostPicker.js +5 -4
- package/dist/views/Posts/NewPostPicker.js.map +1 -1
- package/dist/views/Posts/PostTypesView.d.ts +5 -4
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +8 -0
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/page-editor/PagePreview.d.ts.map +1 -1
- package/dist/views/page-editor/PagePreview.js +10 -1
- package/dist/views/page-editor/PagePreview.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +22 -2
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts +6 -2
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js +8 -8
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +7 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +10 -4
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostHeader.d.ts.map +1 -1
- package/dist/views/post-editor/PostHeader.js +4 -3
- package/dist/views/post-editor/PostHeader.js.map +1 -1
- package/dist/views/post-editor/PostPreview.d.ts.map +1 -1
- package/dist/views/post-editor/PostPreview.js +11 -1
- package/dist/views/post-editor/PostPreview.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts +22 -2
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +4 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/lib/page-editor-service-api.test.ts +180 -0
- package/src/__tests__/lib/pages-service-fetch.test.ts +160 -0
- package/src/__tests__/lib/post-editor-service.test.ts +78 -23
- package/src/__tests__/lib/posts-service.test.ts +134 -163
- package/src/__tests__/views/page-section-editor.test.tsx +65 -0
- package/src/__tests__/views/pages-list-view.test.tsx +119 -0
- package/src/__tests__/views/posts-list-view.render.test.tsx +84 -0
- package/src/index.ts +0 -2
- package/src/lib/page-editor-service.ts +61 -0
- package/src/lib/pages-service.ts +88 -35
- package/src/lib/post-editor-service.ts +41 -40
- package/src/lib/posts-service.ts +109 -180
- package/src/views/Pages/PagesListView.tsx +113 -82
- package/src/views/Posts/NewPostPicker.tsx +5 -4
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/Posts/PostsListView.tsx +9 -0
- package/src/views/page-editor/PagePreview.tsx +10 -1
- package/src/views/page-editor/PageSectionEditor.tsx +25 -1
- package/src/views/page-editor/sections/ArticleBodySection.tsx +12 -16
- package/src/views/post-editor/PostEditorCanvas.tsx +7 -1
- package/src/views/post-editor/PostFieldsModal.tsx +36 -21
- package/src/views/post-editor/PostHeader.tsx +4 -4
- package/src/views/post-editor/PostPreview.tsx +12 -1
- package/src/views/post-editor/PostSectionEditor.tsx +22 -5
- package/dist/views/PostEditor.d.ts +0 -6
- package/dist/views/PostEditor.d.ts.map +0 -1
- package/dist/views/PostEditor.js +0 -79
- package/dist/views/PostEditor.js.map +0 -1
- package/dist/views/Posts.d.ts +0 -5
- package/dist/views/Posts.d.ts.map +0 -1
- package/dist/views/Posts.js +0 -73
- package/dist/views/Posts.js.map +0 -1
- package/src/views/PostEditor.tsx +0 -257
- package/src/views/Posts.tsx +0 -375
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const cmsApi = vi.fn()
|
|
5
|
+
vi.mock('../../lib/api.js', () => ({
|
|
6
|
+
cmsApi: (...args: unknown[]) => cmsApi(...args),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
const { fetchPages, fetchPagesDataset, bulkUpdatePages, bulkChangePageTags } =
|
|
10
|
+
await import('../../lib/pages-service.js')
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
cmsApi.mockReset()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function pageDoc(
|
|
17
|
+
id: string,
|
|
18
|
+
over: Record<string, unknown> = {},
|
|
19
|
+
data: Record<string, unknown> = {},
|
|
20
|
+
) {
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
collection: 'pages',
|
|
24
|
+
title: id,
|
|
25
|
+
slug: id,
|
|
26
|
+
status: 'PUBLISHED',
|
|
27
|
+
createdById: 'u1',
|
|
28
|
+
updatedById: 'u1',
|
|
29
|
+
publishedAt: '2026-01-01T00:00:00Z',
|
|
30
|
+
scheduledAt: null,
|
|
31
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
32
|
+
updatedAt: '2026-01-01T00:00:00Z',
|
|
33
|
+
data: { path: `/${id}`, ...data },
|
|
34
|
+
...over,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Route the three reads fetchPagesDataset performs (users, tags, pages).
|
|
39
|
+
function routeReads(pages: unknown[], tags: unknown[] = []) {
|
|
40
|
+
cmsApi.mockImplementation((endpoint: string) => {
|
|
41
|
+
if (endpoint.startsWith('/users')) {
|
|
42
|
+
return Promise.resolve({
|
|
43
|
+
data: { users: [{ id: 'u1', name: 'Jane Doe', email: 'jane@example.com' }] },
|
|
44
|
+
status: 200,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
if (endpoint.startsWith('/collections/page-tags')) {
|
|
48
|
+
return Promise.resolve({ data: { docs: tags, total: tags.length }, status: 200 })
|
|
49
|
+
}
|
|
50
|
+
if (endpoint.startsWith('/collections/pages')) {
|
|
51
|
+
return Promise.resolve({ data: { docs: pages, total: pages.length }, status: 200 })
|
|
52
|
+
}
|
|
53
|
+
return Promise.resolve({ data: null, status: 200 })
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('fetchPagesDataset', () => {
|
|
58
|
+
it('returns every page plus tag counts derived from the full set', async () => {
|
|
59
|
+
routeReads(
|
|
60
|
+
[
|
|
61
|
+
pageDoc('p1', { status: 'DRAFT' }, { tags: ['a'] }),
|
|
62
|
+
pageDoc('p2', {}, { tags: ['a', 'b'] }),
|
|
63
|
+
pageDoc('p3'), // untagged
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
{ id: 't1', slug: 'a', title: 'Alpha', data: { color: 'green', sortOrder: 0 } },
|
|
67
|
+
{ id: 't2', slug: 'b', title: 'Beta', data: { sortOrder: 1 } },
|
|
68
|
+
],
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
const ds = await fetchPagesDataset()
|
|
72
|
+
expect(ds.pages).toHaveLength(3)
|
|
73
|
+
expect(ds.tags.find((t) => t.slug === 'a')?.pageCount).toBe(2)
|
|
74
|
+
expect(ds.tags.find((t) => t.slug === 'b')?.pageCount).toBe(1)
|
|
75
|
+
expect(ds.authors).toHaveLength(1)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('fetchPages counts', () => {
|
|
80
|
+
it('reports grandTotal as the unfiltered count, independent of tag membership', async () => {
|
|
81
|
+
// p3 is untagged: a tag-sum total would undercount (the I-1 bug). The
|
|
82
|
+
// grandTotal must reflect all three pages.
|
|
83
|
+
routeReads(
|
|
84
|
+
[pageDoc('p1', {}, { tags: ['a'] }), pageDoc('p2', {}, { tags: ['a'] }), pageDoc('p3')],
|
|
85
|
+
[{ id: 't1', slug: 'a', title: 'Alpha', data: {} }],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
const res = await fetchPages()
|
|
89
|
+
expect(res.grandTotal).toBe(3)
|
|
90
|
+
expect(res.total).toBe(3)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('paginates the flat view and keeps total/grandTotal stable', async () => {
|
|
94
|
+
routeReads([pageDoc('p1'), pageDoc('p2'), pageDoc('p3')])
|
|
95
|
+
|
|
96
|
+
const first = await fetchPages({ page: 1, pageSize: 2 })
|
|
97
|
+
expect(first.pages).toHaveLength(2)
|
|
98
|
+
expect(first.total).toBe(3)
|
|
99
|
+
expect(first.grandTotal).toBe(3)
|
|
100
|
+
|
|
101
|
+
const second = await fetchPages({ page: 2, pageSize: 2 })
|
|
102
|
+
expect(second.pages).toHaveLength(1)
|
|
103
|
+
expect(second.total).toBe(3)
|
|
104
|
+
expect(second.grandTotal).toBe(3)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('total reflects the filter while grandTotal does not', async () => {
|
|
108
|
+
routeReads([
|
|
109
|
+
pageDoc('p1', { status: 'DRAFT' }),
|
|
110
|
+
pageDoc('p2', { status: 'PUBLISHED' }),
|
|
111
|
+
pageDoc('p3', { status: 'PUBLISHED' }),
|
|
112
|
+
])
|
|
113
|
+
|
|
114
|
+
const res = await fetchPages({ status: 'DRAFT' })
|
|
115
|
+
expect(res.total).toBe(1)
|
|
116
|
+
expect(res.grandTotal).toBe(3)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('bulk operations', () => {
|
|
121
|
+
it('counts ok/failed across a batch and issues one request per id', async () => {
|
|
122
|
+
cmsApi.mockImplementation((endpoint: string) =>
|
|
123
|
+
Promise.resolve(
|
|
124
|
+
endpoint.includes('bad') ? { error: 'nope', status: 500 } : { data: {}, status: 200 },
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const result = await bulkUpdatePages(['a', 'bad', 'c'], { status: 'PUBLISHED' })
|
|
129
|
+
expect(result).toEqual({ ok: 2, failed: 1 })
|
|
130
|
+
expect(cmsApi).toHaveBeenCalledTimes(3)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('does not drop or double-process work above the concurrency limit', async () => {
|
|
134
|
+
cmsApi.mockResolvedValue({ data: {}, status: 200 })
|
|
135
|
+
const ids = Array.from({ length: 12 }, (_, i) => `p${i}`)
|
|
136
|
+
const result = await bulkUpdatePages(ids, { status: 'DRAFT' })
|
|
137
|
+
expect(result).toEqual({ ok: 12, failed: 0 })
|
|
138
|
+
expect(cmsApi).toHaveBeenCalledTimes(12)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('treats a thrown request as a failure rather than aborting the batch', async () => {
|
|
142
|
+
let call = 0
|
|
143
|
+
cmsApi.mockImplementation(() => {
|
|
144
|
+
call++
|
|
145
|
+
if (call === 2) return Promise.reject(new Error('network'))
|
|
146
|
+
return Promise.resolve({ data: {}, status: 200 })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
const result = await bulkChangePageTags(
|
|
150
|
+
[
|
|
151
|
+
{ id: 'a', tags: [], primaryTagId: null },
|
|
152
|
+
{ id: 'b', tags: [], primaryTagId: null },
|
|
153
|
+
{ id: 'c', tags: [], primaryTagId: null },
|
|
154
|
+
],
|
|
155
|
+
{ add: ['x'] },
|
|
156
|
+
)
|
|
157
|
+
expect(result.ok).toBe(2)
|
|
158
|
+
expect(result.failed).toBe(1)
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -135,26 +135,21 @@ describe('validateTemplate', () => {
|
|
|
135
135
|
|
|
136
136
|
describe('emptyPostFromTemplate', () => {
|
|
137
137
|
it('seeds sections from the type template with fresh ids', async () => {
|
|
138
|
-
// fetchTemplateForEditor
|
|
138
|
+
// fetchTemplateForEditor reads the dedicated /post-templates/:type endpoint,
|
|
139
|
+
// whose `{ data }` envelope is unwrapped by cmsApi to { postType, docId, header, sections }.
|
|
139
140
|
cmsApiMock.mockResolvedValueOnce({
|
|
140
141
|
data: {
|
|
141
|
-
|
|
142
|
+
postType: 'blog',
|
|
143
|
+
docId: 'tmpl-1',
|
|
144
|
+
header: { layout: 'left' },
|
|
145
|
+
sections: [
|
|
142
146
|
{
|
|
143
|
-
id: '
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
id: 'orig',
|
|
150
|
-
sectionType: 'article-body',
|
|
151
|
-
name: 'Body',
|
|
152
|
-
visible: true,
|
|
153
|
-
content: {},
|
|
154
|
-
settings: {},
|
|
155
|
-
},
|
|
156
|
-
],
|
|
157
|
-
},
|
|
147
|
+
id: 'orig',
|
|
148
|
+
sectionType: 'article-body',
|
|
149
|
+
name: 'Body',
|
|
150
|
+
visible: true,
|
|
151
|
+
content: {},
|
|
152
|
+
settings: {},
|
|
158
153
|
},
|
|
159
154
|
],
|
|
160
155
|
},
|
|
@@ -166,22 +161,82 @@ describe('emptyPostFromTemplate', () => {
|
|
|
166
161
|
expect(post.sections[0]!.sectionType).toBe('article-body')
|
|
167
162
|
// cloned: a fresh id, not the template's section id
|
|
168
163
|
expect(post.sections[0]!.id).not.toBe('orig')
|
|
164
|
+
// the endpoint was addressed by post type, not the raw collection API
|
|
165
|
+
expect(cmsApiMock.mock.calls[0]![0]).toBe('/post-templates/blog')
|
|
169
166
|
})
|
|
170
167
|
|
|
171
|
-
it('
|
|
172
|
-
|
|
168
|
+
it('seeds the default starting template when the type has no saved template', async () => {
|
|
169
|
+
// Unsaved type → endpoint returns docId null + empty sections; the service
|
|
170
|
+
// backfills the default (article-body + related-posts) so the post is never blank.
|
|
171
|
+
cmsApiMock.mockResolvedValueOnce({ data: { postType: 'news', docId: null, sections: [] } })
|
|
173
172
|
const post = await emptyPostFromTemplate('news')
|
|
174
|
-
expect(post.sections).toEqual([])
|
|
173
|
+
expect(post.sections.map((s) => s.sectionType)).toEqual(['article-body', 'related-posts'])
|
|
174
|
+
// cloned with fresh, distinct ids
|
|
175
|
+
expect(post.sections[0]!.id).not.toBe(post.sections[1]!.id)
|
|
175
176
|
})
|
|
176
177
|
})
|
|
177
178
|
|
|
178
179
|
describe('fetchTemplateForEditor', () => {
|
|
179
|
-
it('
|
|
180
|
-
cmsApiMock.mockResolvedValueOnce({
|
|
180
|
+
it('maps a saved template from the dedicated endpoint', async () => {
|
|
181
|
+
cmsApiMock.mockResolvedValueOnce({
|
|
182
|
+
data: {
|
|
183
|
+
postType: 'blog',
|
|
184
|
+
docId: 'tmpl-9',
|
|
185
|
+
header: { layout: 'overlay' },
|
|
186
|
+
sections: [
|
|
187
|
+
{ id: 's1', sectionType: 'quote', name: 'Q', visible: true, content: {}, settings: {} },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
const t = await fetchTemplateForEditor('blog')
|
|
192
|
+
expect(t.docId).toBe('tmpl-9')
|
|
193
|
+
expect(t.header.layout).toBe('overlay')
|
|
194
|
+
expect(t.sections).toHaveLength(1)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('seeds the default starting template when the type has no saved template', async () => {
|
|
198
|
+
cmsApiMock.mockResolvedValueOnce({ data: { postType: 'blog', docId: null, sections: [] } })
|
|
181
199
|
const t = await fetchTemplateForEditor('blog')
|
|
182
200
|
expect(t.docId).toBeNull()
|
|
183
201
|
expect(t.header.layout).toBe('centered')
|
|
184
|
-
expect(t.sections).toEqual([])
|
|
202
|
+
expect(t.sections.map((s) => s.sectionType)).toEqual(['article-body', 'related-posts'])
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('falls back to the default template when the request fails', async () => {
|
|
206
|
+
cmsApiMock.mockResolvedValueOnce({ error: 'network down' })
|
|
207
|
+
const t = await fetchTemplateForEditor('blog')
|
|
208
|
+
expect(t.docId).toBeNull()
|
|
209
|
+
expect(t.sections.map((s) => s.sectionType)).toEqual(['article-body', 'related-posts'])
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('saveTemplate', () => {
|
|
214
|
+
it('PUTs header + sections to the dedicated endpoint', async () => {
|
|
215
|
+
cmsApiMock.mockResolvedValueOnce({
|
|
216
|
+
data: { postType: 'blog', docId: 'tmpl-1', header: { layout: 'left' }, sections: [] },
|
|
217
|
+
})
|
|
218
|
+
const { saveTemplate } = await import('../../lib/post-editor-service.js')
|
|
219
|
+
const out = await saveTemplate({
|
|
220
|
+
docId: null,
|
|
221
|
+
postType: 'blog',
|
|
222
|
+
header: {
|
|
223
|
+
layout: 'left',
|
|
224
|
+
showFeaturedImage: true,
|
|
225
|
+
showExcerpt: true,
|
|
226
|
+
showAuthor: true,
|
|
227
|
+
showDate: true,
|
|
228
|
+
showCategory: true,
|
|
229
|
+
showReadingTime: false,
|
|
230
|
+
},
|
|
231
|
+
sections: [],
|
|
232
|
+
})
|
|
233
|
+
const [endpoint, init] = cmsApiMock.mock.calls[0]!
|
|
234
|
+
expect(endpoint).toBe('/post-templates/blog')
|
|
235
|
+
expect(init.method).toBe('PUT')
|
|
236
|
+
const body = JSON.parse(init.body as string)
|
|
237
|
+
expect(body.header.layout).toBe('left')
|
|
238
|
+
expect(Array.isArray(body.sections)).toBe(true)
|
|
239
|
+
expect(out.docId).toBe('tmpl-1')
|
|
185
240
|
})
|
|
186
241
|
})
|
|
187
242
|
|
|
@@ -10,37 +10,6 @@ vi.mock('../../lib/api.js', () => ({
|
|
|
10
10
|
|
|
11
11
|
const { fetchPosts, fetchPostTypes } = await import('../../lib/posts-service.js')
|
|
12
12
|
|
|
13
|
-
interface DocFixture {
|
|
14
|
-
id: string
|
|
15
|
-
collection: string
|
|
16
|
-
title: string
|
|
17
|
-
slug: string
|
|
18
|
-
status: 'PUBLISHED' | 'DRAFT' | 'SCHEDULED'
|
|
19
|
-
createdById: string
|
|
20
|
-
updatedById: string
|
|
21
|
-
publishedAt: string | null
|
|
22
|
-
scheduledAt: string | null
|
|
23
|
-
createdAt: string
|
|
24
|
-
updatedAt: string
|
|
25
|
-
data: Record<string, unknown> | null
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function doc(
|
|
29
|
-
o: Partial<DocFixture> & Pick<DocFixture, 'id' | 'collection' | 'title' | 'slug'>,
|
|
30
|
-
): DocFixture {
|
|
31
|
-
return {
|
|
32
|
-
status: 'PUBLISHED',
|
|
33
|
-
createdById: 'u-david',
|
|
34
|
-
updatedById: 'u-david',
|
|
35
|
-
publishedAt: '2026-05-10T00:00:00.000Z',
|
|
36
|
-
scheduledAt: null,
|
|
37
|
-
createdAt: '2026-05-10T00:00:00.000Z',
|
|
38
|
-
updatedAt: '2026-05-10T00:00:00.000Z',
|
|
39
|
-
data: { seoScore: 80 },
|
|
40
|
-
...o,
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
13
|
const COLLECTIONS_RESPONSE = [
|
|
45
14
|
{
|
|
46
15
|
slug: 'blog',
|
|
@@ -54,30 +23,6 @@ const COLLECTIONS_RESPONSE = [
|
|
|
54
23
|
hidden: false,
|
|
55
24
|
fieldCount: 12,
|
|
56
25
|
},
|
|
57
|
-
{
|
|
58
|
-
slug: 'case-studies',
|
|
59
|
-
labels: { singular: 'Case Study', plural: 'Case Studies' },
|
|
60
|
-
type: 'post',
|
|
61
|
-
urlPrefix: 'case-studies',
|
|
62
|
-
description: 'Case studies',
|
|
63
|
-
icon: 'briefcase',
|
|
64
|
-
color: 'green',
|
|
65
|
-
group: 'Posts',
|
|
66
|
-
hidden: false,
|
|
67
|
-
fieldCount: 13,
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
slug: 'news',
|
|
71
|
-
labels: { singular: 'News Item', plural: 'News' },
|
|
72
|
-
type: 'post',
|
|
73
|
-
urlPrefix: 'news',
|
|
74
|
-
description: 'News',
|
|
75
|
-
icon: 'newspaper',
|
|
76
|
-
color: 'orange',
|
|
77
|
-
group: 'Posts',
|
|
78
|
-
hidden: false,
|
|
79
|
-
fieldCount: 11,
|
|
80
|
-
},
|
|
81
26
|
{
|
|
82
27
|
slug: 'pages',
|
|
83
28
|
labels: { singular: 'Page', plural: 'Pages' },
|
|
@@ -92,84 +37,95 @@ const COLLECTIONS_RESPONSE = [
|
|
|
92
37
|
},
|
|
93
38
|
]
|
|
94
39
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
40
|
+
// Canned response from the server-side `GET /posts` aggregate. The server now
|
|
41
|
+
// owns filtering/sorting/pagination, so the client tests assert the mapping
|
|
42
|
+
// from this envelope onto the Post / PostType / PostAuthor shapes and that the
|
|
43
|
+
// query params are forwarded.
|
|
44
|
+
const POSTS_RESPONSE = {
|
|
45
|
+
posts: [
|
|
46
|
+
{
|
|
47
|
+
id: 'n1',
|
|
48
|
+
collection: 'news',
|
|
49
|
+
title: 'Q2 2026 Product Roadmap Update',
|
|
50
|
+
slug: 'q2-roadmap',
|
|
51
|
+
status: 'SCHEDULED' as const,
|
|
52
|
+
publishedAt: null,
|
|
53
|
+
scheduledAt: '2026-06-01T00:00:00.000Z',
|
|
54
|
+
createdAt: '2026-05-10T00:00:00.000Z',
|
|
55
|
+
updatedAt: '2026-05-24T00:00:00.000Z',
|
|
56
|
+
seoScore: 62,
|
|
57
|
+
author: { id: 'u-sarah', name: 'Sarah Morgan' },
|
|
58
|
+
},
|
|
59
|
+
{
|
|
106
60
|
id: 'b1',
|
|
107
61
|
collection: 'blog',
|
|
108
62
|
title: 'The Future of AI Content Marketing',
|
|
109
63
|
slug: 'ai-content-marketing',
|
|
110
|
-
|
|
111
|
-
|
|
64
|
+
status: 'PUBLISHED' as const,
|
|
65
|
+
publishedAt: '2026-05-10T00:00:00.000Z',
|
|
66
|
+
scheduledAt: null,
|
|
67
|
+
createdAt: '2026-05-10T00:00:00.000Z',
|
|
112
68
|
updatedAt: '2026-05-18T00:00:00.000Z',
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
collection: 'blog',
|
|
118
|
-
title: 'ActuateCMS vs Contentful',
|
|
119
|
-
slug: 'actuatecms-vs-contentful',
|
|
120
|
-
updatedAt: '2026-05-15T00:00:00.000Z',
|
|
121
|
-
data: { seoScore: 92 },
|
|
122
|
-
}),
|
|
123
|
-
doc({
|
|
69
|
+
seoScore: 88,
|
|
70
|
+
author: { id: 'u-maria', name: 'Maria K' },
|
|
71
|
+
},
|
|
72
|
+
{
|
|
124
73
|
id: 'b3',
|
|
125
74
|
collection: 'blog',
|
|
126
75
|
title: 'Mid-Year Review',
|
|
127
76
|
slug: 'mid-year-cms-trends',
|
|
128
|
-
status: 'DRAFT',
|
|
129
|
-
createdById: 'u-sarah',
|
|
130
|
-
updatedById: 'u-sarah',
|
|
77
|
+
status: 'DRAFT' as const,
|
|
131
78
|
publishedAt: null,
|
|
79
|
+
scheduledAt: null,
|
|
80
|
+
createdAt: '2026-05-10T00:00:00.000Z',
|
|
132
81
|
updatedAt: '2026-05-22T00:00:00.000Z',
|
|
133
|
-
|
|
134
|
-
|
|
82
|
+
seoScore: null,
|
|
83
|
+
author: null,
|
|
84
|
+
},
|
|
135
85
|
],
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
slug: '
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
86
|
+
total: 5,
|
|
87
|
+
page: 1,
|
|
88
|
+
pageSize: 10,
|
|
89
|
+
types: [
|
|
90
|
+
{
|
|
91
|
+
slug: 'blog',
|
|
92
|
+
singular: 'Blog Post',
|
|
93
|
+
plural: 'Blog',
|
|
94
|
+
description: 'Blog posts',
|
|
95
|
+
urlPrefix: 'blog',
|
|
96
|
+
color: 'purple',
|
|
97
|
+
icon: 'file-text',
|
|
98
|
+
fieldCount: 12,
|
|
99
|
+
count: 3,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
slug: 'news',
|
|
103
|
+
singular: 'News Item',
|
|
104
|
+
plural: 'News',
|
|
105
|
+
description: 'News',
|
|
106
|
+
urlPrefix: 'news',
|
|
107
|
+
color: 'orange',
|
|
108
|
+
icon: 'newspaper',
|
|
109
|
+
fieldCount: 11,
|
|
110
|
+
count: 1,
|
|
111
|
+
},
|
|
147
112
|
],
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
collection: 'news',
|
|
152
|
-
title: 'Q2 2026 Product Roadmap Update',
|
|
153
|
-
slug: 'q2-roadmap',
|
|
154
|
-
status: 'SCHEDULED',
|
|
155
|
-
createdById: 'u-sarah',
|
|
156
|
-
updatedById: 'u-sarah',
|
|
157
|
-
publishedAt: null,
|
|
158
|
-
scheduledAt: '2026-06-01T00:00:00.000Z',
|
|
159
|
-
updatedAt: '2026-05-24T00:00:00.000Z',
|
|
160
|
-
data: { seoScore: 62 },
|
|
161
|
-
}),
|
|
113
|
+
authors: [
|
|
114
|
+
{ id: 'u-sarah', name: 'Sarah Morgan' },
|
|
115
|
+
{ id: 'u-maria', name: 'Maria K' },
|
|
162
116
|
],
|
|
163
117
|
}
|
|
164
118
|
|
|
119
|
+
/** Records the last `/posts` URL the service requested. */
|
|
120
|
+
let lastPostsUrl = ''
|
|
121
|
+
|
|
165
122
|
function installDefaultMocks() {
|
|
123
|
+
lastPostsUrl = ''
|
|
166
124
|
cmsApiMock.mockImplementation((url: string) => {
|
|
167
125
|
if (url === '/collections') return { data: COLLECTIONS_RESPONSE }
|
|
168
|
-
if (url.startsWith('/
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const slug = decodeURIComponent(m[1])
|
|
172
|
-
return { data: { docs: DOCS[slug] ?? [], total: DOCS[slug]?.length ?? 0 } }
|
|
126
|
+
if (url.startsWith('/posts')) {
|
|
127
|
+
lastPostsUrl = url
|
|
128
|
+
return { data: POSTS_RESPONSE }
|
|
173
129
|
}
|
|
174
130
|
return { error: `unexpected url: ${url}` }
|
|
175
131
|
})
|
|
@@ -183,7 +139,7 @@ describe('posts-service.fetchPostTypes', () => {
|
|
|
183
139
|
|
|
184
140
|
it('returns only post-typed collections (filters out type: page)', async () => {
|
|
185
141
|
const types = await fetchPostTypes()
|
|
186
|
-
expect(types.map((t) => t.slug).sort()).toEqual(['blog'
|
|
142
|
+
expect(types.map((t) => t.slug).sort()).toEqual(['blog'])
|
|
187
143
|
})
|
|
188
144
|
|
|
189
145
|
it('maps collection metadata onto the PostType shape', async () => {
|
|
@@ -212,68 +168,83 @@ describe('posts-service.fetchPosts', () => {
|
|
|
212
168
|
installDefaultMocks()
|
|
213
169
|
})
|
|
214
170
|
|
|
215
|
-
it('
|
|
216
|
-
const res = await fetchPosts({ pageSize:
|
|
171
|
+
it('maps the server posts onto the Post shape', async () => {
|
|
172
|
+
const res = await fetchPosts({ pageSize: 10 })
|
|
217
173
|
expect(res.total).toBe(5)
|
|
218
|
-
expect(res.posts.map((p) => p.id)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
// scheduledAt is the most recent date in the fixture set.
|
|
232
|
-
expect(res.posts[0]?.id).toBe('n1')
|
|
233
|
-
expect(res.posts[0]?.publishDate).toBe('2026-06-01T00:00:00.000Z')
|
|
174
|
+
expect(res.posts.map((p) => p.id)).toEqual(['n1', 'b1', 'b3'])
|
|
175
|
+
const blogPost = res.posts.find((p) => p.id === 'b1')
|
|
176
|
+
expect(blogPost).toMatchObject({
|
|
177
|
+
title: 'The Future of AI Content Marketing',
|
|
178
|
+
postTypeId: 'blog',
|
|
179
|
+
postTypeName: 'Blog Post',
|
|
180
|
+
postTypeAccentColor: 'purple',
|
|
181
|
+
authorId: 'u-maria',
|
|
182
|
+
authorName: 'Maria K',
|
|
183
|
+
authorInitials: 'MK',
|
|
184
|
+
status: 'PUBLISHED',
|
|
185
|
+
seoScore: 88,
|
|
186
|
+
})
|
|
234
187
|
})
|
|
235
188
|
|
|
236
|
-
it('
|
|
237
|
-
const res = await fetchPosts({
|
|
238
|
-
|
|
239
|
-
expect(
|
|
189
|
+
it('surfaces the scheduled date for SCHEDULED posts as the display date', async () => {
|
|
190
|
+
const res = await fetchPosts({})
|
|
191
|
+
const news = res.posts.find((p) => p.id === 'n1')
|
|
192
|
+
expect(news?.publishDate).toBe('2026-06-01T00:00:00.000Z')
|
|
193
|
+
expect(news?.updatedDate).toBe('2026-06-01T00:00:00.000Z')
|
|
240
194
|
})
|
|
241
195
|
|
|
242
|
-
it('
|
|
243
|
-
const res = await fetchPosts({
|
|
244
|
-
|
|
196
|
+
it('falls back gracefully when a post has no author', async () => {
|
|
197
|
+
const res = await fetchPosts({})
|
|
198
|
+
const orphan = res.posts.find((p) => p.id === 'b3')
|
|
199
|
+
expect(orphan?.authorName).toBe('Unknown')
|
|
200
|
+
expect(orphan?.authorInitials).toBe('??')
|
|
201
|
+
expect(orphan?.authorId).toBe('')
|
|
245
202
|
})
|
|
246
203
|
|
|
247
|
-
it('
|
|
248
|
-
const res = await fetchPosts({
|
|
249
|
-
expect(res.
|
|
204
|
+
it('maps the dataset-wide tab counts from the endpoint', async () => {
|
|
205
|
+
const res = await fetchPosts({})
|
|
206
|
+
expect(res.types.find((t) => t.slug === 'blog')?.postCount).toBe(3)
|
|
207
|
+
expect(res.types.find((t) => t.slug === 'news')?.postCount).toBe(1)
|
|
250
208
|
})
|
|
251
209
|
|
|
252
|
-
it('
|
|
253
|
-
const res = await fetchPosts({
|
|
254
|
-
expect(res.
|
|
255
|
-
expect(res.posts[0]?.id).toBe('n1')
|
|
210
|
+
it('returns the contributor list sorted by name', async () => {
|
|
211
|
+
const res = await fetchPosts({})
|
|
212
|
+
expect(res.authors.map((a) => a.name)).toEqual(['Maria K', 'Sarah Morgan'])
|
|
256
213
|
})
|
|
257
214
|
|
|
258
|
-
it('
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
215
|
+
it('forwards filter / sort / pagination params to the endpoint', async () => {
|
|
216
|
+
await fetchPosts({
|
|
217
|
+
search: 'roadmap',
|
|
218
|
+
postType: 'news',
|
|
219
|
+
status: 'SCHEDULED',
|
|
220
|
+
author: 'u-sarah',
|
|
221
|
+
sortBy: 'title',
|
|
222
|
+
sortDirection: 'asc',
|
|
223
|
+
page: 2,
|
|
224
|
+
pageSize: 25,
|
|
225
|
+
})
|
|
226
|
+
const params = new URLSearchParams(lastPostsUrl.split('?')[1] ?? '')
|
|
227
|
+
expect(params.get('search')).toBe('roadmap')
|
|
228
|
+
expect(params.get('postType')).toBe('news')
|
|
229
|
+
expect(params.get('status')).toBe('SCHEDULED')
|
|
230
|
+
expect(params.get('author')).toBe('u-sarah')
|
|
231
|
+
expect(params.get('sortBy')).toBe('title')
|
|
232
|
+
expect(params.get('sortDirection')).toBe('asc')
|
|
233
|
+
expect(params.get('page')).toBe('2')
|
|
234
|
+
expect(params.get('pageSize')).toBe('25')
|
|
266
235
|
})
|
|
267
236
|
|
|
268
|
-
it('
|
|
269
|
-
|
|
270
|
-
|
|
237
|
+
it('omits "all" filters from the query string', async () => {
|
|
238
|
+
await fetchPosts({ postType: 'all', status: 'all', author: 'all' })
|
|
239
|
+
const params = new URLSearchParams(lastPostsUrl.split('?')[1] ?? '')
|
|
240
|
+
expect(params.has('postType')).toBe(false)
|
|
241
|
+
expect(params.has('status')).toBe(false)
|
|
242
|
+
expect(params.has('author')).toBe(false)
|
|
271
243
|
})
|
|
272
244
|
|
|
273
|
-
it('
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
expect(res.types.find((t) => t.slug === 'blog')?.postCount).toBe(3)
|
|
245
|
+
it('throws when the endpoint returns an error', async () => {
|
|
246
|
+
cmsApiMock.mockReset()
|
|
247
|
+
cmsApiMock.mockImplementation(() => ({ error: 'boom' }))
|
|
248
|
+
await expect(fetchPosts({})).rejects.toThrow('boom')
|
|
278
249
|
})
|
|
279
250
|
})
|