payload-mcp-toolkit 0.7.0 → 0.7.4

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.
Files changed (48) hide show
  1. package/README.md +29 -8
  2. package/dist/api-keys.js +57 -21
  3. package/dist/api-keys.js.map +1 -1
  4. package/dist/auth-strategy.d.ts +18 -7
  5. package/dist/auth-strategy.js +54 -12
  6. package/dist/auth-strategy.js.map +1 -1
  7. package/dist/tools/_helpers.d.ts +34 -0
  8. package/dist/tools/_helpers.js +98 -0
  9. package/dist/tools/_helpers.js.map +1 -1
  10. package/dist/tools/publish-draft.js +33 -1
  11. package/dist/tools/publish-draft.js.map +1 -1
  12. package/dist/tools/publish-global-draft.js +30 -1
  13. package/dist/tools/publish-global-draft.js.map +1 -1
  14. package/package.json +29 -15
  15. package/dist/__tests__/api-keys.test.js +0 -292
  16. package/dist/__tests__/api-keys.test.js.map +0 -1
  17. package/dist/__tests__/auth-strategy.test.js +0 -681
  18. package/dist/__tests__/auth-strategy.test.js.map +0 -1
  19. package/dist/__tests__/conflict-detection.test.js +0 -69
  20. package/dist/__tests__/conflict-detection.test.js.map +0 -1
  21. package/dist/__tests__/delete-document.test.js +0 -70
  22. package/dist/__tests__/delete-document.test.js.map +0 -1
  23. package/dist/__tests__/endpoint.test.js +0 -143
  24. package/dist/__tests__/endpoint.test.js.map +0 -1
  25. package/dist/__tests__/find-document.test.js +0 -178
  26. package/dist/__tests__/find-document.test.js.map +0 -1
  27. package/dist/__tests__/find-global.test.js +0 -173
  28. package/dist/__tests__/find-global.test.js.map +0 -1
  29. package/dist/__tests__/global-versions.test.js +0 -183
  30. package/dist/__tests__/global-versions.test.js.map +0 -1
  31. package/dist/__tests__/hash.test.js +0 -58
  32. package/dist/__tests__/hash.test.js.map +0 -1
  33. package/dist/__tests__/index-integration.test.js +0 -191
  34. package/dist/__tests__/index-integration.test.js.map +0 -1
  35. package/dist/__tests__/introspection.test.js +0 -659
  36. package/dist/__tests__/introspection.test.js.map +0 -1
  37. package/dist/__tests__/patch-global-layout.test.js +0 -474
  38. package/dist/__tests__/patch-global-layout.test.js.map +0 -1
  39. package/dist/__tests__/patch-layout.test.js +0 -171
  40. package/dist/__tests__/patch-layout.test.js.map +0 -1
  41. package/dist/__tests__/registry.test.js +0 -795
  42. package/dist/__tests__/registry.test.js.map +0 -1
  43. package/dist/__tests__/resources.test.js +0 -139
  44. package/dist/__tests__/resources.test.js.map +0 -1
  45. package/dist/__tests__/update-global.test.js +0 -157
  46. package/dist/__tests__/update-global.test.js.map +0 -1
  47. package/dist/__tests__/url-validator.test.js +0 -326
  48. package/dist/__tests__/url-validator.test.js.map +0 -1
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../../src/__tests__/index-integration.test.ts"],"sourcesContent":["import { describe, it, expect } from 'vitest'\r\nimport type { Config } from 'payload'\r\nimport { mcpToolkitPlugin } from '../index'\r\n\r\nfunction baseConfig(): Config {\r\n return {\r\n serverURL: 'https://app.example.com',\r\n secret: 'test-secret',\r\n admin: { user: 'users' },\r\n collections: [\r\n {\r\n slug: 'users',\r\n auth: true,\r\n fields: [{ name: 'email', type: 'email', required: true }],\r\n },\r\n {\r\n slug: 'posts',\r\n fields: [\r\n { name: 'title', type: 'text' },\r\n { name: 'slug', type: 'text' },\r\n ],\r\n },\r\n ],\r\n endpoints: [],\r\n } as never\r\n}\r\n\r\ndescribe('mcpToolkitPlugin integration', () => {\r\n it('appends the api-keys collection and the MCP endpoints additively', () => {\r\n const cfg = mcpToolkitPlugin()(baseConfig())\r\n const slugs = (cfg.collections ?? []).map((c) => c.slug)\r\n expect(slugs).toContain('payload-mcp-api-keys')\r\n expect(slugs).toContain('users')\r\n expect(slugs).toContain('posts')\r\n const endpointPaths = (cfg.endpoints ?? []).map((e) => `${e.method.toUpperCase()} ${e.path}`)\r\n expect(endpointPaths).toEqual(expect.arrayContaining(['POST /mcp', 'GET /mcp']))\r\n })\r\n\r\n it('attaches the bearer strategy to the user collection without losing existing auth config', () => {\r\n const cfg = mcpToolkitPlugin()(baseConfig())\r\n const users = (cfg.collections ?? []).find((c) => c.slug === 'users') as {\r\n auth: { strategies?: Array<{ name: string }> }\r\n }\r\n expect(users).toBeDefined()\r\n const names = users.auth.strategies?.map((s) => s.name) ?? []\r\n expect(names).toContain('mcp-toolkit-bearer')\r\n })\r\n\r\n it('preserves existing auth.strategies on the user collection', () => {\r\n const cfg = baseConfig() as Config & {\r\n collections: Array<{ slug: string; auth?: unknown; fields: unknown[] }>\r\n }\r\n const otherStrategy = { name: 'tenant-shared-strategy', authenticate: async () => ({ user: null }) }\r\n cfg.collections[0]!.auth = { strategies: [otherStrategy] } as never\r\n const out = mcpToolkitPlugin()(cfg as never)\r\n const users = (out.collections ?? []).find((c) => c.slug === 'users') as {\r\n auth: { strategies: Array<{ name: string }> }\r\n }\r\n const names = users.auth.strategies.map((s) => s.name)\r\n expect(names).toEqual(['tenant-shared-strategy', 'mcp-toolkit-bearer'])\r\n })\r\n\r\n it('respects a custom user collection slug from incomingConfig.admin.user', () => {\r\n const cfg = baseConfig() as Config & {\r\n collections: Array<{ slug: string; auth?: unknown; fields: unknown[] }>\r\n admin?: { user?: string }\r\n }\r\n cfg.admin = { user: 'admins' }\r\n cfg.collections[0]!.slug = 'admins'\r\n const out = mcpToolkitPlugin()(cfg as never)\r\n const admins = (out.collections ?? []).find((c) => c.slug === 'admins') as {\r\n auth: { strategies?: Array<{ name: string }> }\r\n }\r\n expect(admins.auth.strategies?.some((s) => s.name === 'mcp-toolkit-bearer')).toBe(true)\r\n })\r\n\r\n it('throws when @payloadcms/plugin-mcp appears to also be registered', () => {\r\n const cfg = baseConfig() as Config\r\n function mcpPlugin() {}\r\n ;(cfg as { plugins: unknown[] }).plugins = [mcpPlugin as never]\r\n expect(() => mcpToolkitPlugin()(cfg)).toThrow(/standalone successor/)\r\n })\r\n\r\n it('throws when an existing collection takes the api-keys slug', () => {\r\n const cfg = baseConfig() as Config & {\r\n collections: Array<{ slug: string; fields: unknown[] }>\r\n }\r\n cfg.collections.push({ slug: 'payload-mcp-api-keys', fields: [] })\r\n expect(() => mcpToolkitPlugin()(cfg as never)).toThrow(/payload-mcp-api-keys/)\r\n })\r\n\r\n it('honours a custom apiKeyCollection.slug', () => {\r\n const cfg = mcpToolkitPlugin({ apiKeyCollection: { slug: 'my-keys' } })(baseConfig())\r\n const slugs = (cfg.collections ?? []).map((c) => c.slug)\r\n expect(slugs).toContain('my-keys')\r\n expect(slugs).not.toContain('payload-mcp-api-keys')\r\n })\r\n\r\n it('a host config with no globals still registers the globalScopes field (matrix shows empty state)', () => {\r\n const cfg = mcpToolkitPlugin()(baseConfig())\r\n // The field always renders under Custom; the matrix component reports\r\n // the absence via its own empty-state copy when availableGlobals is [].\r\n const apiKeys = (cfg.collections ?? []).find((c) => c.slug === 'payload-mcp-api-keys') as {\r\n fields: Array<{\r\n name?: string\r\n admin?: {\r\n condition?: (data: unknown) => boolean\r\n components?: { Field?: { clientProps?: { availableGlobals?: string[] } } }\r\n }\r\n }>\r\n }\r\n const globalScopes = apiKeys.fields.find((f) => f.name === 'globalScopes')!\r\n expect(globalScopes.admin?.condition?.({ preset: 'custom' })).toBe(true)\r\n expect(globalScopes.admin?.condition?.({ preset: 'editor' })).toBe(false)\r\n expect(globalScopes.admin?.components?.Field?.clientProps?.availableGlobals).toEqual([])\r\n })\r\n\r\n it('a host config with one plain global makes globalScopes UI render under Custom', () => {\r\n const cfg = baseConfig() as Config & { globals?: unknown[] }\r\n cfg.globals = [\r\n { slug: 'site-settings', fields: [{ name: 'siteName', type: 'text' }] },\r\n ] as never\r\n const out = mcpToolkitPlugin()(cfg)\r\n const apiKeys = (out.collections ?? []).find((c) => c.slug === 'payload-mcp-api-keys') as {\r\n fields: Array<{\r\n name?: string\r\n admin?: {\r\n condition?: (data: unknown) => boolean\r\n components?: { Field?: { clientProps?: { availableGlobals?: string[] } } }\r\n }\r\n }>\r\n }\r\n const globalScopes = apiKeys.fields.find((f) => f.name === 'globalScopes')!\r\n expect(globalScopes.admin?.condition?.({ preset: 'custom' })).toBe(true)\r\n expect(globalScopes.admin?.components?.Field?.clientProps?.availableGlobals).toEqual([\r\n 'site-settings',\r\n ])\r\n })\r\n\r\n it('excluded globals are filtered out of availableGlobals at registration time', () => {\r\n const cfg = baseConfig() as Config & { globals?: unknown[] }\r\n cfg.globals = [\r\n { slug: 'site-settings', fields: [{ name: 'siteName', type: 'text' }] },\r\n { slug: 'secret-config', fields: [{ name: 'token', type: 'text' }] },\r\n ] as never\r\n const out = mcpToolkitPlugin({ exclude: { globals: ['secret-config'] } })(cfg)\r\n const apiKeys = (out.collections ?? []).find((c) => c.slug === 'payload-mcp-api-keys') as {\r\n fields: Array<{\r\n name?: string\r\n admin?: { components?: { Field?: { clientProps?: { availableGlobals?: string[] } } } }\r\n }>\r\n }\r\n const globalScopes = apiKeys.fields.find((f) => f.name === 'globalScopes')!\r\n expect(globalScopes.admin?.components?.Field?.clientProps?.availableGlobals).toEqual([\r\n 'site-settings',\r\n ])\r\n })\r\n})\r\n"],"names":["describe","it","expect","mcpToolkitPlugin","baseConfig","serverURL","secret","admin","user","collections","slug","auth","fields","name","type","required","endpoints","cfg","slugs","map","c","toContain","endpointPaths","e","method","toUpperCase","path","toEqual","arrayContaining","users","find","toBeDefined","names","strategies","s","otherStrategy","authenticate","out","admins","some","toBe","mcpPlugin","plugins","toThrow","push","apiKeyCollection","not","apiKeys","globalScopes","f","condition","preset","components","Field","clientProps","availableGlobals","globals","exclude"],"mappings":"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"}
@@ -1,659 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { introspectCollection, introspectCollections, introspectBlocks, buildBlockNestingMap, buildRelationshipGraph, hasGlobalDrafts, introspectGlobal, introspectGlobals } from '../introspection';
3
- // ─── Sample schema (kept inline so the test is self-contained) ─────
4
- const Media = {
5
- slug: 'media',
6
- upload: true,
7
- fields: [
8
- {
9
- name: 'alt',
10
- type: 'text',
11
- required: true
12
- }
13
- ]
14
- };
15
- const Categories = {
16
- slug: 'categories',
17
- fields: [
18
- {
19
- name: 'name',
20
- type: 'text',
21
- required: true
22
- },
23
- {
24
- name: 'slug',
25
- type: 'text',
26
- required: true
27
- }
28
- ]
29
- };
30
- const Authors = {
31
- slug: 'authors',
32
- fields: [
33
- {
34
- name: 'name',
35
- type: 'text',
36
- required: true
37
- },
38
- {
39
- name: 'slug',
40
- type: 'text',
41
- required: true
42
- },
43
- {
44
- name: 'avatar',
45
- type: 'upload',
46
- relationTo: 'media'
47
- }
48
- ]
49
- };
50
- // Leaf-style blocks
51
- const Heading = {
52
- slug: 'heading',
53
- fields: [
54
- {
55
- name: 'text',
56
- type: 'text',
57
- required: true
58
- },
59
- {
60
- name: 'level',
61
- type: 'select',
62
- options: [
63
- 'h1',
64
- 'h2',
65
- 'h3'
66
- ],
67
- defaultValue: 'h2'
68
- },
69
- {
70
- name: 'align',
71
- type: 'select',
72
- options: [
73
- 'left',
74
- 'center',
75
- 'right'
76
- ],
77
- defaultValue: 'left'
78
- }
79
- ]
80
- };
81
- const RichText = {
82
- slug: 'richText',
83
- fields: [
84
- {
85
- name: 'content',
86
- type: 'richText'
87
- }
88
- ]
89
- };
90
- const ImageBlock = {
91
- slug: 'image',
92
- fields: [
93
- {
94
- name: 'image',
95
- type: 'upload',
96
- relationTo: 'media',
97
- required: true
98
- },
99
- {
100
- name: 'caption',
101
- type: 'text'
102
- }
103
- ]
104
- };
105
- // Container-style blocks (have nested blocks fields)
106
- const FullWidth = {
107
- slug: 'fullWidth',
108
- fields: [
109
- {
110
- name: 'content',
111
- type: 'blocks',
112
- blocks: [
113
- Heading,
114
- RichText,
115
- ImageBlock
116
- ]
117
- }
118
- ]
119
- };
120
- const HeadingOnly = {
121
- slug: 'headingOnly',
122
- fields: [
123
- {
124
- name: 'content',
125
- type: 'blocks',
126
- maxRows: 1,
127
- blocks: [
128
- Heading
129
- ]
130
- }
131
- ]
132
- };
133
- const CtaBanner = {
134
- slug: 'ctaBanner',
135
- fields: [
136
- {
137
- name: 'headline',
138
- type: 'text',
139
- required: true
140
- },
141
- {
142
- name: 'buttonLabel',
143
- type: 'text'
144
- },
145
- {
146
- name: 'buttonHref',
147
- type: 'text'
148
- }
149
- ]
150
- };
151
- // Deeply-nestable container — exercises the recursive path
152
- const Accordion = {
153
- slug: 'accordion',
154
- fields: [
155
- {
156
- name: 'panels',
157
- type: 'array',
158
- fields: [
159
- {
160
- name: 'title',
161
- type: 'text'
162
- },
163
- {
164
- name: 'body',
165
- type: 'blocks',
166
- blocks: [
167
- Heading,
168
- RichText,
169
- FullWidth
170
- ]
171
- }
172
- ]
173
- }
174
- ]
175
- };
176
- const allBlocks = [
177
- Heading,
178
- RichText,
179
- ImageBlock,
180
- FullWidth,
181
- HeadingOnly,
182
- CtaBanner,
183
- Accordion
184
- ];
185
- const Posts = {
186
- slug: 'posts',
187
- versions: {
188
- drafts: true
189
- },
190
- fields: [
191
- {
192
- name: 'title',
193
- type: 'text',
194
- required: true
195
- },
196
- {
197
- name: 'slug',
198
- type: 'text',
199
- required: true
200
- },
201
- {
202
- name: 'featured',
203
- type: 'checkbox'
204
- },
205
- {
206
- name: 'category',
207
- type: 'relationship',
208
- relationTo: 'categories'
209
- },
210
- {
211
- name: 'authors',
212
- type: 'relationship',
213
- relationTo: 'authors',
214
- hasMany: true
215
- },
216
- {
217
- name: 'coverImage',
218
- type: 'upload',
219
- relationTo: 'media'
220
- },
221
- {
222
- name: 'tags',
223
- type: 'array',
224
- fields: [
225
- {
226
- name: 'tag',
227
- type: 'text'
228
- }
229
- ]
230
- }
231
- ]
232
- };
233
- const Pages = {
234
- slug: 'pages',
235
- versions: {
236
- drafts: true
237
- },
238
- fields: [
239
- {
240
- type: 'tabs',
241
- tabs: [
242
- {
243
- name: 'hero',
244
- label: 'Hero',
245
- fields: [
246
- {
247
- name: 'heroTitle',
248
- type: 'text'
249
- },
250
- {
251
- name: 'heroSize',
252
- type: 'select',
253
- options: [
254
- 'small',
255
- 'medium',
256
- 'large'
257
- ],
258
- defaultValue: 'medium'
259
- }
260
- ]
261
- },
262
- {
263
- label: 'Content',
264
- fields: [
265
- {
266
- name: 'slug',
267
- type: 'text',
268
- required: true
269
- },
270
- {
271
- name: 'layout',
272
- type: 'blocks',
273
- blocks: [
274
- FullWidth,
275
- HeadingOnly,
276
- CtaBanner,
277
- Accordion
278
- ]
279
- }
280
- ]
281
- }
282
- ]
283
- }
284
- ]
285
- };
286
- // ─── introspectCollection ──────────────────────────────────────────
287
- describe('introspectCollection', ()=>{
288
- it('extracts Posts collection fields, relationships, and draft status', ()=>{
289
- const schema = introspectCollection(Posts);
290
- expect(schema.slug).toBe('posts');
291
- expect(schema.hasDrafts).toBe(true);
292
- const fieldNames = schema.fields.map((f)=>f.name);
293
- expect(fieldNames).toContain('title');
294
- expect(fieldNames).toContain('slug');
295
- expect(fieldNames).toContain('featured');
296
- expect(fieldNames).toContain('tags');
297
- const relFieldNames = schema.relationships.map((r)=>r.fieldName);
298
- expect(relFieldNames).toContain('category');
299
- expect(relFieldNames).toContain('authors');
300
- const cover = schema.relationships.find((r)=>r.fieldName === 'coverImage');
301
- expect(cover).toBeDefined();
302
- expect(cover.relationTo).toBe('media');
303
- expect(schema.searchableFields).toContain('title');
304
- expect(schema.searchableFields).toContain('slug');
305
- });
306
- it('extracts Pages collection with tab-nested fields', ()=>{
307
- const schema = introspectCollection(Pages);
308
- expect(schema.slug).toBe('pages');
309
- expect(schema.hasDrafts).toBe(true);
310
- const fieldNames = schema.fields.map((f)=>f.name);
311
- expect(fieldNames).toContain('heroTitle');
312
- expect(fieldNames).toContain('slug');
313
- expect(fieldNames).toContain('layout');
314
- });
315
- it('detects collections without draft support', ()=>{
316
- const schema = introspectCollection(Categories);
317
- expect(schema.hasDrafts).toBe(false);
318
- });
319
- it('extracts select field options from Pages heroSize', ()=>{
320
- const schema = introspectCollection(Pages);
321
- const heroSize = schema.fields.find((f)=>f.name === 'heroSize');
322
- expect(heroSize).toBeDefined();
323
- expect(heroSize.type).toBe('select');
324
- expect(heroSize.options).toBeDefined();
325
- expect(heroSize.options.length).toBe(3);
326
- });
327
- });
328
- // ─── introspectBlocks (flat catalog) ───────────────────────────────
329
- describe('introspectBlocks', ()=>{
330
- it('returns a flat catalog of every block with no section/leaf split', ()=>{
331
- const catalog = introspectBlocks(allBlocks);
332
- const slugs = catalog.blocks.map((b)=>b.slug);
333
- expect(slugs).toEqual([
334
- 'heading',
335
- 'richText',
336
- 'image',
337
- 'fullWidth',
338
- 'headingOnly',
339
- 'ctaBanner',
340
- 'accordion'
341
- ]);
342
- });
343
- it('extracts each block\'s fields including select options', ()=>{
344
- const catalog = introspectBlocks(allBlocks);
345
- const heading = catalog.blocks.find((b)=>b.slug === 'heading');
346
- expect(heading).toBeDefined();
347
- const headingFieldNames = heading.fields.map((f)=>f.name);
348
- expect(headingFieldNames).toEqual([
349
- 'text',
350
- 'level',
351
- 'align'
352
- ]);
353
- const level = heading.fields.find((f)=>f.name === 'level');
354
- expect(level.options).toBeDefined();
355
- });
356
- });
357
- // ─── buildBlockNestingMap ──────────────────────────────────────────
358
- describe('buildBlockNestingMap', ()=>{
359
- it('records the layout field on Pages with the slugs it accepts', ()=>{
360
- const map = buildBlockNestingMap([
361
- Pages,
362
- Posts
363
- ], allBlocks);
364
- const pageLayout = map.find((e)=>e.ownerType === 'collection' && e.owner === 'pages' && e.fieldPath === 'layout');
365
- expect(pageLayout).toBeDefined();
366
- expect(pageLayout.acceptedBlockSlugs).toEqual([
367
- 'fullWidth',
368
- 'headingOnly',
369
- 'ctaBanner',
370
- 'accordion'
371
- ]);
372
- });
373
- it('records nested blocks fields inside container blocks', ()=>{
374
- const map = buildBlockNestingMap([
375
- Pages
376
- ], allBlocks);
377
- const fullWidthContent = map.find((e)=>e.ownerType === 'block' && e.owner === 'fullWidth' && e.fieldPath === 'content');
378
- expect(fullWidthContent).toBeDefined();
379
- expect(fullWidthContent.acceptedBlockSlugs).toEqual([
380
- 'heading',
381
- 'richText',
382
- 'image'
383
- ]);
384
- const headingOnly = map.find((e)=>e.ownerType === 'block' && e.owner === 'headingOnly' && e.fieldPath === 'content');
385
- expect(headingOnly.acceptedBlockSlugs).toEqual([
386
- 'heading'
387
- ]);
388
- expect(headingOnly.maxRows).toBe(1);
389
- });
390
- it('handles arbitrarily-deep nesting via array fields inside blocks', ()=>{
391
- const map = buildBlockNestingMap([
392
- Pages
393
- ], allBlocks);
394
- const accordionPanelBody = map.find((e)=>e.ownerType === 'block' && e.owner === 'accordion' && e.fieldPath === 'panels[].body');
395
- expect(accordionPanelBody).toBeDefined();
396
- expect(accordionPanelBody.acceptedBlockSlugs).toEqual([
397
- 'heading',
398
- 'richText',
399
- 'fullWidth'
400
- ]);
401
- });
402
- it('omits unknown slugs not present in the block list', ()=>{
403
- const Stray = {
404
- slug: 'stray',
405
- fields: [
406
- {
407
- name: 'layout',
408
- type: 'blocks',
409
- blocks: [
410
- Heading,
411
- {
412
- slug: 'mystery',
413
- fields: []
414
- }
415
- ]
416
- }
417
- ]
418
- };
419
- const map = buildBlockNestingMap([
420
- Stray
421
- ], [
422
- Heading
423
- ]) // mystery not in catalog
424
- ;
425
- const stray = map.find((e)=>e.owner === 'stray' && e.fieldPath === 'layout');
426
- expect(stray.acceptedBlockSlugs).toEqual([
427
- 'heading'
428
- ]);
429
- });
430
- it('omits fixed blocks (no nested blocks fields) from the map', ()=>{
431
- const map = buildBlockNestingMap([
432
- Pages
433
- ], allBlocks);
434
- const ctaEntries = map.filter((e)=>e.owner === 'ctaBanner');
435
- expect(ctaEntries).toHaveLength(0);
436
- });
437
- });
438
- // ─── buildRelationshipGraph ────────────────────────────────────────
439
- // ─── Global introspection ──────────────────────────────────────────
440
- const SiteSettings = {
441
- slug: 'site-settings',
442
- fields: [
443
- {
444
- name: 'siteName',
445
- type: 'text',
446
- required: true
447
- },
448
- {
449
- name: 'tagline',
450
- type: 'text'
451
- },
452
- {
453
- name: 'social',
454
- type: 'group',
455
- fields: [
456
- {
457
- name: 'twitter',
458
- type: 'text'
459
- },
460
- {
461
- name: 'instagram',
462
- type: 'text'
463
- }
464
- ]
465
- }
466
- ]
467
- };
468
- const FooterGlobal = {
469
- slug: 'footer',
470
- versions: {
471
- drafts: true
472
- },
473
- fields: [
474
- {
475
- name: 'layout',
476
- type: 'blocks',
477
- blocks: [
478
- Heading,
479
- CtaBanner
480
- ]
481
- }
482
- ]
483
- };
484
- const HeaderGlobal = {
485
- slug: 'header',
486
- fields: [
487
- {
488
- name: 'menu',
489
- type: 'group',
490
- fields: [
491
- {
492
- name: 'label',
493
- type: 'text'
494
- },
495
- {
496
- name: 'links',
497
- type: 'blocks',
498
- blocks: [
499
- Heading
500
- ]
501
- }
502
- ]
503
- }
504
- ]
505
- };
506
- describe('hasGlobalDrafts', ()=>{
507
- it('returns true for { versions: { drafts: true } }', ()=>{
508
- expect(hasGlobalDrafts({
509
- slug: 'g',
510
- versions: {
511
- drafts: true
512
- },
513
- fields: []
514
- })).toBe(true);
515
- });
516
- it('returns false for { versions: { drafts: false } }', ()=>{
517
- expect(hasGlobalDrafts({
518
- slug: 'g',
519
- versions: {
520
- drafts: false
521
- },
522
- fields: []
523
- })).toBe(false);
524
- });
525
- it('returns false when versions is undefined', ()=>{
526
- expect(hasGlobalDrafts({
527
- slug: 'g',
528
- fields: []
529
- })).toBe(false);
530
- });
531
- it('returns false for versions without drafts key', ()=>{
532
- expect(hasGlobalDrafts({
533
- slug: 'g',
534
- versions: {
535
- maxPerDoc: 10
536
- },
537
- fields: []
538
- })).toBe(false);
539
- });
540
- });
541
- describe('introspectGlobal', ()=>{
542
- it('extracts SiteSettings fields and draft/live-preview flags', ()=>{
543
- const schema = introspectGlobal(SiteSettings);
544
- expect(schema.slug).toBe('site-settings');
545
- expect(schema.hasDrafts).toBe(false);
546
- expect(schema.hasLivePreview).toBe(false);
547
- const names = schema.fields.map((f)=>f.name);
548
- expect(names).toContain('siteName');
549
- expect(names).toContain('tagline');
550
- const social = schema.fields.find((f)=>f.name === 'social');
551
- expect(social?.type).toBe('group');
552
- expect(social?.fields?.map((f)=>f.name)).toEqual([
553
- 'twitter',
554
- 'instagram'
555
- ]);
556
- });
557
- it('reports hasDrafts: true when versions.drafts is set', ()=>{
558
- expect(introspectGlobal(FooterGlobal).hasDrafts).toBe(true);
559
- });
560
- });
561
- describe('introspectGlobals', ()=>{
562
- it('returns an empty Map for []', ()=>{
563
- expect(introspectGlobals([]).size).toBe(0);
564
- });
565
- it('keys the map by slug', ()=>{
566
- const map = introspectGlobals([
567
- SiteSettings,
568
- FooterGlobal
569
- ]);
570
- expect(map.has('site-settings')).toBe(true);
571
- expect(map.has('footer')).toBe(true);
572
- });
573
- });
574
- describe('buildBlockNestingMap with globals', ()=>{
575
- it('emits an edge with ownerType "global" for a top-level blocks field', ()=>{
576
- const map = buildBlockNestingMap([], [
577
- FooterGlobal
578
- ], allBlocks);
579
- const edge = map.find((e)=>e.ownerType === 'global' && e.owner === 'footer' && e.fieldPath === 'layout');
580
- expect(edge).toBeDefined();
581
- expect(edge.acceptedBlockSlugs).toEqual([
582
- 'heading',
583
- 'ctaBanner'
584
- ]);
585
- });
586
- it('walks group-nested blocks fields under a global with the dotted path', ()=>{
587
- const map = buildBlockNestingMap([], [
588
- HeaderGlobal
589
- ], allBlocks);
590
- const edge = map.find((e)=>e.ownerType === 'global' && e.owner === 'header' && e.fieldPath === 'menu.links');
591
- expect(edge).toBeDefined();
592
- expect(edge.acceptedBlockSlugs).toEqual([
593
- 'heading'
594
- ]);
595
- });
596
- it('two-arg call (no globals) produces the same edges as before — regression guard', ()=>{
597
- const before = buildBlockNestingMap([
598
- Pages
599
- ], allBlocks);
600
- const after = buildBlockNestingMap([
601
- Pages
602
- ], [], allBlocks);
603
- expect(after).toEqual(before);
604
- });
605
- it('invariant: throws when (owner, fieldPath) appears with different ownerTypes', ()=>{
606
- const ClashCollection = {
607
- slug: 'site-settings',
608
- fields: [
609
- {
610
- name: 'layout',
611
- type: 'blocks',
612
- blocks: [
613
- Heading
614
- ]
615
- }
616
- ]
617
- };
618
- const ClashGlobal = {
619
- slug: 'site-settings',
620
- fields: [
621
- {
622
- name: 'layout',
623
- type: 'blocks',
624
- blocks: [
625
- Heading
626
- ]
627
- }
628
- ]
629
- };
630
- expect(()=>buildBlockNestingMap([
631
- ClashCollection
632
- ], [
633
- ClashGlobal
634
- ], [
635
- Heading
636
- ])).toThrow(/invariant violated/i);
637
- });
638
- });
639
- describe('buildRelationshipGraph', ()=>{
640
- it('builds correct graph from sample collections', ()=>{
641
- const schemas = introspectCollections([
642
- Posts,
643
- Pages,
644
- Categories,
645
- Authors,
646
- Media
647
- ]);
648
- const edges = buildRelationshipGraph(schemas);
649
- const postEdges = edges.filter((e)=>e.fromCollection === 'posts');
650
- const postTargets = postEdges.map((e)=>e.toCollection);
651
- expect(postTargets).toContain('categories');
652
- expect(postTargets).toContain('authors');
653
- expect(postTargets).toContain('media');
654
- const authorEdges = edges.filter((e)=>e.fromCollection === 'authors');
655
- expect(authorEdges.map((e)=>e.toCollection)).toContain('media');
656
- });
657
- });
658
-
659
- //# sourceMappingURL=introspection.test.js.map