diff --git a/src/client/__tests__/getClient.test.ts b/src/client/__tests__/getClient.test.ts new file mode 100644 index 0000000..3d89ea7 --- /dev/null +++ b/src/client/__tests__/getClient.test.ts @@ -0,0 +1,35 @@ +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +import { getClient } from '..'; + +const token = '123'; + +const server = setupServer(); + +describe('[client] getClient', () => { + beforeAll(() => server.listen()); + afterEach(() => { + server.resetHandlers(); + jest.restoreAllMocks(); + }); + afterAll(() => server.close()); + + it('should return a configured GraphQL request client', async () => { + const client = getClient({ token }); + + server.use( + rest.post(`https://gapi.storyblok.com/v1/api`, async (req, res, ctx) => { + expect(req.headers).toHaveProperty('map.token', token); + expect(req.headers).toHaveProperty('map.version', 'published'); + + return res( + ctx.status(200), + ctx.json({ data: { ArticleItem: { content: { title: 'Title' } } } }), + ); + }), + ); + + await client.request(''); + }); +}); diff --git a/src/client/__tests__/getStaticPropsWithSdk.test.ts b/src/client/__tests__/getStaticPropsWithSdk.test.ts new file mode 100644 index 0000000..e4f137e --- /dev/null +++ b/src/client/__tests__/getStaticPropsWithSdk.test.ts @@ -0,0 +1,80 @@ +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +import { getClient, getStaticPropsWithSdk } from '..'; + +const token = '123'; +const previewToken = '456'; + +const server = setupServer(); + +describe('[client] getStaticPropsWithSdk', () => { + beforeAll(() => server.listen()); + afterEach(() => { + server.resetHandlers(); + jest.restoreAllMocks(); + }); + afterAll(() => server.close()); + + it('should inject a configured GraphQL request client', async () => { + const getSdkMock = jest.fn((v) => v); + + const client = getClient({ token }); + const staticPropsWithSdk = getStaticPropsWithSdk( + getSdkMock, + client, + previewToken, + ); + + server.use( + rest.post(`https://gapi.storyblok.com/v1/api`, async (req, res, ctx) => { + expect(req.headers).toHaveProperty('map.token', token); + expect(req.headers).toHaveProperty('map.version', 'published'); + + return res(ctx.status(200), ctx.json({ data: {} })); + }), + ); + + const res = await staticPropsWithSdk(async ({ sdk }) => { + expect(sdk).toBeDefined(); + + await sdk.request(''); + + return { props: { test: true } }; + })({}); + + expect(res.props?.__storyblok_toolkit_preview).not.toBeTruthy(); + expect(res.props?.test).toBeTruthy(); + }); + + it('should configure for draft in preview mode', async () => { + const getSdkMock = jest.fn((v) => v); + + const client = getClient({ token }); + const staticPropsWithSdk = getStaticPropsWithSdk( + getSdkMock, + client, + previewToken, + ); + + server.use( + rest.post(`https://gapi.storyblok.com/v1/api`, async (req, res, ctx) => { + expect(req.headers).toHaveProperty('map.token', previewToken); + expect(req.headers).toHaveProperty('map.version', 'draft'); + + return res(ctx.status(200), ctx.json({ data: {} })); + }), + ); + + const res = await staticPropsWithSdk(async ({ sdk }) => { + expect(sdk).toBeDefined(); + + await sdk.request(''); + + return {} as any; + })({ preview: true }); + + expect(res.props?.__storyblok_toolkit_preview).toBeTruthy(); + expect(Object.keys(res.props).length).toBe(1); + }); +}); diff --git a/src/client/getClient.ts b/src/client/getClient.ts new file mode 100644 index 0000000..f38dec5 --- /dev/null +++ b/src/client/getClient.ts @@ -0,0 +1,36 @@ +import { GraphQLClient } from 'graphql-request'; + +export interface ClientOptions { + /** + * Custom fetch init parameters, `graphql-request` version. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters + */ + additionalOptions?: ConstructorParameters[1]; + /** Storyblok API token (preview or publish) */ + token: string; + /** + * Which version of the story to load. Defaults to `'draft'` in development, + * and `'published'` in production. + * + * @default `process.env.NODE_ENV === 'development' ? 'draft' : 'published'` + */ + version?: 'draft' | 'published'; +} + +export const getClient = ({ + additionalOptions, + token: Token, + version, +}: ClientOptions) => + new GraphQLClient('https://gapi.storyblok.com/v1/api', { + ...(additionalOptions || {}), + headers: { + Token, + Version: + version || process.env.NODE_ENV === 'development' + ? 'draft' + : 'published', + ...(additionalOptions?.headers || {}), + }, + }); diff --git a/src/client/getStaticPropsWithSdk.ts b/src/client/getStaticPropsWithSdk.ts new file mode 100644 index 0000000..15c2c19 --- /dev/null +++ b/src/client/getStaticPropsWithSdk.ts @@ -0,0 +1,45 @@ +import type { ParsedUrlQuery } from 'querystring'; +import { GraphQLClient } from 'graphql-request'; +import type { GetStaticPropsResult, GetStaticPropsContext } from 'next'; + +import { getClient, ClientOptions } from './getClient'; + +type SdkFunctionWrapper = (action: () => Promise) => Promise; +type GetSdk = (client: GraphQLClient, withWrapper?: SdkFunctionWrapper) => T; + +type GetStaticPropsWithSdk< + R, + P extends { [key: string]: any } = { [key: string]: any }, + Q extends ParsedUrlQuery = ParsedUrlQuery +> = ( + context: GetStaticPropsContext & { sdk: R }, +) => Promise>; + +export const getStaticPropsWithSdk = ( + getSdk: GetSdk, + client: GraphQLClient, + storyblokToken?: string, + additionalClientOptions?: ClientOptions['additionalOptions'], +) => (getStaticProps: GetStaticPropsWithSdk) => async ( + context: GetStaticPropsContext, +) => { + const sdk = getSdk( + storyblokToken && context?.preview + ? getClient({ + additionalOptions: additionalClientOptions, + token: storyblokToken, + version: 'draft', + }) + : client, + ); + + const res = await getStaticProps({ ...context, sdk }); + + return { + ...res, + props: { + ...((res as any)?.props || {}), + __storyblok_toolkit_preview: !!context?.preview, + }, + }; +}; diff --git a/src/client/index.ts b/src/client/index.ts index 5990b82..1de6b90 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,78 +1,2 @@ -import type { ParsedUrlQuery } from 'querystring'; -import { GraphQLClient } from 'graphql-request'; -import type { GetStaticPropsResult, GetStaticPropsContext } from 'next'; - -interface ClientOptions { - /** - * Custom fetch init parameters, `graphql-request` version. - * - * @see https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters - */ - additionalOptions?: ConstructorParameters[1]; - /** Storyblok API token (preview or publish) */ - token: string; - /** - * Which version of the story to load. Defaults to `'draft'` in development, - * and `'published'` in production. - * - * @default `process.env.NODE_ENV === 'development' ? 'draft' : 'published'` - */ - version?: 'draft' | 'published'; -} - -type SdkFunctionWrapper = (action: () => Promise) => Promise; -type GetSdk = (client: GraphQLClient, withWrapper?: SdkFunctionWrapper) => T; - -type GetStaticPropsWithSdk< - R, - P extends { [key: string]: any } = { [key: string]: any }, - Q extends ParsedUrlQuery = ParsedUrlQuery -> = ( - context: GetStaticPropsContext & { sdk: R }, -) => Promise>; - -export const getClient = ({ - additionalOptions, - token: Token, - version, -}: ClientOptions) => - new GraphQLClient('https://gapi.storyblok.com/v1/api', { - ...(additionalOptions || {}), - headers: { - Token, - Version: - version || process.env.NODE_ENV === 'development' - ? 'draft' - : 'published', - ...(additionalOptions?.headers || {}), - }, - }); - -export const getStaticPropsWithSdk = ( - getSdk: GetSdk, - client: GraphQLClient, - storyblokToken?: string, - additionalClientOptions?: ClientOptions['additionalOptions'], -) => (getStaticProps: GetStaticPropsWithSdk) => async ( - context: GetStaticPropsContext, -) => { - const sdk = getSdk( - storyblokToken && context?.preview - ? getClient({ - additionalOptions: additionalClientOptions, - token: storyblokToken, - version: 'draft', - }) - : client, - ); - - const res = await getStaticProps({ ...context, sdk }); - - return { - ...res, - props: { - ...((res as any)?.props || {}), - __storyblok_toolkit_preview: !!context?.preview, - }, - }; -}; +export * from './getClient'; +export * from './getStaticPropsWithSdk'; diff --git a/src/next/__tests__/previewHandlers.test.ts b/src/next/__tests__/previewHandlers.test.ts new file mode 100644 index 0000000..7abde9f --- /dev/null +++ b/src/next/__tests__/previewHandlers.test.ts @@ -0,0 +1,127 @@ +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { createMocks } from 'node-mocks-http'; + +import { nextPreviewHandlers } from '../previewHandlers'; + +const EventEmitter = () => {}; + +EventEmitter.prototype.addListener = function () {}; +EventEmitter.prototype.on = function () {}; +EventEmitter.prototype.once = function () {}; +EventEmitter.prototype.removeListener = function () {}; +EventEmitter.prototype.removeAllListeners = function () {}; +// EventEmitter.prototype.removeAllListeners = function([event]) +EventEmitter.prototype.setMaxListeners = function () {}; +EventEmitter.prototype.listeners = function () {}; +EventEmitter.prototype.emit = function () {}; +EventEmitter.prototype.prependListener = function () {}; + +const server = setupServer(); + +const previewToken = 'SECRET'; +const storyblokToken = '1234'; +const slug = 'article/article-1'; + +const handlers = nextPreviewHandlers({ + previewToken, + storyblokToken, +}); + +describe('[next] nextPreviewHandlers', () => { + beforeAll(() => server.listen()); + afterEach(() => { + server.resetHandlers(); + jest.restoreAllMocks(); + }); + afterAll(() => server.close()); + + it('should enable preview mode and redirect if story found', async () => { + server.use( + rest.get( + `https://api.storyblok.com/v1/cdn/stories/${slug}`, + async (_, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ story: { uuid: '123', full_slug: slug } }), + ); + }, + ), + ); + + const setPreviewDataMock = jest.fn(); + EventEmitter.prototype.setPreviewData = setPreviewDataMock; + + const { req, res } = createMocks( + { method: 'GET', query: { slug, token: previewToken } }, + { + eventEmitter: EventEmitter, + }, + ); + + await handlers(req as any, res as any); + + expect(res._getRedirectUrl()).toBe(`/${slug}`); + expect(setPreviewDataMock).toBeCalledWith({}); + }); + + it('reject on invalid token', async () => { + const setPreviewDataMock = jest.fn(); + EventEmitter.prototype.setPreviewData = setPreviewDataMock; + + const { req, res } = createMocks( + { method: 'GET', query: { slug, token: 'invalid' } }, + { + eventEmitter: EventEmitter, + }, + ); + + await handlers(req as any, res as any); + + expect(res._getStatusCode()).toBe(401); + expect(setPreviewDataMock).not.toBeCalled(); + }); + + it('reject if story does not exist', async () => { + server.use( + rest.get( + `https://api.storyblok.com/v1/cdn/stories/${slug}`, + async (_, res, ctx) => { + return res(ctx.status(404), ctx.json({})); + }, + ), + ); + + const setPreviewDataMock = jest.fn(); + EventEmitter.prototype.setPreviewData = setPreviewDataMock; + + const { req, res } = createMocks( + { method: 'GET', query: { slug, token: previewToken } }, + { + eventEmitter: EventEmitter, + }, + ); + + await handlers(req as any, res as any); + + expect(res._getStatusCode()).toBe(400); + expect(setPreviewDataMock).not.toBeCalled(); + }); + + it('should exit preview mode on clear route', async () => { + const clearPreviewData = jest.fn(); + EventEmitter.prototype.clearPreviewData = clearPreviewData; + + const { req, res } = createMocks( + { method: 'GET', query: { slug: ['clear'] } }, + { + eventEmitter: EventEmitter, + }, + ); + + await handlers(req as any, res as any); + + expect(res._getRedirectUrl()).toBe(`/`); + expect(clearPreviewData).toBeCalled(); + }); +}); diff --git a/src/next/previewHandlers.ts b/src/next/previewHandlers.ts index c92f87d..fa6b50a 100644 --- a/src/next/previewHandlers.ts +++ b/src/next/previewHandlers.ts @@ -39,7 +39,7 @@ export const nextPreviewHandlers = ({ // If the slug doesn't exist prevent preview mode from being enabled if (!story || !story?.uuid) { - return res.status(401).json({ message: 'Invalid slug' }); + return res.status(400).json({ message: 'Invalid slug' }); } // Enable Preview Mode by setting the cookies diff --git a/src/utils/__tests__/getExcerpt.test.ts b/src/utils/__tests__/getExcerpt.test.ts new file mode 100644 index 0000000..247c9a3 --- /dev/null +++ b/src/utils/__tests__/getExcerpt.test.ts @@ -0,0 +1,107 @@ +import { getExcerpt } from '../getExcerpt'; + +const richtext = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + text: + 'Far far away, behind the word mountains, far from the countries ', + type: 'text', + }, + { + text: 'Vokalia', + type: 'text', + marks: [ + { + type: 'link', + attrs: { + href: '#', + uuid: null, + anchor: null, + target: null, + linktype: 'story', + }, + }, + ], + }, + { + text: ' and ', + type: 'text', + }, + { + text: 'Consonantia', + type: 'text', + marks: [ + { + type: 'link', + attrs: { + href: '#', + uuid: null, + anchor: null, + target: null, + linktype: 'story', + }, + }, + ], + }, + { + text: + ', there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary regelialia.', + type: 'text', + }, + ], + }, + { + type: 'paragraph', + content: [ + { + text: + 'It is a paradisematic country, in which roasted parts of sentences fly into your mouth.', + type: 'text', + }, + ], + }, + { + type: 'blok', + attrs: { + id: '9e4c398c-0973-4e58-97b7-2ad8e4f710d9', + body: [ + { + _uid: 'i-0562c6fd-620d-4be5-b95a-36e33c4dd091', + body: [], + component: 'button_group', + }, + ], + }, + }, + ], +}; + +describe('[utils] getExcerpt', () => { + it('should return cut off excerpt from richtext', async () => { + const result = getExcerpt(richtext); + + expect(result).toBe( + `Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary regelialia. It is a pa…`, + ); + }); + + it('should return full text if shorter than maxLength', async () => { + const rich = { + type: 'doc', + content: [ + { + text: 'Far far away, behind the word mountains', + type: 'text', + }, + ], + }; + + const result = getExcerpt(rich); + + expect(result).toBe(`Far far away, behind the word mountains`); + }); +}); diff --git a/src/utils/__tests__/getPlainText.test.ts b/src/utils/__tests__/getPlainText.test.ts index 0eb7309..bbaaa99 100644 --- a/src/utils/__tests__/getPlainText.test.ts +++ b/src/utils/__tests__/getPlainText.test.ts @@ -64,6 +64,19 @@ const richtext = { }, ], }, + { + type: 'blok', + attrs: { + id: '9e4c398c-0973-4e58-97b7-2ad8e4f710d9', + body: [ + { + _uid: 'i-0562c6fd-620d-4be5-b95a-36e33c4dd091', + body: [], + component: 'button_group', + }, + ], + }, + }, ], }; @@ -79,4 +92,22 @@ It is a paradisematic country, in which roasted parts of sentences fly into your `, ); }); + + it('should return plaintext without newlines if configured', async () => { + const result = getPlainText(richtext, { addNewlines: false }); + + expect(result).toBe( + `Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary regelialia. It is a paradisematic country, in which roasted parts of sentences fly into your mouth. `, + ); + }); + + it('should return an empty string from empty richtext', async () => { + const rich = { + type: 'doc', + content: [], + }; + const result = getPlainText(rich); + + expect(result).toBe(''); + }); }); diff --git a/src/utils/getPlainText.ts b/src/utils/getPlainText.ts index 5f8d6d2..b2d2430 100644 --- a/src/utils/getPlainText.ts +++ b/src/utils/getPlainText.ts @@ -29,7 +29,7 @@ const renderNode = (node: any, addNewlines: boolean) => { ].includes(node.type) ) { return `${renderNodes(node.content, addNewlines)}${ - addNewlines ? '\n\n' : '' + addNewlines ? '\n\n' : ' ' }`; }