/** * End-to-end test: @storyblok/schema define functions → MAPI round-trip * * Validates that: * 1. Every major MAPI define function produces a valid creation payload. * 2. The MAPI accepts those payloads and creates real resources. * 3. The types returned by the schema-aware mapi-client match the actual * runtime data returned by the API (no type lies). * 4. Zod schemas generated from the OpenAPI spec accept real API responses. * * Run manually (never in CI): * pnpm --filter @storyblok/mapi-integration-tests test:e2e * * Requires .env.qa-engineer-manual at the repo root with: * STORYBLOK_TOKEN= * STORYBLOK_SPACE_ID= */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createManagementApiClient } from "@storyblok/management-api-client"; import type { StoryCreate, StoryUpdate } from "@storyblok/management-api-client"; import { defineBlock, defineField } from "@storyblok/schema"; const token = process.env.STORYBLOK_TOKEN!; const spaceId = Number(process.env.STORYBLOK_SPACE_ID!); const PREFIX = "e2e_schema_"; const STORY_SLUG_PREFIX = "e2e-schema-"; const DATASOURCE_SLUG = "e2e-schema-categories"; const DATASOURCE_NAME = `${PREFIX}categories`; const STORY_NAME = "E2E Schema Test Page"; const STORY_SLUG = `${STORY_SLUG_PREFIX}test-page`; const teaserComponent = defineBlock({ name: `${PREFIX}teaser`, fields: [ defineField("title", { type: "text", required: true }), defineField("image", { type: "asset" }), ], }); // Level-2 container: holds teasers in its `items` bloks field (level 3) const sectionComponent = defineBlock({ name: `${PREFIX}section`, fields: [ defineField("title", { type: "text" }), defineField("items", { type: "bloks", allow: [teaserComponent.name], required: true }), ], }); const pageComponent = defineBlock({ name: `${PREFIX}page`, is_root: true, fields: [ defineField("headline", { type: "text", required: true }), defineField("rating", { type: "number" }), defineField("is_featured", { type: "boolean" }), defineField("description", { type: "richtext" }), defineField("body", { type: "bloks", allow: [teaserComponent.name, sectionComponent.name], required: true, }), defineField("category", { type: "option", source: "internal", datasource: DATASOURCE_SLUG }), defineField("any_blocks", { type: "bloks", required: true }), ], }); interface StoryblokTypes { components: typeof pageComponent | typeof teaserComponent | typeof sectionComponent; } type Blocks = StoryblokTypes["components"]; const client = createManagementApiClient({ personalAccessToken: token, spaceId, throwOnError: true, }).withTypes(); async function cleanup() { // Stories const storiesRes = await client.stories.list({ query: { per_page: 100 } }); for (const story of storiesRes.data?.stories ?? []) { if (story.slug?.startsWith(STORY_SLUG_PREFIX) && story.id) { await client.stories.delete(story.id); } } // Presets const presetsRes = await client.presets.list(); for (const preset of presetsRes.data?.presets ?? []) { if (preset.name?.startsWith(PREFIX) && preset.id) { await client.presets.delete(preset.id); } } // Internal tags const tagsRes = await client.internalTags.list(); for (const tag of tagsRes.data?.internal_tags ?? []) { if (tag.name?.startsWith(PREFIX) && tag.id) { await client.internalTags.delete(tag.id); } } // Components const compsRes = await client.components.list(); for (const comp of compsRes.data?.components ?? []) { if (comp.name?.startsWith(PREFIX) && comp.id) { await client.components.delete(comp.id); } } // Component folders const foldersRes = await client.componentFolders.list(); for (const folder of foldersRes.data?.component_groups ?? []) { if (folder.name?.startsWith(PREFIX) && folder.id) { await client.componentFolders.delete(folder.id); } } // Datasource entries, then datasources const dsRes = await client.datasources.list(); for (const ds of dsRes.data?.datasources ?? []) { if (ds.name?.startsWith(PREFIX) && ds.id) { const entriesRes = await client.datasourceEntries.list({ query: { datasource_id: ds.id, per_page: 100 }, }); for (const entry of entriesRes.data?.datasource_entries ?? []) { if (entry.id) { await client.datasourceEntries.delete(entry.id); } } await client.datasources.delete(ds.id); } } } describe("schema + mapi-client MAPI round-trip", () => { let pageComponentId: number; let teaserComponentId: number; let sectionComponentId: number; let componentFolderId: number; let datasourceId: number; let internalTagId: number; let presetId: number; let storyId: number; beforeAll(async () => { await cleanup(); // 1. Datasource + entries first — the page component schema references the datasource slug, // and the MAPI validates that the datasource exists at component creation time. // Create payloads are plain MAPI wire objects validated by the typed client. const dsRes = await client.datasources.create({ body: { datasource: { name: DATASOURCE_NAME, slug: DATASOURCE_SLUG, }, }, }); datasourceId = dsRes.data!.datasource!.id!; for (const entry of [ { name: "Technology", value: "tech", datasource_id: datasourceId }, { name: "Design", value: "design", datasource_id: datasourceId }, { name: "Business", value: "business", datasource_id: datasourceId }, ]) { await client.datasourceEntries.create({ body: { datasource_entry: entry } }); } // 2. Component folder const folderRes = await client.componentFolders.create({ body: { component_group: { name: `${PREFIX}folder` } }, }); componentFolderId = folderRes.data!.component_group!.id!; const folderUuid = folderRes.data!.component_group!.uuid; // 3. Teaser component (innermost — whitelisted by section) const teaserRes = await client.components.create({ body: { component: { name: teaserComponent.name, schema: { title: { type: "text", required: true, pos: 0 }, image: { type: "asset", pos: 1 }, }, component_group_uuid: folderUuid, }, }, }); teaserComponentId = teaserRes.data!.component!.id!; // 4. Section component (level 2 — whitelists teaser, whitelisted by page) const sectionRes = await client.components.create({ body: { component: { name: sectionComponent.name, schema: { title: { type: "text", pos: 0 }, items: { type: "bloks", component_whitelist: [teaserComponent.name], pos: 1 }, }, component_group_uuid: folderUuid, }, }, }); sectionComponentId = sectionRes.data!.component!.id!; // 5. Page component (level 1 — whitelists both teaser and section in body) const pageRes = await client.components.create({ body: { component: { name: pageComponent.name, schema: { headline: { type: "text", required: true, pos: 0 }, rating: { type: "number", pos: 1 }, is_featured: { type: "boolean", pos: 2 }, description: { type: "richtext", pos: 3 }, body: { type: "bloks", component_whitelist: [teaserComponent.name, sectionComponent.name], pos: 4, }, category: { type: "option", source: "internal", datasource_slug: DATASOURCE_SLUG, pos: 5, }, any_blocks: { type: "bloks", pos: 6 }, }, component_group_uuid: folderUuid, is_root: true, }, }, }); pageComponentId = pageRes.data!.component!.id!; // 5. Internal tag const tagRes = await client.internalTags.create({ body: { internal_tag: { name: `${PREFIX}tag`, object_type: "component" } }, }); internalTagId = tagRes.data!.internal_tag!.id!; // 6. Preset for page component const presetRes = await client.presets.create({ body: { preset: { name: `${PREFIX}default_page`, component_id: pageComponentId, preset: { headline: "Default Headline", rating: 0, is_featured: false }, description: `Default preset for ${pageComponent.name}`, }, }, }); presetId = presetRes.data!.preset!.id!; // 8. Story: body[0]=teaser (level 2), body[1]=section{items:[teaser]} (levels 2+3) const storyPayload: StoryCreate = { name: STORY_NAME, slug: STORY_SLUG, content: { component: pageComponent.name, headline: "Hello from e2e", rating: "42", is_featured: true, description: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Rich text body." }] }], }, body: [ { component: teaserComponent.name, title: "Teaser Title", }, { component: sectionComponent.name, title: "Section Title", items: [ { component: teaserComponent.name, title: "Nested Teaser Title", }, ], }, ], category: "tech", any_blocks: [ { component: teaserComponent.name, title: "Any Block Teaser", }, ], }, }; const storyRes = await client.stories.create({ body: { story: storyPayload } }); storyId = storyRes.data!.story!.id!; }); afterAll(cleanup); describe("components", () => { it("should create page component with the correct name and schema keys", async () => { const res = await client.components.get(pageComponentId); const comp = res.data?.component; expect(comp).toBeDefined(); expect(comp?.name).toBe(pageComponent.name); expect(comp?.schema).toMatchObject({ headline: expect.objectContaining({ type: "text" }), rating: expect.objectContaining({ type: "number" }), is_featured: expect.objectContaining({ type: "boolean" }), description: expect.objectContaining({ type: "richtext" }), body: expect.objectContaining({ type: "bloks" }), category: expect.objectContaining({ type: "option" }), }); expect(comp?.name).toBe(pageComponent.name); }); it("should create teaser component with the correct schema", async () => { const res = await client.components.get(teaserComponentId); const comp = res.data?.component; expect(comp?.name).toBe(teaserComponent.name); expect(comp?.schema).toMatchObject({ title: expect.objectContaining({ type: "text", required: true }), image: expect.objectContaining({ type: "asset" }), }); }); it("should create section component with the correct schema", async () => { const res = await client.components.get(sectionComponentId); const comp = res.data?.component; expect(comp?.name).toBe(sectionComponent.name); expect(comp?.schema).toMatchObject({ title: expect.objectContaining({ type: "text" }), items: expect.objectContaining({ type: "bloks", component_whitelist: [teaserComponent.name], }), }); }); it("should assign components to the correct component folder", async () => { const folderRes = await client.componentFolders.get(componentFolderId); const folder = folderRes.data?.component_group; const pageRes = await client.components.get(pageComponentId); const comp = pageRes.data?.component; expect(folder?.name).toBe(`${PREFIX}folder`); expect(comp?.component_group_uuid).toBe(folder?.uuid); }); }); describe("datasources", () => { it("should create datasource with the correct name and slug", async () => { const res = await client.datasources.list(); const ds = res.data?.datasources?.find((d) => d.id === datasourceId); expect(ds).toBeDefined(); expect(ds?.name).toBe(DATASOURCE_NAME); expect(ds?.slug).toBe(DATASOURCE_SLUG); }); it("should create datasource entries with the correct names and values", async () => { const res = await client.datasourceEntries.list({ query: { datasource_id: datasourceId, per_page: 100 }, }); const entries = res.data?.datasource_entries ?? []; expect(entries.length).toBeGreaterThanOrEqual(3); expect(entries.find((e) => e.value === "tech")?.name).toBe("Technology"); expect(entries.find((e) => e.value === "design")?.name).toBe("Design"); expect(entries.find((e) => e.value === "business")?.name).toBe("Business"); }); }); describe("internalTags", () => { it("should create and retrieve internal tag", async () => { const res = await client.internalTags.list(); const tag = res.data?.internal_tags?.find((t) => t.id === internalTagId); expect(tag).toBeDefined(); expect(tag?.name).toBe(`${PREFIX}tag`); expect(tag?.object_type).toBe("component"); }); }); describe("presets", () => { it("should create preset with the correct component_id and default values", async () => { const res = await client.presets.get(presetId); const preset = res.data?.preset; expect(preset).toBeDefined(); expect(preset?.name).toBe(`${PREFIX}default_page`); expect(preset?.component_id).toBe(pageComponentId); expect(preset?.preset).toMatchObject({ headline: "Default Headline", rating: 0, is_featured: false, }); }); }); describe("stories", () => { it("should match defined schema field types at runtime for story content", async () => { const res = await client.stories.get(storyId); const story = res.data?.story; expect(story).toBeDefined(); expect(story?.name).toBe(STORY_NAME); expect(story?.slug).toBe(STORY_SLUG); // TypeScript narrows story.content to the pageComponent branch here. // Accessing story.content.headline etc. only compiles when the component guard passes, // so this block validates both runtime correctness and compile-time type narrowing. if (story?.content?.component === pageComponent.name) { expect(typeof story.content.headline).toBe("string"); expect(story.content.headline).toBe("Hello from e2e"); expect(typeof story.content.rating).toBe("number"); expect(story.content.rating).toBe(42); expect(typeof story.content.is_featured).toBe("boolean"); expect(story.content.is_featured).toBe(true); expect(story.content.description).toMatchObject({ type: "doc" }); expect(Array.isArray(story.content.body)).toBe(true); expect(story.content.body.length).toBeGreaterThan(0); expect(typeof story.content.category).toBe("string"); expect(story.content.category).toBe("tech"); } else { throw new Error( `Expected story.content.component to be '${pageComponent.name}', got '${story?.content?.component}'`, ); } }); it("should have correct structure for nested teaser blok (two levels: page → teaser)", async () => { const res = await client.stories.get(storyId); const story = res.data?.story; if (story?.content?.component !== pageComponent.name) { throw new Error("Unexpected component discriminant"); } const teaser = story.content.body[0]; expect(teaser).toBeDefined(); expect(teaser?.component).toBe(teaserComponent.name); expect(typeof teaser?.title).toBe("string"); expect(teaser?.title).toBe("Teaser Title"); }); it("should resolve correct types for three-level nested blok (page → section → teaser)", async () => { const res = await client.stories.get(storyId); const story = res.data?.story; if (story?.content?.component !== pageComponent.name) { throw new Error("Unexpected component discriminant at level 1"); } // Level 2: narrow body[1] to section const section = story.content.body[1]; if (section?.component === sectionComponent.name) { expect(typeof section.title).toBe("string"); expect(section.title).toBe("Section Title"); expect(Array.isArray(section.items)).toBe(true); expect(section.items.length).toBeGreaterThan(0); // Level 3: section.items[0] is a typed teaser — not `never` const nestedTeaser = section.items[0]; if (nestedTeaser?.component === teaserComponent.name) { expect(typeof nestedTeaser.title).toBe("string"); expect(nestedTeaser.title).toBe("Nested Teaser Title"); } else { throw new Error( `Expected items[0].component to be '${teaserComponent.name}', got '${nestedTeaser?.component}'`, ); } } else { throw new Error( `Expected body[1].component to be '${sectionComponent.name}', got '${section?.component}'`, ); } }); it("should resolve all schema component types for bloks field without whitelist", async () => { const res = await client.stories.get(storyId); const story = res.data?.story; if (story?.content?.component !== pageComponent.name) { throw new Error("Unexpected component discriminant"); } // any_blocks has no component_whitelist — TypeScript resolves items to the full // schema union (page | teaser | section), not `never` and not a generic block // with only component: string. Accessing blok.title after narrowing to the // teaser discriminant proves the discriminated union is correctly typed. const blok = story.content.any_blocks[0]; if (blok?.component === teaserComponent.name) { expect(typeof blok.title).toBe("string"); expect(blok.title).toBe("Any Block Teaser"); } else { throw new Error( `Expected any_blocks[0].component to be '${teaserComponent.name}', got '${blok?.component}'`, ); } }); it("should round-trip story update correctly", async () => { const updatedPayload: StoryUpdate = { name: `${STORY_NAME} (Updated)`, slug: STORY_SLUG, content: { component: pageComponent.name, headline: "Updated headline", rating: "100", is_featured: false, description: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Updated." }] }], }, body: [], category: "design", any_blocks: [], }, }; await client.stories.update(storyId, { body: { story: updatedPayload } }); const res = await client.stories.get(storyId); const story = res.data?.story; if (story?.content?.component !== pageComponent.name) { throw new Error("Unexpected component discriminant after update"); } expect(story.content.headline).toBe("Updated headline"); expect(story.content.rating).toBe(100); expect(story.content.is_featured).toBe(false); expect(story.content.category).toBe("design"); }); }); });