diff --git a/web/e.cash/data/__tests__/blog.test.js b/web/e.cash/data/__tests__/blog.test.js index e4097f423..b17b38767 100644 --- a/web/e.cash/data/__tests__/blog.test.js +++ b/web/e.cash/data/__tests__/blog.test.js @@ -1,178 +1,205 @@ // Copyright (c) 2023 The Bitcoin developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. import { getPageCount, getBlogPosts, + sortBlogPostsByDate, formatTimestamp, evaluateMediaLink, } from '../blog.js'; import { mockBlogPosts1, mockBlogPosts2, mockBlogPosts3, } from '../__mocks__/blogMock.js'; describe('getPageCount', () => { beforeEach(() => { global.fetch = jest.fn(); }); afterEach(() => { global.fetch.mockClear(); delete global.fetch; }); it('should call an api endpoint and return a number from the response', async () => { const mockResponse = { meta: { pagination: { pageCount: 5, }, }, }; global.fetch.mockResolvedValue({ json: jest.fn().mockResolvedValue(mockResponse), }); const result = await getPageCount(); expect(global.fetch).toHaveBeenCalledWith( 'https://strapi.fabien.cash/api/posts', ); expect(result).toEqual(5); }); it('should throw an error when fetch fails', async () => { global.fetch.mockImplementation(() => { throw new Error('Failed to fetch api'); }); await expect(getPageCount()).rejects.toThrow('Failed to fetch api'); }); it('should throw an error if api returns wrong shape', async () => { const mockBadResponse = { meta: {}, }; global.fetch.mockResolvedValue({ json: jest.fn().mockResolvedValue(mockBadResponse), }); await expect(getPageCount()).rejects.toThrow( "TypeError: Cannot read properties of undefined (reading 'pageCount')", ); }); }); describe('getBlogPosts', () => { beforeEach(() => { global.fetch = jest.fn(); }); afterEach(() => { global.fetch.mockClear(); delete global.fetch; }); it('should fetch each page for the blog posts api and return the responses', async () => { fetch.mockResolvedValueOnce({ json: jest.fn().mockResolvedValue({ meta: { pagination: { pageCount: 3 } }, }), }); fetch.mockResolvedValueOnce({ json: jest.fn().mockResolvedValue({ data: mockBlogPosts1 }), }); fetch.mockResolvedValueOnce({ json: jest.fn().mockResolvedValue({ data: mockBlogPosts2 }), }); fetch.mockResolvedValueOnce({ json: jest.fn().mockResolvedValue({ data: mockBlogPosts3 }), }); const result = await getBlogPosts(); expect(fetch).toHaveBeenNthCalledWith( 1, 'https://strapi.fabien.cash/api/posts', ); expect(fetch).toHaveBeenNthCalledWith( 2, - 'https://strapi.fabien.cash/api/posts?pagination[page]=1&populate=*&sort=id:desc', + 'https://strapi.fabien.cash/api/posts?pagination[page]=1&populate=*&sort=publishedAt:desc', ); expect(fetch).toHaveBeenNthCalledWith( 3, - 'https://strapi.fabien.cash/api/posts?pagination[page]=2&populate=*&sort=id:desc', + 'https://strapi.fabien.cash/api/posts?pagination[page]=2&populate=*&sort=publishedAt:desc', ); expect(fetch).toHaveBeenNthCalledWith( 4, - 'https://strapi.fabien.cash/api/posts?pagination[page]=3&populate=*&sort=id:desc', + 'https://strapi.fabien.cash/api/posts?pagination[page]=3&populate=*&sort=publishedAt:desc', ); expect(result).toEqual({ props: { posts: [ ...mockBlogPosts1, ...mockBlogPosts2, ...mockBlogPosts3, ], }, }); }); it('should throw an error when fetch fails', async () => { global.fetch.mockImplementation(() => { throw new Error('Failed to fetch api'); }); await expect(getBlogPosts()).rejects.toThrow('Failed to fetch api'); }); }); +describe('sortBlogPostsByDate', () => { + it('should sort blog posts by date in descending order', async () => { + const blogPostsSortedByDate = [ + { attributes: { title: 'Post 5', publishedAt: '2022-05-05' } }, + { attributes: { title: 'Post 4', publish_date: '2022-04-04' } }, + { attributes: { title: 'Post 3', publishedAt: '2022-03-03' } }, + { attributes: { title: 'Post 2', publish_date: '2022-02-02' } }, + { attributes: { title: 'Post 1', publishedAt: '2022-01-01' } }, + ]; + + const shuffleArray = originalArray => { + const array = [...originalArray]; + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; + }; + + const shuffledBlogPosts = shuffleArray(blogPostsSortedByDate); + + const result = await sortBlogPostsByDate(shuffledBlogPosts); + expect(result).toEqual(blogPostsSortedByDate); + }); +}); + describe('formatTimestamp', () => { it('should return a formatted date string for a valid timestamp', () => { const timestamp = '2021-07-01T00:00:00Z'; const expectedDateString = 'Jul 1, 2021'; const result = formatTimestamp(timestamp); expect(result).toEqual(expectedDateString); }); it('should throw an error for an invalid timestamp', () => { const invalidTimestamp = 'not-a-timestamp'; expect(() => { formatTimestamp(invalidTimestamp); }).toThrow('Invalid timestamp'); }); }); describe('evaluateMediaLink', () => { it('should return true for a valid URL', () => { const validUrl = 'https://www.example.com'; expect(evaluateMediaLink(validUrl)).toBe(true); }); it('should return false for an invalid URL', () => { const invalidUrl = 'not_a_valid_url'; expect(evaluateMediaLink(invalidUrl)).toBe(false); }); it('should return false for an empty string', () => { expect(evaluateMediaLink('')).toBe(false); }); it('should return false for a null input', () => { expect(evaluateMediaLink(null)).toBe(false); }); it('should return false for an undefined input', () => { expect(evaluateMediaLink(undefined)).toBe(false); }); it('should return false for a non-string input', () => { expect(evaluateMediaLink(123)).toBe(false); }); }); diff --git a/web/e.cash/data/blog.js b/web/e.cash/data/blog.js index 61d86eac9..a59214ad5 100644 --- a/web/e.cash/data/blog.js +++ b/web/e.cash/data/blog.js @@ -1,85 +1,108 @@ // Copyright (c) 2023 The Bitcoin developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. /** * Fetch blog posts page count * The API has a response limit * Need to determine page count in order to fetch all posts * @returns {number} the number of blog post pages */ export async function getPageCount() { let response; try { response = await fetch('https://strapi.fabien.cash/api/posts').then( res => res.json(), ); return response.meta.pagination.pageCount; } catch (err) { throw new Error(err); } } /** * Fetch blog posts and return array of combined responses * Use the response from getPageAmount to call each page * Add each page response to a responses array * return the responses in a props object to be used with getStaticProps * @returns {object} props object containing blog posts responses */ export async function getBlogPosts() { let response, posts = [], propsObj; let pageCount = await getPageCount(); for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) { try { response = await fetch( `https://strapi.fabien.cash/api/posts?pagination[page]=${pageNumber}&populate=*&sort=publishedAt:desc`, ).then(res => res.json()); posts = [...posts, ...response.data]; } catch (err) { throw new Error(err); } } propsObj = { props: { posts }, }; return propsObj; } +/** + * Sort blog posts by date and return them in a props object + * @returns {object} props object containing blog posts responses + * sorted by date to be used with getStaticProps + */ +export async function sortBlogPostsByDate(posts) { + const customSort = (postA, postB) => { + const dateA = + postA.attributes.publish_date || postA.attributes.publishedAt; // Check for the presence of 'publish_date' or 'publishedAt' + const dateB = + postB.attributes.publish_date || postB.attributes.publishedAt; + + // Convert both date formats to comparable formats + const dateAFormatted = new Date(dateA).getTime(); + const dateBFormatted = new Date(dateB).getTime(); + + return dateBFormatted - dateAFormatted; // Sort in descending order (latest first) + }; + // Sort the posts array using the custom sorting function. Use slice() to avoid mutating the original array + const sortedPosts = posts.slice().sort(customSort); + return sortedPosts; +} + /** * Convert a timestamp into a more readable format * @param {string} timestamp - the timestamp to convert * accepts UTC, Unix, and UTC string representation timestamps * should throw error if non valid timestamp is passed to it * @returns {string} formatted date string */ export const formatTimestamp = timestamp => { const date = new Date(timestamp); if (isNaN(date)) { throw new Error('Invalid timestamp'); } const options = { timeZone: 'UTC', month: 'short', day: 'numeric', year: 'numeric', }; return date.toLocaleDateString('en-US', options); }; /** * Evalute if media_link is a valid url * @param {string} string - the value of media_link * @returns {boolen} if it is or isn't a valid url */ export const evaluateMediaLink = string => { let url; try { url = new URL(string); } catch (err) { return false; } return true; }; diff --git a/web/e.cash/pages/blog.js b/web/e.cash/pages/blog.js index 6cf3030be..4943af36a 100644 --- a/web/e.cash/pages/blog.js +++ b/web/e.cash/pages/blog.js @@ -1,111 +1,116 @@ // Copyright (c) 2023 The Bitcoin developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. import Image from 'next/image'; import Layout from '/components/layout'; import H3 from '/components/h3'; import { Container } from '/components/atoms'; -import { getBlogPosts } from '/data/blog.js'; +import { getBlogPosts, sortBlogPostsByDate } from '/data/blog.js'; import { BlogCtn, FeaturedCardCtn, FeaturedCard, CardImage, TextCtn, Tag, CardCtn, Card, DateText, } from '/styles/pages/blog.js'; import { formatTimestamp } from '/data/blog.js'; function Blog(props) { const featuredPosts = props.posts.slice(0, 3); const posts = props.posts.slice(3); return (

{featuredPosts.map((post, index) => ( {post.attributes.type} {post.attributes.title} {formatTimestamp( post.attributes.publish_date ? post.attributes.publish_date : post.attributes.publishedAt, )} {index === 0 ? ( <>

{post.attributes.title}

{post.attributes.short_content}

) : (

{post.attributes.title}

)}
))}
{posts.map((post, index) => ( {post.attributes.type} {post.attributes.title} {formatTimestamp( post.attributes.publish_date ? post.attributes.publish_date : post.attributes.publishedAt, )}

{post.attributes.title}

))}
); } /** * Call function to fetch blog api data and return posts * This only runs at build time, and the build should fail if the api call fails * @returns {object} props - page props to pass to the page * @throws {error} on bad API call or failure to parse API result */ export async function getStaticProps() { const posts = await getBlogPosts(); - return posts; + const orderedPosts = await sortBlogPostsByDate(posts.props.posts); + return { + props: { + posts: orderedPosts, + }, + }; } export default Blog;