@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.
Files changed (53) hide show
  1. package/README.md +324 -0
  2. package/dist/index.cjs +1027 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +232 -0
  5. package/dist/index.d.ts +232 -0
  6. package/dist/index.js +974 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/server.cjs +732 -0
  9. package/dist/server.cjs.map +1 -0
  10. package/dist/server.d.cts +127 -0
  11. package/dist/server.d.ts +127 -0
  12. package/dist/server.js +684 -0
  13. package/dist/server.js.map +1 -0
  14. package/package.json +82 -0
  15. package/src/ArticleCard.tsx +63 -0
  16. package/src/ArticleCategoryGrid.tsx +73 -0
  17. package/src/ArticleSchemas.tsx +82 -0
  18. package/src/ArticleSearchBar.tsx +50 -0
  19. package/src/ArticlesHero.tsx +33 -0
  20. package/src/ArticlesPage.tsx +167 -0
  21. package/src/CategoryArticlesPage.tsx +84 -0
  22. package/src/CommentForm.tsx +98 -0
  23. package/src/CommentItem.tsx +123 -0
  24. package/src/CommentThread.tsx +40 -0
  25. package/src/CommentsSection.tsx +84 -0
  26. package/src/FeaturedArticle.tsx +63 -0
  27. package/src/LatestArticles.tsx +42 -0
  28. package/src/LatestArticlesSection.tsx +68 -0
  29. package/src/__tests__/ArticleCard.test.tsx +78 -0
  30. package/src/__tests__/ArticleCategoryGrid.test.tsx +98 -0
  31. package/src/__tests__/ArticleSchemas.test.tsx +130 -0
  32. package/src/__tests__/ArticleSearchBar.test.tsx +79 -0
  33. package/src/__tests__/ArticlesHero.test.tsx +38 -0
  34. package/src/__tests__/ArticlesPage.test.tsx +155 -0
  35. package/src/__tests__/CategoryArticlesPage.test.tsx +156 -0
  36. package/src/__tests__/CommentForm.test.tsx +80 -0
  37. package/src/__tests__/CommentItem.test.tsx +149 -0
  38. package/src/__tests__/CommentsSection.test.tsx +118 -0
  39. package/src/__tests__/FeaturedArticle.test.tsx +85 -0
  40. package/src/__tests__/LatestArticles.test.tsx +157 -0
  41. package/src/__tests__/LatestArticlesSection.test.tsx +105 -0
  42. package/src/__tests__/jest-dom.d.ts +1 -0
  43. package/src/__tests__/seoUtils.test.ts +217 -0
  44. package/src/__tests__/useArticles.test.ts +207 -0
  45. package/src/articleTypes.ts +21 -0
  46. package/src/articlesConfig.ts +98 -0
  47. package/src/commentTypes.ts +14 -0
  48. package/src/index.ts +35 -0
  49. package/src/markdown.ts +314 -0
  50. package/src/seoUtils.ts +155 -0
  51. package/src/server-articles.ts +265 -0
  52. package/src/server.ts +25 -0
  53. package/src/useArticles.ts +93 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
