@fullstackdatasolutions/articles 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +324 -0
- package/dist/index.cjs +1027 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +232 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.js +974 -0
- package/dist/index.js.map +1 -0
- package/dist/server.cjs +732 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +127 -0
- package/dist/server.d.ts +127 -0
- package/dist/server.js +684 -0
- package/dist/server.js.map +1 -0
- package/package.json +82 -0
- package/src/ArticleCard.tsx +63 -0
- package/src/ArticleCategoryGrid.tsx +73 -0
- package/src/ArticleSchemas.tsx +82 -0
- package/src/ArticleSearchBar.tsx +50 -0
- package/src/ArticlesHero.tsx +33 -0
- package/src/ArticlesPage.tsx +167 -0
- package/src/CategoryArticlesPage.tsx +84 -0
- package/src/CommentForm.tsx +98 -0
- package/src/CommentItem.tsx +123 -0
- package/src/CommentThread.tsx +40 -0
- package/src/CommentsSection.tsx +84 -0
- package/src/FeaturedArticle.tsx +63 -0
- package/src/LatestArticles.tsx +42 -0
- package/src/LatestArticlesSection.tsx +68 -0
- package/src/__tests__/ArticleCard.test.tsx +78 -0
- package/src/__tests__/ArticleCategoryGrid.test.tsx +98 -0
- package/src/__tests__/ArticleSchemas.test.tsx +130 -0
- package/src/__tests__/ArticleSearchBar.test.tsx +79 -0
- package/src/__tests__/ArticlesHero.test.tsx +38 -0
- package/src/__tests__/ArticlesPage.test.tsx +155 -0
- package/src/__tests__/CategoryArticlesPage.test.tsx +156 -0
- package/src/__tests__/CommentForm.test.tsx +80 -0
- package/src/__tests__/CommentItem.test.tsx +149 -0
- package/src/__tests__/CommentsSection.test.tsx +118 -0
- package/src/__tests__/FeaturedArticle.test.tsx +85 -0
- package/src/__tests__/LatestArticles.test.tsx +157 -0
- package/src/__tests__/LatestArticlesSection.test.tsx +105 -0
- package/src/__tests__/jest-dom.d.ts +1 -0
- package/src/__tests__/seoUtils.test.ts +217 -0
- package/src/__tests__/useArticles.test.ts +207 -0
- package/src/articleTypes.ts +21 -0
- package/src/articlesConfig.ts +98 -0
- package/src/commentTypes.ts +14 -0
- package/src/index.ts +35 -0
- package/src/markdown.ts +314 -0
- package/src/seoUtils.ts +155 -0
- package/src/server-articles.ts +265 -0
- package/src/server.ts +25 -0
- package/src/useArticles.ts +93 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen } from '@testing-library/react'
|
|
6
|
+
import type { CategoryInfo } from '../articleTypes'
|
|
7
|
+
import { ArticleCategoryGrid } from '../ArticleCategoryGrid'
|
|
8
|
+
|
|
9
|
+
jest.mock('next/link', () => ({
|
|
10
|
+
__esModule: true,
|
|
11
|
+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
12
|
+
<a href={href}>{children}</a>
|
|
13
|
+
),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
jest.mock('next/image', () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
default: (props: React.ComponentProps<'img'>) => <img alt="test" {...props} />,
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const mockCategories: CategoryInfo[] = [
|
|
22
|
+
{
|
|
23
|
+
name: 'Campaigns',
|
|
24
|
+
slug: 'campaigns',
|
|
25
|
+
count: 5,
|
|
26
|
+
featuredImage: '/articles/campaign-article/image.jpg',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'Volunteer Management',
|
|
30
|
+
slug: 'volunteer-management',
|
|
31
|
+
count: 3,
|
|
32
|
+
featuredImage: '/articles/volunteer-article/image.jpg',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'Technology',
|
|
36
|
+
slug: 'technology',
|
|
37
|
+
count: 1,
|
|
38
|
+
featuredImage: '/articles/tech-article/image.jpg',
|
|
39
|
+
},
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
describe('ArticleCategoryGrid', () => {
|
|
43
|
+
it('renders nothing when categories is empty', () => {
|
|
44
|
+
const { container } = render(<ArticleCategoryGrid categories={[]} />)
|
|
45
|
+
expect(container.firstChild).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders the Browse by Category heading', () => {
|
|
49
|
+
render(<ArticleCategoryGrid categories={mockCategories} />)
|
|
50
|
+
expect(screen.getByText('Browse by Category')).toBeInTheDocument()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('renders a card for each category', () => {
|
|
54
|
+
render(<ArticleCategoryGrid categories={mockCategories} />)
|
|
55
|
+
expect(screen.getByText('Campaigns')).toBeInTheDocument()
|
|
56
|
+
expect(screen.getByText('Volunteer Management')).toBeInTheDocument()
|
|
57
|
+
expect(screen.getByText('Technology')).toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('links each category to the correct category page', () => {
|
|
61
|
+
render(<ArticleCategoryGrid categories={mockCategories} />)
|
|
62
|
+
|
|
63
|
+
const campaignLink = screen.getByRole('link', { name: /Campaigns/i })
|
|
64
|
+
expect(campaignLink).toHaveAttribute('href', '/articles/category/campaigns')
|
|
65
|
+
|
|
66
|
+
const volunteerLink = screen.getByRole('link', {
|
|
67
|
+
name: /Volunteer Management/i,
|
|
68
|
+
})
|
|
69
|
+
expect(volunteerLink).toHaveAttribute('href', '/articles/category/volunteer-management')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renders the article image for each category', () => {
|
|
73
|
+
render(<ArticleCategoryGrid categories={mockCategories} />)
|
|
74
|
+
const images = screen.getAllByRole('img')
|
|
75
|
+
expect(images).toHaveLength(3)
|
|
76
|
+
expect(images[0]).toHaveAttribute('alt', 'Campaigns')
|
|
77
|
+
expect(images[1]).toHaveAttribute('alt', 'Volunteer Management')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const countCases: Array<{ count: number; label: string }> = [
|
|
81
|
+
{ count: 1, label: '1 article' },
|
|
82
|
+
{ count: 5, label: '5 articles' },
|
|
83
|
+
{ count: 3, label: '3 articles' },
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
countCases.forEach(({ count, label }) => {
|
|
87
|
+
it(`renders "${label}" for count ${count}`, () => {
|
|
88
|
+
const cat: CategoryInfo = {
|
|
89
|
+
name: 'Test',
|
|
90
|
+
slug: 'test',
|
|
91
|
+
count,
|
|
92
|
+
featuredImage: '/image.jpg',
|
|
93
|
+
}
|
|
94
|
+
render(<ArticleCategoryGrid categories={[cat]} />)
|
|
95
|
+
expect(screen.getByText(label)).toBeInTheDocument()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { render } from '@testing-library/react'
|
|
2
|
+
import { Article } from '../articleTypes'
|
|
3
|
+
import { ArticleSchema, BreadcrumbSchema, CollectionPageSchema } from '../ArticleSchemas'
|
|
4
|
+
|
|
5
|
+
const mockArticle: Article = {
|
|
6
|
+
slug: 'test-article',
|
|
7
|
+
title: 'Test Article',
|
|
8
|
+
excerpt: 'A test excerpt.',
|
|
9
|
+
date: '2025-03-01',
|
|
10
|
+
author: 'Jane Doe',
|
|
11
|
+
category: 'Campaigns',
|
|
12
|
+
categories: ['Campaigns'],
|
|
13
|
+
readTime: '3 min read',
|
|
14
|
+
featuredImage: '/articles/test/image.jpg',
|
|
15
|
+
tags: ['campaigns', 'testing'],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseSchemaScript(container: HTMLElement) {
|
|
19
|
+
const script = container.querySelector('script[type="application/ld+json"]')
|
|
20
|
+
expect(script).not.toBeNull()
|
|
21
|
+
return JSON.parse(script!.innerHTML)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('ArticleSchema', () => {
|
|
25
|
+
it('renders a ld+json script tag', () => {
|
|
26
|
+
const { container } = render(
|
|
27
|
+
<ArticleSchema
|
|
28
|
+
article={mockArticle}
|
|
29
|
+
articleUrl="https://example.com/articles/test-article"
|
|
30
|
+
siteName="Example Site"
|
|
31
|
+
/>
|
|
32
|
+
)
|
|
33
|
+
expect(container.querySelector('script[type="application/ld+json"]')).not.toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('outputs schema.org/Article type with correct fields', () => {
|
|
37
|
+
const { container } = render(
|
|
38
|
+
<ArticleSchema
|
|
39
|
+
article={mockArticle}
|
|
40
|
+
articleUrl="https://example.com/articles/test-article"
|
|
41
|
+
siteName="Example Site"
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
const schema = parseSchemaScript(container)
|
|
45
|
+
expect(schema['@type']).toBe('Article')
|
|
46
|
+
expect(schema.headline).toBe('Test Article')
|
|
47
|
+
expect(schema.description).toBe('A test excerpt.')
|
|
48
|
+
expect(schema.author.name).toBe('Jane Doe')
|
|
49
|
+
expect(schema.publisher.name).toBe('Example Site')
|
|
50
|
+
expect(schema.mainEntityOfPage['@id']).toBe('https://example.com/articles/test-article')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('includes ISO datePublished when article has a date', () => {
|
|
54
|
+
const { container } = render(
|
|
55
|
+
<ArticleSchema
|
|
56
|
+
article={mockArticle}
|
|
57
|
+
articleUrl="https://example.com/articles/test-article"
|
|
58
|
+
siteName="Example Site"
|
|
59
|
+
/>
|
|
60
|
+
)
|
|
61
|
+
const schema = parseSchemaScript(container)
|
|
62
|
+
expect(schema.datePublished).toBe(new Date('2025-03-01').toISOString())
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('omits datePublished when article has no date', () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<ArticleSchema
|
|
68
|
+
article={{ ...mockArticle, date: undefined }}
|
|
69
|
+
articleUrl="https://example.com/articles/test-article"
|
|
70
|
+
siteName="Example Site"
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
const schema = parseSchemaScript(container)
|
|
74
|
+
expect(schema.datePublished).toBeUndefined()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('BreadcrumbSchema', () => {
|
|
79
|
+
const items = [
|
|
80
|
+
{ name: 'Home', url: 'https://example.com' },
|
|
81
|
+
{ name: 'Articles', url: 'https://example.com/articles' },
|
|
82
|
+
{ name: 'Test Article', url: 'https://example.com/articles/test-article' },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
it('renders a ld+json script tag', () => {
|
|
86
|
+
const { container } = render(<BreadcrumbSchema items={items} />)
|
|
87
|
+
expect(container.querySelector('script[type="application/ld+json"]')).not.toBeNull()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('outputs schema.org/BreadcrumbList with correct positions', () => {
|
|
91
|
+
const { container } = render(<BreadcrumbSchema items={items} />)
|
|
92
|
+
const schema = parseSchemaScript(container)
|
|
93
|
+
expect(schema['@type']).toBe('BreadcrumbList')
|
|
94
|
+
expect(schema.itemListElement).toHaveLength(3)
|
|
95
|
+
expect(schema.itemListElement[0].position).toBe(1)
|
|
96
|
+
expect(schema.itemListElement[0].name).toBe('Home')
|
|
97
|
+
expect(schema.itemListElement[2].position).toBe(3)
|
|
98
|
+
expect(schema.itemListElement[2].name).toBe('Test Article')
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('CollectionPageSchema', () => {
|
|
103
|
+
it('renders a ld+json script tag', () => {
|
|
104
|
+
const { container } = render(
|
|
105
|
+
<CollectionPageSchema
|
|
106
|
+
title="Articles | Example"
|
|
107
|
+
description="All articles."
|
|
108
|
+
url="https://example.com/articles"
|
|
109
|
+
articleCount={42}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
expect(container.querySelector('script[type="application/ld+json"]')).not.toBeNull()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('outputs schema.org/CollectionPage with correct fields', () => {
|
|
116
|
+
const { container } = render(
|
|
117
|
+
<CollectionPageSchema
|
|
118
|
+
title="Articles | Example"
|
|
119
|
+
description="All articles."
|
|
120
|
+
url="https://example.com/articles"
|
|
121
|
+
articleCount={42}
|
|
122
|
+
/>
|
|
123
|
+
)
|
|
124
|
+
const schema = parseSchemaScript(container)
|
|
125
|
+
expect(schema['@type']).toBe('CollectionPage')
|
|
126
|
+
expect(schema.name).toBe('Articles | Example')
|
|
127
|
+
expect(schema.numberOfItems).toBe(42)
|
|
128
|
+
expect(schema.url).toBe('https://example.com/articles')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
6
|
+
import { ArticleSearchBar } from '../ArticleSearchBar'
|
|
7
|
+
|
|
8
|
+
describe('ArticleSearchBar', () => {
|
|
9
|
+
it('renders the search input', () => {
|
|
10
|
+
render(<ArticleSearchBar value="" onChange={jest.fn()} />)
|
|
11
|
+
expect(
|
|
12
|
+
screen.getByPlaceholderText('Search articles (3+ characters required)...')
|
|
13
|
+
).toBeInTheDocument()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('calls onChange with the typed value', () => {
|
|
17
|
+
const onChange = jest.fn()
|
|
18
|
+
render(<ArticleSearchBar value="" onChange={onChange} />)
|
|
19
|
+
fireEvent.change(screen.getByPlaceholderText('Search articles (3+ characters required)...'), {
|
|
20
|
+
target: { value: 'hello' },
|
|
21
|
+
})
|
|
22
|
+
expect(onChange).toHaveBeenCalledWith('hello')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('shows the clear button when value is non-empty', () => {
|
|
26
|
+
render(<ArticleSearchBar value="test" onChange={jest.fn()} />)
|
|
27
|
+
expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('hides the clear button when value is empty', () => {
|
|
31
|
+
render(<ArticleSearchBar value="" onChange={jest.fn()} />)
|
|
32
|
+
expect(screen.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('calls onChange with empty string when clear button is clicked', () => {
|
|
36
|
+
const onChange = jest.fn()
|
|
37
|
+
render(<ArticleSearchBar value="test" onChange={onChange} />)
|
|
38
|
+
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
|
|
39
|
+
expect(onChange).toHaveBeenCalledWith('')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('disables the input when disabled prop is true', () => {
|
|
43
|
+
render(<ArticleSearchBar value="" onChange={jest.fn()} disabled />)
|
|
44
|
+
expect(
|
|
45
|
+
screen.getByPlaceholderText('Search articles (3+ characters required)...')
|
|
46
|
+
).toBeDisabled()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const resultCountCases = [
|
|
50
|
+
{
|
|
51
|
+
resultCount: 0,
|
|
52
|
+
value: 'xyz',
|
|
53
|
+
expected: 'No articles found matching your search.',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
resultCount: 1,
|
|
57
|
+
value: 'test',
|
|
58
|
+
expected: 'Found 1 article matching "test"',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
resultCount: 4,
|
|
62
|
+
value: 'camp',
|
|
63
|
+
expected: 'Found 4 articles matching "camp"',
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
resultCountCases.forEach(({ resultCount, value, expected }) => {
|
|
68
|
+
it(`shows "${expected}" for resultCount ${resultCount}`, () => {
|
|
69
|
+
render(<ArticleSearchBar value={value} onChange={jest.fn()} resultCount={resultCount} />)
|
|
70
|
+
expect(screen.getByText(expected)).toBeInTheDocument()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('does not show result count line when value is empty', () => {
|
|
75
|
+
render(<ArticleSearchBar value="" onChange={jest.fn()} resultCount={5} />)
|
|
76
|
+
expect(screen.queryByText(/Found/)).not.toBeInTheDocument()
|
|
77
|
+
expect(screen.queryByText(/No articles/)).not.toBeInTheDocument()
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { ArticlesHero } from '../ArticlesHero'
|
|
3
|
+
import { render, screen } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
jest.mock('next/link', () => ({
|
|
6
|
+
__esModule: true,
|
|
7
|
+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
8
|
+
<a href={href}>{children}</a>
|
|
9
|
+
),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
describe('ArticlesHero', () => {
|
|
13
|
+
it('renders the page heading', () => {
|
|
14
|
+
render(<ArticlesHero />)
|
|
15
|
+
expect(screen.getByRole('heading', { name: 'Vox Populus Insights' })).toBeInTheDocument()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('renders the tagline text', () => {
|
|
19
|
+
render(<ArticlesHero />)
|
|
20
|
+
expect(screen.getByText(/expert analysis, campaign strategies/i)).toBeInTheDocument()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('renders a link to the latest articles anchor', () => {
|
|
24
|
+
render(<ArticlesHero />)
|
|
25
|
+
expect(screen.getByRole('link', { name: 'Browse Latest Articles' })).toHaveAttribute(
|
|
26
|
+
'href',
|
|
27
|
+
'#latest'
|
|
28
|
+
)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('renders a link to the contact page', () => {
|
|
32
|
+
render(<ArticlesHero />)
|
|
33
|
+
expect(screen.getByRole('link', { name: 'Contact Our Team' })).toHaveAttribute(
|
|
34
|
+
'href',
|
|
35
|
+
'/contact'
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { ArticlesPage } from '../ArticlesPage'
|
|
3
|
+
import type { ArticlesConfig } from '../articlesConfig'
|
|
4
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
5
|
+
|
|
6
|
+
jest.mock('next/link', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
|
9
|
+
<a href={href}>{children}</a>
|
|
10
|
+
),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
jest.mock('next/image', () => ({
|
|
14
|
+
__esModule: true,
|
|
15
|
+
default: (props: React.ComponentProps<'img'>) => {
|
|
16
|
+
const { alt = '', ...rest } = props
|
|
17
|
+
return <img alt={alt} {...rest} />
|
|
18
|
+
},
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
const mockArticles = [
|
|
22
|
+
{
|
|
23
|
+
slug: 'article-1',
|
|
24
|
+
title: 'Featured Article',
|
|
25
|
+
excerpt: 'Featured excerpt',
|
|
26
|
+
date: '2025-01-02',
|
|
27
|
+
author: 'Author',
|
|
28
|
+
category: 'Campaigns',
|
|
29
|
+
categories: ['Campaigns'],
|
|
30
|
+
readTime: '3 min read',
|
|
31
|
+
featuredImage: '/articles/article-1/image.jpg',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
slug: 'article-2',
|
|
35
|
+
title: 'Second Article',
|
|
36
|
+
excerpt: 'Second excerpt',
|
|
37
|
+
date: '2025-01-01',
|
|
38
|
+
author: 'Author',
|
|
39
|
+
category: 'Volunteers',
|
|
40
|
+
categories: ['Volunteers'],
|
|
41
|
+
readTime: '2 min read',
|
|
42
|
+
featuredImage: '/articles/article-2/image.jpg',
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
const baseConfig: ArticlesConfig = {
|
|
47
|
+
siteUrl: 'https://example.com',
|
|
48
|
+
siteName: 'Test Site',
|
|
49
|
+
pageSize: 6,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mockFetchSuccess() {
|
|
53
|
+
;(globalThis.fetch as jest.Mock).mockResolvedValue({
|
|
54
|
+
ok: true,
|
|
55
|
+
json: async () => ({ articles: mockArticles }),
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('ArticlesPage', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
jest.clearAllMocks()
|
|
62
|
+
globalThis.fetch = jest.fn()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('shows loading state in the latest section while hero renders immediately', () => {
|
|
66
|
+
;(globalThis.fetch as jest.Mock).mockReturnValue(new Promise(() => {}))
|
|
67
|
+
render(<ArticlesPage config={{ ...baseConfig, layout: ['hero', 'latest'] }} />)
|
|
68
|
+
// Hero is static — visible before any fetch completes
|
|
69
|
+
expect(screen.getByText('Vox Populus Insights')).toBeInTheDocument()
|
|
70
|
+
// Latest section shows a spinner while articles load
|
|
71
|
+
expect(screen.getByText('Loading articles...')).toBeInTheDocument()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('renders all default layout sections after load', async () => {
|
|
75
|
+
mockFetchSuccess()
|
|
76
|
+
render(<ArticlesPage config={baseConfig} />)
|
|
77
|
+
|
|
78
|
+
// Static sections render immediately — no need to wait
|
|
79
|
+
expect(screen.getByText('Vox Populus Insights')).toBeInTheDocument()
|
|
80
|
+
|
|
81
|
+
// Article sections appear after fetch resolves
|
|
82
|
+
await waitFor(() => expect(screen.getByText('FEATURED ARTICLE')).toBeInTheDocument())
|
|
83
|
+
expect(screen.getByText('Latest Articles')).toBeInTheDocument()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('respects a custom layout order — omits sections not in the array', async () => {
|
|
87
|
+
mockFetchSuccess()
|
|
88
|
+
const config: ArticlesConfig = {
|
|
89
|
+
...baseConfig,
|
|
90
|
+
layout: ['search', 'latest'],
|
|
91
|
+
}
|
|
92
|
+
render(<ArticlesPage config={config} />)
|
|
93
|
+
|
|
94
|
+
await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
|
|
95
|
+
|
|
96
|
+
// hero not in layout
|
|
97
|
+
expect(screen.queryByText('Vox Populus Insights')).not.toBeInTheDocument()
|
|
98
|
+
// newsletter not in layout
|
|
99
|
+
expect(screen.queryByTestId('newsletter')).not.toBeInTheDocument()
|
|
100
|
+
// search is in layout
|
|
101
|
+
expect(
|
|
102
|
+
screen.getByPlaceholderText('Search articles (3+ characters required)...')
|
|
103
|
+
).toBeInTheDocument()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('applies theme values as CSS custom properties', async () => {
|
|
107
|
+
mockFetchSuccess()
|
|
108
|
+
const config: ArticlesConfig = {
|
|
109
|
+
...baseConfig,
|
|
110
|
+
layout: ['latest'],
|
|
111
|
+
theme: { headerColor: '#ff0000', linkColor: '#00ff00' },
|
|
112
|
+
}
|
|
113
|
+
const { container } = render(<ArticlesPage config={config} />)
|
|
114
|
+
|
|
115
|
+
await waitFor(() => expect(screen.getByText('Latest Articles')).toBeInTheDocument())
|
|
116
|
+
|
|
117
|
+
const wrapper = container.firstChild as HTMLElement
|
|
118
|
+
expect(wrapper.style.getPropertyValue('--articles-header-color')).toBe('#ff0000')
|
|
119
|
+
expect(wrapper.style.getPropertyValue('--articles-link-color')).toBe('#00ff00')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('shows inline error in the latest section when fetch fails', async () => {
|
|
123
|
+
;(globalThis.fetch as jest.Mock).mockResolvedValue({ ok: false })
|
|
124
|
+
render(<ArticlesPage config={{ ...baseConfig, layout: ['latest'] }} />)
|
|
125
|
+
|
|
126
|
+
await waitFor(() => expect(screen.getByText('Error Loading Articles')).toBeInTheDocument())
|
|
127
|
+
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('uses pageSize from config', async () => {
|
|
131
|
+
const manyArticles = Array.from({ length: 10 }, (_, i) => ({
|
|
132
|
+
...mockArticles[0],
|
|
133
|
+
slug: `article-${i + 1}`,
|
|
134
|
+
title: `Article ${i + 1}`,
|
|
135
|
+
}))
|
|
136
|
+
;(globalThis.fetch as jest.Mock).mockResolvedValue({
|
|
137
|
+
ok: true,
|
|
138
|
+
json: async () => ({ articles: manyArticles }),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const config: ArticlesConfig = {
|
|
142
|
+
...baseConfig,
|
|
143
|
+
pageSize: 3,
|
|
144
|
+
layout: ['latest'],
|
|
145
|
+
}
|
|
146
|
+
render(<ArticlesPage config={config} />)
|
|
147
|
+
|
|
148
|
+
// 'featured' is not in the layout so no slice — articles 1-3 visible
|
|
149
|
+
await waitFor(() => expect(screen.getByText('Article 1')).toBeInTheDocument())
|
|
150
|
+
expect(screen.getByText('Article 2')).toBeInTheDocument()
|
|
151
|
+
expect(screen.getByText('Article 3')).toBeInTheDocument()
|
|
152
|
+
expect(screen.queryByText('Article 4')).not.toBeInTheDocument()
|
|
153
|
+
expect(screen.getByRole('button', { name: 'Load more articles' })).toBeInTheDocument()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import { render, screen } from '@testing-library/react'
|
|
6
|
+
import type { Article } from '../articleTypes'
|
|
7
|
+
import { CategoryArticlesPage } from '../CategoryArticlesPage'
|
|
8
|
+
import type { ArticlesConfig } from '../articlesConfig'
|
|
9
|
+
|
|
10
|
+
jest.mock('next/link', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
default: ({
|
|
13
|
+
href,
|
|
14
|
+
children,
|
|
15
|
+
...rest
|
|
16
|
+
}: {
|
|
17
|
+
href: string
|
|
18
|
+
children: React.ReactNode
|
|
19
|
+
[key: string]: unknown
|
|
20
|
+
}) => (
|
|
21
|
+
<a href={href} {...rest}>
|
|
22
|
+
{children}
|
|
23
|
+
</a>
|
|
24
|
+
),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
jest.mock('next/image', () => ({
|
|
28
|
+
__esModule: true,
|
|
29
|
+
default: (props: React.ComponentProps<'img'>) => <img {...props} alt={props.alt ?? ''} />,
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
jest.mock('../LatestArticles', () => ({
|
|
33
|
+
LatestArticles: ({ articles }: { articles: Article[] }) => (
|
|
34
|
+
<div data-testid="latest-articles">
|
|
35
|
+
{articles.map((a) => (
|
|
36
|
+
<div key={a.slug} data-testid="article-card">
|
|
37
|
+
{a.title}
|
|
38
|
+
</div>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
),
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
jest.mock('../ArticleSchemas', () => ({
|
|
45
|
+
BreadcrumbSchema: () => null,
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
const baseConfig: ArticlesConfig = { siteUrl: 'https://example.com', siteName: 'Test Site' }
|
|
49
|
+
|
|
50
|
+
const makeArticle = (overrides: Partial<Article> = {}): Article => ({
|
|
51
|
+
slug: 'test-article',
|
|
52
|
+
title: 'Test Article',
|
|
53
|
+
excerpt: 'An excerpt.',
|
|
54
|
+
author: 'Author',
|
|
55
|
+
category: 'Campaigns',
|
|
56
|
+
categories: ['Campaigns'],
|
|
57
|
+
readTime: '3 min read',
|
|
58
|
+
featuredImage: '/articles/test-article/hero.jpg',
|
|
59
|
+
tags: [],
|
|
60
|
+
...overrides,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('CategoryArticlesPage', () => {
|
|
64
|
+
it('renders nothing when articles array is empty', () => {
|
|
65
|
+
const { container } = render(
|
|
66
|
+
<CategoryArticlesPage category="campaigns" articles={[]} config={baseConfig} />
|
|
67
|
+
)
|
|
68
|
+
expect(container).toBeEmptyDOMElement()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('renders the category name in the hero', () => {
|
|
72
|
+
render(
|
|
73
|
+
<CategoryArticlesPage
|
|
74
|
+
category="campaigns"
|
|
75
|
+
articles={[makeArticle({ category: 'Campaigns' })]}
|
|
76
|
+
config={baseConfig}
|
|
77
|
+
/>
|
|
78
|
+
)
|
|
79
|
+
expect(screen.getByText('Campaigns')).toBeInTheDocument()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('renders plural article count in the hero', () => {
|
|
83
|
+
render(
|
|
84
|
+
<CategoryArticlesPage
|
|
85
|
+
category="campaigns"
|
|
86
|
+
articles={[makeArticle({ slug: 'a1' }), makeArticle({ slug: 'a2' })]}
|
|
87
|
+
config={baseConfig}
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
expect(screen.getByText('2 articles')).toBeInTheDocument()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("uses singular 'article' when count is 1", () => {
|
|
94
|
+
render(
|
|
95
|
+
<CategoryArticlesPage category="campaigns" articles={[makeArticle()]} config={baseConfig} />
|
|
96
|
+
)
|
|
97
|
+
expect(screen.getByText('1 article')).toBeInTheDocument()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('renders an article card for each article via LatestArticles', () => {
|
|
101
|
+
render(
|
|
102
|
+
<CategoryArticlesPage
|
|
103
|
+
category="campaigns"
|
|
104
|
+
articles={[
|
|
105
|
+
makeArticle({ slug: 'a1', title: 'Article One' }),
|
|
106
|
+
makeArticle({ slug: 'a2', title: 'Article Two' }),
|
|
107
|
+
makeArticle({ slug: 'a3', title: 'Article Three' }),
|
|
108
|
+
]}
|
|
109
|
+
config={baseConfig}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
expect(screen.getAllByTestId('article-card')).toHaveLength(3)
|
|
113
|
+
expect(screen.getByText('Article One')).toBeInTheDocument()
|
|
114
|
+
expect(screen.getByText('Article Three')).toBeInTheDocument()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('renders the back link to /articles', () => {
|
|
118
|
+
render(
|
|
119
|
+
<CategoryArticlesPage category="campaigns" articles={[makeArticle()]} config={baseConfig} />
|
|
120
|
+
)
|
|
121
|
+
expect(screen.getByRole('link', { name: /All Articles/i })).toHaveAttribute('href', '/articles')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('renders the hero image from the first article', () => {
|
|
125
|
+
render(
|
|
126
|
+
<CategoryArticlesPage
|
|
127
|
+
category="campaigns"
|
|
128
|
+
articles={[makeArticle({ featuredImage: '/articles/hero.jpg', category: 'Campaigns' })]}
|
|
129
|
+
config={baseConfig}
|
|
130
|
+
/>
|
|
131
|
+
)
|
|
132
|
+
expect(screen.getByAltText('Campaigns')).toHaveAttribute('src', '/articles/hero.jpg')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('shows the long description from config when provided', () => {
|
|
136
|
+
const config: ArticlesConfig = {
|
|
137
|
+
...baseConfig,
|
|
138
|
+
categoryDescriptions: {
|
|
139
|
+
campaigns: { short: 'Short desc', long: 'A longer category description' },
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
render(<CategoryArticlesPage category="campaigns" articles={[makeArticle()]} config={config} />)
|
|
143
|
+
expect(screen.getByText('A longer category description')).toBeInTheDocument()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('falls back to a generated short description when no config description is provided', () => {
|
|
147
|
+
render(
|
|
148
|
+
<CategoryArticlesPage
|
|
149
|
+
category="campaigns"
|
|
150
|
+
articles={[makeArticle({ category: 'Campaigns' })]}
|
|
151
|
+
config={baseConfig}
|
|
152
|
+
/>
|
|
153
|
+
)
|
|
154
|
+
expect(screen.getByText('Browse all articles in Campaigns.')).toBeInTheDocument()
|
|
155
|
+
})
|
|
156
|
+
})
|