diff --git a/frontend/src/components/ErrorScreen.test.tsx b/frontend/src/components/ErrorScreen.test.tsx
new file mode 100644
index 0000000..2f3bca3
--- /dev/null
+++ b/frontend/src/components/ErrorScreen.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from '@testing-library/react'
+import { HelmetProvider } from 'react-helmet-async'
+import { describe, expect, it } from 'vitest'
+
+import ErrorScreen from '@/components/ErrorScreen'
+
+describe ('ErrorScreen', () => {
+ it.each ([
+ [403, '権限ないよ(笑)'],
+ [404, 'ページないよ(笑)'],
+ [500, '鯖でエラー出たって(嘲笑)'],
+ [503, '鯖死んでるよ(泣)'],
+ ]) ('renders status %s', (status, message) => {
+ render (
+
+
+ ,
+ )
+
+ expect (screen.getByText (String (status))).toBeInTheDocument ()
+ expect (screen.getByText (message)).toBeInTheDocument ()
+ expect (screen.getByAltText ('逃げたギター')).toBeInTheDocument ()
+ })
+
+ it ('throws for unsupported statuses', () => {
+ expect (() => render (
+
+
+ ,
+ )).toThrow ()
+ })
+})
diff --git a/frontend/src/components/MenuSeparator.test.tsx b/frontend/src/components/MenuSeparator.test.tsx
new file mode 100644
index 0000000..9032f91
--- /dev/null
+++ b/frontend/src/components/MenuSeparator.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import MenuSeparator from '@/components/MenuSeparator'
+
+describe ('MenuSeparator', () => {
+ it ('renders desktop and mobile separators', () => {
+ render ()
+
+ expect (screen.getByText ('|')).toHaveClass ('md:inline')
+ expect (document.querySelector ('hr')).toHaveClass ('md:hidden')
+ })
+})
diff --git a/frontend/src/components/PostEditForm.test.tsx b/frontend/src/components/PostEditForm.test.tsx
new file mode 100644
index 0000000..1fc58a9
--- /dev/null
+++ b/frontend/src/components/PostEditForm.test.tsx
@@ -0,0 +1,69 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import PostEditForm from '@/components/PostEditForm'
+import { buildPost, buildTag } from '@/test/factories'
+
+const postsApi = vi.hoisted (() => ({
+ updatePost: vi.fn (),
+}))
+
+const api = vi.hoisted (() => ({
+ isApiError: vi.fn (() => false),
+}))
+
+const toastApi = vi.hoisted (() => ({
+ toast: vi.fn (),
+}))
+
+vi.mock ('@/lib/posts', () => postsApi)
+vi.mock ('@/lib/api', () => api)
+vi.mock ('@/components/ui/use-toast', () => toastApi)
+vi.mock ('@/components/dialogues/DialogueProvider', () => ({
+ useDialogue: () => ({
+ choice: vi.fn (),
+ }),
+}))
+
+describe ('PostEditForm', () => {
+ it ('submits edited post fields with the current base version', async () => {
+ const onSave = vi.fn ()
+ const post = buildPost ({
+ id: 8,
+ versionNo: 4,
+ title: 'old',
+ tags: [
+ buildTag ({ name: 'general-tag', category: 'general' }),
+ buildTag ({ id: 2, name: 'nico-tag', category: 'nico' }),
+ ],
+ parentPosts: [buildPost ({ id: 2, title: 'parent' })],
+ })
+ postsApi.updatePost.mockResolvedValueOnce ({
+ ...post,
+ versionNo: 5,
+ title: 'new',
+ tags: [buildTag ({ name: 'new-tag' })],
+ })
+
+ render ()
+
+ const [title, parentIds] = screen.getAllByRole ('textbox')
+ fireEvent.change (title, { target: { value: 'new' } })
+ fireEvent.change (parentIds, { target: { value: '3 4' } })
+ fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
+
+ await waitFor (() => {
+ expect (postsApi.updatePost).toHaveBeenCalledWith (
+ expect.objectContaining ({
+ id: 8,
+ title: 'new',
+ parentPostIds: '3 4',
+ tags: 'general-tag',
+ }),
+ { baseVersionNo: 4 },
+ )
+ })
+ expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
+ expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' })
+ })
+})
diff --git a/frontend/src/components/PostEmbed.test.tsx b/frontend/src/components/PostEmbed.test.tsx
new file mode 100644
index 0000000..e34713e
--- /dev/null
+++ b/frontend/src/components/PostEmbed.test.tsx
@@ -0,0 +1,63 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import PostEmbed from '@/components/PostEmbed'
+import { buildPost } from '@/test/factories'
+
+const dialogue = vi.hoisted (() => ({
+ confirm: vi.fn (),
+}))
+
+vi.mock ('@/components/dialogues/DialogueProvider', () => ({
+ useDialogue: () => dialogue,
+}))
+
+vi.mock ('@/components/NicoViewer', () => ({
+ default: ({ id }: { id: string }) => Nico:{id}
,
+}))
+
+vi.mock ('react-youtube', () => ({
+ default: ({ videoId }: { videoId: string }) => YouTube:{videoId}
,
+}))
+
+describe ('PostEmbed', () => {
+ beforeEach (() => {
+ vi.clearAllMocks ()
+ })
+
+ it ('embeds nicovideo watch URLs', () => {
+ render ()
+
+ expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument ()
+ })
+
+ it ('embeds x/twitter status URLs', () => {
+ render ()
+
+ expect (screen.getByRole ('link', { name: '@someone' })).toBeInTheDocument ()
+ })
+
+ it ('embeds youtube watch URLs', () => {
+ render ()
+
+ expect (screen.getByText ('YouTube:abc123')).toBeInTheDocument ()
+ })
+
+ it ('asks before framing unknown external pages', async () => {
+ dialogue.confirm.mockResolvedValueOnce (true)
+ render (
+ ,
+ )
+
+ fireEvent.click (screen.getByRole ('link', { name: '外部ページを表示' }))
+
+ await waitFor (() => {
+ expect (dialogue.confirm).toHaveBeenCalled ()
+ })
+ expect (await screen.findByTitle ('external')).toHaveAttribute (
+ 'src',
+ 'https://example.com/page',
+ )
+ })
+})
diff --git a/frontend/src/components/PostFormTagsArea.test.tsx b/frontend/src/components/PostFormTagsArea.test.tsx
new file mode 100644
index 0000000..b9586db
--- /dev/null
+++ b/frontend/src/components/PostFormTagsArea.test.tsx
@@ -0,0 +1,34 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import PostFormTagsArea from '@/components/PostFormTagsArea'
+import { buildTag } from '@/test/factories'
+import { renderWithProviders } from '@/test/render'
+
+const api = vi.hoisted (() => ({
+ apiGet: vi.fn (),
+}))
+
+vi.mock ('@/lib/api', () => api)
+
+describe ('PostFormTagsArea', () => {
+ it ('updates text and fetches autocomplete for the selected token', async () => {
+ const setTags = vi.fn ()
+ api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })])
+
+ renderWithProviders ()
+
+ const textarea = screen.getByRole ('textbox')
+ fireEvent.focus (textarea)
+ fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } })
+ fireEvent.change (textarea, { target: { value: '虹夏' } })
+
+ await waitFor (() => {
+ expect (api.apiGet).toHaveBeenCalledWith (
+ '/tags/autocomplete',
+ { params: { q: '虹', nico: '0' } },
+ )
+ })
+ expect (setTags).toHaveBeenCalledWith ('虹夏')
+ })
+})
diff --git a/frontend/src/components/PostList.test.tsx b/frontend/src/components/PostList.test.tsx
new file mode 100644
index 0000000..ccb6340
--- /dev/null
+++ b/frontend/src/components/PostList.test.tsx
@@ -0,0 +1,34 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import PostList from '@/components/PostList'
+import { buildPost } from '@/test/factories'
+import { renderWithProviders } from '@/test/render'
+
+describe ('PostList', () => {
+ it ('renders post thumbnails as links to post details', () => {
+ renderWithProviders (
+ ,
+ )
+
+ expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute (
+ 'href',
+ '/posts/1',
+ )
+ expect (
+ screen.getByRole ('link', { name: 'https://example.com/second' }),
+ ).toHaveAttribute ('href', '/posts/2')
+ })
+
+ it ('calls the optional click handler', () => {
+ const onClick = vi.fn ()
+ renderWithProviders ()
+
+ fireEvent.click (screen.getByRole ('link', { name: 'テスト投稿' }))
+
+ expect (onClick).toHaveBeenCalledTimes (1)
+ })
+})
diff --git a/frontend/src/components/PostOriginalCreatedTimeField.test.tsx b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx
new file mode 100644
index 0000000..a061d65
--- /dev/null
+++ b/frontend/src/components/PostOriginalCreatedTimeField.test.tsx
@@ -0,0 +1,63 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
+
+describe ('PostOriginalCreatedTimeField', () => {
+ it ('updates from and before values', () => {
+ const setFrom = vi.fn ()
+ const setBefore = vi.fn ()
+
+ render (
+ ,
+ )
+
+ const inputs = screen.getAllByDisplayValue ('')
+ fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } })
+ fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } })
+
+ expect (setFrom).toHaveBeenCalledWith (expect.any (String))
+ expect (setBefore).toHaveBeenCalledWith (expect.any (String))
+ })
+
+ it ('infers an exclusive before value on blur', () => {
+ const setBefore = vi.fn ()
+
+ render (
+ ,
+ )
+
+ const input = screen.getAllByDisplayValue ('')[0]
+ fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } })
+
+ expect (setBefore).toHaveBeenCalledWith (expect.any (String))
+ })
+
+ it ('resets both values', () => {
+ const setFrom = vi.fn ()
+ const setBefore = vi.fn ()
+
+ render (
+ ,
+ )
+
+ const buttons = screen.getAllByRole ('button', { name: 'リセット' })
+ fireEvent.click (buttons[0])
+ fireEvent.click (buttons[1])
+
+ expect (setFrom).toHaveBeenCalledWith (null)
+ expect (setBefore).toHaveBeenCalledWith (null)
+ })
+})
diff --git a/frontend/src/components/RouteBlockerOverlay.test.tsx b/frontend/src/components/RouteBlockerOverlay.test.tsx
new file mode 100644
index 0000000..1219e5b
--- /dev/null
+++ b/frontend/src/components/RouteBlockerOverlay.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+
+import RouteBlockerOverlay, { useOverlayStore } from '@/components/RouteBlockerOverlay'
+
+describe ('RouteBlockerOverlay', () => {
+ afterEach (() => {
+ useOverlayStore.setState ({ active: false })
+ document.body.style.overflow = ''
+ document.body.removeAttribute ('aria-busy')
+ })
+
+ it ('renders nothing while inactive', () => {
+ useOverlayStore.setState ({ active: false })
+
+ const { container } = render ()
+
+ expect (container).toBeEmptyDOMElement ()
+ })
+
+ it ('renders a blocking progressbar and marks the body busy while active', () => {
+ useOverlayStore.setState ({ active: true })
+
+ render ()
+
+ expect (screen.getByRole ('progressbar', { name: 'Loading' })).toBeInTheDocument ()
+ expect (document.body).toHaveAttribute ('aria-busy', 'true')
+ expect (document.body.style.overflow).toBe ('hidden')
+ })
+})
diff --git a/frontend/src/components/SortHeader.test.tsx b/frontend/src/components/SortHeader.test.tsx
new file mode 100644
index 0000000..f11b93e
--- /dev/null
+++ b/frontend/src/components/SortHeader.test.tsx
@@ -0,0 +1,39 @@
+import { screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import SortHeader from '@/components/SortHeader'
+import { renderWithProviders } from '@/test/render'
+
+describe ('SortHeader', () => {
+ it ('toggles the active sort direction and resets the page', () => {
+ renderWithProviders (
+ ,
+ { route: '/posts?tags=x&page=4&order=title%3Aasc' },
+ )
+
+ expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute (
+ 'href',
+ '/posts?tags=x&page=1&order=title%3Adesc',
+ )
+ })
+
+ it ('uses default direction for inactive fields', () => {
+ renderWithProviders (
+ ,
+ { route: '/posts?page=2' },
+ )
+
+ expect (screen.getByRole ('link', { name: '更新' })).toHaveAttribute (
+ 'href',
+ '/posts?page=1&order=updated_at%3Adesc',
+ )
+ })
+})
diff --git a/frontend/src/components/TagLink.test.tsx b/frontend/src/components/TagLink.test.tsx
new file mode 100644
index 0000000..3c2a152
--- /dev/null
+++ b/frontend/src/components/TagLink.test.tsx
@@ -0,0 +1,45 @@
+import { screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import TagLink from '@/components/TagLink'
+import { buildTag } from '@/test/factories'
+import { renderWithProviders } from '@/test/render'
+
+describe ('TagLink', () => {
+ it ('links tag names to post search and shows counts', () => {
+ renderWithProviders (
+ ,
+ )
+
+ expect (screen.getByRole ('link', { name: '虹 夏' })).toHaveAttribute (
+ 'href',
+ '/posts?tags=%E8%99%B9+%E5%A4%8F',
+ )
+ expect (screen.getByText ('4')).toBeInTheDocument ()
+ })
+
+ it ('links wiki markers to the correct detail route', () => {
+ renderWithProviders (
+ ,
+ )
+
+ expect (screen.getByRole ('link', { name: '?' })).toHaveAttribute (
+ 'href',
+ '/wiki/a%2Fb',
+ )
+ })
+
+ it ('renders aliases and non-link tags when requested', () => {
+ renderWithProviders (
+ ,
+ )
+
+ expect (screen.getByText ('別名')).toBeInTheDocument ()
+ expect (screen.getByText ('正式名')).toBeInTheDocument ()
+ expect (screen.queryByRole ('link')).not.toBeInTheDocument ()
+ })
+})
diff --git a/frontend/src/components/TagSearchBox.test.tsx b/frontend/src/components/TagSearchBox.test.tsx
new file mode 100644
index 0000000..5b77518
--- /dev/null
+++ b/frontend/src/components/TagSearchBox.test.tsx
@@ -0,0 +1,30 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import TagSearchBox from '@/components/TagSearchBox'
+import { buildTag } from '@/test/factories'
+import { renderWithProviders } from '@/test/render'
+
+describe ('TagSearchBox', () => {
+ it ('renders suggestions and selects tags on mouse down', () => {
+ const handleSelect = vi.fn ()
+ const tag = buildTag ({ id: 9, name: '候補', postCount: 2 })
+
+ renderWithProviders (
+ ,
+ )
+
+ fireEvent.mouseDown (screen.getByText ('候補'))
+
+ expect (handleSelect).toHaveBeenCalledWith (tag)
+ expect (screen.getByText ('2')).toBeInTheDocument ()
+ })
+
+ it ('renders nothing when suggestions are empty', () => {
+ const { container } = renderWithProviders (
+ ,
+ )
+
+ expect (container).toBeEmptyDOMElement ()
+ })
+})
diff --git a/frontend/src/components/TopNavUser.test.tsx b/frontend/src/components/TopNavUser.test.tsx
new file mode 100644
index 0000000..49e1ee2
--- /dev/null
+++ b/frontend/src/components/TopNavUser.test.tsx
@@ -0,0 +1,29 @@
+import { screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import TopNavUser from '@/components/TopNavUser'
+import { buildUser } from '@/test/factories'
+import { renderWithProviders } from '@/test/render'
+
+describe ('TopNavUser', () => {
+ it ('renders nothing without a user', () => {
+ const { container } = renderWithProviders ()
+
+ expect (container).toBeEmptyDOMElement ()
+ })
+
+ it ('links named users to settings', () => {
+ renderWithProviders ()
+
+ expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute (
+ 'href',
+ '/users/settings',
+ )
+ })
+
+ it ('uses the anonymous display name', () => {
+ renderWithProviders ()
+
+ expect (screen.getByRole ('link', { name: '名もなきニジラー' })).toBeInTheDocument ()
+ })
+})
diff --git a/frontend/src/components/TwitterEmbed.test.tsx b/frontend/src/components/TwitterEmbed.test.tsx
new file mode 100644
index 0000000..25f2c27
--- /dev/null
+++ b/frontend/src/components/TwitterEmbed.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import TwitterEmbed from '@/components/TwitterEmbed'
+
+describe ('TwitterEmbed', () => {
+ it ('renders tweet and user links', () => {
+ render ()
+
+ expect (screen.getByRole ('link', { name: '@user_name' })).toHaveAttribute (
+ 'href',
+ 'https://twitter.com/user_name?ref_src=twsrc%3Etfw',
+ )
+ expect (screen.getByRole ('link', { name: /\d/ })).toHaveAttribute (
+ 'href',
+ 'https://twitter.com/user_name/status/12345?ref_src=twsrc%5Etfw',
+ )
+ })
+})
diff --git a/frontend/src/components/common/DateTimeField.test.tsx b/frontend/src/components/common/DateTimeField.test.tsx
new file mode 100644
index 0000000..19605c7
--- /dev/null
+++ b/frontend/src/components/common/DateTimeField.test.tsx
@@ -0,0 +1,27 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import DateTimeField from '@/components/common/DateTimeField'
+
+describe ('DateTimeField', () => {
+ it ('renders an ISO value as a datetime-local value', () => {
+ render ()
+
+ const input = screen.getByLabelText ('日時')
+
+ expect (input).toHaveValue ('2026-01-02T12:04')
+ })
+
+ it ('reports local changes as ISO strings and empty values as null', () => {
+ const handleChange = vi.fn ()
+ render ()
+
+ const input = screen.getByLabelText ('日時')
+ fireEvent.change (input, { target: { value: '2026-01-02T03:04' } })
+ fireEvent.change (input, { target: { value: '' } })
+
+ const first = handleChange.mock.calls[0]?.[0]
+ expect (new Date (first).getFullYear ()).toBe (2026)
+ expect (handleChange).toHaveBeenLastCalledWith (null)
+ })
+})
diff --git a/frontend/src/components/common/Label.test.tsx b/frontend/src/components/common/Label.test.tsx
new file mode 100644
index 0000000..c7a5666
--- /dev/null
+++ b/frontend/src/components/common/Label.test.tsx
@@ -0,0 +1,26 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import Label from '@/components/common/Label'
+
+describe ('Label', () => {
+ it ('renders a plain label', () => {
+ render ()
+
+ expect (screen.getByText ('名前')).toBeInTheDocument ()
+ })
+
+ it ('renders and toggles the optional checkbox', () => {
+ const handleChange = vi.fn ()
+
+ render (
+ ,
+ )
+
+ fireEvent.click (screen.getByRole ('checkbox', { name: '不明' }))
+
+ expect (handleChange).toHaveBeenCalledTimes (1)
+ })
+})
diff --git a/frontend/src/components/common/Pagination.test.tsx b/frontend/src/components/common/Pagination.test.tsx
new file mode 100644
index 0000000..d5a659b
--- /dev/null
+++ b/frontend/src/components/common/Pagination.test.tsx
@@ -0,0 +1,38 @@
+import { screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import Pagination from '@/components/common/Pagination'
+import { renderWithProviders } from '@/test/render'
+
+describe ('Pagination', () => {
+ it ('builds page links while preserving existing query parameters', () => {
+ renderWithProviders (
+ ,
+ { route: '/posts?tags=abc&page=3' },
+ )
+
+ expect (screen.getByLabelText ('前のページ')).toHaveAttribute (
+ 'href',
+ '/posts?tags=abc&page=2',
+ )
+ expect (screen.getByLabelText ('次のページ')).toHaveAttribute (
+ 'href',
+ '/posts?tags=abc&page=4',
+ )
+ expect (screen.getByText ('3')).toHaveAttribute ('aria-current', 'page')
+ })
+
+ it ('does not render active previous and next controls at the edges', () => {
+ const { rerender } = renderWithProviders (
+ ,
+ { route: '/tags' },
+ )
+
+ expect (screen.queryByLabelText ('前のページ')).not.toBeInTheDocument ()
+ expect (screen.queryByLabelText ('次のページ')).not.toBeInTheDocument ()
+
+ rerender ()
+
+ expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/tags?page=2')
+ })
+})
diff --git a/frontend/src/components/common/TabGroup.test.tsx b/frontend/src/components/common/TabGroup.test.tsx
new file mode 100644
index 0000000..424be1c
--- /dev/null
+++ b/frontend/src/components/common/TabGroup.test.tsx
@@ -0,0 +1,23 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import TabGroup, { Tab } from '@/components/common/TabGroup'
+
+describe ('TabGroup', () => {
+ it ('uses the init tab and switches tabs when clicked', () => {
+ render (
+
+ Alpha
+ Beta
+ ,
+ )
+
+ expect (screen.queryByText ('Alpha')).not.toBeInTheDocument ()
+ expect (screen.getByText ('Beta')).toBeInTheDocument ()
+
+ fireEvent.click (screen.getByText ('A'))
+
+ expect (screen.getByText ('Alpha')).toBeInTheDocument ()
+ expect (screen.queryByText ('Beta')).not.toBeInTheDocument ()
+ })
+})
diff --git a/frontend/src/components/common/TagInput.test.tsx b/frontend/src/components/common/TagInput.test.tsx
new file mode 100644
index 0000000..dffc950
--- /dev/null
+++ b/frontend/src/components/common/TagInput.test.tsx
@@ -0,0 +1,44 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import TagInput from '@/components/common/TagInput'
+import { buildTag } from '@/test/factories'
+
+const api = vi.hoisted (() => ({
+ apiGet: vi.fn (),
+}))
+
+vi.mock ('@/lib/api', () => api)
+
+describe ('TagInput', () => {
+ beforeEach (() => {
+ vi.clearAllMocks ()
+ })
+
+ it ('updates value and fetches autocomplete for the last token', async () => {
+ const setValue = vi.fn ()
+ api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
+
+ render ()
+
+ fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } })
+
+ await waitFor (() => {
+ expect (api.apiGet).toHaveBeenCalledWith (
+ '/tags/autocomplete',
+ { params: { q: '虹夏' } },
+ )
+ })
+ expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
+ })
+
+ it ('does not fetch when the last token is blank', () => {
+ const setValue = vi.fn ()
+ render ()
+
+ fireEvent.change (screen.getByRole ('textbox'), { target: { value: ' ' } })
+
+ expect (api.apiGet).not.toHaveBeenCalled ()
+ expect (setValue).toHaveBeenCalledWith (' ')
+ })
+})
diff --git a/frontend/src/components/common/TypographyAndForm.test.tsx b/frontend/src/components/common/TypographyAndForm.test.tsx
new file mode 100644
index 0000000..b94e41c
--- /dev/null
+++ b/frontend/src/components/common/TypographyAndForm.test.tsx
@@ -0,0 +1,39 @@
+import { createRef } from 'react'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import Form from '@/components/common/Form'
+import SectionTitle from '@/components/common/SectionTitle'
+import SubsectionTitle from '@/components/common/SubsectionTitle'
+import TextArea from '@/components/common/TextArea'
+
+describe ('common typography and form components', () => {
+ it ('renders Form children inside the standard container', () => {
+ render ()
+
+ expect (screen.getByText ('Content').parentElement).toHaveClass ('max-w-xl')
+ })
+
+ it ('renders SectionTitle as an h2 and merges custom classes', () => {
+ render (Section)
+
+ const heading = screen.getByRole ('heading', { level: 2, name: 'Section' })
+ expect (heading).toHaveClass ('text-xl')
+ expect (heading).toHaveClass ('custom')
+ })
+
+ it ('renders SubsectionTitle as an h3', () => {
+ render (Subsection)
+
+ expect (screen.getByRole ('heading', { level: 3, name: 'Subsection' })).toBeInTheDocument ()
+ })
+
+ it ('forwards refs and props to TextArea', () => {
+ const ref = createRef ()
+
+ render ()
+
+ expect (ref.current).toBe (screen.getByLabelText ('Body'))
+ expect (screen.getByLabelText ('Body')).toHaveValue ('text')
+ })
+})
diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts
new file mode 100644
index 0000000..e5a6306
--- /dev/null
+++ b/frontend/src/lib/api.test.ts
@@ -0,0 +1,96 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted (() => {
+ const client = {
+ delete: vi.fn (),
+ get: vi.fn (),
+ patch: vi.fn (),
+ post: vi.fn (),
+ put: vi.fn (),
+ }
+
+ return {
+ client,
+ isAxiosError: vi.fn (),
+ }
+})
+
+vi.mock ('axios', () => ({
+ default: {
+ create: vi.fn (() => mocks.client),
+ isAxiosError: mocks.isAxiosError,
+ },
+}))
+
+vi.mock ('@/config', () => ({
+ API_BASE_URL: '/api',
+}))
+
+describe ('api helpers', () => {
+ beforeEach (() => {
+ vi.clearAllMocks ()
+ localStorage.clear ()
+ })
+
+ it ('adds the transfer code header and camelizes get responses', async () => {
+ localStorage.setItem ('user_code', 'abc123')
+ mocks.client.get.mockResolvedValueOnce ({
+ data: { post_id: 1, nested_value: { created_at: 'now' } },
+ })
+
+ const { apiGet } = await import ('@/lib/api')
+ const data = await apiGet<{ postId: number; nestedValue: { createdAt: string } }> (
+ '/posts/1',
+ { headers: { 'X-Extra': '1' }, params: { page: 2 } },
+ )
+
+ expect (mocks.client.get).toHaveBeenCalledWith (
+ '/posts/1',
+ {
+ headers: { 'X-Transfer-Code': 'abc123', 'X-Extra': '1' },
+ params: { page: 2 },
+ },
+ )
+ expect (data).toEqual ({ postId: 1, nestedValue: { createdAt: 'now' } })
+ })
+
+ it ('passes an empty body for post-like requests when body is omitted', async () => {
+ mocks.client.patch.mockResolvedValueOnce ({ data: { ok_value: true } })
+
+ const { apiPatch } = await import ('@/lib/api')
+ const data = await apiPatch<{ okValue: boolean }> ('/posts/1')
+
+ expect (mocks.client.patch).toHaveBeenCalledWith (
+ '/posts/1',
+ {},
+ { headers: { 'X-Transfer-Code': '' } },
+ )
+ expect (data.okValue).toBe (true)
+ })
+
+ it ('does not camelize blob responses', async () => {
+ const blob = new Blob (['csv'])
+ mocks.client.get.mockResolvedValueOnce ({ data: blob })
+
+ const { apiGet } = await import ('@/lib/api')
+ const data = await apiGet ('/exports', { responseType: 'blob' })
+
+ expect (data).toBe (blob)
+ })
+
+ it ('delegates deletes and exposes axios error detection', async () => {
+ const err = new Error ('bad')
+ mocks.client.delete.mockResolvedValueOnce ({})
+ mocks.isAxiosError.mockReturnValueOnce (true)
+
+ const { apiDelete, isApiError } = await import ('@/lib/api')
+ await apiDelete ('/posts/1')
+
+ expect (mocks.client.delete).toHaveBeenCalledWith (
+ '/posts/1',
+ { headers: { 'X-Transfer-Code': '' } },
+ )
+ expect (isApiError (err)).toBe (true)
+ expect (mocks.isAxiosError).toHaveBeenCalledWith (err)
+ })
+})
diff --git a/frontend/src/lib/posts.test.ts b/frontend/src/lib/posts.test.ts
new file mode 100644
index 0000000..724afe4
--- /dev/null
+++ b/frontend/src/lib/posts.test.ts
@@ -0,0 +1,117 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { fetchPostChanges, fetchPosts, toggleViewedFlg, updatePost } from '@/lib/posts'
+
+import type { FetchPostsParams } from '@/types'
+
+const api = vi.hoisted (() => ({
+ apiDelete: vi.fn (),
+ apiGet: vi.fn (),
+ apiPost: vi.fn (),
+ apiPut: vi.fn (),
+}))
+
+vi.mock ('@/lib/api', () => api)
+
+const baseParams: FetchPostsParams = {
+ url: '',
+ title: '',
+ tags: '',
+ match: 'all',
+ originalCreatedFrom: '',
+ originalCreatedTo: '',
+ createdFrom: '',
+ createdTo: '',
+ updatedFrom: '',
+ updatedTo: '',
+ page: 1,
+ limit: 20,
+ order: 'updated_at:desc',
+}
+
+describe ('posts API functions', () => {
+ beforeEach (() => {
+ vi.clearAllMocks ()
+ })
+
+ it ('maps post search parameters to backend snake_case names', async () => {
+ api.apiGet.mockResolvedValueOnce ({ posts: [], count: 0 })
+
+ await fetchPosts ({
+ ...baseParams,
+ title: 'title',
+ tags: 'a b',
+ originalCreatedFrom: '2026-01-01',
+ updatedTo: '2026-02-01',
+ })
+
+ expect (api.apiGet).toHaveBeenCalledWith (
+ '/posts',
+ {
+ params: {
+ title: 'title',
+ tags: 'a b',
+ match: 'all',
+ original_created_from: '2026-01-01',
+ updated_to: '2026-02-01',
+ page: 1,
+ limit: 20,
+ order: 'updated_at:desc',
+ },
+ },
+ )
+ })
+
+ it ('updates posts with version and merge controls', async () => {
+ api.apiPut.mockResolvedValueOnce ({ id: 5 })
+
+ await updatePost (
+ {
+ id: 5,
+ title: 'new title',
+ tags: 'tag',
+ parentPostIds: '1 2',
+ originalCreatedFrom: null,
+ originalCreatedBefore: '2026-01-02T00:00:00Z',
+ },
+ { baseVersionNo: 7, force: true, merge: false },
+ )
+
+ expect (api.apiPut).toHaveBeenCalledWith (
+ '/posts/5',
+ {
+ title: 'new title',
+ tags: 'tag',
+ parent_post_ids: '1 2',
+ original_created_from: null,
+ original_created_before: '2026-01-02T00:00:00Z',
+ },
+ {
+ params: {
+ base_version_no: '7',
+ force: '1',
+ merge: '0',
+ },
+ },
+ )
+ })
+
+ it ('uses the viewed endpoint method matching the requested state', async () => {
+ await toggleViewedFlg ('9', true)
+ await toggleViewedFlg ('9', false)
+
+ expect (api.apiPost).toHaveBeenCalledWith ('/posts/9/viewed')
+ expect (api.apiDelete).toHaveBeenCalledWith ('/posts/9/viewed')
+ })
+
+ it ('keeps optional post history filters out when blank', async () => {
+ api.apiGet.mockResolvedValueOnce ({ versions: [], count: 0 })
+
+ await fetchPostChanges ({ page: 2, limit: 50 })
+
+ expect (api.apiGet).toHaveBeenCalledWith (
+ '/posts/versions',
+ { params: { page: 2, limit: 50 } },
+ )
+ })
+})
diff --git a/frontend/src/lib/prefetchers.test.ts b/frontend/src/lib/prefetchers.test.ts
new file mode 100644
index 0000000..b33cb65
--- /dev/null
+++ b/frontend/src/lib/prefetchers.test.ts
@@ -0,0 +1,112 @@
+import { QueryClient } from '@tanstack/react-query'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { prefetchForURL } from '@/lib/prefetchers'
+
+const postsApi = vi.hoisted (() => ({
+ fetchPost: vi.fn (),
+ fetchPostChanges: vi.fn (),
+ fetchPosts: vi.fn (),
+}))
+
+const tagsApi = vi.hoisted (() => ({
+ fetchTag: vi.fn (),
+ fetchTagByName: vi.fn (),
+ fetchTagChanges: vi.fn (),
+ fetchTags: vi.fn (),
+}))
+
+const wikiApi = vi.hoisted (() => ({
+ fetchWikiPage: vi.fn (),
+ fetchWikiPageByTitle: vi.fn (),
+ fetchWikiPages: vi.fn (),
+}))
+
+vi.mock ('@/lib/posts', () => postsApi)
+vi.mock ('@/lib/tags', () => tagsApi)
+vi.mock ('@/lib/wiki', () => wikiApi)
+
+const qc = () => new QueryClient ({
+ defaultOptions: { queries: { retry: false } },
+})
+
+describe ('prefetchForURL', () => {
+ beforeEach (() => {
+ vi.clearAllMocks ()
+ postsApi.fetchPosts.mockResolvedValue ({ posts: [], count: 0 })
+ postsApi.fetchPost.mockResolvedValue ({ id: 1 })
+ postsApi.fetchPostChanges.mockResolvedValue ({ versions: [], count: 0 })
+ tagsApi.fetchTags.mockResolvedValue ({ tags: [], count: 0 })
+ tagsApi.fetchTag.mockResolvedValue ({ id: 1 })
+ tagsApi.fetchTagByName.mockResolvedValue (null)
+ tagsApi.fetchTagChanges.mockResolvedValue ({ versions: [], count: 0 })
+ wikiApi.fetchWikiPages.mockResolvedValue ([])
+ wikiApi.fetchWikiPage.mockResolvedValue ({ id: 1 })
+ wikiApi.fetchWikiPageByTitle.mockResolvedValue (null)
+ })
+
+ it ('prefetches post indexes from query parameters', async () => {
+ await prefetchForURL (
+ qc (),
+ 'http://localhost/posts?tags=a+b&match=any&page=2&limit=5&order=title%3Aasc',
+ )
+
+ expect (postsApi.fetchPosts).toHaveBeenCalledWith (
+ expect.objectContaining ({
+ tags: 'a b',
+ match: 'any',
+ page: 2,
+ limit: 5,
+ order: 'title:asc',
+ }),
+ )
+ })
+
+ it ('prefetches post detail pages', async () => {
+ await prefetchForURL (qc (), 'http://localhost/posts/12')
+
+ expect (postsApi.fetchPost).toHaveBeenCalledWith ('12')
+ })
+
+ it ('prefetches tag indexes from query parameters', async () => {
+ await prefetchForURL (
+ qc (),
+ 'http://localhost/tags?post=9&name=x&category=general&page=4&post_count_lte=10',
+ )
+
+ expect (tagsApi.fetchTags).toHaveBeenCalledWith (
+ expect.objectContaining ({
+ post: 9,
+ name: 'x',
+ category: 'general',
+ page: 4,
+ postCountLTE: 10,
+ }),
+ )
+ })
+
+ it ('prefetches wiki show pages and related tag/post data', async () => {
+ wikiApi.fetchWikiPageByTitle.mockResolvedValueOnce ({
+ id: 3,
+ title: 'Actual',
+ body: 'body',
+ })
+
+ await prefetchForURL (qc (), 'http://localhost/wiki/Alias')
+
+ expect (wikiApi.fetchWikiPageByTitle).toHaveBeenCalledWith ('Alias', { version: undefined })
+ expect (wikiApi.fetchWikiPage).toHaveBeenCalledWith ('3', {})
+ expect (tagsApi.fetchTagByName).toHaveBeenCalledWith ('Actual')
+ expect (postsApi.fetchPosts).toHaveBeenCalledWith (
+ expect.objectContaining ({ tags: 'Actual', limit: 8 }),
+ )
+ })
+
+ it ('ignores routes without a prefetcher', async () => {
+ await prefetchForURL (qc (), 'http://localhost/unknown')
+
+ expect (postsApi.fetchPosts).not.toHaveBeenCalled ()
+ expect (tagsApi.fetchTags).not.toHaveBeenCalled ()
+ expect (wikiApi.fetchWikiPages).not.toHaveBeenCalled ()
+ })
+})
diff --git a/frontend/src/lib/queryKeys.test.ts b/frontend/src/lib/queryKeys.test.ts
new file mode 100644
index 0000000..45a9ee5
--- /dev/null
+++ b/frontend/src/lib/queryKeys.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest'
+
+import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
+
+describe ('query keys', () => {
+ it ('uses stable namespaces for posts, tags, and wiki', () => {
+ expect (postsKeys.show ('3')).toEqual (['posts', '3'])
+ expect (postsKeys.related ('3')).toEqual (['related', '3'])
+ expect (tagsKeys.deerjikists ('7')).toEqual (['tags', 'deerjikists', '7'])
+ expect (wikiKeys.show ('Title', { version: '2' })).toEqual (
+ ['wiki', 'Title', { version: '2' }],
+ )
+ })
+})
diff --git a/frontend/src/lib/remark-wiki-autolink.test.ts b/frontend/src/lib/remark-wiki-autolink.test.ts
new file mode 100644
index 0000000..57e634b
--- /dev/null
+++ b/frontend/src/lib/remark-wiki-autolink.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+
+import remarkWikiAutolink from '@/lib/remark-wiki-autolink'
+
+import type { Root } from 'mdast'
+
+describe ('remarkWikiAutolink', () => {
+ it ('links matching wiki page names and prefers longer matches', () => {
+ const tree: Root = {
+ type: 'root',
+ children: [{
+ type: 'paragraph',
+ children: [{ type: 'text', value: '虹夏 and 虹' }],
+ }],
+ }
+
+ remarkWikiAutolink (['虹', '虹夏']) (tree)
+
+ expect (tree.children[0]).toMatchObject ({
+ type: 'paragraph',
+ children: [
+ {
+ type: 'link',
+ url: '/wiki/%E8%99%B9%E5%A4%8F',
+ children: [{ type: 'text', value: '虹夏' }],
+ },
+ { type: 'text', value: ' and ' },
+ {
+ type: 'link',
+ url: '/wiki/%E8%99%B9',
+ children: [{ type: 'text', value: '虹' }],
+ },
+ ],
+ })
+ })
+
+ it ('does not link text inside existing links or code', () => {
+ const tree: Root = {
+ type: 'root',
+ children: [
+ {
+ type: 'paragraph',
+ children: [{
+ type: 'link',
+ url: '/existing',
+ children: [{ type: 'text', value: '虹' }],
+ }],
+ },
+ {
+ type: 'code',
+ value: '虹',
+ },
+ ],
+ }
+
+ remarkWikiAutolink (['虹']) (tree)
+
+ expect (tree.children[0]).toMatchObject ({
+ type: 'paragraph',
+ children: [{
+ type: 'link',
+ url: '/existing',
+ children: [{ type: 'text', value: '虹' }],
+ }],
+ })
+ expect (tree.children[1]).toMatchObject ({ type: 'code', value: '虹' })
+ })
+})
diff --git a/frontend/src/lib/tags.test.ts b/frontend/src/lib/tags.test.ts
new file mode 100644
index 0000000..f51455c
--- /dev/null
+++ b/frontend/src/lib/tags.test.ts
@@ -0,0 +1,67 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { fetchTag, fetchTagByName, fetchTags } from '@/lib/tags'
+
+import type { FetchTagsParams } from '@/types'
+
+const api = vi.hoisted (() => ({
+ apiGet: vi.fn (),
+}))
+
+vi.mock ('@/lib/api', () => api)
+
+const baseParams: FetchTagsParams = {
+ post: null,
+ name: '',
+ category: null,
+ postCountGTE: 0,
+ postCountLTE: null,
+ createdFrom: '',
+ createdTo: '',
+ updatedFrom: '',
+ updatedTo: '',
+ page: 1,
+ limit: 30,
+ order: 'updated_at:desc',
+}
+
+describe ('tags API functions', () => {
+ beforeEach (() => {
+ vi.clearAllMocks ()
+ })
+
+ it ('maps tag filters to backend parameters', async () => {
+ api.apiGet.mockResolvedValueOnce ({ tags: [], count: 0 })
+
+ await fetchTags ({
+ ...baseParams,
+ name: '虹',
+ category: 'character',
+ postCountGTE: 10,
+ postCountLTE: 20,
+ })
+
+ expect (api.apiGet).toHaveBeenCalledWith (
+ '/tags',
+ {
+ params: {
+ name: '虹',
+ category: 'character',
+ post_count_gte: 10,
+ post_count_lte: 20,
+ page: 1,
+ limit: 30,
+ order: 'updated_at:desc',
+ },
+ },
+ )
+ })
+
+ it ('returns null when tag fetches fail', async () => {
+ api.apiGet.mockRejectedValueOnce (new Error ('missing'))
+ api.apiGet.mockRejectedValueOnce (new Error ('missing'))
+
+ await expect (fetchTag ('1')).resolves.toBeNull ()
+ await expect (fetchTagByName ('unknown')).resolves.toBeNull ()
+ })
+})
diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts
new file mode 100644
index 0000000..c85e150
--- /dev/null
+++ b/frontend/src/lib/utils.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+
+import { cn, originalCreatedAtString, toDate } from '@/lib/utils'
+
+describe ('utils', () => {
+ it ('converts strings to dates and leaves date instances intact', () => {
+ const date = new Date ('2026-01-02T03:04:05Z')
+
+ expect (toDate ('2026-01-02T03:04:05Z')).toBeInstanceOf (Date)
+ expect (toDate (date)).toBe (date)
+ })
+
+ it ('merges conditional Tailwind classes', () => {
+ const hidden = false
+
+ expect (cn ('p-2', hidden && 'hidden', 'p-4')).toBe ('p-4')
+ })
+
+ it ('renders unknown original creation time ranges', () => {
+ expect (originalCreatedAtString (null, null)).toBe ('年月日不詳')
+ expect (
+ originalCreatedAtString (
+ '2026-01-01T00:00:00+09:00',
+ '2026-01-02T00:00:00+09:00',
+ ),
+ ).toContain ('時刻不詳')
+ })
+})
diff --git a/frontend/src/lib/wiki.test.ts b/frontend/src/lib/wiki.test.ts
new file mode 100644
index 0000000..6a9984d
--- /dev/null
+++ b/frontend/src/lib/wiki.test.ts
@@ -0,0 +1,48 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki'
+
+const api = vi.hoisted (() => ({
+ apiGet: vi.fn (),
+}))
+
+vi.mock ('@/lib/api', () => api)
+
+describe ('wiki API functions', () => {
+ beforeEach (() => {
+ vi.clearAllMocks ()
+ })
+
+ it ('fetches wiki index and show pages with expected parameters', async () => {
+ api.apiGet.mockResolvedValueOnce ([])
+ api.apiGet.mockResolvedValueOnce ({ id: 1 })
+
+ await fetchWikiPages ({ title: '虹' })
+ await fetchWikiPage ('1', { version: '3' })
+
+ expect (api.apiGet).toHaveBeenNthCalledWith (
+ 1,
+ '/wiki',
+ { params: { title: '虹' } },
+ )
+ expect (api.apiGet).toHaveBeenNthCalledWith (
+ 2,
+ '/wiki/1',
+ { params: { version: '3' } },
+ )
+ })
+
+ it ('encodes title path segments and returns null on misses', async () => {
+ api.apiGet.mockResolvedValueOnce ({ id: 2 })
+ api.apiGet.mockRejectedValueOnce (new Error ('missing'))
+
+ await fetchWikiPageByTitle ('a/b c', { version: undefined })
+ await expect (fetchWikiPageByTitle ('missing', {})).resolves.toBeNull ()
+
+ expect (api.apiGet).toHaveBeenNthCalledWith (
+ 1,
+ '/wiki/title/a%2Fb%20c',
+ { params: { version: undefined } },
+ )
+ })
+})
diff --git a/frontend/src/pages/materials/MaterialBasePage.test.tsx b/frontend/src/pages/materials/MaterialBasePage.test.tsx
new file mode 100644
index 0000000..975632a
--- /dev/null
+++ b/frontend/src/pages/materials/MaterialBasePage.test.tsx
@@ -0,0 +1,26 @@
+import { screen } from '@testing-library/react'
+import { Route, Routes } from 'react-router-dom'
+import { describe, expect, it, vi } from 'vitest'
+
+import MaterialBasePage from '@/pages/materials/MaterialBasePage'
+import { renderWithProviders } from '@/test/render'
+
+vi.mock ('@/components/MaterialSidebar', () => ({
+ default: () => ,
+}))
+
+describe ('MaterialBasePage', () => {
+ it ('renders the material sidebar and nested route outlet', () => {
+ renderWithProviders (
+
+ }>
+ Outlet content}/>
+
+ ,
+ { route: '/materials' },
+ )
+
+ expect (screen.getByText ('Material sidebar')).toBeInTheDocument ()
+ expect (screen.getByText ('Outlet content')).toBeInTheDocument ()
+ })
+})
diff --git a/frontend/src/pages/materials/MaterialDetailPage.test.tsx b/frontend/src/pages/materials/MaterialDetailPage.test.tsx
new file mode 100644
index 0000000..2b697a9
--- /dev/null
+++ b/frontend/src/pages/materials/MaterialDetailPage.test.tsx
@@ -0,0 +1,86 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { Route, Routes } from 'react-router-dom'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
+import { buildMaterial, buildTag } from '@/test/factories'
+import { renderWithProviders } from '@/test/render'
+
+const api = vi.hoisted (() => ({
+ apiGet: vi.fn (),
+ apiPut: vi.fn (),
+}))
+
+const wikiApi = vi.hoisted (() => ({
+ fetchWikiPages: vi.fn (),
+}))
+
+const toastApi = vi.hoisted (() => ({
+ toast: vi.fn (),
+}))
+
+vi.mock ('@/lib/api', () => api)
+vi.mock ('@/lib/wiki', () => wikiApi)
+vi.mock ('@/components/ui/use-toast', () => toastApi)
+
+const renderPage = () =>
+ renderWithProviders (
+
+ }/>
+ ,
+ { route: '/materials/8' },
+ )
+
+describe ('MaterialDetailPage', () => {
+ beforeEach (() => {
+ vi.clearAllMocks ()
+ api.apiGet.mockResolvedValue ([])
+ wikiApi.fetchWikiPages.mockResolvedValue ([])
+ vi.stubGlobal ('fetch', vi.fn (async () => ({
+ blob: async () => new Blob (['image'], { type: 'image/png' }),
+ })))
+ })
+
+ it ('loads and displays material detail', async () => {
+ api.apiGet.mockResolvedValueOnce (
+ buildMaterial ({
+ id: 8,
+ tag: buildTag ({ name: '素材タグ' }),
+ file: 'image.png',
+ contentType: 'image/png',
+ }),
+ )
+
+ renderPage ()
+
+ await waitFor (() => {
+ expect (api.apiGet).toHaveBeenCalledWith ('/materials/8')
+ })
+ expect (await screen.findByAltText ('素材タグ')).toHaveAttribute ('src', 'image.png')
+ })
+
+ it ('submits edited material fields', async () => {
+ api.apiGet.mockResolvedValueOnce (
+ buildMaterial ({ id: 8, tag: buildTag ({ name: 'old' }), url: '' }),
+ )
+ api.apiPut.mockResolvedValueOnce (
+ buildMaterial ({ id: 8, tag: buildTag ({ name: 'new' }) }),
+ )
+
+ renderPage ()
+
+ fireEvent.click (await screen.findByText ('編輯'))
+ const textboxes = screen.getAllByRole ('textbox')
+ fireEvent.change (textboxes[0], { target: { value: 'new' } })
+ fireEvent.change (textboxes[1], { target: { value: 'https://example.com/ref' } })
+ fireEvent.click (screen.getByRole ('button', { name: '更新' }))
+
+ await waitFor (() => {
+ expect (api.apiPut).toHaveBeenCalledWith ('/materials/8', expect.any (FormData))
+ })
+ const formData = api.apiPut.mock.calls[0]?.[1] as FormData
+ expect (formData.get ('tag')).toBe ('new')
+ expect (formData.get ('url')).toBe ('https://example.com/ref')
+ expect (toastApi.toast).toHaveBeenCalledWith ({ title: '更新成功!' })
+ })
+})
diff --git a/frontend/src/pages/materials/MaterialListPage.test.tsx b/frontend/src/pages/materials/MaterialListPage.test.tsx
new file mode 100644
index 0000000..cc10674
--- /dev/null
+++ b/frontend/src/pages/materials/MaterialListPage.test.tsx
@@ -0,0 +1,62 @@
+import { screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import MaterialListPage from '@/pages/materials/MaterialListPage'
+import { buildMaterial, buildTag } from '@/test/factories'
+import { renderWithProviders } from '@/test/render'
+
+const api = vi.hoisted (() => ({
+ apiGet: vi.fn (),
+}))
+
+vi.mock ('@/lib/api', () => api)
+
+describe ('MaterialListPage', () => {
+ it ('shows the empty selection guide without a tag query', () => {
+ renderWithProviders (, { route: '/materials' })
+
+ expect (screen.getByText ('左のリストから照会したいタグを選択してください。')).toBeInTheDocument ()
+ expect (screen.getByRole ('link', { name: '素材を新規追加する' })).toHaveAttribute (
+ 'href',
+ '/materials/new',
+ )
+ })
+
+ it ('loads materials for a tag query', async () => {
+ const tag = {
+ ...buildTag ({
+ id: 4,
+ name: '素材タグ',
+ category: 'material',
+ }),
+ material: buildMaterial ({ id: 8, contentType: 'image/png', file: 'image.png' }),
+ children: [],
+ }
+ api.apiGet.mockResolvedValueOnce (tag)
+
+ renderWithProviders (, { route: '/materials?tag=%E7%B4%A0%E6%9D%90' })
+
+ await waitFor (() => {
+ expect (api.apiGet).toHaveBeenCalledWith (
+ '/tags/name/%E7%B4%A0%E6%9D%90/materials',
+ )
+ })
+ expect (await screen.findByRole ('link', { name: '素材タグ' })).toBeInTheDocument ()
+ expect (screen.getByRole ('link', { name: '' })).toHaveAttribute ('href', '/materials/8')
+ })
+
+ it ('offers adding a missing non-meme material', async () => {
+ api.apiGet.mockResolvedValueOnce ({
+ ...buildTag ({ name: '未登録', category: 'material' }),
+ material: null,
+ children: [],
+ })
+
+ renderWithProviders (, { route: '/materials?tag=x' })
+
+ expect (await screen.findByRole ('link', { name: '追加' })).toHaveAttribute (
+ 'href',
+ '/materials/new?tag=%E6%9C%AA%E7%99%BB%E9%8C%B2',
+ )
+ })
+})
diff --git a/frontend/src/pages/materials/MaterialNewPage.test.tsx b/frontend/src/pages/materials/MaterialNewPage.test.tsx
new file mode 100644
index 0000000..c33a5dc
--- /dev/null
+++ b/frontend/src/pages/materials/MaterialNewPage.test.tsx
@@ -0,0 +1,38 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import MaterialNewPage from '@/pages/materials/MaterialNewPage'
+import { renderWithProviders } from '@/test/render'
+
+const api = vi.hoisted (() => ({
+ apiPost: vi.fn (),
+}))
+
+const toastApi = vi.hoisted (() => ({
+ toast: vi.fn (),
+}))
+
+vi.mock ('@/lib/api', () => api)
+vi.mock ('@/components/ui/use-toast', () => toastApi)
+
+describe ('MaterialNewPage', () => {
+ it ('initializes tag from query and submits form data', async () => {
+ api.apiPost.mockResolvedValueOnce ({})
+
+ renderWithProviders (, { route: '/materials/new?tag=%E8%99%B9%E5%A4%8F' })
+
+ expect (screen.getAllByRole ('textbox')[0]).toHaveValue ('虹夏')
+ fireEvent.change (screen.getAllByRole ('textbox')[1], {
+ target: { value: 'https://example.com/ref' },
+ })
+ fireEvent.click (screen.getByRole ('button', { name: '追加' }))
+
+ await waitFor (() => {
+ expect (api.apiPost).toHaveBeenCalledWith ('/materials', expect.any (FormData))
+ })
+ const formData = api.apiPost.mock.calls[0]?.[1] as FormData
+ expect (formData.get ('tag')).toBe ('虹夏')
+ expect (formData.get ('url')).toBe ('https://example.com/ref')
+ expect (toastApi.toast).toHaveBeenCalledWith ({ title: '送信成功!' })
+ })
+})
diff --git a/frontend/src/pages/posts/PostDetailPage.test.tsx b/frontend/src/pages/posts/PostDetailPage.test.tsx
new file mode 100644
index 0000000..1630f6d
--- /dev/null
+++ b/frontend/src/pages/posts/PostDetailPage.test.tsx
@@ -0,0 +1,109 @@
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { Route, Routes } from 'react-router-dom'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import PostDetailPage from '@/pages/posts/PostDetailPage'
+import { buildPost, buildUser } from '@/test/factories'
+import { renderWithProviders } from '@/test/render'
+
+import type { ReactNode } from 'react'
+
+const postsApi = vi.hoisted (() => ({
+ fetchPost: vi.fn (),
+ toggleViewedFlg: vi.fn (),
+}))
+
+const api = vi.hoisted (() => ({
+ isApiError: vi.fn (() => false),
+}))
+
+vi.mock ('@/lib/posts', () => postsApi)
+vi.mock ('@/lib/api', () => api)
+vi.mock ('@/components/PostEmbed', () => ({
+ default: ({ post }: { post: { url: string } }) => Embed:{post.url}
,
+}))
+vi.mock ('@/components/TagDetailSidebar', () => ({
+ default: () => ,
+}))
+vi.mock ('@/components/PostEditForm', () => ({
+ default: () => Post edit form
,
+}))
+vi.mock ('framer-motion', () => ({
+ motion: {
+ div: ({ children }: { children?: ReactNode }) => {children}
,
+ main: ({ children }: { children?: ReactNode }) => {children},
+ },
+}))
+
+const renderPage = (user = buildUser ({ role: 'member' })) =>
+ renderWithProviders (
+
+ }/>
+ ,
+ { route: '/posts/9' },
+ )
+
+describe ('PostDetailPage', () => {
+ beforeEach (() => {
+ vi.clearAllMocks ()
+ postsApi.toggleViewedFlg.mockResolvedValue (undefined)
+ })
+
+ it ('loads and displays a post detail', async () => {
+ postsApi.fetchPost.mockResolvedValue (
+ buildPost ({
+ id: 9,
+ url: 'https://example.com/9',
+ related: [],
+ thumbnail: null,
+ thumbnailBase: null,
+ }),
+ )
+
+ renderPage ()
+
+ await waitFor (() => {
+ expect (postsApi.fetchPost).toHaveBeenCalledWith ('9')
+ })
+ expect (await screen.findByText ('Embed:https://example.com/9')).toBeInTheDocument ()
+ expect (screen.getByRole ('button', { name: '未閲覧' })).toBeInTheDocument ()
+ expect (screen.getByText ('まだないよ(笑)')).toBeInTheDocument ()
+ })
+
+ it ('toggles viewed state through the mutation', async () => {
+ postsApi.fetchPost.mockResolvedValue (
+ buildPost ({ id: 9, viewed: false, thumbnail: null, thumbnailBase: null }),
+ )
+
+ renderPage ()
+
+ fireEvent.click (await screen.findByRole ('button', { name: '未閲覧' }))
+
+ await waitFor (() => {
+ expect (postsApi.toggleViewedFlg).toHaveBeenCalledWith ('9', true)
+ })
+ })
+
+ it ('shows the edit tab for members', async () => {
+ postsApi.fetchPost.mockResolvedValue (
+ buildPost ({ id: 9, thumbnail: null, thumbnailBase: null }),
+ )
+
+ renderPage (buildUser ({ role: 'member' }))
+
+ fireEvent.click (await screen.findByText ('編輯'))
+
+ expect (screen.getByText ('Post edit form')).toBeInTheDocument ()
+ })
+
+ it ('hides the edit tab for guests', async () => {
+ postsApi.fetchPost.mockResolvedValue (
+ buildPost ({ id: 9, thumbnail: null, thumbnailBase: null }),
+ )
+
+ renderPage (buildUser ({ role: 'guest' }))
+
+ expect (await screen.findByText ('関聯')).toBeInTheDocument ()
+ expect (screen.queryByText ('編輯')).not.toBeInTheDocument ()
+ })
+})
diff --git a/frontend/src/pages/posts/PostDetailPage.tsx b/frontend/src/pages/posts/PostDetailPage.tsx
index 43f325c..906c5cb 100644
--- a/frontend/src/pages/posts/PostDetailPage.tsx
+++ b/frontend/src/pages/posts/PostDetailPage.tsx
@@ -44,17 +44,15 @@ const PostDetailPage: FC = ({ user }) => {
const [status, setStatus] = useState (200)
const changeViewedFlg = useMutation ({
- mutationFn: async () => {
- const cur = qc.getQueryData (postKey)
- const next = !(cur?.viewed)
+ mutationFn: async (next: boolean) => {
await toggleViewedFlg (postId, next)
return next
},
- onMutate: async () => {
+ onMutate: async (next: boolean) => {
await qc.cancelQueries ({ queryKey: postKey })
const prev = qc.getQueryData (postKey)
qc.setQueryData (postKey,
- (cur: Post | undefined) => cur ? { ...cur, viewed: !(cur.viewed) } : cur)
+ (cur: Post | undefined) => cur ? { ...cur, viewed: next } : cur)
return { prev }
},
onError: (...[, , ctx]) => {
@@ -155,7 +153,7 @@ const PostDetailPage: FC = ({ user }) => {
ref={embedRef}
post={post}
onLoadComplete={() => embedRef.current?.play ()}/>
-