6
+ import { CommentForm } from '../CommentForm'
7
+
8
+ const mockFetch = jest.fn()
9
+ globalThis.fetch = mockFetch
10
+
11
+ describe('CommentForm', () => {
12
+ beforeEach(() => {
13
+ jest.clearAllMocks()
14
+ })
15
+
16
+ it('renders a textarea and submit button', () => {
17
+ render(<CommentForm articleSlug="my-article" onSuccess={jest.fn()} />)
18
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
19
+ expect(screen.getByRole('button', { name: /post comment/i })).toBeInTheDocument()
20
+ })
21
+
22
+ it('uses reply placeholder and label when parentId is provided', () => {
23
+ render(<CommentForm articleSlug="my-article" parentId="parent-1" onSuccess={jest.fn()} />)
24
+ expect(screen.getByPlaceholderText('Write a reply…')).toBeInTheDocument()
25
+ expect(screen.getByRole('button', { name: /^reply$/i })).toBeInTheDocument()
26
+ })
27
+
28
+ it('submit button is disabled when textarea is empty', () => {
29
+ render(<CommentForm articleSlug="my-article" onSuccess={jest.fn()} />)
30
+ expect(screen.getByRole('button', { name: /post comment/i })).toBeDisabled()
31
+ })
32
+
33
+ it('submit button is enabled once text is entered', () => {
34
+ render(<CommentForm articleSlug="my-article" onSuccess={jest.fn()} />)
35
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Hello world' } })
36
+ expect(screen.getByRole('button', { name: /post comment/i })).toBeEnabled()
37
+ })
38
+
39
+ it('shows a Cancel button and calls onCancel when clicked', () => {
40
+ const onCancel = jest.fn()
41
+ render(<CommentForm articleSlug="my-article" onSuccess={jest.fn()} onCancel={onCancel} />)
42
+ fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
43
+ expect(onCancel).toHaveBeenCalledTimes(1)
44
+ })
45
+
46
+ it('calls onSuccess and clears the field after a successful POST', async () => {
47
+ const onSuccess = jest.fn()
48
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ comment: {} }) })
49
+
50
+ render(<CommentForm articleSlug="my-article" onSuccess={onSuccess} />)
51
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Great post!' } })
52
+ fireEvent.click(screen.getByRole('button', { name: /post comment/i }))
53
+
54
+ await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1))
55
+ expect(screen.getByRole('textbox')).toHaveValue('')
56
+ expect(mockFetch).toHaveBeenCalledWith(
57
+ '/api/articles/my-article/comments',
58
+ expect.objectContaining({ method: 'POST' })
59
+ )
60
+ })
61
+
62
+ it('shows an error message when the server returns a non-ok response', async () => {
63
+ mockFetch.mockResolvedValueOnce({
64
+ ok: false,
65
+ json: async () => ({ error: 'Server error' }),
66
+ })
67
+
68
+ render(<CommentForm articleSlug="my-article" onSuccess={jest.fn()} />)
69
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Test' } })
70
+ fireEvent.click(screen.getByRole('button', { name: /post comment/i }))
71
+
72
+ await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument())
73
+ })
74
+
75
+ it('shows character count warning when approaching the limit', () => {
76
+ render(<CommentForm articleSlug="my-article" onSuccess={jest.fn()} />)
77
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'x'.repeat(1850) } })
78
+ expect(screen.getByText(/characters remaining/i)).toBeInTheDocument()
79
+ })
80
+ })
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
6
+ import { CommentItem } from '../CommentItem'
7
+ import type { ArticleCommentWithReplies } from '../commentTypes'
8
+
9
+ const mockFetch = jest.fn()
10
+ globalThis.fetch = mockFetch
11
+
12
+ const baseComment: ArticleCommentWithReplies = {
13
+ id: 'c1',
14
+ articleSlug: 'my-article',
15
+ body: 'Hello world',
16
+ isDeleted: false,
17
+ createdAt: new Date(Date.now() - 60_000).toISOString(),
18
+ authorId: 'user@example.com',
19
+ authorName: 'Alice',
20
+ parentId: null,
21
+ replies: [],
22
+ }
23
+
24
+ describe('CommentItem', () => {
25
+ beforeEach(() => jest.clearAllMocks())
26
+
27
+ it('renders the comment body and author name', () => {
28
+ render(<CommentItem comment={baseComment} articleSlug="my-article" onRefresh={jest.fn()} />)
29
+ expect(screen.getByText('Hello world')).toBeInTheDocument()
30
+ expect(screen.getByText('Alice')).toBeInTheDocument()
31
+ })
32
+
33
+ it('renders "Comment removed" and hides body when isDeleted is true', () => {
34
+ render(
35
+ <CommentItem
36
+ comment={{ ...baseComment, isDeleted: true, body: '' }}
37
+ articleSlug="my-article"
38
+ onRefresh={jest.fn()}
39
+ />
40
+ )
41
+ expect(screen.getByText('Comment removed')).toBeInTheDocument()
42
+ expect(screen.queryByText('Hello world')).not.toBeInTheDocument()
43
+ })
44
+
45
+ it('shows Reply button when currentUserId is set and comment is top-level', () => {
46
+ render(
47
+ <CommentItem
48
+ comment={baseComment}
49
+ articleSlug="my-article"
50
+ currentUserId="other@example.com"
51
+ onRefresh={jest.fn()}
52
+ />
53
+ )
54
+ expect(screen.getByRole('button', { name: /reply/i })).toBeInTheDocument()
55
+ })
56
+
57
+ it('does not show Reply button on replies (isReply=true)', () => {
58
+ render(
59
+ <CommentItem
60
+ comment={{ ...baseComment, parentId: 'parent-1' }}
61
+ articleSlug="my-article"
62
+ currentUserId="other@example.com"
63
+ isReply
64
+ onRefresh={jest.fn()}
65
+ />
66
+ )
67
+ expect(screen.queryByRole('button', { name: /reply/i })).not.toBeInTheDocument()
68
+ })
69
+
70
+ it('shows Delete button only to the comment author', () => {
71
+ render(
72
+ <CommentItem
73
+ comment={baseComment}
74
+ articleSlug="my-article"
75
+ currentUserId="user@example.com"
76
+ onRefresh={jest.fn()}
77
+ />
78
+ )
79
+ expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument()
80
+ })
81
+
82
+ it('does not show Delete button to a different user', () => {
83
+ render(
84
+ <CommentItem
85
+ comment={baseComment}
86
+ articleSlug="my-article"
87
+ currentUserId="other@example.com"
88
+ onRefresh={jest.fn()}
89
+ />
90
+ )
91
+ expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument()
92
+ })
93
+
94
+ it('calls DELETE and onRefresh when Delete is clicked', async () => {
95
+ const onRefresh = jest.fn()
96
+ mockFetch.mockResolvedValueOnce({ ok: true })
97
+
98
+ render(
99
+ <CommentItem
100
+ comment={baseComment}
101
+ articleSlug="my-article"
102
+ currentUserId="user@example.com"
103
+ onRefresh={onRefresh}
104
+ />
105
+ )
106
+ fireEvent.click(screen.getByRole('button', { name: /delete/i }))
107
+
108
+ await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1))
109
+ expect(mockFetch).toHaveBeenCalledWith(
110
+ '/api/articles/my-article/comments/c1',
111
+ expect.objectContaining({ method: 'DELETE' })
112
+ )
113
+ })
114
+
115
+ it('renders nested replies', () => {
116
+ const reply = {
117
+ ...baseComment,
118
+ id: 'r1',
119
+ body: 'A reply',
120
+ authorName: 'Bob',
121
+ parentId: 'c1',
122
+ }
123
+ render(
124
+ <CommentItem
125
+ comment={{ ...baseComment, replies: [reply] }}
126
+ articleSlug="my-article"
127
+ onRefresh={jest.fn()}
128
+ />
129
+ )
130
+ expect(screen.getByText('A reply')).toBeInTheDocument()
131
+ expect(screen.getByText('Bob')).toBeInTheDocument()
132
+ })
133
+
134
+ it('toggles the reply form on Reply click', () => {
135
+ render(
136
+ <CommentItem
137
+ comment={baseComment}
138
+ articleSlug="my-article"
139
+ currentUserId="other@example.com"
140
+ onRefresh={jest.fn()}
141
+ />
142
+ )
143
+ fireEvent.click(screen.getByRole('button', { name: /reply/i }))
144
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
145
+ // Two Cancel buttons exist once the reply form is open (toggle + form); click either
146
+ fireEvent.click(screen.getAllByRole('button', { name: /cancel/i })[0])
147
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
148
+ })
149
+ })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { render, screen, waitFor } from '@testing-library/react'
6
+ import { CommentsSection } from '../CommentsSection'
7
+ import type { CommentsConfig } from '../articlesConfig'
8
+ import type { ArticleCommentWithReplies } from '../commentTypes'
9
+
10
+ const mockFetch = jest.fn()
11
+ globalThis.fetch = mockFetch
12
+
13
+ const mockUseSession = jest.fn()
14
+ jest.mock('next-auth/react', () => ({
15
+ useSession: () => mockUseSession(),
16
+ signIn: jest.fn(),
17
+ }))
18
+
19
+ const enabledConfig: CommentsConfig = { enabled: true }
20
+
21
+ const mockComments: ArticleCommentWithReplies[] = [
22
+ {
23
+ id: 'c1',
24
+ articleSlug: 'my-article',
25
+ body: 'First comment',
26
+ isDeleted: false,
27
+ createdAt: new Date().toISOString(),
28
+ authorId: 'alice@example.com',
29
+ authorName: 'Alice',
30
+ parentId: null,
31
+ replies: [],
32
+ },
33
+ ]
34
+
35
+ describe('CommentsSection', () => {
36
+ beforeEach(() => {
37
+ jest.clearAllMocks()
38
+ mockUseSession.mockReturnValue({ data: null, status: 'unauthenticated' })
39
+ })
40
+
41
+ it('renders nothing when comments are disabled globally', () => {
42
+ const { container } = render(
43
+ <CommentsSection articleSlug="my-article" config={{ enabled: false }} />
44
+ )
45
+ expect(container).toBeEmptyDOMElement()
46
+ })
47
+
48
+ it('renders nothing when the article is disabled via perArticleOverride', () => {
49
+ const { container } = render(
50
+ <CommentsSection
51
+ articleSlug="my-article"
52
+ config={{ enabled: true, perArticleOverride: { 'my-article': false } }}
53
+ />
54
+ )
55
+ expect(container).toBeEmptyDOMElement()
56
+ })
57
+
58
+ it('shows "Sign in" prompt for unauthenticated users', async () => {
59
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ comments: [] }) })
60
+
61
+ render(<CommentsSection articleSlug="my-article" config={enabledConfig} />)
62
+
63
+ await waitFor(() => expect(screen.queryByText(/loading comments/i)).not.toBeInTheDocument())
64
+ expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
65
+ })
66
+
67
+ it('shows the comment form for authenticated users', async () => {
68
+ mockUseSession.mockReturnValue({
69
+ data: { user: { email: 'alice@example.com', name: 'Alice' } },
70
+ status: 'authenticated',
71
+ })
72
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ comments: [] }) })
73
+
74
+ render(<CommentsSection articleSlug="my-article" config={enabledConfig} />)
75
+
76
+ await waitFor(() => expect(screen.queryByText(/loading comments/i)).not.toBeInTheDocument())
77
+ expect(screen.getByRole('textbox')).toBeInTheDocument()
78
+ })
79
+
80
+ it('renders fetched comments', async () => {
81
+ mockFetch.mockResolvedValueOnce({
82
+ ok: true,
83
+ json: async () => ({ comments: mockComments }),
84
+ })
85
+
86
+ render(<CommentsSection articleSlug="my-article" config={enabledConfig} />)
87
+
88
+ await waitFor(() => expect(screen.getByText('First comment')).toBeInTheDocument())
89
+ expect(screen.getByText('Alice')).toBeInTheDocument()
90
+ })
91
+
92
+ it('shows the correct comment count', async () => {
93
+ mockFetch.mockResolvedValueOnce({
94
+ ok: true,
95
+ json: async () => ({ comments: mockComments }),
96
+ })
97
+
98
+ render(<CommentsSection articleSlug="my-article" config={enabledConfig} />)
99
+
100
+ await waitFor(() => expect(screen.getByText('1 comment')).toBeInTheDocument())
101
+ })
102
+
103
+ it('shows an error message when the fetch fails', async () => {
104
+ mockFetch.mockRejectedValueOnce(new Error('network error'))
105
+
106
+ render(<CommentsSection articleSlug="my-article" config={enabledConfig} />)
107
+
108
+ await waitFor(() => expect(screen.getByText(/could not load comments/i)).toBeInTheDocument())
109
+ })
110
+
111
+ it('shows empty state when no comments exist', async () => {
112
+ mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ comments: [] }) })
113
+
114
+ render(<CommentsSection articleSlug="my-article" config={enabledConfig} />)
115
+
116
+ await waitFor(() => expect(screen.getByText(/no comments yet/i)).toBeInTheDocument())
117
+ })
118
+ })
@@ -0,0 +1,85 @@
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 { FeaturedArticle } from '../FeaturedArticle'
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 {...props} alt={props.alt || ''} />,
19
+ }))
20
+
21
+ const baseArticle: Article = {
22
+ slug: 'featured-slug',
23
+ title: 'Featured Article Title',
24
+ excerpt: 'Featured excerpt text.',
25
+ author: 'John Smith',
26
+ category: 'Technology',
27
+ categories: ['Technology'],
28
+ readTime: '8 min read',
29
+ featuredImage: '/articles/featured-slug/hero.jpg',
30
+ tags: ['tech'],
31
+ }
32
+
33
+ describe('FeaturedArticle', () => {
34
+ it('renders the FEATURED ARTICLE label', () => {
35
+ render(<FeaturedArticle article={baseArticle} />)
36
+ expect(screen.getByText('FEATURED ARTICLE')).toBeInTheDocument()
37
+ })
38
+
39
+ it('renders the article title', () => {
40
+ render(<FeaturedArticle article={baseArticle} />)
41
+ expect(screen.getByText('Featured Article Title')).toBeInTheDocument()
42
+ })
43
+
44
+ it('renders the excerpt', () => {
45
+ render(<FeaturedArticle article={baseArticle} />)
46
+ expect(screen.getByText('Featured excerpt text.')).toBeInTheDocument()
47
+ })
48
+
49
+ it('renders the author', () => {
50
+ render(<FeaturedArticle article={baseArticle} />)
51
+ expect(screen.getByText('John Smith')).toBeInTheDocument()
52
+ })
53
+
54
+ it('renders the read time', () => {
55
+ render(<FeaturedArticle article={baseArticle} />)
56
+ expect(screen.getByText('8 min read')).toBeInTheDocument()
57
+ })
58
+
59
+ it('renders the category badge', () => {
60
+ render(<FeaturedArticle article={baseArticle} />)
61
+ expect(screen.getByText('Technology')).toBeInTheDocument()
62
+ })
63
+
64
+ it('links to the correct article page', () => {
65
+ render(<FeaturedArticle article={baseArticle} />)
66
+ const link = screen.getByRole('link', { name: /Read Full Article/i })
67
+ expect(link).toHaveAttribute('href', '/articles/featured-slug')
68
+ })
69
+
70
+ it('renders the featured image with correct alt text', () => {
71
+ render(<FeaturedArticle article={baseArticle} />)
72
+ const img = screen.getByAltText('Featured Article Title')
73
+ expect(img).toBeInTheDocument()
74
+ })
75
+
76
+ it('renders the date when article.date is set', () => {
77
+ render(<FeaturedArticle article={{ ...baseArticle, date: '2025-11-01' }} />)
78
+ expect(screen.getByText('2025-11-01')).toBeInTheDocument()
79
+ })
80
+
81
+ it('does not render a date element when article.date is absent', () => {
82
+ render(<FeaturedArticle article={baseArticle} />)
83
+ expect(screen.queryByText(/2025/)).not.toBeInTheDocument()
84
+ })
85
+ })
@@ -0,0 +1,157 @@
1
+ import React from 'react'
2
+ import { LatestArticles } from '../LatestArticles'
3
+ import type { Article } from '../articleTypes'
4
+ import { render, screen, fireEvent } 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
+ function makeArticle(n: number): Article {
22
+ return {
23
+ slug: `article-${n}`,
24
+ title: `Article ${n}`,
25
+ excerpt: `Excerpt for article ${n}`,
26
+ date: '2025-01-01',
27
+ author: 'Author',
28
+ category: 'Test',
29
+ categories: ['Test'],
30
+ readTime: '3 min read',
31
+ featuredImage: `/articles/article-${n}/image.jpg`,
32
+ tags: ['test'],
33
+ }
34
+ }
35
+
36
+ function makeArticles(count: number): Article[] {
37
+ return Array.from({ length: count }, (_, i) => makeArticle(i + 1))
38
+ }
39
+
40
+ describe('LatestArticles', () => {
41
+ it('renders the first pageSize articles on mount', () => {
42
+ render(<LatestArticles articles={makeArticles(10)} pageSize={3} />)
43
+
44
+ expect(screen.getByText('Article 1')).toBeInTheDocument()
45
+ expect(screen.getByText('Article 2')).toBeInTheDocument()
46
+ expect(screen.getByText('Article 3')).toBeInTheDocument()
47
+ expect(screen.queryByText('Article 4')).not.toBeInTheDocument()
48
+ })
49
+
50
+ it('does not show Load more button when article count equals pageSize', () => {
51
+ render(<LatestArticles articles={makeArticles(3)} pageSize={3} />)
52
+
53
+ expect(screen.queryByRole('button', { name: 'Load more articles' })).not.toBeInTheDocument()
54
+ })
55
+
56
+ it('does not show Load more button when article count is less than pageSize', () => {
57
+ render(<LatestArticles articles={makeArticles(2)} pageSize={6} />)
58
+
59
+ expect(screen.queryByRole('button', { name: 'Load more articles' })).not.toBeInTheDocument()
60
+ })
61
+
62
+ it('does not show Load more button when articles array is empty', () => {
63
+ render(<LatestArticles articles={[]} pageSize={6} />)
64
+
65
+ expect(screen.queryByRole('button', { name: 'Load more articles' })).not.toBeInTheDocument()
66
+ })
67
+
68
+ it('shows Load more button when there are more articles than pageSize', () => {
69
+ render(<LatestArticles articles={makeArticles(7)} pageSize={3} />)
70
+
71
+ expect(screen.getByRole('button', { name: 'Load more articles' })).toBeInTheDocument()
72
+ })
73
+
74
+ it('appends the next pageSize articles on button click', () => {
75
+ render(<LatestArticles articles={makeArticles(7)} pageSize={3} />)
76
+
77
+ fireEvent.click(screen.getByRole('button', { name: 'Load more articles' }))
78
+
79
+ expect(screen.getByText('Article 4')).toBeInTheDocument()
80
+ expect(screen.getByText('Article 5')).toBeInTheDocument()
81
+ expect(screen.getByText('Article 6')).toBeInTheDocument()
82
+ expect(screen.queryByText('Article 7')).not.toBeInTheDocument()
83
+ })
84
+
85
+ it('hides the button once the last article is loaded', () => {
86
+ render(<LatestArticles articles={makeArticles(6)} pageSize={3} />)
87
+
88
+ fireEvent.click(screen.getByRole('button', { name: 'Load more articles' }))
89
+
90
+ expect(screen.queryByRole('button', { name: 'Load more articles' })).not.toBeInTheDocument()
91
+ for (let i = 1; i <= 6; i++) {
92
+ expect(screen.getByText(`Article ${i}`)).toBeInTheDocument()
93
+ }
94
+ })
95
+
96
+ it('loads articles across multiple clicks until exhausted', () => {
97
+ render(<LatestArticles articles={makeArticles(10)} pageSize={3} />)
98
+
99
+ // click 1: visible 1-3 -> 1-6
100
+ expect(screen.getByRole('button', { name: 'Load more articles' })).toBeInTheDocument()
101
+ fireEvent.click(screen.getByRole('button', { name: 'Load more articles' }))
102
+ expect(screen.getByText('Article 6')).toBeInTheDocument()
103
+ expect(screen.queryByText('Article 7')).not.toBeInTheDocument()
104
+
105
+ // click 2: visible 1-6 -> 1-9
106
+ expect(screen.getByRole('button', { name: 'Load more articles' })).toBeInTheDocument()
107
+ fireEvent.click(screen.getByRole('button', { name: 'Load more articles' }))
108
+ expect(screen.getByText('Article 9')).toBeInTheDocument()
109
+ expect(screen.queryByText('Article 10')).not.toBeInTheDocument()
110
+
111
+ // click 3: visible 1-9 -> 1-10 (last batch is smaller than pageSize)
112
+ expect(screen.getByRole('button', { name: 'Load more articles' })).toBeInTheDocument()
113
+ fireEvent.click(screen.getByRole('button', { name: 'Load more articles' }))
114
+ expect(screen.getByText('Article 10')).toBeInTheDocument()
115
+ expect(screen.queryByRole('button', { name: 'Load more articles' })).not.toBeInTheDocument()
116
+ })
117
+
118
+ it('resets to pageSize when the articles prop changes', () => {
119
+ const { rerender } = render(<LatestArticles articles={makeArticles(10)} pageSize={3} />)
120
+
121
+ fireEvent.click(screen.getByRole('button', { name: 'Load more articles' }))
122
+ expect(screen.getByText('Article 4')).toBeInTheDocument()
123
+
124
+ const newArticles = Array.from({ length: 8 }, (_, i) => ({
125
+ ...makeArticle(i + 1),
126
+ slug: `new-${i + 1}`,
127
+ title: `New Article ${i + 1}`,
128
+ }))
129
+ rerender(<LatestArticles articles={newArticles} pageSize={3} />)
130
+
131
+ expect(screen.getByText('New Article 1')).toBeInTheDocument()
132
+ expect(screen.queryByText('New Article 4')).not.toBeInTheDocument()
133
+ expect(screen.getByRole('button', { name: 'Load more articles' })).toBeInTheDocument()
134
+ })
135
+
136
+ it('resets visible count when pageSize prop changes', () => {
137
+ const articles = makeArticles(10)
138
+ const { rerender } = render(<LatestArticles articles={articles} pageSize={6} />)
139
+
140
+ expect(screen.getByText('Article 6')).toBeInTheDocument()
141
+ expect(screen.queryByText('Article 7')).not.toBeInTheDocument()
142
+
143
+ rerender(<LatestArticles articles={articles} pageSize={3} />)
144
+
145
+ expect(screen.getByText('Article 3')).toBeInTheDocument()
146
+ expect(screen.queryByText('Article 4')).not.toBeInTheDocument()
147
+ })
148
+
149
+ it('shows all articles when pageSize exceeds article count', () => {
150
+ render(<LatestArticles articles={makeArticles(4)} pageSize={10} />)
151
+
152
+ for (let i = 1; i <= 4; i++) {
153
+ expect(screen.getByText(`Article ${i}`)).toBeInTheDocument()
154
+ }
155
+ expect(screen.queryByRole('button', { name: 'Load more articles' })).not.toBeInTheDocument()
156
+ })
157
+ })