From a2caca2317e303299387c19b456d2d4904c9c0df Mon Sep 17 00:00:00 2001 From: Curtis Timson Date: Sun, 7 Jun 2026 20:58:44 +0100 Subject: [PATCH] test: add unit tests for rss, sitemap routes and ThemeContext Cover the highest-value untested logic where regressions would be silent: feed/sitemap XML generation (item cap, conditional description, per-tag lastmod) and theme localStorage persistence. Route tests run in the node jest env so the Response global is available. Co-Authored-By: Claude Sonnet 4.6 --- src/app/context/ThemeContext.test.tsx | 34 +++++++++ src/app/rss.xml/route.test.ts | 91 ++++++++++++++++++++++ src/app/sitemap.xml/route.test.ts | 106 ++++++++++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 src/app/context/ThemeContext.test.tsx create mode 100644 src/app/rss.xml/route.test.ts create mode 100644 src/app/sitemap.xml/route.test.ts diff --git a/src/app/context/ThemeContext.test.tsx b/src/app/context/ThemeContext.test.tsx new file mode 100644 index 0000000..50c9516 --- /dev/null +++ b/src/app/context/ThemeContext.test.tsx @@ -0,0 +1,34 @@ +import React, { useContext } from 'react' +import { renderHook, act } from '@testing-library/react' +import { ThemeContext, ThemeProvider } from './ThemeContext' + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children as React.JSX.Element} +) + +describe('ThemeContext', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('defaults to light when localStorage is empty', async () => { + const { result } = renderHook(() => useContext(ThemeContext), { wrapper }) + await act(async () => {}) + expect(result.current.theme).toBe('light') + }) + + it('hydrates to the stored theme on mount', async () => { + localStorage.setItem('theme', 'dark') + const { result } = renderHook(() => useContext(ThemeContext), { wrapper }) + await act(async () => {}) + expect(result.current.theme).toBe('dark') + }) + + it('changeTheme updates the theme and persists to localStorage', async () => { + const { result } = renderHook(() => useContext(ThemeContext), { wrapper }) + await act(async () => {}) + act(() => result.current.changeTheme('dark')) + expect(result.current.theme).toBe('dark') + expect(localStorage.getItem('theme')).toBe('dark') + }) +}) diff --git a/src/app/rss.xml/route.test.ts b/src/app/rss.xml/route.test.ts new file mode 100644 index 0000000..f542cbe --- /dev/null +++ b/src/app/rss.xml/route.test.ts @@ -0,0 +1,91 @@ +/** + * @jest-environment node + */ +import { getPosts } from '../util/posts' +import { config } from '../config' +import { formatDateToRFC822 } from './formatDateToRFC822' +import { GET } from './route' +import type { Post } from '../types' + +jest.mock('../util/posts', () => ({ + getPosts: jest.fn(), +})) + +const mockGetPosts = getPosts as jest.Mock + +function makePost(overrides: Partial = {}): Post { + return { + title: 'A Post', + slug: 'a-post', + description: 'A description', + date: new Date('2024-01-02T03:04:05Z'), + url: 'https://www.curtiscode.dev/post/a-post', + tags: ['foo'], + content: 'content', + ...overrides, + } as Post +} + +async function getBody() { + const res = await GET() + return { res, body: await res.text() } +} + +describe('rss.xml GET', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('responds with application/xml content type', async () => { + mockGetPosts.mockReturnValue([makePost()]) + const { res } = await getBody() + expect(res.headers.get('content-type')).toBe('application/xml') + }) + + it('uses the channel title, link and description from config', async () => { + mockGetPosts.mockReturnValue([makePost()]) + const { body } = await getBody() + expect(body).toContain(`${config.title}`) + expect(body).toContain(`${config.url}`) + expect(body).toContain(`${config.rssFeedDescription}`) + }) + + it('renders one per post', async () => { + mockGetPosts.mockReturnValue([ + makePost({ url: 'https://www.curtiscode.dev/post/one' }), + makePost({ url: 'https://www.curtiscode.dev/post/two' }), + makePost({ url: 'https://www.curtiscode.dev/post/three' }), + ]) + const { body } = await getBody() + expect((body.match(//g) ?? []).length).toBe(3) + }) + + it('renders a CDATA description only for posts that have one', async () => { + mockGetPosts.mockReturnValue([ + makePost({ title: 'With Desc', description: 'Has a description' }), + makePost({ title: 'No Desc', description: undefined }), + ]) + const { body } = await getBody() + expect(body).toContain('') + // One channel-level plus exactly one item-level description + expect((body.match(//g) ?? []).length).toBe(2) + }) + + it('formats pubDate and lastBuildDate as RFC-822', async () => { + const date = new Date('2024-01-02T03:04:05Z') + mockGetPosts.mockReturnValue([makePost({ date })]) + const { body } = await getBody() + const expected = formatDateToRFC822(date) + expect(body).toContain(`${expected}`) + expect(body).toContain(`${expected}`) + }) + + it('caps the feed at 50 items', async () => { + const posts = Array.from({ length: 60 }, (_, i) => + makePost({ url: `https://www.curtiscode.dev/post/${i}` }), + ) + mockGetPosts.mockReturnValue(posts) + const { body } = await getBody() + expect((body.match(//g) ?? []).length).toBe(50) + }) +}) diff --git a/src/app/sitemap.xml/route.test.ts b/src/app/sitemap.xml/route.test.ts new file mode 100644 index 0000000..1cab02b --- /dev/null +++ b/src/app/sitemap.xml/route.test.ts @@ -0,0 +1,106 @@ +/** + * @jest-environment node + */ +import { getPosts } from '../util/posts' +import { config } from '../config' +import { GET } from './route' +import type { Post } from '../types' + +jest.mock('../util/posts', () => { + const actual = jest.requireActual('../util/posts') + return { + ...actual, + getPosts: jest.fn(), + } +}) + +const mockGetPosts = getPosts as jest.Mock + +function makePost(overrides: Partial = {}): Post { + return { + title: 'A Post', + slug: 'a-post', + date: new Date('2024-01-01T00:00:00.000Z'), + url: 'https://www.curtiscode.dev/post/a-post', + tags: ['foo'], + content: 'content', + ...overrides, + } as Post +} + +function lastmodForTag(body: string, tag: string): string | null { + const re = new RegExp( + `${config.url}/tag/${tag}\\s*([^<]+)`, + ) + const match = body.match(re) + return match ? match[1] : null +} + +async function getBody() { + const res = await GET() + return { res, body: await res.text() } +} + +describe('sitemap.xml GET', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + const posts = [ + makePost({ + url: 'https://www.curtiscode.dev/post/a', + tags: ['foo'], + date: new Date('2024-01-01T00:00:00.000Z'), + }), + makePost({ + url: 'https://www.curtiscode.dev/post/b', + tags: ['foo', 'bar'], + date: new Date('2024-03-01T00:00:00.000Z'), + }), + makePost({ + url: 'https://www.curtiscode.dev/post/c', + tags: ['bar'], + date: new Date('2024-05-01T00:00:00.000Z'), + }), + ] + + it('responds with application/xml content type', async () => { + mockGetPosts.mockReturnValue(posts) + const { res } = await getBody() + expect(res.headers.get('content-type')).toBe('application/xml') + }) + + it('includes the homepage url', async () => { + mockGetPosts.mockReturnValue(posts) + const { body } = await getBody() + expect(body).toContain('https://www.curtiscode.dev/') + }) + + it('includes a url for each post using the ISO date as lastmod', async () => { + mockGetPosts.mockReturnValue(posts) + const { body } = await getBody() + for (const post of posts) { + expect(body).toContain(`${post.url}`) + expect(body).toContain(`${post.date.toISOString()}`) + } + }) + + it('includes a url for each top tag', async () => { + mockGetPosts.mockReturnValue(posts) + const { body } = await getBody() + expect(body).toContain(`${config.url}/tag/foo`) + expect(body).toContain(`${config.url}/tag/bar`) + }) + + it('uses the newest post date as lastmod for each tag', async () => { + mockGetPosts.mockReturnValue(posts) + const { body } = await getBody() + // foo: newest is post b (2024-03-01); bar: newest is post c (2024-05-01) + expect(lastmodForTag(body, 'foo')).toBe( + new Date('2024-03-01T00:00:00.000Z').toISOString(), + ) + expect(lastmodForTag(body, 'bar')).toBe( + new Date('2024-05-01T00:00:00.000Z').toISOString(), + ) + }) +})