このコミットが含まれているのは:
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { menuOutline } from '@/components/TopNav'
|
||||
import { buildUser } from '@/test/factories'
|
||||
|
||||
const submenuItem = (role: 'guest' | 'member' | 'admin', section: string, item: string) => {
|
||||
const menu = menuOutline ({
|
||||
user: buildUser ({ role }),
|
||||
wikiId: section === 'Wiki' ? 10 : null,
|
||||
pathName: section === 'Wiki' ? '/wiki/page' : '/posts' })
|
||||
return menu.find (_1 => _1.name === section)?.subMenu.find (_1 => _1.name === item)
|
||||
}
|
||||
|
||||
describe ('menuOutline', () => {
|
||||
it ('uses content-edit permission for post, material, and Wiki actions', () => {
|
||||
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 ('keeps material suppression admin-only', () => {
|
||||
expect (submenuItem ('member', '素材', '抑止')?.visible).toBe (false)
|
||||
expect (submenuItem ('admin', '素材', '抑止')?.visible).toBe (true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
|
||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
|
||||
const FormBody = (
|
||||
{ controls,
|
||||
onSelect }: { controls: DialogueFormControls
|
||||
onSelect: () => Promise<boolean> | boolean },
|
||||
) => {
|
||||
useEffect (() => {
|
||||
controls.setActions ([{
|
||||
label: '左操作',
|
||||
placement: 'start',
|
||||
onSelect }, {
|
||||
label: '保存',
|
||||
onSelect: () => true }])
|
||||
}, [controls, onSelect])
|
||||
|
||||
return <div>長いフォーム本文</div>
|
||||
}
|
||||
|
||||
const NestedConfirmFormBody = ({ controls }: { controls: DialogueFormControls }) => {
|
||||
const reset = useCallback (async () => {
|
||||
await controls.confirm ({
|
||||
title: '変更をリセットしますか?',
|
||||
confirmText: 'リセット' })
|
||||
return false
|
||||
}, [controls])
|
||||
|
||||
return <FormBody controls={controls} onSelect={reset}/>
|
||||
}
|
||||
|
||||
describe ('DialogueProvider', () => {
|
||||
it ('keeps ordinary dialogues in FIFO order', async () => {
|
||||
const Launcher = () => {
|
||||
const dialogue = useDialogue ()
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
void dialogue.confirm ({ title: '一件目' })
|
||||
void dialogue.confirm ({ title: '二件目' })
|
||||
}}>
|
||||
開く
|
||||
</button>)
|
||||
}
|
||||
|
||||
render (<DialogueProvider><Launcher/></DialogueProvider>)
|
||||
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
|
||||
|
||||
expect (screen.getByText ('一件目')).toBeInTheDocument ()
|
||||
expect (screen.queryByText ('二件目')).not.toBeInTheDocument ()
|
||||
fireEvent.click (screen.getByRole ('button', { name: '確定' }))
|
||||
expect (await screen.findByText ('二件目')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('keeps a large form open when an action returns false', async () => {
|
||||
const action = vi.fn ().mockResolvedValue (false)
|
||||
const Launcher = () => {
|
||||
const dialogue = useDialogue ()
|
||||
return (
|
||||
<button
|
||||
onClick={() => void dialogue.form ({
|
||||
title: '投稿を編輯',
|
||||
description: '説明',
|
||||
size: 'large',
|
||||
body: controls => <FormBody controls={controls} onSelect={action}/> })}>
|
||||
開く
|
||||
</button>)
|
||||
}
|
||||
|
||||
render (<DialogueProvider><Launcher/></DialogueProvider>)
|
||||
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
|
||||
|
||||
const dialogue = screen.getByRole ('dialog')
|
||||
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')
|
||||
fireEvent.click (screen.getByRole ('button', { name: '左操作' }))
|
||||
|
||||
await waitFor (() => expect (action).toHaveBeenCalledTimes (1))
|
||||
expect (screen.getByText ('投稿を編輯')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('opens a nested confirmation over a form and returns to the same form', async () => {
|
||||
const Launcher = () => {
|
||||
const dialogue = useDialogue ()
|
||||
return (
|
||||
<button
|
||||
onClick={() => void dialogue.form ({
|
||||
title: '投稿を編輯',
|
||||
body: controls => <NestedConfirmFormBody controls={controls}/> })}>
|
||||
開く
|
||||
</button>)
|
||||
}
|
||||
|
||||
render (<DialogueProvider><Launcher/></DialogueProvider>)
|
||||
fireEvent.click (screen.getByRole ('button', { name: '開く' }))
|
||||
const action = await screen.findByRole ('button', { name: '左操作' })
|
||||
fireEvent.click (action)
|
||||
|
||||
expect (await screen.findByText ('変更をリセットしますか?')).toBeInTheDocument ()
|
||||
fireEvent.click (screen.getByRole ('button', { name: 'リセット' }))
|
||||
await waitFor (() => {
|
||||
expect (screen.queryByText ('変更をリセットしますか?')).not.toBeInTheDocument ()
|
||||
})
|
||||
expect (screen.getByText ('投稿を編輯')).toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostImportRowForm from '@/components/posts/import/PostImportRowForm'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
|
||||
import type { DialogueFormAction, DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||
|
||||
describe ('PostImportRowForm', () => {
|
||||
it ('resets only the draft, then saves with resetRequested', async () => {
|
||||
const row = buildPostImportRow ()
|
||||
row.attributes.title = 'manual title'
|
||||
row.provenance.title = 'manual'
|
||||
const actions: DialogueFormAction[][] = []
|
||||
const controls: DialogueFormControls = {
|
||||
close: vi.fn (),
|
||||
confirm: vi.fn ().mockResolvedValue (true),
|
||||
setActions: next => actions.push (next) }
|
||||
const invalidRow = buildPostImportRow ({
|
||||
validationErrors: { title: ['タイトルを確認してください.'] } })
|
||||
const onSave = vi.fn ().mockResolvedValue ({ saved: false, row: invalidRow })
|
||||
|
||||
render (<PostImportRowForm row={row} controls={controls} onSave={onSave}/>)
|
||||
const titleInput = screen.getByDisplayValue ('manual title')
|
||||
|
||||
await waitFor (() => expect (actions.at (-1)?.length).toBe (2))
|
||||
const reset = actions.at (-1)?.find (_1 => _1.label === '変更をリセット')
|
||||
expect (reset).toMatchObject ({ placement: 'start', variant: 'danger', disabled: false })
|
||||
await act (async () => {
|
||||
await reset?.onSelect ()
|
||||
})
|
||||
|
||||
expect (controls.confirm).toHaveBeenCalled ()
|
||||
expect (titleInput).toHaveValue ('')
|
||||
expect (onSave).not.toHaveBeenCalled ()
|
||||
|
||||
const save = actions.at (-1)?.find (_1 => _1.label === '編輯内容を保存')
|
||||
await act (async () => {
|
||||
await save?.onSelect ()
|
||||
})
|
||||
|
||||
expect (onSave).toHaveBeenCalledWith ({
|
||||
draft: expect.objectContaining ({ title: '' }),
|
||||
resetRequested: true })
|
||||
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('does not reset the draft when confirmation is cancelled', async () => {
|
||||
const row = buildPostImportRow ({ attributes: { title: 'manual title' } })
|
||||
let actions: DialogueFormAction[] = []
|
||||
const controls: DialogueFormControls = {
|
||||
close: vi.fn (),
|
||||
confirm: vi.fn ().mockResolvedValue (false),
|
||||
setActions: next => {
|
||||
actions = next
|
||||
} }
|
||||
|
||||
render (
|
||||
<PostImportRowForm
|
||||
row={row}
|
||||
controls={controls}
|
||||
onSave={vi.fn ()}/>)
|
||||
await waitFor (() => expect (actions.length).toBe (2))
|
||||
|
||||
await act (async () => {
|
||||
await actions.find (_1 => _1.label === '変更をリセット')?.onSelect ()
|
||||
})
|
||||
|
||||
expect (screen.getByDisplayValue ('manual title')).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('marks edited fields and areas invalid from field errors', () => {
|
||||
const row = buildPostImportRow ({
|
||||
validationErrors: { url: ['URL error'], tags: ['tag error'] },
|
||||
importErrors: { duration: ['duration error'] },
|
||||
fieldWarnings: { title: ['title warning'] } })
|
||||
|
||||
render (
|
||||
<PostImportRowForm
|
||||
row={row}
|
||||
controls={{ close: vi.fn (), confirm: vi.fn (), setActions: vi.fn () }}
|
||||
onSave={vi.fn ()}/>)
|
||||
|
||||
expect (screen.getByText ('URL error')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('tag error')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('duration error')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('title warning')).toBeInTheDocument ()
|
||||
expect (screen.getAllByRole ('textbox').filter (
|
||||
_1 => _1.getAttribute ('aria-invalid') === 'true')).toHaveLength (3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
|
||||
describe ('displayPostImportStatus', () => {
|
||||
it ('shows only ready, warning, and skipped states', () => {
|
||||
expect (displayPostImportStatus (buildPostImportRow ())).toBe ('ready')
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
status: 'warning',
|
||||
fieldWarnings: { title: ['warning'] } }))).toBe ('warning')
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
skipReason: 'existing',
|
||||
existingPostId: 2 }))).toBe ('skipped')
|
||||
})
|
||||
|
||||
it ('does not expose validation, failure, or created states as badges', () => {
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
status: 'error',
|
||||
validationErrors: { title: ['invalid'] } }))).toBeNull ()
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
importStatus: 'failed' }))).toBeNull ()
|
||||
expect (displayPostImportStatus (buildPostImportRow ({
|
||||
importStatus: 'created',
|
||||
createdPostId: 3 }))).toBeNull ()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { creatableImportRows,
|
||||
initialisePreviewRows,
|
||||
mergeImportResults,
|
||||
mergeValidatedImportRows,
|
||||
processableImportRows,
|
||||
resultSummaryCounts,
|
||||
retryImportRow,
|
||||
reviewSummaryCounts } from '@/lib/postImportSession'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
|
||||
describe ('post import row state', () => {
|
||||
it ('separates processable existing rows from creatable rows', () => {
|
||||
const ready = buildPostImportRow ({ sourceRow: 1 })
|
||||
const existing = buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
skipReason: 'existing',
|
||||
existingPostId: 20 })
|
||||
const invalid = buildPostImportRow ({
|
||||
sourceRow: 3,
|
||||
status: 'error',
|
||||
validationErrors: { url: ['invalid'] } })
|
||||
const created = buildPostImportRow ({
|
||||
sourceRow: 4,
|
||||
importStatus: 'created',
|
||||
createdPostId: 40 })
|
||||
const rows = [ready, existing, invalid, created]
|
||||
|
||||
expect (processableImportRows (rows)).toEqual ([ready, existing])
|
||||
expect (creatableImportRows (rows)).toEqual ([ready])
|
||||
expect (reviewSummaryCounts (rows)).toEqual ({
|
||||
total: 4,
|
||||
submittable: 1,
|
||||
skipPlanned: 1 })
|
||||
})
|
||||
|
||||
it ('preserves terminal rows while merging validation results', () => {
|
||||
const created = buildPostImportRow ({
|
||||
sourceRow: 1,
|
||||
importStatus: 'created',
|
||||
createdPostId: 10,
|
||||
attributes: { title: 'created title' } })
|
||||
const pending = buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
fieldWarnings: { title: ['old warning'] },
|
||||
provenance: { title: 'manual' },
|
||||
attributes: { title: 'manual title' } })
|
||||
const validated = [
|
||||
buildPostImportRow ({ sourceRow: 1, attributes: { title: 'changed' } }),
|
||||
buildPostImportRow ({
|
||||
sourceRow: 2,
|
||||
fieldWarnings: { title: ['fetch warning'], tags: ['tag warning'] },
|
||||
provenance: { title: 'manual' },
|
||||
attributes: { title: 'manual title' } })]
|
||||
|
||||
const result = mergeValidatedImportRows ([created, pending], validated)
|
||||
|
||||
expect (result[0]).toBe (created)
|
||||
expect (result[1]?.fieldWarnings).toEqual ({ tags: ['tag warning'] })
|
||||
})
|
||||
|
||||
it ('updates the reset snapshot only after metadata URL changes', () => {
|
||||
const current = buildPostImportRow ({
|
||||
attributes: { title: 'manual title' },
|
||||
metadataUrl: 'https://example.com/old' })
|
||||
const validated = buildPostImportRow ({
|
||||
attributes: { title: 'new metadata title' },
|
||||
metadataUrl: 'https://example.com/new',
|
||||
fieldWarnings: { title: ['warning'] },
|
||||
baseWarnings: ['base warning'] })
|
||||
|
||||
const result = mergeValidatedImportRows ([current], [validated])[0]
|
||||
|
||||
expect (result?.resetSnapshot).toMatchObject ({
|
||||
attributes: { title: 'new metadata title' },
|
||||
fieldWarnings: { title: ['warning'] },
|
||||
baseWarnings: ['base warning'],
|
||||
metadataUrl: 'https://example.com/new' })
|
||||
})
|
||||
|
||||
it ('merges result states and clears incompatible post identifiers', () => {
|
||||
const created = mergeImportResults ([buildPostImportRow ({
|
||||
skipReason: 'existing',
|
||||
existingPostId: 2 })], [{
|
||||
sourceRow: 1,
|
||||
status: 'created',
|
||||
post: { id: 3 } }])[0]
|
||||
const skipped = mergeImportResults ([buildPostImportRow ({
|
||||
createdPostId: 3,
|
||||
importStatus: 'created' })], [{
|
||||
sourceRow: 1,
|
||||
status: 'skipped',
|
||||
existingPostId: 4 }])[0]
|
||||
const failed = mergeImportResults ([buildPostImportRow ({
|
||||
skipReason: 'existing',
|
||||
existingPostId: 4 })], [{
|
||||
sourceRow: 1,
|
||||
status: 'failed',
|
||||
errors: { base: ['failure'] } }])[0]
|
||||
|
||||
expect (created).toMatchObject ({
|
||||
importStatus: 'created',
|
||||
createdPostId: 3,
|
||||
existingPostId: undefined,
|
||||
skipReason: undefined })
|
||||
expect (skipped).toMatchObject ({
|
||||
importStatus: 'skipped',
|
||||
existingPostId: 4,
|
||||
createdPostId: undefined,
|
||||
skipReason: 'existing' })
|
||||
expect (failed).toMatchObject ({
|
||||
importStatus: 'failed',
|
||||
createdPostId: undefined,
|
||||
existingPostId: undefined,
|
||||
skipReason: undefined,
|
||||
importErrors: { base: ['failure'] } })
|
||||
})
|
||||
|
||||
it ('retries only the selected failed row and counts results exclusively', () => {
|
||||
const rows = [
|
||||
buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 1 }),
|
||||
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
|
||||
existingPostId: 2, skipReason: 'existing' }),
|
||||
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed',
|
||||
importErrors: { base: ['failed'] } })]
|
||||
|
||||
expect (resultSummaryCounts (rows)).toEqual ({ created: 1, skipped: 1, failed: 1 })
|
||||
expect (retryImportRow (rows, 3)[2]).toMatchObject ({
|
||||
importStatus: 'pending',
|
||||
importErrors: undefined })
|
||||
})
|
||||
|
||||
it ('copies reset snapshot values instead of sharing mutable records', () => {
|
||||
const row = buildPostImportRow ({ fieldWarnings: { title: ['warning'] } })
|
||||
const initialised = initialisePreviewRows ([row])[0]
|
||||
expect (initialised).toBeDefined ()
|
||||
if (initialised == null)
|
||||
return
|
||||
|
||||
initialised.attributes.title = 'changed'
|
||||
initialised.fieldWarnings.title?.push ('another')
|
||||
|
||||
expect (initialised.resetSnapshot.attributes.title).toBe ('')
|
||||
expect (initialised.resetSnapshot.fieldWarnings.title).toEqual (['warning'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { countImportSourceLines, validateImportSource } from '@/lib/postImportSession'
|
||||
|
||||
describe ('post import source validation', () => {
|
||||
it ('counts trimmed non-empty CRLF and LF rows', () => {
|
||||
expect (countImportSourceLines (' one \r\n\r\n two\n')).toBe (2)
|
||||
})
|
||||
|
||||
it ('reports invalid protocols and malformed URLs with original line numbers', () => {
|
||||
const issues = validateImportSource (
|
||||
'\nftp://example.com/file\nhttps://exa mple.com/path')
|
||||
|
||||
expect (issues).toEqual ([
|
||||
{
|
||||
sourceRow: 2,
|
||||
message: 'HTTP または HTTPS の URL ではありません.',
|
||||
url: 'ftp://example.com/file' },
|
||||
{
|
||||
sourceRow: 3,
|
||||
message: 'URL の形式が不正です.',
|
||||
url: 'https://exa mple.com/path' }])
|
||||
})
|
||||
|
||||
it ('detects duplicates after frontend URL normalisation', () => {
|
||||
const issues = validateImportSource (
|
||||
'https://EXAMPLE.com/path/\nhttps://example.com/path')
|
||||
|
||||
expect (issues).toEqual ([{
|
||||
sourceRow: 2,
|
||||
message: '1 行目と同じ URL です.',
|
||||
url: 'https://example.com/path' }])
|
||||
})
|
||||
|
||||
it ('rejects oversized URLs and rows beyond the maximum count', () => {
|
||||
const oversized = `https://example.com/${ 'a'.repeat (20 * 1024) }`
|
||||
const tooMany = Array.from (
|
||||
{ length: 101 },
|
||||
(_, index) => `https://example.com/${ index }`).join ('\n')
|
||||
|
||||
expect (validateImportSource (oversized)[0]).toMatchObject ({
|
||||
sourceRow: 1,
|
||||
message: 'URL が長すぎます.' })
|
||||
expect (validateImportSource (tooMany).at (-1)).toEqual ({
|
||||
sourceRow: 101,
|
||||
message: '取込件数は 100 件までです.',
|
||||
url: 'https://example.com/100' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { cleanupExpiredPostImportSessions,
|
||||
clearPostImportSourceDraft,
|
||||
loadPostImportSession,
|
||||
loadPostImportSourceDraft,
|
||||
savePostImportSession,
|
||||
savePostImportSourceDraft } from '@/lib/postImportSession'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
|
||||
describe ('post import storage', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.useRealTimers ()
|
||||
})
|
||||
|
||||
it ('round-trips a valid session and source draft', () => {
|
||||
const row = buildPostImportRow ({
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 })
|
||||
|
||||
expect (savePostImportSession ('session', {
|
||||
source: row.url,
|
||||
rows: [row],
|
||||
repairMode: 'all' })).toBe (true)
|
||||
expect (loadPostImportSession ('session')).toMatchObject ({
|
||||
version: 2,
|
||||
source: row.url,
|
||||
rows: [{
|
||||
importStatus: 'skipped',
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 }] })
|
||||
|
||||
expect (savePostImportSourceDraft ('https://example.com')).toBe (true)
|
||||
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 }]
|
||||
|
||||
for (const [index, row] of invalidRows.entries ())
|
||||
{
|
||||
sessionStorage.setItem (`post-import-session:invalid-${ index }`, JSON.stringify ({
|
||||
...session,
|
||||
rows: [row] }))
|
||||
expect (loadPostImportSession (`invalid-${ index }`)).toBeNull ()
|
||||
}
|
||||
})
|
||||
|
||||
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, index) => {
|
||||
sessionStorage.setItem (`post-import-session:shape-${ index }`, JSON.stringify ({
|
||||
version: 2,
|
||||
savedAt: new Date ().toISOString (),
|
||||
source: '',
|
||||
rows: [invalidRow],
|
||||
repairMode: 'all' }))
|
||||
expect (loadPostImportSession (`shape-${ index }`)).toBeNull ()
|
||||
})
|
||||
})
|
||||
|
||||
it ('removes expired and malformed sessions without touching current sessions', () => {
|
||||
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 ()
|
||||
})
|
||||
|
||||
it ('reports storage access failures without throwing', () => {
|
||||
const onError = vi.fn ()
|
||||
vi.spyOn (Storage.prototype, 'setItem').mockImplementation (() => {
|
||||
throw new DOMException ('quota')
|
||||
})
|
||||
|
||||
expect (savePostImportSourceDraft ('source', onError)).toBe (false)
|
||||
expect (onError).toHaveBeenCalledWith ('ブラウザへ保存できませんでした.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { savePostImportSession } from '@/lib/postImportSession'
|
||||
import PostImportResultPage from '@/pages/posts/PostImportResultPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { buildPostImportRow } from '@/test/postImportFactories'
|
||||
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)
|
||||
|
||||
const ReviewLocation = () => {
|
||||
const location = useLocation ()
|
||||
return <div>{`review route ${ location.search }`}</div>
|
||||
}
|
||||
|
||||
const renderPage = () => renderWithProviders (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/result"
|
||||
element={<PostImportResultPage user={buildUser ()}/>}/>
|
||||
<Route path="/posts/import/:sessionId/review" element={<ReviewLocation/>}/>
|
||||
</Routes>,
|
||||
{ route: '/posts/import/session/result' })
|
||||
|
||||
describe ('PostImportResultPage', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.clearAllMocks ()
|
||||
})
|
||||
|
||||
it ('shows exclusive result counts, post links, and failed-row actions', async () => {
|
||||
const rows = [
|
||||
buildPostImportRow ({ sourceRow: 1, importStatus: 'created', createdPostId: 11 }),
|
||||
buildPostImportRow ({ sourceRow: 2, importStatus: 'skipped',
|
||||
skipReason: 'existing', existingPostId: 22 }),
|
||||
buildPostImportRow ({ sourceRow: 3, importStatus: 'failed',
|
||||
importErrors: { base: ['登録中に失敗しました.'] } })]
|
||||
savePostImportSession ('session', { source: '', rows, repairMode: 'all' })
|
||||
|
||||
renderPage ()
|
||||
|
||||
expect (await screen.findByText (
|
||||
/登録成功\s*1件.*スキップ\s*1件.*失敗\s*1件/)).toBeInTheDocument ()
|
||||
expect (screen.getAllByRole ('link', { name: '投稿を開く' })
|
||||
.map (_1 => _1.getAttribute ('href'))).toEqual (['/posts/11', '/posts/22'])
|
||||
expect (screen.getByText ('登録中に失敗しました.')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('button', { name: '編輯' })).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('button', { name: '再試行' })).toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('returns a retry validation error to the review dialogue route', async () => {
|
||||
const failed = buildPostImportRow ({
|
||||
importStatus: 'failed',
|
||||
importErrors: { base: ['old error'] } })
|
||||
const invalid = buildPostImportRow ({
|
||||
importStatus: 'pending',
|
||||
status: 'error',
|
||||
validationErrors: { title: ['タイトルを確認してください.'] } })
|
||||
savePostImportSession ('session', {
|
||||
source: failed.url,
|
||||
rows: [failed],
|
||||
repairMode: 'all' })
|
||||
api.apiPost.mockResolvedValue ({ rows: [invalid] })
|
||||
|
||||
renderPage ()
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '再試行' }))
|
||||
|
||||
expect (await screen.findByText ('review route ?edit=1')).toBeInTheDocument ()
|
||||
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||
const saved = JSON.parse (
|
||||
sessionStorage.getItem ('post-import-session:session') ?? '{}')
|
||||
expect (saved.rows[0]).toMatchObject ({
|
||||
importStatus: 'pending',
|
||||
validationErrors: { title: ['タイトルを確認してください.'] } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { 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'
|
||||
|
||||
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)
|
||||
|
||||
const renderPage = () => renderWithProviders (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/posts/import/:sessionId/review"
|
||||
element={<PostImportReviewPage user={buildUser ()}/>}/>
|
||||
<Route path="/posts/import/:sessionId/result" element={<div>result route</div>}/>
|
||||
</Routes>,
|
||||
{ route: '/posts/import/session/review' })
|
||||
|
||||
describe ('PostImportReviewPage', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.clearAllMocks ()
|
||||
})
|
||||
|
||||
it ('keeps the review route and opens the first invalid row dialogue', async () => {
|
||||
const row = buildPostImportRow ({ attributes: { title: 'title' } })
|
||||
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
|
||||
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ({
|
||||
attributes: { title: 'title' },
|
||||
status: 'error',
|
||||
validationErrors: { title: ['タイトルを確認してください.'] } })] })
|
||||
|
||||
renderPage ()
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '取込実行' }))
|
||||
|
||||
expect (await screen.findByText ('投稿を編輯')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('タイトルを確認してください.')).toBeInTheDocument ()
|
||||
expect (api.apiPost).toHaveBeenCalledTimes (1)
|
||||
expect (screen.queryByText ('result route')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('submits existing rows for a formal skipped result and opens result route', async () => {
|
||||
const row = buildPostImportRow ({
|
||||
skipReason: 'existing',
|
||||
existingPostId: 10 })
|
||||
savePostImportSession ('session', { source: row.url, rows: [row], repairMode: 'all' })
|
||||
api.apiPost
|
||||
.mockResolvedValueOnce ({ rows: [row] })
|
||||
.mockResolvedValueOnce ({
|
||||
created: 0,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
rows: [{ sourceRow: 1, status: 'skipped', existingPostId: 10 }] })
|
||||
|
||||
renderPage ()
|
||||
fireEvent.click (await screen.findByRole ('button', { name: '取込実行' }))
|
||||
|
||||
expect (await screen.findByText ('result route')).toBeInTheDocument ()
|
||||
expect (api.apiPost).toHaveBeenCalledTimes (2)
|
||||
expect (api.apiPost.mock.calls[1]?.[1]).toMatchObject ({
|
||||
rows: [expect.objectContaining ({ sourceRow: 1, url: row.url })] })
|
||||
await waitFor (() => {
|
||||
const saved = JSON.parse (
|
||||
sessionStorage.getItem ('post-import-session:session') ?? '{}')
|
||||
expect (saved.rows[0]).toMatchObject ({
|
||||
importStatus: 'skipped',
|
||||
existingPostId: 10,
|
||||
skipReason: 'existing' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { fireEvent, screen, waitFor } 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 }))
|
||||
|
||||
describe ('PostImportSourcePage', () => {
|
||||
beforeEach (() => {
|
||||
sessionStorage.clear ()
|
||||
vi.clearAllMocks ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
})
|
||||
|
||||
it ('shows no empty error initially and validates only after Next is pressed', () => {
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
|
||||
expect (screen.queryByText ('URL を入力してください.')).not.toBeInTheDocument ()
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
expect (screen.getByText ('URL を入力してください.')).toBeInTheDocument ()
|
||||
expect (api.apiPost).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('shows frontend URL issues with line numbers without requesting preview', () => {
|
||||
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 ('stores a successful preview before navigating to the review route', async () => {
|
||||
api.apiPost.mockResolvedValue ({ rows: [buildPostImportRow ()] })
|
||||
renderWithProviders (<PostImportSourcePage user={buildUser ()}/>)
|
||||
|
||||
fireEvent.change (screen.getByRole ('textbox', { name: '' }), {
|
||||
target: { value: 'https://example.com/post' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '次へ' }))
|
||||
|
||||
await waitFor (() => {
|
||||
expect (router.navigate).toHaveBeenCalledWith (
|
||||
expect.stringMatching (/^\/posts\/import\/[^/]+\/review$/))
|
||||
})
|
||||
const sessionKeys = Array.from ({ length: sessionStorage.length }, (_, index) =>
|
||||
sessionStorage.key (index)).filter (_1 => _1?.startsWith ('post-import-session:'))
|
||||
expect (sessionKeys).toHaveLength (1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { PostImportRow } from '@/lib/postImportSession'
|
||||
|
||||
export const buildPostImportRow = (
|
||||
overrides: Partial<PostImportRow> = {},
|
||||
): PostImportRow => {
|
||||
const attributes = {
|
||||
title: '',
|
||||
thumbnailBase: '',
|
||||
originalCreatedFrom: '',
|
||||
originalCreatedBefore: '',
|
||||
duration: '',
|
||||
tags: '',
|
||||
parentPostIds: '',
|
||||
...overrides.attributes }
|
||||
const provenance = {
|
||||
url: 'manual' as const,
|
||||
title: 'automatic' as const,
|
||||
thumbnailBase: 'automatic' as const,
|
||||
originalCreatedFrom: 'automatic' as const,
|
||||
originalCreatedBefore: 'automatic' as const,
|
||||
duration: 'automatic' as const,
|
||||
tags: 'automatic' as const,
|
||||
parentPostIds: 'automatic' as const,
|
||||
...overrides.provenance }
|
||||
const fieldWarnings = { ...overrides.fieldWarnings }
|
||||
const baseWarnings = [...(overrides.baseWarnings ?? [])]
|
||||
const tagSources = {
|
||||
automatic: overrides.tagSources?.automatic ?? '',
|
||||
manual: overrides.tagSources?.manual ?? '' }
|
||||
const url = overrides.url ?? 'https://example.com/post'
|
||||
|
||||
return {
|
||||
...overrides,
|
||||
sourceRow: overrides.sourceRow ?? 1,
|
||||
url,
|
||||
attributes,
|
||||
fieldWarnings,
|
||||
baseWarnings,
|
||||
validationErrors: overrides.validationErrors ?? {},
|
||||
provenance,
|
||||
tagSources,
|
||||
status: overrides.status ?? 'ready',
|
||||
resetSnapshot: overrides.resetSnapshot ?? {
|
||||
url,
|
||||
attributes: { ...attributes },
|
||||
provenance: { ...provenance },
|
||||
tagSources: { ...tagSources },
|
||||
fieldWarnings: Object.fromEntries (
|
||||
Object.entries (fieldWarnings).map (([key, values]) => [key, [...values]])),
|
||||
baseWarnings: [...baseWarnings],
|
||||
metadataUrl: overrides.metadataUrl } }
|
||||
}
|
||||
新しい課題から参照
ユーザをブロックする