@manablox/api-rpc 0.1.0 → 0.2.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.
@@ -0,0 +1,253 @@
1
+ import { WORKFLOW_CONDITION_OPERATORS, WORKFLOW_EVENTS, type WorkflowStep } from '@manablox/core';
2
+ import { z } from 'zod';
3
+ import { authed, scoped } from '../base.js';
4
+ import { uuid } from '../schemas.js';
5
+
6
+ const template = z.string().max(20_000);
7
+
8
+ const eventTrigger = z.object({
9
+ kind: z.literal('event'),
10
+ events: z.array(z.enum(WORKFLOW_EVENTS)).max(10),
11
+ typeIds: z.array(z.string().max(120)).max(200).default([]),
12
+ locales: z.array(z.string().max(10)).max(50).default([]),
13
+ });
14
+
15
+ const selection = z.object({
16
+ typeIds: z.array(z.string().max(120)).max(200).default([]),
17
+ status: z.enum(['any', 'draft', 'published']).default('any'),
18
+ changedWithinHours: z
19
+ .number()
20
+ .int()
21
+ .min(1)
22
+ .max(24 * 365)
23
+ .nullable()
24
+ .default(null),
25
+ locale: z.string().max(10).nullable().default(null),
26
+ });
27
+
28
+ const scheduleTrigger = z.object({
29
+ kind: z.literal('schedule'),
30
+ cron: z.string().max(100),
31
+ timezone: z.string().max(60).default('UTC'),
32
+ selection: selection.nullable().default(null),
33
+ perDocument: z.boolean().default(false),
34
+ });
35
+
36
+ const stepBase = {
37
+ id: z.string().max(64).default(''),
38
+ name: z.string().max(200).default(''),
39
+ enabled: z.boolean().default(true),
40
+ continueOnError: z.boolean().default(false),
41
+ };
42
+
43
+ const emailStep = z.object({
44
+ ...stepBase,
45
+ type: z.literal('email'),
46
+ to: z.array(z.string().max(500)).max(50).default([]),
47
+ toRoles: z.array(z.string().max(64)).max(50).default([]),
48
+ subject: template,
49
+ body: template,
50
+ html: z.boolean().default(false),
51
+ });
52
+
53
+ const httpStep = z.object({
54
+ ...stepBase,
55
+ type: z.literal('http'),
56
+ method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('POST'),
57
+ url: z.string().max(2000),
58
+ headers: z
59
+ .array(z.object({ name: z.string().max(200), value: z.string().max(4000) }))
60
+ .max(50)
61
+ .default([]),
62
+ body: z
63
+ .object({
64
+ mode: z.enum(['event', 'custom', 'none']).default('event'),
65
+ template: template.default(''),
66
+ })
67
+ .default({ mode: 'event', template: '' }),
68
+ secret: z.string().max(500).nullable().default(null),
69
+ timeoutMs: z.number().int().min(1000).max(120_000).default(10_000),
70
+ });
71
+
72
+ const pushStep = z.object({
73
+ ...stepBase,
74
+ type: z.literal('push'),
75
+ roles: z.array(z.string().max(64)).max(50).default([]),
76
+ userIds: z.array(uuid).max(200).default([]),
77
+ title: template,
78
+ body: template.default(''),
79
+ url: z.string().max(2000).default(''),
80
+ });
81
+
82
+ const rules = z
83
+ .array(
84
+ z.object({
85
+ field: z.string().max(300),
86
+ operator: z.enum(WORKFLOW_CONDITION_OPERATORS),
87
+ value: z.string().max(4000).default(''),
88
+ }),
89
+ )
90
+ .max(50);
91
+
92
+ const conditionStep = z.object({
93
+ ...stepBase,
94
+ type: z.literal('condition'),
95
+ match: z.enum(['all', 'any']).default('all'),
96
+ rules,
97
+ });
98
+
99
+ const delayStep = z.object({
100
+ ...stepBase,
101
+ type: z.literal('delay'),
102
+ minutes: z
103
+ .number()
104
+ .min(1)
105
+ .max(60 * 24 * 30),
106
+ });
107
+
108
+ /** A fork carries two chains of its own, so the step schema refers to itself (Zod 4 getters). */
109
+ const branchStep = z.object({
110
+ ...stepBase,
111
+ type: z.literal('branch'),
112
+ match: z.enum(['all', 'any']).default('all'),
113
+ rules,
114
+ // Annotated by hand: the getter's inferred type would go through `step`, which is
115
+ // being defined in terms of this object.
116
+ get then(): z.ZodType<WorkflowStep[], WorkflowStepInput[] | undefined> {
117
+ return z.array(step).max(50).default([]) as never;
118
+ },
119
+ get else(): z.ZodType<WorkflowStep[], WorkflowStepInput[] | undefined> {
120
+ return z.array(step).max(50).default([]) as never;
121
+ },
122
+ });
123
+
124
+ /** What the client may send for a fork; the sides are optional and default to empty. */
125
+ export interface WorkflowBranchStepInput {
126
+ id?: string | undefined;
127
+ name?: string | undefined;
128
+ enabled?: boolean | undefined;
129
+ continueOnError?: boolean | undefined;
130
+ type: 'branch';
131
+ match?: 'all' | 'any' | undefined;
132
+ rules: z.input<typeof rules>;
133
+ then?: WorkflowStepInput[] | undefined;
134
+ else?: WorkflowStepInput[] | undefined;
135
+ }
136
+
137
+ export type WorkflowStepInput =
138
+ | z.input<typeof emailStep>
139
+ | z.input<typeof httpStep>
140
+ | z.input<typeof pushStep>
141
+ | z.input<typeof conditionStep>
142
+ | z.input<typeof delayStep>
143
+ | WorkflowBranchStepInput;
144
+
145
+ const step = z.discriminatedUnion('type', [
146
+ emailStep,
147
+ httpStep,
148
+ pushStep,
149
+ conditionStep,
150
+ branchStep,
151
+ delayStep,
152
+ ]);
153
+
154
+ const workflowSchema = z.object({
155
+ spaceId: uuid,
156
+ name: z.string().max(200),
157
+ description: z.string().max(2000).nullable().optional(),
158
+ enabled: z.boolean().optional(),
159
+ trigger: z.discriminatedUnion('kind', [eventTrigger, scheduleTrigger]),
160
+ steps: z.array(step).max(50),
161
+ });
162
+
163
+ const pushSubscription = z.object({
164
+ endpoint: z.string().max(4000),
165
+ keys: z.object({ p256dh: z.string().max(500), auth: z.string().max(500) }),
166
+ });
167
+
168
+ /**
169
+ * Workflows. The rules — a cron that parses, a step with somewhere to go — live in
170
+ * `WorkflowService`; a procedure here is an input schema, a permission and one call.
171
+ */
172
+ export const workflowRouter = {
173
+ /** The events, step types and operators the editor offers, and what the instance can send. */
174
+ catalog: authed.handler(async ({ context }) => context.workflows.catalog()),
175
+
176
+ list: scoped('workflow:read')
177
+ .input(z.object({ spaceId: uuid }))
178
+ .handler(async ({ input, context }) => context.workflows.list(input.spaceId)),
179
+
180
+ get: scoped('workflow:read')
181
+ .input(z.object({ spaceId: uuid, id: uuid }))
182
+ .handler(async ({ input, context }) => context.workflows.get(input.spaceId, input.id)),
183
+
184
+ create: scoped('workflow:write')
185
+ .input(workflowSchema)
186
+ .handler(async ({ input, context }) => {
187
+ const { spaceId, ...data } = input;
188
+ return context.workflows.create(spaceId, data);
189
+ }),
190
+
191
+ update: scoped('workflow:write')
192
+ .input(workflowSchema.extend({ id: uuid }))
193
+ .handler(async ({ input, context }) => {
194
+ const { spaceId, id, ...data } = input;
195
+ return context.workflows.update(spaceId, id, data);
196
+ }),
197
+
198
+ setEnabled: scoped('workflow:write')
199
+ .input(z.object({ spaceId: uuid, id: uuid, enabled: z.boolean() }))
200
+ .handler(async ({ input, context }) =>
201
+ context.workflows.setEnabled(input.spaceId, input.id, input.enabled),
202
+ ),
203
+
204
+ delete: scoped('workflow:write')
205
+ .input(z.object({ spaceId: uuid, id: uuid }))
206
+ .handler(async ({ input, context }) => {
207
+ await context.workflows.delete(input.spaceId, input.id);
208
+ return { ok: true };
209
+ }),
210
+
211
+ /** The latest runs of a workflow, newest first, each with its step log. */
212
+ runs: scoped('workflow:read')
213
+ .input(
214
+ z.object({ spaceId: uuid, id: uuid, limit: z.number().int().min(1).max(200).default(50) }),
215
+ )
216
+ .handler(async ({ input, context }) =>
217
+ context.workflows.runs(input.spaceId, input.id, input.limit),
218
+ ),
219
+
220
+ run: scoped('workflow:read')
221
+ .input(z.object({ spaceId: uuid, id: uuid }))
222
+ .handler(async ({ input, context }) => context.workflows.run(input.spaceId, input.id)),
223
+
224
+ /** Runs the workflow now, against a document when one is named, and returns the run. */
225
+ runNow: scoped('workflow:write')
226
+ .input(z.object({ spaceId: uuid, id: uuid, contentId: uuid.nullable().optional() }))
227
+ .handler(async ({ input, context }) =>
228
+ context.workflows.runNow(input.spaceId, input.id, input.contentId ?? null),
229
+ ),
230
+
231
+ // --- the caller's own push subscriptions ------------------------------------------
232
+
233
+ pushSubscriptions: authed.handler(async ({ context }) =>
234
+ context.workflows.subscriptions(context.principal.userId),
235
+ ),
236
+
237
+ pushSubscribe: authed
238
+ .input(pushSubscription)
239
+ .handler(async ({ input, context }) =>
240
+ context.workflows.subscribe(
241
+ context.principal.userId,
242
+ input,
243
+ context.headers.get('user-agent'),
244
+ ),
245
+ ),
246
+
247
+ pushUnsubscribe: authed
248
+ .input(z.object({ endpoint: z.string().max(4000) }))
249
+ .handler(async ({ input, context }) => {
250
+ await context.workflows.unsubscribe(context.principal.userId, input.endpoint);
251
+ return { ok: true };
252
+ }),
253
+ };
package/src/schemas.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+
3
+ /** Input primitives shared by every management router. */
4
+ export const uuid = z.string().uuid();
5
+
6
+ /** BCP-47-ish, the way the rest of the system stores it: `en`, `de-AT`. */
7
+ export const locale = z.string().min(2).max(10);
8
+ export const localeList = z.array(locale).min(1);
9
+
10
+ /**
11
+ * A technical name: lower-case, starts with a letter, may carry digits, `_` and `-`. It
12
+ * keys the GraphQL schema and the public API's space pinning, so it is stricter than a
13
+ * label.
14
+ */
15
+ export const machineName = z
16
+ .string()
17
+ .regex(/^[a-z][a-z0-9_-]*$/)
18
+ .max(64);
19
+
20
+ /** A role's machine name: one of the built-in five, or a role created for the space. */
21
+ export const spaceRole = machineName;
22
+
23
+ export const searchTerm = z.string().max(200);
24
+
25
+ /** `image/png`, or a family with a trailing slash: `image/`. */
26
+ export const mimeTypePattern = z
27
+ .string()
28
+ .regex(/^[a-z0-9-]+\/([a-z0-9.+-]+)?$/)
29
+ .max(100);
30
+
31
+ /** `limit`/`offset` with the caller's defaults and ceiling. */
32
+ export function pagination(options: { limit: number; max: number }) {
33
+ return z.object({
34
+ limit: z.number().int().min(1).max(options.max).default(options.limit),
35
+ offset: z.number().int().min(0).default(0),
36
+ });
37
+ }
38
+
39
+ /** Every space-scoped procedure names its space; `scoped()` reads it from here. */
40
+ export const spaceScoped = z.object({ spaceId: uuid });
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { assetRouter } from '../src/routers/asset.js';
3
+ import { failure, invoke, principal, SPACE_ID, stubContext } from './helpers.js';
4
+
5
+ const ASSET_ID = '88888888-8888-4888-8888-888888888888';
6
+
7
+ const asset = (overrides = {}) => ({
8
+ id: ASSET_ID,
9
+ spaceId: SPACE_ID,
10
+ filename: 'hero.jpg',
11
+ name: 'hero',
12
+ mimeType: 'image/jpeg',
13
+ size: 4,
14
+ meta: {},
15
+ ...overrides,
16
+ });
17
+
18
+ function context(overrides: Record<string, unknown> = {}) {
19
+ const assets = { list: vi.fn(), findById: vi.fn(), update: vi.fn() };
20
+ const urlFor = (row: { id: string }, preset?: string) =>
21
+ `/media/${row.id}${preset ? `/${preset}` : ''}`;
22
+ const media = {
23
+ limits: vi.fn(),
24
+ urlFor: vi.fn(urlFor),
25
+ // The real `present()` in miniature: the row, its URL, a thumbnail for images.
26
+ present: (row: { id: string; mimeType: string }) => ({
27
+ ...row,
28
+ url: urlFor(row),
29
+ thumbnailUrl: row.mimeType.startsWith('image/') ? urlFor(row, 'thumb') : null,
30
+ }),
31
+ setImageEdits: vi.fn(),
32
+ delete: vi.fn(),
33
+ };
34
+ const ctx = stubContext({ media: media as never, ...overrides });
35
+ (ctx.repos as unknown as { assets: typeof assets }).assets = assets;
36
+ return { ctx, assets, media };
37
+ }
38
+
39
+ describe('assets.list / get', () => {
40
+ it('decorates every row with its URL, and a thumbnail only for images', async () => {
41
+ const { ctx, assets } = context();
42
+ assets.list.mockResolvedValue({
43
+ items: [
44
+ asset(),
45
+ asset({ id: '99999999-9999-4999-8999-999999999999', mimeType: 'application/pdf' }),
46
+ ],
47
+ total: 2,
48
+ limit: 40,
49
+ offset: 0,
50
+ });
51
+ const page = await invoke<{ items: Array<{ url: string; thumbnailUrl: string | null }> }>(
52
+ assetRouter.list,
53
+ { spaceId: SPACE_ID },
54
+ ctx,
55
+ );
56
+ expect(page.items[0]).toMatchObject({
57
+ url: `/media/${ASSET_ID}`,
58
+ thumbnailUrl: `/media/${ASSET_ID}/thumb`,
59
+ });
60
+ expect(page.items[1]?.thumbnailUrl).toBeNull();
61
+ expect(assets.list).toHaveBeenCalledWith({ spaceId: SPACE_ID }, { limit: 40, offset: 0 });
62
+ });
63
+
64
+ it('forwards the search and type filter only when given', async () => {
65
+ const { ctx, assets } = context();
66
+ assets.list.mockResolvedValue({ items: [], total: 0, limit: 10, offset: 5 });
67
+ await invoke(
68
+ assetRouter.list,
69
+ { spaceId: SPACE_ID, search: 'hero', mimeType: 'image/', limit: 10, offset: 5 },
70
+ ctx,
71
+ );
72
+ expect(assets.list).toHaveBeenCalledWith(
73
+ { spaceId: SPACE_ID, mimeType: 'image/', search: 'hero' },
74
+ { limit: 10, offset: 5 },
75
+ );
76
+ });
77
+
78
+ it('answers null for an asset that is not there', async () => {
79
+ const { ctx, assets } = context();
80
+ assets.findById.mockResolvedValue(null);
81
+ expect(await invoke(assetRouter.get, { spaceId: SPACE_ID, id: ASSET_ID }, ctx)).toBeNull();
82
+ assets.findById.mockResolvedValue(asset());
83
+ expect(await invoke(assetRouter.get, { spaceId: SPACE_ID, id: ASSET_ID }, ctx)).toMatchObject({
84
+ id: ASSET_ID,
85
+ url: `/media/${ASSET_ID}`,
86
+ });
87
+ });
88
+
89
+ it('needs asset:read, which a member of another space lacks', async () => {
90
+ const { ctx } = context({ principal: principal({ spaces: {} }) });
91
+ expect(await failure(invoke(assetRouter.list, { spaceId: SPACE_ID }, ctx))).toMatchObject({
92
+ code: 'FORBIDDEN',
93
+ });
94
+ });
95
+ });
96
+
97
+ describe('assets.update / setImageEdits / delete', () => {
98
+ it('writes only the named fields, never the space', async () => {
99
+ const { ctx, assets } = context();
100
+ assets.update.mockResolvedValue(asset({ alt: 'A hero' }));
101
+ await invoke(assetRouter.update, { spaceId: SPACE_ID, id: ASSET_ID, alt: 'A hero' }, ctx);
102
+ expect(assets.update).toHaveBeenCalledWith(ASSET_ID, { alt: 'A hero' });
103
+ });
104
+
105
+ it('states the whole edit: an omitted crop or focal point clears it', async () => {
106
+ const { ctx, media } = context();
107
+ media.setImageEdits.mockResolvedValue(asset());
108
+ const result = await invoke<{ thumbnailUrl: string }>(
109
+ assetRouter.setImageEdits,
110
+ { spaceId: SPACE_ID, id: ASSET_ID, focalPoint: { x: 0.5, y: 0.25 } },
111
+ ctx,
112
+ );
113
+ expect(media.setImageEdits).toHaveBeenCalledWith(ASSET_ID, {
114
+ crop: null,
115
+ focalPoint: { x: 0.5, y: 0.25 },
116
+ });
117
+ expect(result.thumbnailUrl).toBe(`/media/${ASSET_ID}/thumb`);
118
+ });
119
+
120
+ it('refuses a focal point outside the image', async () => {
121
+ const { ctx, media } = context();
122
+ const result = await failure(
123
+ invoke(
124
+ assetRouter.setImageEdits,
125
+ { spaceId: SPACE_ID, id: ASSET_ID, focalPoint: { x: 2, y: 0 } },
126
+ ctx,
127
+ ),
128
+ );
129
+ expect(result.code).toBe('BAD_REQUEST');
130
+ expect(media.setImageEdits).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it('lets an editor delete and an author only upload', async () => {
134
+ const { ctx, media } = context({ principal: principal({ spaces: { [SPACE_ID]: 'author' } }) });
135
+ media.delete.mockResolvedValue(undefined);
136
+ expect(
137
+ await failure(invoke(assetRouter.delete, { spaceId: SPACE_ID, id: ASSET_ID }, ctx)),
138
+ ).toMatchObject({ code: 'FORBIDDEN' });
139
+ const editor = { ...ctx, principal: principal({ spaces: { [SPACE_ID]: 'editor' } }) };
140
+ expect(await invoke(assetRouter.delete, { spaceId: SPACE_ID, id: ASSET_ID }, editor)).toEqual({
141
+ ok: true,
142
+ });
143
+ });
144
+ });
@@ -0,0 +1,256 @@
1
+ import { ManabloxError } from '@manablox/core';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { contentRouter } from '../src/routers/content.js';
4
+ import {
5
+ failure,
6
+ invoke,
7
+ mocks,
8
+ principal,
9
+ SPACE_ID,
10
+ stubContext,
11
+ TYPE_ID,
12
+ USER_ID,
13
+ } from './helpers.js';
14
+
15
+ const DOC_ID = '88888888-8888-4888-8888-888888888888';
16
+ const OTHER_TYPE_ID = '77777777-7777-4777-8777-777777777777';
17
+
18
+ function content() {
19
+ return {
20
+ list: vi.fn(),
21
+ tree: vi.fn(),
22
+ get: vi.fn(),
23
+ initFields: vi.fn(),
24
+ create: vi.fn(),
25
+ update: vi.fn(),
26
+ delete: vi.fn(),
27
+ publish: vi.fn(),
28
+ unpublish: vi.fn(),
29
+ move: vi.fn(),
30
+ translations: vi.fn(),
31
+ createTranslation: vi.fn(),
32
+ restore: vi.fn(),
33
+ };
34
+ }
35
+
36
+ const row = (overrides = {}) => ({
37
+ id: DOC_ID,
38
+ spaceId: SPACE_ID,
39
+ typeId: TYPE_ID,
40
+ locale: 'en',
41
+ title: 'Hello',
42
+ slug: 'hello',
43
+ fields: {},
44
+ ...overrides,
45
+ });
46
+
47
+ const page = { items: [], total: 0, limit: 25, offset: 0 };
48
+
49
+ /** A role that reads and writes one type only, the way a custom grant narrows it. */
50
+ const narrowed = () =>
51
+ principal({
52
+ spaces: { [SPACE_ID]: 'blogger' },
53
+ permissions: {
54
+ [SPACE_ID]: [
55
+ 'space:read',
56
+ 'contentType:read',
57
+ `content:read:${TYPE_ID}`,
58
+ `content:write:${TYPE_ID}`,
59
+ ],
60
+ },
61
+ });
62
+
63
+ describe('content.list', () => {
64
+ it('reads the space out of the filter and passes the actor along', async () => {
65
+ const service = content();
66
+ service.list.mockResolvedValue(page);
67
+ const ctx = stubContext({ content: service as never });
68
+ await invoke(contentRouter.list, { filter: { spaceId: SPACE_ID } }, ctx);
69
+ expect(service.list).toHaveBeenCalledWith({ spaceId: SPACE_ID }, { limit: 25, offset: 0 }, [], {
70
+ actor: { userId: USER_ID, roles: ['editor', 'owner'] },
71
+ });
72
+ });
73
+
74
+ it('narrows a type-scoped role to the types it may read, and answers empty when none remain', async () => {
75
+ const service = content();
76
+ service.list.mockResolvedValue(page);
77
+ const ctx = stubContext({ content: service as never, principal: narrowed() });
78
+
79
+ await invoke(contentRouter.list, { filter: { spaceId: SPACE_ID } }, ctx);
80
+ expect(service.list.mock.calls[0]?.[0]).toEqual({ spaceId: SPACE_ID, typeIds: [TYPE_ID] });
81
+
82
+ const empty = await invoke(
83
+ contentRouter.list,
84
+ { filter: { spaceId: SPACE_ID, typeIds: [OTHER_TYPE_ID] } },
85
+ ctx,
86
+ );
87
+ expect(empty).toEqual(page);
88
+ expect(service.list).toHaveBeenCalledTimes(1);
89
+ });
90
+
91
+ it('refuses a filter operator and a sort key it does not know', async () => {
92
+ const ctx = stubContext({ content: content() as never });
93
+ const bad = await failure(
94
+ invoke(
95
+ contentRouter.list,
96
+ { filter: { spaceId: SPACE_ID, fields: [{ name: 'x', op: 'regex' }] } },
97
+ ctx,
98
+ ),
99
+ );
100
+ expect(bad.code).toBe('BAD_REQUEST');
101
+ const badSort = await failure(
102
+ invoke(contentRouter.list, { filter: { spaceId: SPACE_ID }, sort: [{ by: 'colour' }] }, ctx),
103
+ );
104
+ expect(badSort.code).toBe('BAD_REQUEST');
105
+ });
106
+ });
107
+
108
+ describe('content.get / delete / publish', () => {
109
+ it('reads the document without a second lookup for a broad role', async () => {
110
+ const service = content();
111
+ service.get.mockResolvedValue(row());
112
+ const ctx = stubContext({ content: service as never });
113
+ expect(await invoke(contentRouter.get, { spaceId: SPACE_ID, id: DOC_ID }, ctx)).toEqual(row());
114
+ expect(mocks(ctx).content.findById).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it('checks the document’s type for a narrowed role, and hides one it may not read', async () => {
118
+ const service = content();
119
+ service.get.mockResolvedValue(row());
120
+ const ctx = stubContext({ content: service as never, principal: narrowed() });
121
+ const repos = mocks(ctx);
122
+
123
+ repos.content.findById.mockResolvedValue(row());
124
+ expect(await invoke(contentRouter.get, { spaceId: SPACE_ID, id: DOC_ID }, ctx)).toEqual(row());
125
+
126
+ repos.content.findById.mockResolvedValue(row({ typeId: OTHER_TYPE_ID }));
127
+ expect(
128
+ await failure(invoke(contentRouter.get, { spaceId: SPACE_ID, id: DOC_ID }, ctx)),
129
+ ).toMatchObject({ code: 'FORBIDDEN' });
130
+
131
+ repos.content.findById.mockResolvedValue(null);
132
+ expect(
133
+ await failure(invoke(contentRouter.get, { spaceId: SPACE_ID, id: DOC_ID }, ctx)),
134
+ ).toMatchObject({ code: 'NOT_FOUND', key: 'content.notFound' });
135
+ });
136
+
137
+ it('keeps publishing from an author, who may write but never publish', async () => {
138
+ const service = content();
139
+ service.publish.mockResolvedValue(row());
140
+ const ctx = stubContext({
141
+ content: service as never,
142
+ principal: principal({ spaces: { [SPACE_ID]: 'author' } }),
143
+ });
144
+ expect(
145
+ await failure(invoke(contentRouter.publish, { spaceId: SPACE_ID, id: DOC_ID }, ctx)),
146
+ ).toMatchObject({ code: 'FORBIDDEN' });
147
+ expect(await invoke(contentRouter.delete, { spaceId: SPACE_ID, id: DOC_ID }, ctx)).toEqual({
148
+ deleted: undefined,
149
+ });
150
+ });
151
+ });
152
+
153
+ describe('content.create / update', () => {
154
+ it('defaults the locale and fields and checks the type for a narrowed role', async () => {
155
+ const service = content();
156
+ service.create.mockResolvedValue(row());
157
+ const ctx = stubContext({ content: service as never, principal: narrowed() });
158
+ await invoke(contentRouter.create, { spaceId: SPACE_ID, typeId: TYPE_ID, title: 'Hello' }, ctx);
159
+ expect(service.create).toHaveBeenCalledWith(
160
+ { spaceId: SPACE_ID, typeId: TYPE_ID, title: 'Hello', locale: 'en', fields: {} },
161
+ { userId: USER_ID, roles: ['editor', 'blogger'] },
162
+ );
163
+ expect(
164
+ await failure(
165
+ invoke(contentRouter.create, { spaceId: SPACE_ID, typeId: OTHER_TYPE_ID, title: 'X' }, ctx),
166
+ ),
167
+ ).toMatchObject({ code: 'FORBIDDEN' });
168
+ });
169
+
170
+ it('maps a validation error to BAD_REQUEST with every detail', async () => {
171
+ const service = content();
172
+ service.update.mockRejectedValue(
173
+ ManabloxError.validation(
174
+ [
175
+ { key: 'content.slug.duplicate', path: ['slug'] },
176
+ { key: 'field.required', path: ['fields', 'body'] },
177
+ ],
178
+ 'content.validation.failed',
179
+ ),
180
+ );
181
+ const ctx = stubContext({ content: service as never });
182
+ const result = await failure(
183
+ invoke(
184
+ contentRouter.update,
185
+ { spaceId: SPACE_ID, id: DOC_ID, typeId: TYPE_ID, title: 'Hello' },
186
+ ctx,
187
+ ),
188
+ );
189
+ expect(result).toMatchObject({ code: 'BAD_REQUEST', key: 'content.validation.failed' });
190
+ expect(result.details?.map((d) => d.path)).toEqual([['slug'], ['fields', 'body']]);
191
+ });
192
+
193
+ it('refuses an empty title before the service', async () => {
194
+ const service = content();
195
+ const ctx = stubContext({ content: service as never });
196
+ const result = await failure(
197
+ invoke(contentRouter.create, { spaceId: SPACE_ID, typeId: TYPE_ID, title: '' }, ctx),
198
+ );
199
+ expect(result.code).toBe('BAD_REQUEST');
200
+ expect(service.create).not.toHaveBeenCalled();
201
+ });
202
+ });
203
+
204
+ describe('content.move / translations / blank', () => {
205
+ it('hands the destination through and maps a cross-space parent to BAD_REQUEST', async () => {
206
+ const service = content();
207
+ service.move.mockResolvedValueOnce(row());
208
+ service.move.mockRejectedValueOnce(
209
+ ManabloxError.badRequest('content.parent.notInSpace', { parentId: DOC_ID }),
210
+ );
211
+ const ctx = stubContext({ content: service as never });
212
+ await invoke(
213
+ contentRouter.move,
214
+ { spaceId: SPACE_ID, id: DOC_ID, parentId: null, position: 2 },
215
+ ctx,
216
+ );
217
+ expect(service.move).toHaveBeenCalledWith(SPACE_ID, DOC_ID, null, 2);
218
+ expect(
219
+ await failure(
220
+ invoke(
221
+ contentRouter.move,
222
+ { spaceId: SPACE_ID, id: DOC_ID, parentId: DOC_ID, position: 0 },
223
+ ctx,
224
+ ),
225
+ ),
226
+ ).toMatchObject({ code: 'BAD_REQUEST', key: 'content.parent.notInSpace' });
227
+ });
228
+
229
+ it('starts a translation in the named locale', async () => {
230
+ const service = content();
231
+ service.createTranslation.mockResolvedValue(row({ locale: 'de' }));
232
+ const ctx = stubContext({ content: service as never });
233
+ await invoke(
234
+ contentRouter.createTranslation,
235
+ { spaceId: SPACE_ID, id: DOC_ID, locale: 'de' },
236
+ ctx,
237
+ );
238
+ expect(service.createTranslation).toHaveBeenCalledWith(SPACE_ID, DOC_ID, 'de', {
239
+ userId: USER_ID,
240
+ roles: ['editor', 'owner'],
241
+ });
242
+ });
243
+
244
+ it('opens a blank document with the type’s defaults', async () => {
245
+ const service = content();
246
+ service.initFields.mockResolvedValue({ body: '' });
247
+ const ctx = stubContext({ content: service as never });
248
+ (ctx.manablox as unknown as { contentTypes: { get: () => unknown } }).contentTypes.get =
249
+ () => ({
250
+ id: TYPE_ID,
251
+ });
252
+ expect(await invoke(contentRouter.blank, { spaceId: SPACE_ID, typeId: TYPE_ID }, ctx)).toEqual({
253
+ fields: { body: '' },
254
+ });
255
+ });
256
+ });