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(),
+ )
+ })
+})