diff --git a/.env.template b/.env.template index fa3202a..4f0bed4 100755 --- a/.env.template +++ b/.env.template @@ -1,2 +1,3 @@ BLOG_API_URL=http://localhost:8000/api/posts -ROOT_URL=http://localhost:3000 \ No newline at end of file +ROOT_URL=http://localhost:3000 +BLOG_GRAPHQL_URL=http://localhost:8000/graphql \ No newline at end of file diff --git a/apollo-client.ts b/apollo-client.ts new file mode 100644 index 0000000..5e5f0cb --- /dev/null +++ b/apollo-client.ts @@ -0,0 +1,8 @@ +import { ApolloClient, InMemoryCache } from '@apollo/client'; + +const apolloClient = new ApolloClient({ + uri: process.env.BLOG_GRAPHQL_URL, + cache: new InMemoryCache(), +}); + +export default apolloClient; diff --git a/app/components/posts/post-search.tsx b/app/components/posts/post-search.tsx index b7e6bc5..870b724 100755 --- a/app/components/posts/post-search.tsx +++ b/app/components/posts/post-search.tsx @@ -4,20 +4,25 @@ import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useDebouncedCallback } from 'use-debounce'; +export function getSearchParams(searchParams: URLSearchParams, term: string) { + const params = new URLSearchParams(searchParams); + params.set('page', '1'); + + if (term) { + params.set('query', term); + } else { + params.delete('query'); + } + return params; +} + export default function Search({ placeholder }: { placeholder: string }) { const searchParams = useSearchParams(); const pathname = usePathname(); const { replace } = useRouter(); const handleSearch = useDebouncedCallback((term) => { - const params = new URLSearchParams(searchParams); - params.set('page', '1'); - - if (term) { - params.set('query', term); - } else { - params.delete('query'); - } + const params = getSearchParams(searchParams, term); replace(`/?${params.toString()}`); }, 300); diff --git a/app/page.tsx b/app/page.tsx index 83ff8f2..814b78d 100755 --- a/app/page.tsx +++ b/app/page.tsx @@ -24,7 +24,7 @@ export default async function Home({ searchParams }: HomeProps) { currentPage: currentPage, query: query, }; - const { posts, totalPages } = await activeDataProvider.getAll(options); + const { posts, totalPages } = await activeDataProvider.getPosts(options); return ( <> diff --git a/data-providers/active.ts b/data-providers/active.ts index f630cb7..7a690d8 100755 --- a/data-providers/active.ts +++ b/data-providers/active.ts @@ -5,8 +5,10 @@ // const jsonData = JSON.parse(readFileSync("tests/sample-data.json", "utf-8")); // export const activeDataProvider = new MemoryDataProvider(jsonData); // Rest-API Data Provider -import { RestAPIDataProvider } from './rest-api'; +//import { RestAPIDataProvider } from './rest-api'; +import apolloClient from '../apollo-client'; +import { GraphqlDataProvider } from './graphql'; -const activeDataProvider = new RestAPIDataProvider(); +const activeDataProvider = new GraphqlDataProvider(apolloClient); export default activeDataProvider; diff --git a/data-providers/base.ts b/data-providers/base.ts index d6dd24f..8a0e617 100755 --- a/data-providers/base.ts +++ b/data-providers/base.ts @@ -2,14 +2,12 @@ import { Post } from '../app/lib/definitions'; import { IDataProvider, PaginatedPosts, PostSearchOptions } from './interface'; export abstract class BaseDataProvider implements IDataProvider { - abstract getPostsFromStorage( - options: PostSearchOptions, - ): Promise; - abstract getSinglePostFromStorage(slug: string): Promise; + abstract getPosts(options: PostSearchOptions): Promise; + abstract getPost(slug: string): Promise; async getAll(options: PostSearchOptions): Promise { return new Promise(async (resolve, reject) => { - const paginatedPosts = await this.getPostsFromStorage(options); + const paginatedPosts = await this.getPosts(options); resolve(paginatedPosts); }); @@ -17,7 +15,7 @@ export abstract class BaseDataProvider implements IDataProvider { async getBySlug(slug: string): Promise { return new Promise(async (resolve, reject) => { - const matchingPost = await this.getSinglePostFromStorage(slug); + const matchingPost = await this.getPost(slug); if (matchingPost) { resolve(matchingPost); } else { diff --git a/data-providers/graphql.ts b/data-providers/graphql.ts new file mode 100755 index 0000000..e3dcc2c --- /dev/null +++ b/data-providers/graphql.ts @@ -0,0 +1,109 @@ +import { ApolloClient, NormalizedCacheObject, gql } from '@apollo/client'; + +import { POST_PAGE_SIZE } from '../app/constants'; +import { Post, PostsAPIResponse } from '../app/lib/definitions'; +import { BaseDataProvider } from './base'; +import { PaginatedPosts, PostSearchOptions } from './interface'; +import { calculateTotalPages } from './utils'; + +const POST_FRAGMENT = gql` + fragment PostFragment on PostType { + title + date + content + slug + tags { + name + } + } +`; + +export const GET_POST = gql` + query GetPosts($slug: String!) { + post(slug: $slug) { + ...PostFragment + } + } + ${POST_FRAGMENT} +`; + +export const GET_POSTS = gql` + query GetPost($search: String, $limit: Int, $offset: Int) { + posts(search: $search, limit: $limit, offset: $offset) { + ...PostFragment + } + totalPosts(search: $search) + } + ${POST_FRAGMENT} +`; + +export const GET_POST_METADATA = gql` + query GetPostMetadata($slug: String!) { + post(slug: $slug) { + title + metaDescription + } + } +`; + +type PostsOffsetBased = { + posts: Post[]; + totalPosts: number; +}; + +export class GraphqlDataProvider extends BaseDataProvider { + private client: ApolloClient; + + constructor(client: ApolloClient) { + super(); + this.client = client; + } + + async getPosts(options: PostSearchOptions): Promise { + const currentPage = options.currentPage || 1; + const limit = options.pageSize || POST_PAGE_SIZE; + + const offset = (currentPage - 1) * limit; + + const variables = options.query + ? { search: options.query, limit, offset } + : { limit, offset }; + + const { data } = await this.client.query({ + query: GET_POSTS, + variables, + }); + return { + posts: data.posts, + totalPages: calculateTotalPages(data.totalPosts, POST_PAGE_SIZE), + }; + } + + async getPost(slug: string): Promise { + try { + const { data } = await this.client.query<{ post: Post }>({ + query: GET_POST, + variables: { slug }, + }); + + return data.post || null; + } catch (error) { + console.error('Error fetching post:', error); + return null; + } + } + + async getPostMetadata(slug: string): Promise { + try { + const { data } = await this.client.query<{ post: Post }>({ + query: GET_POST_METADATA, + variables: { slug }, + }); + + return data.post || null; + } catch (error) { + console.error('Error fetching post metadata:', error); + return null; + } + } +} diff --git a/data-providers/memory.ts b/data-providers/memory.ts index 8c65643..2103de1 100755 --- a/data-providers/memory.ts +++ b/data-providers/memory.ts @@ -13,9 +13,7 @@ export class MemoryDataProvider extends BaseDataProvider { this.postsFromJson = postsFromJson as Post[]; } - async getPostsFromStorage( - options: PostSearchOptions, - ): Promise { + async getPosts(options: PostSearchOptions): Promise { const posts = this.postsFromJson.map((post: Post) => { return { title: post.title, @@ -33,7 +31,7 @@ export class MemoryDataProvider extends BaseDataProvider { }; } - async getSinglePostFromStorage(slug: string): Promise { + async getPost(slug: string): Promise { const post = this.postsFromJson.find( (post: Post) => post.slug === slug, ); diff --git a/data-providers/rest-api.ts b/data-providers/rest-api.ts index 30f61fb..f81842f 100755 --- a/data-providers/rest-api.ts +++ b/data-providers/rest-api.ts @@ -5,9 +5,7 @@ import { PaginatedPosts, PostSearchOptions } from './interface'; import { calculateTotalPages } from './utils'; export class RestAPIDataProvider extends BaseDataProvider { - async getPostsFromStorage( - options: PostSearchOptions, - ): Promise { + async getPosts(options: PostSearchOptions): Promise { const apiUrl = process.env.BLOG_API_URL; if (!apiUrl) { throw new Error('BLOG_API_URL is not set'); @@ -49,7 +47,7 @@ export class RestAPIDataProvider extends BaseDataProvider { }; } - async getSinglePostFromStorage(slug: string): Promise { + async getPost(slug: string): Promise { const response = await fetch(`${process.env.BLOG_API_URL}/${slug}`); if (!response.ok) { return null; @@ -59,4 +57,4 @@ export class RestAPIDataProvider extends BaseDataProvider { return jsonResponse; } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 854f56a..7df1793 100755 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:e2e": "exec playwright test" }, "dependencies": { + "@apollo/client": "^3.12.3", "@heroicons/react": "^2.1.5", "@mapbox/rehype-prism": "^0.9.0", "@mdx-js/loader": "^3.0.1", @@ -24,6 +25,7 @@ "@vercel/speed-insights": "^1.0.12", "clsx": "^2.1.1", "dotenv": "^16.4.5", + "graphql": "^16.9.0", "highlight.js": "^11.10.0", "install": "^0.13.0", "next": "^15.0.0-rc.0", @@ -38,7 +40,9 @@ }, "devDependencies": { "@playwright/test": "^1.45.3", + "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.2", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/node": "^20.14.13", "@types/react": "^18.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0719d3b..4821e6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@apollo/client': + specifier: ^3.12.3 + version: 3.12.3(@types/react@18.3.3)(graphql@16.9.0)(react-dom@19.0.0-rc-e948a5ac-20240807(react@19.0.0-rc-e948a5ac-20240807))(react@19.0.0-rc-e948a5ac-20240807) '@heroicons/react': specifier: ^2.1.5 version: 2.1.5(react@19.0.0-rc-e948a5ac-20240807) @@ -41,6 +44,9 @@ importers: dotenv: specifier: ^16.4.5 version: 16.4.5 + graphql: + specifier: ^16.9.0 + version: 16.9.0 highlight.js: specifier: ^11.10.0 version: 11.10.0 @@ -78,9 +84,15 @@ importers: '@playwright/test': specifier: ^1.45.3 version: 1.45.3 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.6.3 '@testing-library/react': specifier: ^16.0.0 version: 16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-e948a5ac-20240807(react@19.0.0-rc-e948a5ac-20240807))(react@19.0.0-rc-e948a5ac-20240807) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.5.2(@testing-library/dom@10.4.0) '@trivago/prettier-plugin-sort-imports': specifier: ^4.3.0 version: 4.3.0(prettier@3.3.3) @@ -135,6 +147,9 @@ importers: packages: + '@adobe/css-tools@4.4.1': + resolution: {integrity: sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -143,6 +158,24 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@apollo/client@3.12.3': + resolution: {integrity: sha512-KZ5zymRdb8bMbGUb1wP2U04ff7qIGgaC1BCdCVC+IPFiXkxEhHBc5fDEQOwAUT+vUo9KbBh3g7QK/JCOswn59w==} + peerDependencies: + graphql: ^15.0.0 || ^16.0.0 + graphql-ws: ^5.5.5 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc + subscriptions-transport-ws: ^0.9.0 || ^0.11.0 + peerDependenciesMeta: + graphql-ws: + optional: true + react: + optional: true + react-dom: + optional: true + subscriptions-transport-ws: + optional: true + '@babel/code-frame@7.24.7': resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} engines: {node: '>=6.9.0'} @@ -428,6 +461,11 @@ packages: resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@graphql-typed-document-node/core@3.2.0': + resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + '@heroicons/react@2.1.5': resolution: {integrity: sha512-FuzFN+BsHa+7OxbvAERtgBTNeZpUjgM/MIizfVkSCL2/edriN0Hx/DWRCR//aPYwO5QX/YlgLGXk+E3PcfZwjA==} peerDependencies: @@ -871,6 +909,10 @@ packages: resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} + '@testing-library/jest-dom@6.6.3': + resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.0.0': resolution: {integrity: sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ==} engines: {node: '>=18'} @@ -886,6 +928,12 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.5.2': + resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@trivago/prettier-plugin-sort-imports@4.3.0': resolution: {integrity: sha512-r3n0onD3BTOVUNPhR4lhVK4/pABGpbA7bW3eumZnYdKaHkf1qEC+Mag6DPbGNuuh0eG8AaYj+YqmVHSiGslaTQ==} peerDependencies: @@ -1132,6 +1180,22 @@ packages: '@webassemblyjs/wast-printer@1.12.1': resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==} + '@wry/caches@1.0.1': + resolution: {integrity: sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==} + engines: {node: '>=8'} + + '@wry/context@0.7.4': + resolution: {integrity: sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==} + engines: {node: '>=8'} + + '@wry/equality@0.5.7': + resolution: {integrity: sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==} + engines: {node: '>=8'} + + '@wry/trie@0.5.0': + resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==} + engines: {node: '>=8'} + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -1338,6 +1402,10 @@ packages: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1435,6 +1503,9 @@ packages: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1548,6 +1619,9 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@16.4.5: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} @@ -1910,6 +1984,16 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + graphql-tag@2.12.6: + resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} + engines: {node: '>=10'} + peerDependencies: + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + + graphql@16.9.0: + resolution: {integrity: sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} @@ -1996,6 +2080,9 @@ packages: resolution: {integrity: sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==} engines: {node: '>=12.0.0'} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -2034,6 +2121,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -2517,6 +2608,10 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2643,6 +2738,9 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + optimism@0.18.1: + resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2854,6 +2952,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.6: resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} engines: {node: '>= 0.4'} @@ -2868,6 +2970,17 @@ packages: resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} engines: {node: '>= 0.4'} + rehackt@0.1.0: + resolution: {integrity: sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==} + peerDependencies: + '@types/react': '*' + react: '*' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + rehype-autolink-headings@7.1.0: resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} @@ -2907,6 +3020,10 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + response-iterator@0.2.6: + resolution: {integrity: sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw==} + engines: {node: '>=0.8'} + reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3095,6 +3212,10 @@ packages: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3139,6 +3260,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -3232,6 +3357,10 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-invariant@0.10.3: + resolution: {integrity: sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==} + engines: {node: '>=8'} + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -3568,11 +3697,19 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zen-observable-ts@1.2.5: + resolution: {integrity: sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==} + + zen-observable@0.8.15: + resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} snapshots: + '@adobe/css-tools@4.4.1': {} + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -3580,6 +3717,29 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 + '@apollo/client@3.12.3(@types/react@18.3.3)(graphql@16.9.0)(react-dom@19.0.0-rc-e948a5ac-20240807(react@19.0.0-rc-e948a5ac-20240807))(react@19.0.0-rc-e948a5ac-20240807)': + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.9.0) + '@wry/caches': 1.0.1 + '@wry/equality': 0.5.7 + '@wry/trie': 0.5.0 + graphql: 16.9.0 + graphql-tag: 2.12.6(graphql@16.9.0) + hoist-non-react-statics: 3.3.2 + optimism: 0.18.1 + prop-types: 15.8.1 + rehackt: 0.1.0(@types/react@18.3.3)(react@19.0.0-rc-e948a5ac-20240807) + response-iterator: 0.2.6 + symbol-observable: 4.0.0 + ts-invariant: 0.10.3 + tslib: 2.6.3 + zen-observable-ts: 1.2.5 + optionalDependencies: + react: 19.0.0-rc-e948a5ac-20240807 + react-dom: 19.0.0-rc-e948a5ac-20240807(react@19.0.0-rc-e948a5ac-20240807) + transitivePeerDependencies: + - '@types/react' + '@babel/code-frame@7.24.7': dependencies: '@babel/highlight': 7.24.7 @@ -3854,6 +4014,10 @@ snapshots: '@eslint/js@8.57.0': {} + '@graphql-typed-document-node/core@3.2.0(graphql@16.9.0)': + dependencies: + graphql: 16.9.0 + '@heroicons/react@2.1.5(react@19.0.0-rc-e948a5ac-20240807)': dependencies: react: 19.0.0-rc-e948a5ac-20240807 @@ -4216,6 +4380,16 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 + '@testing-library/jest-dom@6.6.3': + dependencies: + '@adobe/css-tools': 4.4.1 + aria-query: 5.3.0 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + '@testing-library/react@16.0.0(@testing-library/dom@10.4.0)(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@19.0.0-rc-e948a5ac-20240807(react@19.0.0-rc-e948a5ac-20240807))(react@19.0.0-rc-e948a5ac-20240807)': dependencies: '@babel/runtime': 7.25.0 @@ -4226,6 +4400,10 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': + dependencies: + '@testing-library/dom': 10.4.0 + '@trivago/prettier-plugin-sort-imports@4.3.0(prettier@3.3.3)': dependencies: '@babel/generator': 7.17.7 @@ -4537,6 +4715,22 @@ snapshots: '@webassemblyjs/ast': 1.12.1 '@xtuc/long': 4.2.2 + '@wry/caches@1.0.1': + dependencies: + tslib: 2.6.3 + + '@wry/context@0.7.4': + dependencies: + tslib: 2.6.3 + + '@wry/equality@0.5.7': + dependencies: + tslib: 2.6.3 + + '@wry/trie@0.5.0': + dependencies: + tslib: 2.6.3 + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} @@ -4758,6 +4952,11 @@ snapshots: escape-string-regexp: 1.0.5 supports-color: 5.5.0 + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4847,6 +5046,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + cssesc@3.0.0: {} cssstyle@4.0.1: @@ -4962,6 +5163,8 @@ snapshots: dom-accessibility-api@0.5.16: {} + dom-accessibility-api@0.6.3: {} + dotenv@16.4.5: {} eastasianwidth@0.2.0: {} @@ -5528,6 +5731,13 @@ snapshots: graphemer@1.4.0: {} + graphql-tag@2.12.6(graphql@16.9.0): + dependencies: + graphql: 16.9.0 + tslib: 2.6.3 + + graphql@16.9.0: {} + has-bigints@1.0.2: {} has-flag@3.0.0: {} @@ -5694,6 +5904,10 @@ snapshots: highlight.js@11.9.0: {} + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -5731,6 +5945,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -6399,6 +6615,8 @@ snapshots: mimic-fn@4.0.0: {} + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -6534,6 +6752,13 @@ snapshots: dependencies: mimic-fn: 4.0.0 + optimism@0.18.1: + dependencies: + '@wry/caches': 1.0.1 + '@wry/context': 0.7.4 + '@wry/trie': 0.5.0 + tslib: 2.6.3 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -6732,6 +6957,11 @@ snapshots: dependencies: picomatch: 2.3.1 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.6: dependencies: call-bind: 1.0.7 @@ -6757,6 +6987,11 @@ snapshots: es-errors: 1.3.0 set-function-name: 2.0.2 + rehackt@0.1.0(@types/react@18.3.3)(react@19.0.0-rc-e948a5ac-20240807): + optionalDependencies: + '@types/react': 18.3.3 + react: 19.0.0-rc-e948a5ac-20240807 + rehype-autolink-headings@7.1.0: dependencies: '@types/hast': 3.0.4 @@ -6830,6 +7065,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + response-iterator@0.2.6: {} + reusify@1.0.4: {} rimraf@3.0.2: @@ -7072,6 +7309,10 @@ snapshots: strip-final-newline@3.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} style-to-object@0.4.4: @@ -7113,6 +7354,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-observable@4.0.0: {} + symbol-tree@3.2.4: {} tailwindcss@3.4.7(ts-node@10.9.2(@swc/core@1.7.6(@swc/helpers@0.5.11))(@types/node@20.14.13)(typescript@5.5.4)): @@ -7213,6 +7456,10 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-invariant@0.10.3: + dependencies: + tslib: 2.6.3 + ts-node@10.9.2(@swc/core@1.7.6(@swc/helpers@0.5.11))(@types/node@20.14.13)(typescript@5.5.4): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -7623,4 +7870,10 @@ snapshots: yocto-queue@0.1.0: {} + zen-observable-ts@1.2.5: + dependencies: + zen-observable: 0.8.15 + + zen-observable@0.8.15: {} + zwitch@2.0.4: {} diff --git a/tests/unit-tests/data-providers/graphql.test.ts b/tests/unit-tests/data-providers/graphql.test.ts new file mode 100644 index 0000000..1063a45 --- /dev/null +++ b/tests/unit-tests/data-providers/graphql.test.ts @@ -0,0 +1,204 @@ +import { describe } from 'node:test'; +import { expect, test, vi } from 'vitest'; + +import { + GET_POST, + GET_POSTS, + GET_POST_METADATA, + GraphqlDataProvider, +} from '../../../data-providers/graphql'; +import { generateMockPosts, generateMockRestAPIResponse } from '../utils'; + +// Mock the global fetch function +global.fetch = vi.fn(); + +describe('GraphqlDataProvider.getPosts tests', () => { + test('getPosts should fetch posts with query and page size', async () => { + const mockClient = { + query: vi.fn().mockResolvedValue({ + data: { + posts: generateMockPosts(6), + totalPosts: 6, + }, + }), + }; + + const graphqlDataProvider = new GraphqlDataProvider(mockClient as any); + + const expectedResponse = { + totalPages: 2, + posts: generateMockPosts(6), + }; + + const response = await graphqlDataProvider.getPosts({ + query: 'test', + pageSize: 3, + currentPage: 1, + }); + + expect(response).toEqual(expectedResponse); + expect(mockClient.query).toHaveBeenCalledWith({ + query: GET_POSTS, + variables: { search: 'test', limit: 3, offset: 0 }, + }); + }); + + test('getPosts should fetch posts without query', async () => { + const mockClient = { + query: vi.fn().mockResolvedValue({ + data: { + posts: generateMockPosts(6), + totalPosts: 6, + }, + }), + }; + + const graphqlDataProvider = new GraphqlDataProvider(mockClient as any); + + const expectedResponse = { + totalPages: 2, + posts: generateMockPosts(6), + }; + + const response = await graphqlDataProvider.getPosts({ + pageSize: 3, + currentPage: 1, + }); + + expect(response).toEqual(expectedResponse); + expect(mockClient.query).toHaveBeenCalledWith({ + query: GET_POSTS, + variables: { limit: 3, offset: 0 }, + }); + }); + + test('getPosts should handle errors gracefully', async () => { + const mockClient = { + query: vi.fn().mockRejectedValue(new Error('GraphQL error')), + }; + + const graphqlDataProvider = new GraphqlDataProvider(mockClient as any); + + await expect( + graphqlDataProvider.getPosts({ + query: 'test', + pageSize: 3, + currentPage: 1, + }), + ).rejects.toThrow('GraphQL error'); + }); +}); + +describe('GraphqlDataProvider.getPost tests', () => { + test('getPost should fetch a post by slug', async () => { + const mockPost = { title: 'Test Post', slug: 'test-post' }; + const mockClient = { + query: vi.fn().mockResolvedValue({ + data: { post: mockPost }, + }), + }; + + const graphqlDataProvider = new GraphqlDataProvider(mockClient as any); + + const response = await graphqlDataProvider.getPost('test-post'); + + expect(response).toEqual(mockPost); + expect(mockClient.query).toHaveBeenCalledWith({ + query: GET_POST, + variables: { slug: 'test-post' }, + }); + }); + + test('getPost should return null if post not found', async () => { + const mockClient = { + query: vi.fn().mockResolvedValue({ + data: { post: null }, + }), + }; + + const graphqlDataProvider = new GraphqlDataProvider(mockClient as any); + + const response = await graphqlDataProvider.getPost('non-existent-post'); + + expect(response).toBeNull(); + expect(mockClient.query).toHaveBeenCalledWith({ + query: GET_POST, + variables: { slug: 'non-existent-post' }, + }); + }); + + test('getPost should handle errors gracefully', async () => { + const mockClient = { + query: vi.fn().mockRejectedValue(new Error('GraphQL error')), + }; + + const graphqlDataProvider = new GraphqlDataProvider(mockClient as any); + + const response = await graphqlDataProvider.getPost('test-post'); + + expect(response).toBeNull(); + expect(mockClient.query).toHaveBeenCalledWith({ + query: GET_POST, + variables: { slug: 'test-post' }, + }); + }); +}); + +describe('GraphqlDataProvider.getPostMetadata tests', () => { + test('getPostMetadata should fetch post metadata by slug', async () => { + const mockMetadata = { + title: 'Test Post', + metaDescription: 'Test Description', + }; + const mockClient = { + query: vi.fn().mockResolvedValue({ + data: { post: mockMetadata }, + }), + }; + + const graphqlDataProvider = new GraphqlDataProvider(mockClient as any); + + const response = await graphqlDataProvider.getPostMetadata('test-post'); + + expect(response).toEqual(mockMetadata); + expect(mockClient.query).toHaveBeenCalledWith({ + query: GET_POST_METADATA, + variables: { slug: 'test-post' }, + }); + }); + + test('getPostMetadata should return null if metadata not found', async () => { + const mockClient = { + query: vi.fn().mockResolvedValue({ + data: { post: null }, + }), + }; + + const graphqlDataProvider = new GraphqlDataProvider(mockClient as any); + + const response = + await graphqlDataProvider.getPostMetadata('non-existent-post'); + + expect(response).toBeNull(); + expect(mockClient.query).toHaveBeenCalledWith({ + query: GET_POST_METADATA, + variables: { slug: 'non-existent-post' }, + }); + }); + + test('getPostMetadata should handle errors gracefully', async () => { + const mockClient = { + query: vi.fn().mockRejectedValue(new Error('GraphQL error')), + }; + + const graphqlDataProvider = new GraphqlDataProvider(mockClient as any); + + const response = await graphqlDataProvider.getPostMetadata('test-post'); + + expect(response).toBeNull(); + expect(mockClient.query).toHaveBeenCalledWith({ + query: GET_POST_METADATA, + variables: { slug: 'test-post' }, + }); + }); +}); diff --git a/tests/unit-tests/data-providers/rest-api.test.ts b/tests/unit-tests/data-providers/rest-api.test.ts index ab720e3..ad29026 100644 --- a/tests/unit-tests/data-providers/rest-api.test.ts +++ b/tests/unit-tests/data-providers/rest-api.test.ts @@ -14,9 +14,9 @@ const restAPIDataProvider = new RestAPIDataProvider(); describe('RestAPIDataProvider.getPostsFromStorage tests', () => { test('getPostsFromStorage should throw an error if BLOG_API_URL is not set', async () => { delete process.env.BLOG_API_URL; - await expect( - restAPIDataProvider.getPostsFromStorage({}), - ).rejects.toThrow('BLOG_API_URL is not set'); + await expect(restAPIDataProvider.getPosts({})).rejects.toThrow( + 'BLOG_API_URL is not set', + ); }); test('getPostsFromStorage should fetch posts with query and page size', async () => { @@ -35,7 +35,7 @@ describe('RestAPIDataProvider.getPostsFromStorage tests', () => { posts: mockRestAPIResponse.results, }; - const response = await restAPIDataProvider.getPostsFromStorage({ + const response = await restAPIDataProvider.getPosts({ query: 'test', pageSize: 3, }); @@ -53,9 +53,9 @@ describe('RestAPIDataProvider.getPostsFromStorage tests', () => { ok: false, }); - await expect( - restAPIDataProvider.getPostsFromStorage({}), - ).rejects.toThrow('Failed to fetch posts'); + await expect(restAPIDataProvider.getPosts({})).rejects.toThrow( + 'Failed to fetch posts', + ); }); }); @@ -69,8 +69,7 @@ describe('RestAPIDataProvider.getSinglePostFromStorage tests', () => { json: async () => mockPost, }); - const response = - await restAPIDataProvider.getSinglePostFromStorage('post-1'); + const response = await restAPIDataProvider.getPost('post-1'); expect(response).toEqual(mockPost); expect(global.fetch).toHaveBeenCalledWith( 'https://api.example.com/posts/post-1', @@ -84,8 +83,7 @@ describe('RestAPIDataProvider.getSinglePostFromStorage tests', () => { ok: false, }); - const response = - await restAPIDataProvider.getSinglePostFromStorage('post-1'); + const response = await restAPIDataProvider.getPost('post-1'); expect(response).toBeNull(); }); }); diff --git a/tests/unit-tests/lib/__snapshots__/post.search.test.tsx.snap b/tests/unit-tests/lib/__snapshots__/post.search.test.tsx.snap new file mode 100644 index 0000000..0abfe61 --- /dev/null +++ b/tests/unit-tests/lib/__snapshots__/post.search.test.tsx.snap @@ -0,0 +1,109 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Search Component > should render search matching the snapshot 1`] = ` +
+
+ + + +
+
+`; + +exports[`Search Component > should render the search component matching the snapshot 1`] = ` +
+
+ + + +
+
+`; + +exports[`Search Component > should render the search matching the snapshot 1`] = ` +
+
+ + + +
+
+`; diff --git a/tests/unit-tests/lib/post.search.test.tsx b/tests/unit-tests/lib/post.search.test.tsx new file mode 100755 index 0000000..2048334 --- /dev/null +++ b/tests/unit-tests/lib/post.search.test.tsx @@ -0,0 +1,54 @@ +import { render } from '@testing-library/react'; +import { usePathname } from 'next/navigation'; +import { describe, expect, test, vi } from 'vitest'; + +import Search, { + getSearchParams, +} from '../../../app/components/posts/post-search'; + +vi.mock('next/navigation', () => ({ + usePathname: vi.fn(), + useRouter: vi.fn(() => ({ + replace: vi.fn(), + })), + useSearchParams: vi.fn(() => new URLSearchParams()), +})); + +describe('Search Component', () => { + test('should render the search component matching the snapshot', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + test('should call usePathname', () => { + render(); + expect(usePathname).toHaveBeenCalled(); + }); +}); + +describe('getSearchParams', () => { + test('should set page to 1 and add query parameter if term is provided', () => { + const searchParams = new URLSearchParams(); + const term = 'test'; + const result = getSearchParams(searchParams, term); + expect(result.get('page')).toBe('1'); + expect(result.get('query')).toBe(term); + }); + + test('should set page to 1 and remove query parameter if term is empty', () => { + const searchParams = new URLSearchParams({ query: 'test' }); + const term = ''; + const result = getSearchParams(searchParams, term); + expect(result.get('page')).toBe('1'); + expect(result.has('query')).toBe(false); + }); + + test('should preserve other existing parameters', () => { + const searchParams = new URLSearchParams({ other: 'value' }); + const term = 'test'; + const result = getSearchParams(searchParams, term); + expect(result.get('page')).toBe('1'); + expect(result.get('query')).toBe(term); + expect(result.get('other')).toBe('value'); + }); +}); diff --git a/tests/unit-tests/pages.test.tsx b/tests/unit-tests/pages.test.tsx index 68bf069..151feba 100755 --- a/tests/unit-tests/pages.test.tsx +++ b/tests/unit-tests/pages.test.tsx @@ -41,8 +41,8 @@ test('Home page component should match the snapshot', async () => { page: '1', }; - vi.spyOn(activeDataProvider, 'getPostsFromStorage').mockImplementation(() => - memoryDataProvider.getPostsFromStorage(searchParams), + vi.spyOn(activeDataProvider, 'getPosts').mockImplementation(() => + memoryDataProvider.getPosts(searchParams), ); const { container } = render( @@ -63,11 +63,8 @@ describe('Single Post Page', () => { slug: postSlug, }; - vi.spyOn( - activeDataProvider, - 'getSinglePostFromStorage', - ).mockImplementation(() => - memoryDataProvider.getSinglePostFromStorage(postSlug), + vi.spyOn(activeDataProvider, 'getPost').mockImplementation(() => + memoryDataProvider.getPost(postSlug), ); const { container } = render( @@ -85,11 +82,8 @@ describe('Single Post Page', () => { test('generateMetadata should return metadata for a valid post', async () => { const postSlug = 'post-1'; - vi.spyOn( - activeDataProvider, - 'getSinglePostFromStorage', - ).mockImplementation(() => - memoryDataProvider.getSinglePostFromStorage(postSlug), + vi.spyOn(activeDataProvider, 'getPostMetadata').mockImplementation(() => + memoryDataProvider.getPost(postSlug), ); const props: SinglePostPageProps = { @@ -111,10 +105,7 @@ describe('Single Post Page', () => { params: { slug: 'non-existent-post' }, }; - vi.spyOn( - activeDataProvider, - 'getSinglePostFromStorage', - ).mockResolvedValue(null); + vi.spyOn(activeDataProvider, 'getPost').mockResolvedValue(null); const result = await generateMetadata(props); diff --git a/vitest.config.ts b/vitest.config.ts index f239c9e..832ef09 100755 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [react()], test: { + setupFiles: ['./vitest.setup.ts'], include: [ 'tests/unit-tests/*.(spec|test).(ts|tsx)', 'tests/unit-tests/**/*.(spec|test).(ts|tsx)', diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..331666c --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; \ No newline at end of file