From 1e1bbd988db5b2e248980f6c5ff6d8c88a956324 Mon Sep 17 00:00:00 2001 From: Edoardo Dusi Date: Mon, 23 Dec 2024 12:18:43 +0100 Subject: [PATCH] fix: resolve nested relations in content blocks properly (#903) * fix: resolve nested relations in content blocks properly * test: add comprehensive test suite for relation resolution and edge cases --- src/index.test.ts | 511 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 135 ++++++++---- 2 files changed, 606 insertions(+), 40 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index e9dbd38e..c2e3af9a 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -235,6 +235,147 @@ describe('storyblokClient', () => { headers: {}, }); }); + + it('should handle API errors gracefully', async () => { + const mockGet = vi.fn().mockRejectedValue({ + status: 404, + statusText: 'Not Found', + }); + + client.client = { + get: mockGet, + post: vi.fn(), + setFetchOptions: vi.fn(), + baseURL: 'https://api.storyblok.com/v2', + }; + + await expect(client.get('cdn/stories/non-existent')) + .rejects + .toMatchObject({ + status: 404, + }); + }); + + it('should fetch and return a complex story object correctly', async () => { + const mockComplexStory = { + data: { + story: { + id: 123456, + uuid: 'story-uuid-123', + name: 'Complex Page', + slug: 'complex-page', + full_slug: 'folder/complex-page', + created_at: '2023-01-01T12:00:00.000Z', + published_at: '2023-01-02T12:00:00.000Z', + first_published_at: '2023-01-02T12:00:00.000Z', + content: { + _uid: 'content-123', + component: 'page', + title: 'Complex Page Title', + subtitle: 'Complex Page Subtitle', + intro: { + _uid: 'intro-123', + component: 'intro', + heading: 'Welcome to our page', + text: 'Some introduction text', + }, + body: [ + { + _uid: 'text-block-123', + component: 'text_block', + text: 'First paragraph of content', + }, + { + _uid: 'image-block-123', + component: 'image', + src: 'https://example.com/image.jpg', + alt: 'Example image', + }, + { + _uid: 'related-items-123', + component: 'related_items', + items: ['uuid1', 'uuid2'], // Relations that we won't resolve in this test + }, + ], + seo: { + _uid: 'seo-123', + component: 'seo', + title: 'SEO Title', + description: 'SEO Description', + og_image: 'https://example.com/og-image.jpg', + }, + }, + position: 1, + is_startpage: false, + parent_id: 654321, + group_id: '789-group', + alternates: [], + translated_slugs: [], + default_full_slug: null, + lang: 'default', + }, + }, + headers: {}, + status: 200, + statusText: 'OK', + }; + + const mockGet = vi.fn().mockResolvedValue(mockComplexStory); + + client.client = { + get: mockGet, + post: vi.fn(), + setFetchOptions: vi.fn(), + baseURL: 'https://api.storyblok.com/v2', + }; + + const result = await client.get('cdn/stories/folder/complex-page'); + + // Verify the complete story structure is returned correctly + expect(result.data.story).toMatchObject({ + id: 123456, + uuid: 'story-uuid-123', + name: 'Complex Page', + slug: 'complex-page', + full_slug: 'folder/complex-page', + content: expect.objectContaining({ + _uid: 'content-123', + component: 'page', + title: 'Complex Page Title', + subtitle: 'Complex Page Subtitle', + intro: expect.objectContaining({ + _uid: 'intro-123', + component: 'intro', + }), + body: expect.arrayContaining([ + expect.objectContaining({ + component: 'text_block', + }), + expect.objectContaining({ + component: 'image', + }), + expect.objectContaining({ + component: 'related_items', + }), + ]), + }), + }); + + // Verify specific nested properties + expect(result.data.story.content.seo).toEqual({ + _uid: 'seo-123', + component: 'seo', + title: 'SEO Title', + description: 'SEO Description', + og_image: 'https://example.com/og-image.jpg', + }); + + // Verify that relations array exists but remains unresolved + expect(result.data.story.content.body[2].items).toEqual(['uuid1', 'uuid2']); + + // Verify the API was called only once (no relation resolution) + expect(mockGet).toHaveBeenCalledTimes(1); + }); }); describe('getAll', () => { @@ -403,4 +544,374 @@ describe('storyblokClient', () => { it('should return access token', () => { expect(client.getToken()).toBe('test-token'); }); + + describe('relation resolution', () => { + it('should resolve nested relations within content blocks', async () => { + const TEST_UUID = 'this-is-a-test-uuid'; + + const mockResponse = { + data: { + story: { + content: { + _uid: 'parent-uid', + component: 'page', + body: [{ + _uid: 'slider-uid', + component: 'event_slider', + spots: [{ + _uid: 'event-uid', + component: 'event', + content: { + _uid: 'content-uid', + component: 'event', + event_type: TEST_UUID, + }, + }], + }], + }, + }, + rel_uuids: [TEST_UUID], + }, + headers: {}, + status: 200, + statusText: 'OK', + }; + + const mockRelationsResponse = { + data: { + stories: [{ + _uid: 'type-uid', + uuid: TEST_UUID, + content: { + name: 'Test Event Type', + component: 'event_type', + }, + }], + }, + headers: {}, + status: 200, + statusText: 'OK', + }; + + // Setup the mock client's get method + const mockGet = vi.fn() + .mockImplementationOnce(() => Promise.resolve(mockResponse)) + .mockImplementationOnce(() => Promise.resolve(mockRelationsResponse)); + + // Replace the client's fetch instance + client.client = { + get: mockGet, + post: vi.fn(), + setFetchOptions: vi.fn(), + }; + + const result = await client.get('cdn/stories/test', { + resolve_relations: [ + 'event.event_type', + 'event_slider.spots', + ], + version: 'draft', + }); + + // Verify that the UUID was replaced with the resolved object + const resolvedEventType = result.data.story.content.body[0].spots[0].content.event_type; + expect(resolvedEventType).toEqual({ + _uid: 'type-uid', + uuid: TEST_UUID, + content: { + name: 'Test Event Type', + component: 'event_type', + }, + _stopResolving: true, + }); + + // Verify that get was called two times + expect(mockGet).toHaveBeenCalledTimes(2); + }); + + it('should resolve an array of relations', async () => { + const TEST_UUIDS = ['tag-1-uuid', 'tag-2-uuid']; + + const mockResponse = { + data: { + story: { + content: { + _uid: 'root-uid', + component: 'post', + tags: TEST_UUIDS, + }, + }, + rel_uuids: TEST_UUIDS, + }, + headers: {}, + status: 200, + statusText: 'OK', + }; + + const mockRelationsResponse = { + data: { + stories: [ + { + _uid: 'tag-1-uid', + uuid: TEST_UUIDS[0], + content: { + name: 'Tag 1', + component: 'tag', + }, + }, + { + _uid: 'tag-2-uid', + uuid: TEST_UUIDS[1], + content: { + name: 'Tag 2', + component: 'tag', + }, + }, + ], + }, + headers: {}, + status: 200, + statusText: 'OK', + }; + + const mockGet = vi.fn() + .mockImplementationOnce(() => Promise.resolve(mockResponse)) + .mockImplementationOnce(() => Promise.resolve(mockRelationsResponse)); + + client.client = { + get: mockGet, + post: vi.fn(), + setFetchOptions: vi.fn(), + baseURL: 'https://api.storyblok.com/v2', + }; + + const result = await client.get('cdn/stories/test', { + resolve_relations: ['post.tags'], + version: 'draft', + }); + + expect(result.data.story.content.tags).toEqual([ + { + _uid: 'tag-1-uid', + uuid: TEST_UUIDS[0], + content: { + name: 'Tag 1', + component: 'tag', + }, + _stopResolving: true, + }, + { + _uid: 'tag-2-uid', + uuid: TEST_UUIDS[1], + content: { + name: 'Tag 2', + component: 'tag', + }, + _stopResolving: true, + }, + ]); + }); + + it('should resolve multiple relation patterns simultaneously', async () => { + const AUTHOR_UUID = 'author-uuid'; + const CATEGORY_UUID = 'category-uuid'; + + const mockResponse = { + data: { + story: { + content: { + _uid: 'root-uid', + component: 'post', + author: AUTHOR_UUID, + category: CATEGORY_UUID, + }, + }, + rel_uuids: [AUTHOR_UUID, CATEGORY_UUID], + }, + headers: {}, + status: 200, + statusText: 'OK', + }; + + const mockRelationsResponse = { + data: { + stories: [ + { + _uid: 'author-uid', + uuid: AUTHOR_UUID, + content: { + name: 'John Doe', + component: 'author', + }, + }, + { + _uid: 'category-uid', + uuid: CATEGORY_UUID, + content: { + name: 'Technology', + component: 'category', + }, + }, + ], + }, + headers: {}, + status: 200, + statusText: 'OK', + }; + + const mockGet = vi.fn() + .mockImplementationOnce(() => Promise.resolve(mockResponse)) + .mockImplementationOnce(() => Promise.resolve(mockRelationsResponse)); + + client.client = { + get: mockGet, + post: vi.fn(), + setFetchOptions: vi.fn(), + baseURL: 'https://api.storyblok.com/v2', + }; + + const result = await client.get('cdn/stories/test', { + resolve_relations: ['post.author', 'post.category'], + version: 'draft', + }); + + expect(result.data.story.content.author).toEqual({ + _uid: 'author-uid', + uuid: AUTHOR_UUID, + content: { + name: 'John Doe', + component: 'author', + }, + _stopResolving: true, + }); + + expect(result.data.story.content.category).toEqual({ + _uid: 'category-uid', + uuid: CATEGORY_UUID, + content: { + name: 'Technology', + component: 'category', + }, + _stopResolving: true, + }); + }); + + it('should handle content with no relations to resolve', async () => { + const mockResponse = { + data: { + story: { + content: { + _uid: 'test-story-uid', + component: 'page', + title: 'Simple Page', + text: 'Just some text content', + number: 42, + boolean: true, + }, + }, + }, + headers: {}, + status: 200, + statusText: 'OK', + }; + + const mockGet = vi.fn() + .mockImplementationOnce(() => Promise.resolve(mockResponse)); + + client.client = { + get: mockGet, + post: vi.fn(), + setFetchOptions: vi.fn(), + baseURL: 'https://api.storyblok.com/v2', + }; + + const result = await client.get('cdn/stories/test', { + resolve_relations: ['page.author'], // Even with resolve_relations, nothing should change + version: 'draft', + }); + + // Verify the content remains unchanged + expect(result.data.story.content).toEqual({ + _uid: 'test-story-uid', + component: 'page', + title: 'Simple Page', + text: 'Just some text content', + number: 42, + boolean: true, + }); + + // Verify that only one API call was made (no relations to resolve) + expect(mockGet).toHaveBeenCalledTimes(1); + }); + + it('should handle invalid relation patterns gracefully', async () => { + const mockResponse = { + data: { + story: { + content: { + _uid: 'test-uid', + component: 'page', + relation_field: 'some-uuid', + }, + }, + }, + headers: {}, + status: 200, + statusText: 'OK', + }; + + const mockGet = vi.fn() + .mockImplementationOnce(() => Promise.resolve(mockResponse)); + + client.client = { + get: mockGet, + post: vi.fn(), + setFetchOptions: vi.fn(), + baseURL: 'https://api.storyblok.com/v2', + }; + + const result = await client.get('cdn/stories/test', { + resolve_relations: ['invalid.pattern'], + version: 'draft', + }); + + // Should not throw and return original content + expect(result.data.story.content.relation_field).toBe('some-uuid'); + }); + + it('should handle empty resolve_relations array', async () => { + const mockResponse = { + data: { + story: { + content: { + _uid: 'test-uid', + component: 'page', + relation_field: 'some-uuid', + }, + }, + }, + headers: {}, + status: 200, + statusText: 'OK', + }; + + const mockGet = vi.fn() + .mockImplementationOnce(() => Promise.resolve(mockResponse)); + + client.client = { + get: mockGet, + post: vi.fn(), + setFetchOptions: vi.fn(), + baseURL: 'https://api.storyblok.com/v2', + }; + + const result = await client.get('cdn/stories/test', { + resolve_relations: [], + version: 'draft', + }); + + expect(result.data.story.content.relation_field).toBe('some-uuid'); + expect(mockGet).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index b4c3f441..df14a646 100755 --- a/src/index.ts +++ b/src/index.ts @@ -380,72 +380,127 @@ class Storyblok { * @returns string | object */ private getStoryReference(resolveId: string, uuid: string): string | JSON { - if (!this.relations[resolveId][uuid]) { - return uuid; + const result = this.relations[resolveId][uuid] + ? JSON.parse(this.stringifiedStoriesCache[uuid] || JSON.stringify(this.relations[resolveId][uuid])) + : uuid; + return result; + } + + /** + * Resolves a field's value by replacing UUIDs with their corresponding story references + * @param jtree - The JSON tree object containing the field to resolve + * @param treeItem - The key of the field to resolve + * @param resolveId - The unique identifier for the current resolution context + * + * This method handles both single string UUIDs and arrays of UUIDs: + * - For single strings: directly replaces the UUID with the story reference + * - For arrays: maps through each UUID and replaces with corresponding story references + */ + private _resolveField( + jtree: ISbStoriesParams, + treeItem: keyof ISbStoriesParams, + resolveId: string, + ): void { + const item = jtree[treeItem]; + if (typeof item === 'string') { + jtree[treeItem] = this.getStoryReference(resolveId, item); } - if (!this.stringifiedStoriesCache[uuid]) { - this.stringifiedStoriesCache[uuid] = JSON.stringify( - this.relations[resolveId][uuid], - ); + else if (Array.isArray(item)) { + jtree[treeItem] = item.map(uuid => + this.getStoryReference(resolveId, uuid), + ).filter(Boolean); } - return JSON.parse(this.stringifiedStoriesCache[uuid]); } + /** + * Inserts relations into the JSON tree by resolving references + * @param jtree - The JSON tree object to process + * @param treeItem - The current field being processed + * @param fields - The relation patterns to resolve (string or array of strings) + * @param resolveId - The unique identifier for the current resolution context + * + * This method handles two types of relation patterns: + * 1. Nested relations: matches fields that end with the current field name + * Example: If treeItem is "event_type", it matches patterns like "*.event_type" + * + * 2. Direct component relations: matches exact component.field patterns + * Example: "event.event_type" for component "event" and field "event_type" + * + * The method supports both string and array formats for the fields parameter, + * allowing flexible specification of relation patterns. + */ private _insertRelations( jtree: ISbStoriesParams, treeItem: keyof ISbStoriesParams, - fields: string | Array, + fields: string | string[], resolveId: string, ): void { - if (fields.includes(`${jtree.component}.${treeItem}`)) { - if (typeof jtree[treeItem] === 'string') { - jtree[treeItem] = this.getStoryReference(resolveId, jtree[treeItem]); - } - else if (Array.isArray(jtree[treeItem])) { - jtree[treeItem] = jtree[treeItem as keyof ISbStoriesParams] - .map((uuid: string) => this.getStoryReference(resolveId, uuid)) - .filter(Boolean); - } + // Check for nested relations (e.g., "*.event_type" or "spots.event_type") + const fieldPattern = Array.isArray(fields) + ? fields.find(f => f.endsWith(`.${treeItem}`)) + : fields.endsWith(`.${treeItem}`); + + if (fieldPattern) { + // If we found a matching pattern, resolve this field + this._resolveField(jtree, treeItem, resolveId); + return; + } + + // If no nested pattern matched, check for direct component.field pattern + // e.g., "event.event_type" for a field within its immediate parent component + const fieldPath = jtree.component ? `${jtree.component}.${treeItem}` : treeItem; + // Check if this exact pattern exists in the fields to resolve + if (Array.isArray(fields) ? fields.includes(fieldPath) : fields === fieldPath) { + // + this._resolveField(jtree, treeItem, resolveId); } } + /** + * Recursively traverses and resolves relations in the story content tree + * @param story - The story object containing the content to process + * @param fields - The relation patterns to resolve + * @param resolveId - The unique identifier for the current resolution context + */ private iterateTree( story: ISbStoryData, fields: string | Array, resolveId: string, ): void { - const enrich = (jtree: ISbStoriesParams | any) => { - if (jtree == null) { + // Internal recursive function to process each node in the tree + const enrich = (jtree: ISbStoriesParams | any, path = '') => { + // Skip processing if node is null/undefined or marked to stop resolving + if (!jtree || jtree._stopResolving) { return; } - if (jtree.constructor === Array) { - for (let item = 0; item < jtree.length; item++) { - enrich(jtree[item]); - } + + // Handle arrays by recursively processing each element + // Maintains path context by adding array indices + if (Array.isArray(jtree)) { + jtree.forEach((item, index) => enrich(item, `${path}[${index}]`)); } - else if (jtree.constructor === Object) { - if (jtree._stopResolving) { - return; - } - for (const treeItem in jtree) { + // Handle object nodes + else if (typeof jtree === 'object') { + // Process each property in the object + for (const key in jtree) { + // Build the current path for the context + const newPath = path ? `${path}.${key}` : key; + + // If this is a component (has component and _uid) or a link, + // attempt to resolve its relations and links if ((jtree.component && jtree._uid) || jtree.type === 'link') { - this._insertRelations( - jtree, - treeItem as keyof ISbStoriesParams, - fields, - resolveId, - ); - this._insertLinks( - jtree, - treeItem as keyof ISbStoriesParams, - resolveId, - ); + this._insertRelations(jtree, key as keyof ISbStoriesParams, fields, resolveId); + this._insertLinks(jtree, key as keyof ISbStoriesParams, resolveId); } - enrich(jtree[treeItem]); + + // Continue traversing deeper into the tree + // This ensures we process nested components and their relations + enrich(jtree[key], newPath); } } }; + // Start the traversal from the story's content enrich(story.content); }