このコミットが含まれているのは:
2026-07-18 21:59:22 +09:00
コミット 2f87669699
38個のファイルの変更869行の追加1307行の削除
+5 -3
ファイルの表示
@@ -78,16 +78,18 @@ describe ('PostEditForm', () => {
render (<PostEditForm post={post} onSave={vi.fn ()}/>)
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
.toHaveValue ('180.5')
const tags = screen.getAllByRole ('textbox')[2]
fireEvent.change (tags, { target: { value: 'general-tag' } })
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.queryByText ('動画時間')).not.toBeInTheDocument ()
fireEvent.change (tags, {
target: { value: '動画 general-tag' },
})
expect (screen.getByPlaceholderText ('例: 2 / 2.5 / 1:23')).toHaveValue ('180.5')
expect (screen.getByText ('動画時間').parentElement?.querySelector ('input'))
.toHaveValue ('180.5')
})
it ('shows deduplicated original-created endpoint errors on the shared datetime field', async () => {
+1 -1
ファイルの表示
@@ -73,7 +73,7 @@ describe ('PostOriginalCreatedTimeField', () => {
setOriginalCreatedBefore={vi.fn ()}/>,
)
const input = screen.getDisplayValue ('2024-01-01T12:34')
const input = screen.getByDisplayValue ('2024-01-01T12:34')
fireEvent.change (input, { target: { value: '2024-01-01T12:35' } })
expect (setFrom).toHaveBeenCalledWith ('2024-01-01T03:35Z')
+2 -4
ファイルの表示
@@ -17,21 +17,19 @@ describe ('menuOutline', () => {
for (const role of ['member', 'admin'] as const)
{
expect (submenuItem (role, '広場', '追加')?.visible).toBe (true)
expect (submenuItem (role, '広場', '取込')?.visible).toBe (true)
expect (submenuItem (role, '素材', '追加')?.visible).toBe (true)
expect (submenuItem (role, 'Wiki', '新規')?.visible).toBe (true)
expect (submenuItem (role, 'Wiki', '編輯')?.visible).toBe (true)
}
expect (submenuItem ('guest', '広場', '追加')?.visible).toBe (false)
expect (submenuItem ('guest', '広場', '取込')?.visible).toBe (false)
expect (submenuItem ('guest', '素材', '追加')?.visible).toBe (false)
expect (submenuItem ('guest', 'Wiki', '新規')?.visible).toBe (false)
expect (submenuItem ('guest', 'Wiki', '編輯')?.visible).toBe (false)
})
it ('uses /posts/new as the import entrypoint', () => {
expect (submenuItem ('member', '広場', '取込')?.to).toBe ('/posts/new')
it ('uses /posts/new as the post creation entrypoint', () => {
expect (submenuItem ('member', '広場', '追加')?.to).toBe ('/posts/new')
})
it ('keeps material suppression admin-only', () => {
+3 -1
ファイルの表示
@@ -80,7 +80,9 @@ describe ('DialogueProvider', () => {
expect (dialogue).toHaveClass ('max-h-[calc(100dvh-1rem)]', 'flex-col', 'max-w-3xl')
await waitFor (() => expect (screen.getByRole ('button', { name: '左操作' }))
.toBeInTheDocument ())
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass ('w-full', 'sm:w-auto')
expect (screen.getByRole ('button', { name: '左操作' })).toHaveClass (
'w-full',
'md:w-auto')
fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
-10
ファイルの表示
@@ -2,8 +2,6 @@ import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { buildPostImportRow } from '@/test/postImportFactories'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
@@ -14,14 +12,6 @@ vi.mock ('@/components/posts/PostCreationDataFields', () => ({
}))
describe ('PostCreationDataFields usage', () => {
it ('is used by PostNewPage', async () => {
const { default: PostNewPage } = await import ('@/pages/posts/PostNewPage')
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
expect (screen.getByTestId ('shared-fields')).toBeInTheDocument ()
})
it ('is used by PostImportRowForm', async () => {
const { default: PostImportRowForm } = await import (
'@/components/posts/import/PostImportRowForm')
+4 -2
ファイルの表示
@@ -135,7 +135,7 @@ describe ('PostImportRowForm', () => {
})
it (
'shows the shared creation field order without duration and without file upload UI',
'shows upload input only when the thumbnail URL is blank',
async () => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
@@ -164,7 +164,9 @@ describe ('PostImportRowForm', () => {
'オリジナルの作成日時',
'タグ',
'親投稿'])
expect (container.querySelector ('input[type="file"]')).toBeNull ()
expect (container.querySelector ('input[type="file"]')).toHaveAttribute (
'accept',
'image/*')
expect (screen.queryByPlaceholderText ('例: 2 / 2.5 / 1:23')).not.toBeInTheDocument ()
expect (screen.getByDisplayValue ('tag1')).toBeInTheDocument ()
+32 -57
ファイルの表示
@@ -1,14 +1,8 @@
import { render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('PostImportThumbnailPreview', () => {
beforeEach (() => {
vi.clearAllMocks ()
@@ -16,69 +10,50 @@ describe ('PostImportThumbnailPreview', () => {
globalThis.URL.revokeObjectURL = vi.fn ()
})
it ('uses a backend-fetched blob URL instead of the external thumbnail URL directly', async () => {
api.apiGet.mockResolvedValueOnce (new Blob (['img'], { type: 'image/png' }))
it ('renders the remote URL directly without a backend proxy', () => {
render (
<PostImportThumbnailPreview
url="https://example.com/thumbnail.jpg"
className="h-10 w-10"/>)
<PostImportThumbnailPreview
url="https://example.com/thumbnail.jpg"
className="h-10 w-10"/>)
await waitFor (() => {
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
})
expect (screen.getByRole ('img')).not.toHaveAttribute (
'src',
'https://example.com/thumbnail.jpg')
expect (api.apiGet).toHaveBeenCalledWith ('/preview/thumbnail', {
params: { url: 'https://example.com/thumbnail.jpg' },
responseType: 'blob' })
expect (screen.getByRole ('img')).toHaveAttribute (
'src',
'https://example.com/thumbnail.jpg')
expect (screen.getByRole ('img')).toHaveAttribute (
'referrerpolicy',
'no-referrer')
})
it ('does not render the unsafe URL directly when preview fetching fails', async () => {
api.apiGet.mockRejectedValueOnce (new Error ('unsafe'))
it ('shows the empty frame after the remote image fails', () => {
const { container } = render (
<PostImportThumbnailPreview
url="http://127.0.0.1/private.png"
className="h-10 w-10"/>)
<PostImportThumbnailPreview
url="https://example.com/missing.jpg"
className="h-10 w-10"/>)
await waitFor (() => {
expect (screen.queryByRole ('img')).toBeNull ()
})
expect (screen.queryByText ('サムネールを表示できません')).toBeNull ()
expect (screen.queryByText ('なし')).toBeNull ()
fireEvent.error (screen.getByRole ('img'))
expect (screen.queryByRole ('img')).toBeNull ()
expect (container.querySelector ('div.rounded.border.bg-muted')).not.toBeNull ()
expect (container.textContent).toBe ('')
expect (screen.queryByRole ('img')).toBeNull ()
})
it ('revokes the old object URL when the source URL changes', async () => {
const createObjectUrlMock =
globalThis.URL.createObjectURL as unknown as ReturnType<typeof vi.fn>
createObjectUrlMock
.mockReturnValueOnce ('blob:first')
.mockReturnValueOnce ('blob:second')
api.apiGet
.mockResolvedValueOnce (new Blob (['first'], { type: 'image/png' }))
.mockResolvedValueOnce (new Blob (['second'], { type: 'image/png' }))
it ('uses and revokes an object URL only when the remote URL is blank', () => {
const file = new File (['image'], 'thumbnail.png', { type: 'image/png' })
const { rerender, unmount } = render (
<PostImportThumbnailPreview url="" file={file} className="h-10 w-10"/>)
const { rerender } = render (
<PostImportThumbnailPreview
url="https://example.com/first.jpg"
className="h-10 w-10"/>)
await waitFor (() => {
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:first')
})
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:preview')
rerender (
<PostImportThumbnailPreview
url="https://example.com/second.jpg"
className="h-10 w-10"/>)
<PostImportThumbnailPreview
url="https://example.com/remote.jpg"
file={file}
className="h-10 w-10"/>)
expect (screen.getByRole ('img')).toHaveAttribute (
'src',
'https://example.com/remote.jpg')
await waitFor (() => {
expect (screen.getByRole ('img')).toHaveAttribute ('src', 'blob:second')
})
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:first')
unmount ()
expect (globalThis.URL.revokeObjectURL).toHaveBeenCalledWith ('blob:preview')
})
})
+4 -4
ファイルの表示
@@ -16,14 +16,14 @@ describe ('displayPostImportStatus', () => {
skipReason: 'manual' }))).toBe ('skipped')
})
it ('does not expose validation, failure, or created states as badges', () => {
it ('distinguishes validation, failure, and created states', () => {
expect (displayPostImportStatus (buildPostImportRow ({
status: 'error',
validationErrors: { title: ['invalid'] } }))).toBeNull ()
validationErrors: { title: ['invalid'] } }))).toBe ('error')
expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'failed' }))).toBeNull ()
importStatus: 'failed' }))).toBe ('failed')
expect (displayPostImportStatus (buildPostImportRow ({
importStatus: 'created',
createdPostId: 3 }))).toBeNull ()
createdPostId: 3 }))).toBe ('created')
})
})
+2 -1
ファイルの表示
@@ -16,7 +16,7 @@ import { creatableImportRows,
resultRowMessages,
resultSummaryCounts,
retryImportRow,
reviewSummaryCounts } from '@/lib/postImportSession'
reviewSummaryCounts } from '@/lib/postImportRows'
import { buildPostImportRow } from '@/test/postImportFactories'
describe ('post import row state', () => {
@@ -274,6 +274,7 @@ describe ('post import row state', () => {
thumbnailBase: '',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '2',
tags: 'edited-tag',
parentPostIds: '' },
true)
+4 -1
ファイルの表示
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import { countImportSourceLines, validateImportSource } from '@/lib/postImportSession'
import {
countImportSourceLines,
validateImportSource,
} from '@/lib/postImportSourceValidation'
describe ('post import source validation', () => {
it ('counts trimmed non-empty CRLF and LF rows', () => {
+15 -108
ファイルの表示
@@ -1,124 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanupExpiredPostImportSessions,
clearPostImportSourceDraft,
loadPostImportSession,
loadPostImportSourceDraft,
savePostImportSession,
savePostImportSourceDraft } from '@/lib/postImportSession'
import { buildPostImportRow } from '@/test/postImportFactories'
import {
clearPostImportSourceDraft,
loadPostImportSourceDraft,
savePostImportSourceDraft,
} from '@/lib/postImportStorage'
describe ('post import storage', () => {
describe ('post import source draft storage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.useRealTimers ()
vi.restoreAllMocks ()
})
it ('round-trips a valid session and source draft', () => {
const row = buildPostImportRow ({
attributes: { duration: '2.5' },
recoverable: true,
importStatus: 'pending',
validationErrors: { title: ['invalid'] } })
const skipped = buildPostImportRow ({
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 10 })
const manual = buildPostImportRow ({
sourceRow: 3,
skipReason: 'manual' })
expect (savePostImportSession ({
source: skipped.url,
rows: [row, skipped, manual],
repairMode: 'all' })).toBe (true)
expect (loadPostImportSession ()).toMatchObject ({
version: 2,
source: skipped.url,
rows: [{
attributes: { duration: '2.5' },
recoverable: true,
importStatus: 'pending' },
{
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 10 },
{
skipReason: 'manual' }] })
it ('round-trips and clears the URL list source draft', () => {
expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
expect (loadPostImportSourceDraft ()).toEqual ({ source: 'https://example.com' })
expect (loadPostImportSourceDraft ()).toEqual ({
source: 'https://example.com' })
clearPostImportSourceDraft ()
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
})
it ('rejects inconsistent post IDs and terminal statuses', () => {
const session = {
version: 2,
savedAt: new Date ().toISOString (),
source: '',
repairMode: 'all',
rows: [buildPostImportRow ()] }
const invalidRows = [
{ ...session.rows[0], skipReason: 'existing', existingPostId: undefined },
{ ...session.rows[0], existingPostId: 2, skipReason: undefined },
{ ...session.rows[0], importStatus: 'created', createdPostId: undefined },
{ ...session.rows[0], importStatus: 'failed', createdPostId: 3 },
{ ...session.rows[0], importStatus: 'created', recoverable: true },
{ ...session.rows[0], importStatus: 'skipped', recoverable: true },
{ ...session.rows[0], recoverable: true }]
it ('ignores malformed stored drafts', () => {
sessionStorage.setItem ('post-import-source-draft', '{')
for (const row of invalidRows)
{
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
...session,
rows: [row] }))
expect (loadPostImportSession ()).toBeNull ()
sessionStorage.clear ()
}
})
it ('rejects invalid attribute, provenance, tag-source, and snapshot data', () => {
const row = buildPostImportRow ()
const invalidRows = [
{ ...row, attributes: { title: [] } },
{ ...row, attributes: { unknown: 'value' } },
{ ...row, provenance: { title: 'mapped' } },
{ ...row, tagSources: { mapped: 'tag' } },
{ ...row, resetSnapshot: { ...row.resetSnapshot,
fieldWarnings: { title: 'warning' } } }]
invalidRows.forEach (invalidRow => {
sessionStorage.setItem ('post-import-session:current', JSON.stringify ({
version: 2,
savedAt: new Date ().toISOString (),
source: '',
rows: [invalidRow],
repairMode: 'all' }))
expect (loadPostImportSession ()).toBeNull ()
sessionStorage.clear ()
})
})
it ('keeps only the current fixed-key session and removes legacy prefixed entries', () => {
const current = {
version: 2,
savedAt: new Date ().toISOString (),
source: '',
rows: [buildPostImportRow ()],
repairMode: 'all' }
const expired = {
...current,
savedAt: new Date (Date.now () - 25 * 60 * 60 * 1000).toISOString () }
sessionStorage.setItem ('post-import-session:current', JSON.stringify (current))
sessionStorage.setItem ('post-import-session:expired', JSON.stringify (expired))
sessionStorage.setItem ('post-import-session:malformed', '{')
cleanupExpiredPostImportSessions ()
expect (sessionStorage.getItem ('post-import-session:current')).not.toBeNull ()
expect (sessionStorage.getItem ('post-import-session:expired')).toBeNull ()
expect (sessionStorage.getItem ('post-import-session:malformed')).toBeNull ()
expect (loadPostImportSourceDraft ()).toEqual ({ source: '' })
})
it ('reports storage access failures without throwing', () => {
+43
ファイルの表示
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
buildPostNewReviewPath,
hasPostNewReviewState,
isPostNewReviewPathWithinLimit,
parsePostNewReviewUrls,
postNewReviewPathByteLength,
} from '@/lib/postNewQueryState'
describe ('post new review URL state', () => {
it ('encodes each URL separately and preserves literal plus signs', () => {
const urls = [
'https://example.com/one+a',
'https://example.com/two?value=b+c']
const path = buildPostNewReviewPath (urls)
expect (path).toBe (
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
expect (parsePostNewReviewUrls (path.slice ('/posts/new'.length))).toEqual (urls)
})
it ('uses only the raw urls parameter as review state', () => {
expect (hasPostNewReviewState ('?session_id=old&meta=old')).toBe (false)
expect (hasPostNewReviewState ('?unknown=value&urls=')).toBe (true)
expect (parsePostNewReviewUrls ('?unknown=value&urls=one+two&meta=old'))
.toEqual (['one', 'two'])
})
it ('allows at most a 4095-byte request target', () => {
const baseUrl = 'https://example.com/'
const baseLength = postNewReviewPathByteLength ([baseUrl])
const allowed = `${ baseUrl }${ 'a'.repeat (4_095 - baseLength) }`
const denied = `${ allowed }a`
expect (postNewReviewPathByteLength ([allowed])).toBe (4_095)
expect (isPostNewReviewPathWithinLimit ([allowed])).toBe (true)
expect (postNewReviewPathByteLength ([denied])).toBe (4_096)
expect (isPostNewReviewPathWithinLimit ([denied])).toBe (false)
})
})
+1 -1
ファイルの表示
@@ -1,5 +1,5 @@
const POST_NEW_REVIEW_PATH_PREFIX = '/posts/new?urls='
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 6_144
const MAX_POST_NEW_REVIEW_TARGET_BYTES = 4_096
const textEncoder = new TextEncoder ()
+77
ファイルの表示
@@ -0,0 +1,77 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { renderWithProviders } from '@/test/render'
const GuardHarness = () => {
const [dirty, setDirty] = useState (true)
const location = useLocation ()
const navigate = useNavigate ()
const discard = vi.fn (() => setDirty (false))
const { allowNextNavigation } = useUnsavedChangesGuard ({
dirty,
onDiscard: discard })
return (
<>
<output aria-label="location">{location.pathname}{location.search}</output>
<button type="button" onClick={() => navigate ('/next?tab=one')}>move</button>
<button
type="button"
onClick={() => {
allowNextNavigation ()
navigate ('/allowed')
}}>
allowed move
</button>
</>)
}
describe ('useUnsavedChangesGuard', () => {
it ('blocks route changes and resets a cancelled transition', async () => {
renderWithProviders (<GuardHarness/>, { route: '/current' })
fireEvent.click (screen.getByRole ('button', { name: 'move' }))
expect (within (await screen.findByRole ('dialog')).getByText (
'変更が破棄してページ移動しますか?')).toBeInTheDocument ()
expect (screen.getByLabelText ('location')).toHaveTextContent ('/current')
fireEvent.click (screen.getByRole ('button', { name: '取消' }))
await waitFor (() => {
expect (screen.queryByRole ('dialog')).not.toBeInTheDocument ()
})
expect (screen.getByLabelText ('location')).toHaveTextContent ('/current')
})
it ('proceeds with the blocked transition after discard is confirmed', async () => {
renderWithProviders (<GuardHarness/>, { route: '/current' })
fireEvent.click (screen.getByRole ('button', { name: 'move' }))
fireEvent.click (await screen.findByRole ('button', {
name: '変更を破棄して移動' }))
await waitFor (() => {
expect (screen.getByLabelText ('location')).toHaveTextContent ('/next?tab=one')
})
})
it ('allows only the next navigation without leaving a bypass token', async () => {
renderWithProviders (<GuardHarness/>, { route: '/current' })
fireEvent.click (screen.getByRole ('button', { name: 'allowed move' }))
await waitFor (() => {
expect (screen.getByLabelText ('location')).toHaveTextContent ('/allowed')
})
fireEvent.click (screen.getByRole ('button', { name: 'move' }))
expect (within (await screen.findByRole ('dialog')).getByText (
'変更が破棄してページ移動しますか?')).toBeInTheDocument ()
expect (screen.getByLabelText ('location')).toHaveTextContent ('/allowed')
})
})
+34 -30
ファイルの表示
@@ -38,6 +38,7 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
const bypassNextNavigationRef = useRef<symbol | null> (null)
const [revision, setRevision] = useState (0)
const dialogueOpenRef = useRef (false)
const handlingBlockedTransitionRef = useRef (false)
const registerUnsavedChangesSource = useCallback ((
source: UnsavedChangesSource,
@@ -73,6 +74,8 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
[sources],
)
const hasUnsavedChanges = dirtySources.length > 0
const hasUnsavedChangesRef = useRef (hasUnsavedChanges)
hasUnsavedChangesRef.current = hasUnsavedChanges
const shouldBlock = useCallback (() => {
if (bypassNextNavigationRef.current != null)
{
@@ -80,11 +83,11 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
return false
}
return hasUnsavedChanges
}, [hasUnsavedChanges])
return hasUnsavedChangesRef.current
}, [])
const blocker = useBlocker (shouldBlock)
const discardDirtyChanges = useCallback (async (): Promise<boolean> => {
const confirmDiscardChanges = useCallback (async (): Promise<boolean> => {
if (!(hasUnsavedChanges))
return true
@@ -96,54 +99,55 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
try
{
const confirmed = await dialogue.confirm ({
title: '変更破棄してページ移動しますか?',
title: '変更破棄してページ移動しますか?',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false
try
{
for (const source of dirtySources)
await source.discard ()
}
catch
{
return false
}
return true
}
finally
{
dialogueOpenRef.current = false
}
}, [dialogue, dirtySources, hasUnsavedChanges])
}, [dialogue, hasUnsavedChanges])
useEffect (() => {
if (blocker.state !== 'blocked')
if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current)
return
let active = true
handlingBlockedTransitionRef.current = true
void (async () => {
const confirmed = await discardDirtyChanges ()
if (!(active))
return
try
{
const confirmed = await confirmDiscardChanges ()
if (!(confirmed))
{
blocker.reset ()
return
}
if (confirmed)
let discardResults: Array<void | Promise<void>>
try
{
blocker.proceed ()
return
discardResults = dirtySources.map (source => source.discard ())
}
catch
{
blocker.reset ()
return
}
blocker.reset ()
blocker.proceed ()
await Promise.allSettled (discardResults)
}
finally
{
handlingBlockedTransitionRef.current = false
}
}) ()
return () => {
active = false
}
}, [blocker, discardDirtyChanges])
}, [blocker, confirmDiscardChanges, dirtySources])
useEffect (() => {
if (!(hasUnsavedChanges))
-48
ファイルの表示
@@ -1,48 +0,0 @@
import { render, screen } from '@testing-library/react'
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'
import { describe, expect, it } from 'vitest'
describe ('legacy post import routes', () => {
it ('redirects /posts/import to /posts/new', () => {
render (
<MemoryRouter initialEntries={['/posts/import']}>
<Routes>
<Route path="/posts/import" element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
it ('redirects legacy review routes to /posts/new', () => {
render (
<MemoryRouter initialEntries={['/posts/import/legacy/review']}>
<Routes>
<Route
path="/posts/import/:sessionId/review"
element={<Navigate to="/posts/new" replace/>}/>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
it ('redirects legacy result routes to /posts', () => {
render (
<MemoryRouter initialEntries={['/posts/import/legacy/result']}>
<Routes>
<Route
path="/posts/import/:sessionId/result"
element={<Navigate to="/posts" replace/>}/>
<Route
path="/posts/new/:sessionId/result"
element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
</Routes>
</MemoryRouter>)
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
})
+134 -425
ファイルの表示
@@ -1,448 +1,157 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { loadPostImportSession, savePostImportSession } from '@/lib/postImportSession'
import PostImportReviewPage from '@/pages/posts/PostImportReviewPage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render'
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
import type { PostImportRow } from '@/lib/postImportSession'
import type { ReactNode } from 'react'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
form: vi.fn (() => Promise.resolve ()),
}))
isApiError: vi.fn (() => false) }))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/lib/dialogues/useDialogue', () => ({
default: () => dialogue,
}))
vi.mock ('framer-motion', () => ({
AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>,
motion: {
div: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
main: ({ children }: { children?: ReactNode }) => <main>{children}</main> } }))
const renderReviewPage = (route = '/posts/new/review') =>
render (
<HelmetProvider>
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/posts/new" element={<div>SOURCE ROUTE</div>}/>
<Route
path="/posts/new/review"
element={<PostImportReviewPage user={buildUser ()}/>}/>
<Route path="/posts" element={<div>POSTS ROUTE</div>}/>
</Routes>
</MemoryRouter>
</HelmetProvider>)
const metadata = (url: string, title: string) => ({
url,
title,
thumbnailBase: 'https://example.com/thumbnail.jpg',
originalCreatedFrom: '',
originalCreatedBefore: '',
duration: '',
tags: '',
parentPostIds: [],
fieldWarnings: { },
baseWarnings: [],
displayTags: [] })
const LocationProbe = () => {
const location = useLocation ()
return <output aria-label="current-location">{location.pathname}{location.search}</output>
}
const renderReviewPage = (urls: string[]) => {
const search = urls.map (url => encodeURIComponent (url)).join ('+')
return renderWithProviders (
<>
<PostImportReviewPage user={buildUser ()}/>
<LocationProbe/>
</>,
{ route: `/posts/new?urls=${ search }` })
}
describe ('PostImportReviewPage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
globalThis.URL.createObjectURL = vi.fn (() => 'blob:preview')
globalThis.URL.revokeObjectURL = vi.fn ()
api.apiGet.mockResolvedValue (new Blob (['img'], { type: 'image/png' }))
api.isApiError.mockReturnValue (false)
})
it ('redirects to /posts/new when no current work exists', async () => {
renderReviewPage ()
it ('fetches metadata with at most four concurrent requests', async () => {
const resolvers: Array<(value: ReturnType<typeof metadata>) => void> = []
api.apiGet.mockImplementation ((_path, options) =>
new Promise (resolve => {
resolvers.push (resolve)
const url = String (options.params.url)
void url
}))
const urls = Array.from (
{ length: 6 },
(_, index) => `https://example.com/${ index + 1 }`)
renderReviewPage (urls)
await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (4))
resolvers[0]?.(metadata (urls[0]!, 'first'))
expect (await screen.findAllByText ('first')).toHaveLength (2)
await waitFor (() => expect (api.apiGet).toHaveBeenCalledTimes (5))
expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
for (let index = 1; index < resolvers.length; ++index)
resolvers[index]?.(metadata (urls[index]!, `post ${ index + 1 }`))
})
it ('keeps successful rows when another metadata request fails', async () => {
api.apiGet
.mockResolvedValueOnce (metadata ('https://example.com/one', 'first'))
.mockRejectedValueOnce (new TypeError ('network'))
renderReviewPage (['https://example.com/one', 'https://example.com/two'])
expect (await screen.findAllByText ('first')).toHaveLength (2)
await waitFor (() => {
expect (screen.getAllByText ('登録不可')).toHaveLength (2)
})
expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
})
it ('shows existing posts with Active Storage thumbnail precedence', async () => {
api.apiGet.mockResolvedValue ({
...metadata ('https://example.com/post', ''),
existingPostId: 24,
existingPost: {
id: 24,
title: 'existing post',
url: 'https://example.com/post',
thumbnail: 'https://example.com/storage.jpg',
thumbnailBase: 'https://example.com/base.jpg' } })
renderReviewPage (['https://example.com/post'])
const disclosure = await screen.findByRole ('button', {
name: '既存投稿による自動スキップ 1件' })
expect (screen.getByRole ('button', { name: '追加' })).toBeDisabled ()
fireEvent.click (disclosure)
expect ((await screen.findAllByRole ('img', { name: 'サムネール' }))[0])
.toHaveAttribute ('src', 'https://example.com/storage.jpg')
})
it ('keeps a recoverable bulk failure on the review screen', async () => {
api.apiGet.mockResolvedValue (
metadata ('https://example.com/post', 'new post'))
api.apiPost.mockResolvedValue ({
results: [{
status: 'failed',
recoverable: true,
errors: { title: ['invalid title'] } }] })
renderReviewPage (['https://example.com/post'])
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
expect (await screen.findAllByText ('登録失敗')).toHaveLength (2)
expect (screen.getAllByText ('invalid title')).toHaveLength (2)
expect (screen.getAllByRole ('button', { name: '編輯' })[0]).toBeEnabled ()
expect (screen.getAllByRole ('button', { name: '再試行' })[0]).toBeEnabled ()
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts/new?')
})
it ('navigates to /posts after every submitted row is created', async () => {
api.apiGet.mockResolvedValue (
metadata ('https://example.com/post', 'new post'))
api.apiPost.mockResolvedValue ({
results: [{ status: 'created', post: { id: 42 } }] })
renderReviewPage (['https://example.com/post'])
fireEvent.click (await screen.findByRole ('button', { name: '追加' }))
await waitFor (() => {
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
expect (screen.getByLabelText ('current-location')).toHaveTextContent ('/posts')
})
})
it ('restores the current work on review reload', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'restored row' } })] })
renderReviewPage ()
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
expect (await screen.findByText ('restored row')).toBeInTheDocument ()
})
it ('does not expose メタデータ to the user', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
fieldWarnings: { title: ['自動取得に失敗しました.'] } })] })
renderReviewPage ()
expect (screen.queryByText (/メタデータ/)).not.toBeInTheDocument ()
})
it ('returns to /posts/new while keeping the current work', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'restored row' } })] })
renderReviewPage ()
fireEvent.click (await screen.findByRole ('button', { name: 'URL リスト入力へ戻る' }))
await waitFor (() => {
expect (screen.getByText ('SOURCE ROUTE')).toBeInTheDocument ()
})
expect (loadPostImportSession ()?.rows[0]?.attributes.title).toBe ('restored row')
})
it ('disables row editing and navigation while batch import is running', async () => {
let resolveValidation: ((value: { rows: PostImportRow[] }) => void) | null = null
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost.mockImplementationOnce (() =>
new Promise<{ rows: PostImportRow[] }> (resolve => {
resolveValidation = resolve
}))
api.apiPost.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 0,
rows: [] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByRole ('button', { name: '編輯' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: 'URL リスト入力へ戻る' })).toBeDisabled ()
expect (screen.getByRole ('button', { name: '取込実行' })).toBeDisabled ()
})
resolveValidation?.({ rows: [buildPostImportRow ({ sourceRow: 1 })] })
})
it ('does not call import when validation omits a requested source row', async () => {
savePostImportSession ({
source: 'https://example.com/post-1\nhttps://example.com/post-2',
repairMode: 'all',
rows: [
buildPostImportRow ({ sourceRow: 1 }),
buildPostImportRow ({ sourceRow: 2 })] })
api.apiPost.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (toastApi.toast).toHaveBeenCalledWith (
expect.objectContaining ({ title: '再検証結果が不完全でした' }))
})
expect (api.apiPost).toHaveBeenCalledTimes (1)
})
it ('navigates to /posts when all rows finish as created or skipped', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 1,
status: 'created',
post: { id: 1 } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
expect (loadPostImportSession ()).toBeNull ()
})
it ('stays on review when a recoverable failed row remains', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({ sourceRow: 1 })] })
.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 1,
rows: [{
sourceRow: 1,
status: 'failed',
recoverable: true,
errors: { title: ['invalid'] } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
expect (await screen.findByText ('invalid')).toBeInTheDocument ()
expect (screen.queryByText ('POSTS ROUTE')).not.toBeInTheDocument ()
})
it ('keeps edited recoverable rows in session before the next batch submit', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'old title', duration: '2' },
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title', duration: '2' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'edited title', duration: '2' },
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 0,
skipped: 0,
failed: 1,
rows: [{
sourceRow: 1,
status: 'failed',
recoverable: true,
errors: { title: ['invalid'] } }] })
dialogue.form.mockImplementationOnce (async options => {
let actions: DialogueFormAction[] = []
const controls: DialogueFormControls = {
close: vi.fn (),
confirm: vi.fn (),
setActions: next => {
actions = next
} }
render (options.body (controls))
await waitFor (() => expect (actions.length).toBe (2))
fireEvent.change (screen.getByDisplayValue ('old title'), {
target: { value: 'edited title' } })
await actions.find (action => action.label === '編輯内容を保存')?.onSelect ()
})
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '編輯' }))
await waitFor (() => {
const saved = loadPostImportSession ()
expect (saved?.rows[0]?.attributes.title).toBe ('edited title')
expect (saved?.rows[0]?.importStatus).toBe ('pending')
})
fireEvent.click (screen.getByRole ('button', { name: '取込実行' }))
await waitFor (() => {
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.title)
.toBe ('edited title')
expect (api.apiPost.mock.calls[1]?.[1]?.rows?.[0]?.attributes?.duration)
.toBe ('2')
})
})
it ('retries only failed rows and does not resend created or skipped rows', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1 }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'skipped',
skipReason: 'existing',
existingPostId: 2 }),
buildPostImportRow ({
sourceRow: 3,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 3,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 0,
skipped: 1,
failed: 0,
rows: [{
sourceRow: 3,
status: 'skipped',
existingPostId: 9 }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (api.apiPost.mock.calls[1]?.[1]?.rows).toEqual ([
expect.objectContaining ({ sourceRow: 3 })])
})
})
it ('allows ready rows to be manually skipped and excludes them from API payloads', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({ sourceRow: 1 })] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
expect (api.apiPost).not.toHaveBeenCalled ()
})
it ('restores values and errors when manual skip is cleared', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [buildPostImportRow ({
sourceRow: 1,
status: 'error',
attributes: { title: 'kept title' },
validationErrors: { title: ['invalid'] } })] })
renderReviewPage ()
const toggle = await screen.findByRole ('checkbox', { name: 'スキップ' })
fireEvent.click (toggle)
fireEvent.click (screen.getByRole ('checkbox', { name: 'スキップ' }))
expect (screen.getByText ('kept title')).toBeInTheDocument ()
expect (screen.getByText ('invalid')).toBeInTheDocument ()
})
it ('moves existing skipped rows into the collapsed section', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'all',
rows: [
buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'existing row' },
skipReason: 'existing',
existingPostId: 2 }),
buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'normal row' } })] })
renderReviewPage ()
expect (screen.getByText ('既存投稿による自動スキップ 1件')).toBeInTheDocument ()
expect (screen.queryByText ('existing row')).not.toBeInTheDocument ()
expect (screen.getByText ('normal row')).toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '既存投稿による自動スキップ 1件' }))
expect (screen.getByText ('existing row')).toBeInTheDocument ()
expect (screen.queryAllByRole ('button', { name: '編輯' })).toHaveLength (1)
expect (screen.queryAllByRole ('checkbox', { name: 'スキップ' })).toHaveLength (1)
})
it ('navigates to /posts when the last failed row completes', async () => {
savePostImportSession ({
source: 'https://example.com/post',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 1,
importStatus: 'created',
createdPostId: 1 }),
buildPostImportRow ({
sourceRow: 2,
importStatus: 'failed',
recoverable: true,
importErrors: { base: ['failed'] } })] })
api.apiPost
.mockResolvedValueOnce ({
rows: [buildPostImportRow ({
sourceRow: 2,
importStatus: 'pending',
recoverable: true })] })
.mockResolvedValueOnce ({
created: 1,
skipped: 0,
failed: 0,
rows: [{
sourceRow: 2,
status: 'created',
post: { id: 2 } }] })
renderReviewPage ()
fireEvent.click (screen.getByRole ('button', { name: '再試行' }))
await waitFor (() => {
expect (screen.getByText ('POSTS ROUTE')).toBeInTheDocument ()
})
})
it ('sorts recoverable pending validation rows to the top in repair mode', () => {
savePostImportSession ({
source: '',
repairMode: 'failed',
rows: [
buildPostImportRow ({
sourceRow: 2,
attributes: { title: 'normal row' } }),
buildPostImportRow ({
sourceRow: 1,
attributes: { title: 'repair row' },
importStatus: 'pending',
recoverable: true,
validationErrors: { title: ['invalid'] } })] })
const { container } = renderReviewPage ()
const titles = Array.from (container.querySelectorAll ('.line-clamp-2')).map (
node => node.textContent)
expect (titles[0]).toBe ('repair row')
expect (api.apiPost).toHaveBeenCalledWith ('/posts/bulk', expect.any (FormData))
})
})
+6 -5
ファイルの表示
@@ -169,16 +169,17 @@ const buildInitialRows = (urls: string[]): PostImportRow[] => {
urls.map ((url, index) => {
const sourceRow = index + 1
const rowIssues = issuesByRow[sourceRow] ?? []
const validationErrors: Record<string, string[]> =
rowIssues.length > 0
? { url: [...rowIssues] }
: { }
return {
sourceRow,
url,
attributes: emptyAttributes (),
fieldWarnings: { },
baseWarnings: [],
validationErrors:
rowIssues.length > 0
? { url: [...rowIssues] }
: { },
validationErrors,
provenance: emptyProvenance (),
tagSources: emptyTagSources (),
status: rowIssues.length > 0 ? 'error' : 'pending',
@@ -481,7 +482,7 @@ const mergePreviewRow = (
nextRow.attributes.tags = preview.tags ?? ''
nextRow.displayTags = cloneDisplayTags (preview.displayTags)
}
nextRow.tagSources.automatic = preview.tags ?? ''
nextRow.tagSources!.automatic = preview.tags ?? ''
if (currentRow.provenance.parentPostIds !== 'manual')
nextRow.attributes.parentPostIds = String (preview.parentPostIds ?? '')
if (currentRow.provenance.duration !== 'manual')
+22 -49
ファイルの表示
@@ -1,21 +1,12 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostImportSourcePage from '@/pages/posts/PostImportSourcePage'
import { buildUser } from '@/test/factories'
import { buildPostImportRow } from '@/test/postImportFactories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiPost: vi.fn (),
isApiError: vi.fn () }))
const router = vi.hoisted (() => ({ navigate: vi.fn () }))
const toastApi = vi.hoisted (() => ({ toast: vi.fn () }))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('react-router-dom', async importOriginal => ({
...await importOriginal<typeof import('react-router-dom')> (),
useNavigate: () => router.navigate }))
@@ -24,71 +15,53 @@ describe ('PostImportSourcePage', () => {
beforeEach (() => {
sessionStorage.clear ()
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('shows no empty error initially and validates only after Next is pressed', () => {
it ('validates an empty source only after Next is pressed', () => {
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
expect (screen.queryByText ('投稿インポート')).not.toBeInTheDocument ()
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
expect (api.apiPost).not.toHaveBeenCalled ()
expect (router.navigate).not.toHaveBeenCalled ()
})
it ('shows frontend URL issues with line numbers without requesting preview', () => {
it ('reports frontend URL issues with original line numbers', () => {
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
const input = screen.getByRole ('textbox', { name: '' })
fireEvent.change (input, {
target: { value: '\nftp://example.com/file\nhttps://example.com/valid' } })
expect (screen.queryByText (/2 行目/)).not.toBeInTheDocument ()
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (screen.getByText (/2 行目: HTTP または HTTPS/)).toBeInTheDocument ()
expect (screen.getByText ('ftp://example.com/file')).toBeInTheDocument ()
expect (input).toHaveAttribute ('aria-invalid', 'true')
expect (input.getAttribute ('aria-describedby')).toContain ('post-import-source-issues')
expect (api.apiPost).not.toHaveBeenCalled ()
})
it ('shows backend URL errors against original input without a session', async () => {
api.apiPost.mockResolvedValue ({
rows: [buildPostImportRow ({
sourceRow: 2,
url: 'https://example.com/canonical',
status: 'error',
validationErrors: { url: ['URL が重複しています.'] } })] })
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
target: { value: '\nhttps://example.com/original' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
expect (await screen.findByText ('2 行目: URL が重複しています.')).toBeInTheDocument ()
expect (screen.getAllByText ('https://example.com/original')).toHaveLength (2)
expect (router.navigate).not.toHaveBeenCalled ()
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index))).not.toContainEqual(
expect.stringMatching (/^post-import-session:/))
})
it ('navigates to /posts/new with query state after a successful preview', async () => {
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
it ('navigates with individually encoded URLs without calling an API', () => {
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
target: { value: 'https://example.com/post' } })
target: {
value: 'https://example.com/one+a\nhttps://example.com/two?value=b+c' } })
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
await waitFor (() => {
expect (router.navigate).toHaveBeenCalledWith (
expect.stringMatching (/^\/posts\/new\?/))
})
expect (Array.from ({ length: sessionStorage.length }, (_, index) =>
sessionStorage.key (index))).not.toContainEqual(
expect.stringMatching (/^post-import-session:/))
expect (router.navigate).toHaveBeenCalledWith (
'/posts/new?urls=https%3A%2F%2Fexample.com%2Fone%2Ba'
+ '+https%3A%2F%2Fexample.com%2Ftwo%3Fvalue%3Db%2Bc')
})
it ('disables Next when the encoded request target reaches 4096 bytes', () => {
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
const input = screen.getByRole ('textbox', { name: '' })
fireEvent.change (input, {
target: { value: `https://example.com/${ 'a'.repeat (4_100) }` } })
expect (screen.getByRole ('button', { name: '次へ' })).toBeDisabled ()
})
})
+9 -13
ファイルの表示
@@ -1,5 +1,4 @@
import { screen } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostNewPage from '@/pages/posts/PostNewPage'
@@ -7,6 +6,7 @@ import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (),
isApiError: vi.fn () }))
@@ -16,27 +16,23 @@ describe ('PostNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
api.apiGet.mockResolvedValue ({
url: 'https://example.com/post',
title: 'post',
tags: '' })
sessionStorage.clear ()
})
it ('shows the source page on /posts/new', () => {
renderWithProviders (
<MemoryRouter initialEntries={['/posts/new']}>
<Routes>
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>)
renderWithProviders (<PostNewPage user={buildUser ()}/>, {
route: '/posts/new' })
expect (screen.getByRole ('heading', { name: '広場に投稿を追加' })).toBeInTheDocument ()
})
it ('shows the review page when query state is present', () => {
renderWithProviders (
<MemoryRouter initialEntries={['/posts/new?url=https%3A%2F%2Fexample.com%2Fpost']}>
<Routes>
<Route path="/posts/new" element={<PostNewPage user={buildUser ()}/>}/>
</Routes>
</MemoryRouter>)
renderWithProviders (<PostNewPage user={buildUser ()}/>, {
route: '/posts/new?urls=https%3A%2F%2Fexample.com%2Fpost' })
expect (screen.getByRole ('heading', { name: '追加内容確認' })).toBeInTheDocument ()
expect (screen.queryByText ('広場に投稿を追加')).not.toBeInTheDocument ()
+4 -5
ファイルの表示
@@ -6,7 +6,6 @@ import { dateString } from '@/lib/utils'
import { buildTag, buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
import type { NicoTag } from '@/types'
const api = vi.hoisted (() => ({
@@ -27,10 +26,10 @@ const scrollIntoView = vi.fn ()
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue,
}))
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
...await importOriginal<
typeof import('@/components/dialogues/DialogueProvider')> (),
useDialogue: () => dialogue }))
const buildNicoTag = (values: Partial<NicoTag> = {}): NicoTag => ({
...buildTag (),
+4 -4
ファイルの表示
@@ -40,10 +40,10 @@ const postEmbed = vi.hoisted (() => ({
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
default: ({ children }: { children: ReactNode }) => <>{children}</>,
useDialogue: () => dialogue,
}))
vi.mock ('@/components/dialogues/DialogueProvider', async importOriginal => ({
...await importOriginal<
typeof import('@/components/dialogues/DialogueProvider')> (),
useDialogue: () => dialogue }))
vi.mock ('@/components/PostEmbed', () => ({
default: (props: {
ref?: { current: unknown }
+1 -1
ファイルの表示
@@ -1,4 +1,4 @@
import type { PostImportRow } from '@/lib/postImportSession'
import type { PostImportRow } from '@/lib/postImportTypes'
export const buildPostImportRow = (
overrides: Partial<PostImportRow> = {},
+33 -15
ファイルの表示
@@ -1,7 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import { createContext, useContext, useState } from 'react'
import { HelmetProvider } from 'react-helmet-async'
import { MemoryRouter } from 'react-router-dom'
import { createMemoryRouter, RouterProvider } from 'react-router-dom'
import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
@@ -13,6 +14,20 @@ type Options = {
route?: string
}
const TestContentContext = createContext<ReactNode> (null)
const TestRoute = () => {
const children = useContext (TestContentContext)
return (
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
{children}
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>)
}
export const renderWithProviders = (
ui: ReactElement,
options: Options = {},
@@ -22,20 +37,23 @@ export const renderWithProviders = (
queries: { retry: false } },
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<HelmetProvider>
<MemoryRouter initialEntries={[options.route ?? '/']}>
<DialogueProvider>
<UnsavedChangesGuardProvider>
<KeyboardShortcutsProvider>
{children}
</KeyboardShortcutsProvider>
</UnsavedChangesGuardProvider>
</DialogueProvider>
</MemoryRouter>
</HelmetProvider>
</QueryClientProvider>)
const Wrapper = ({ children }: { children: ReactNode }) => {
const [router] = useState (() => createMemoryRouter ([{
path: '*',
element: <TestRoute/> }], {
initialEntries: [options.route ?? '/'] }))
return (
<QueryClientProvider client={queryClient}>
<HelmetProvider>
<DialogueProvider>
<TestContentContext.Provider value={children}>
<RouterProvider router={router}/>
</TestContentContext.Provider>
</DialogueProvider>
</HelmetProvider>
</QueryClientProvider>)
}
return {
queryClient,