このコミットが含まれているのは:
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
isApiError: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
|
||||
describe ('extractValidationError', () => {
|
||||
it ('extracts field and base errors from 422 validation responses', async () => {
|
||||
api.isApiError.mockReturnValueOnce (true)
|
||||
|
||||
const { extractValidationError } = await import ('@/lib/apiErrors')
|
||||
const validationError = extractValidationError<'name'> ({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
type: 'validation_error',
|
||||
message: '入力内容を確認してください.',
|
||||
errors: { name: ['名前は必須です.'] },
|
||||
base_errors: ['全体エラー'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect (validationError).toEqual ({
|
||||
message: '入力内容を確認してください.',
|
||||
fieldErrors: { name: ['名前は必須です.'] },
|
||||
baseErrors: ['全体エラー'],
|
||||
})
|
||||
})
|
||||
|
||||
it ('preserves dotted field keys for indexed form rows', async () => {
|
||||
api.isApiError.mockReturnValueOnce (true)
|
||||
|
||||
const { extractValidationError } = await import ('@/lib/apiErrors')
|
||||
const validationError = extractValidationError<'deerjikists.0.platform'> ({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
type: 'validation_error',
|
||||
errors: { 'deerjikists.0.platform': ['プラットフォームを入力してください.'] },
|
||||
base_errors: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect (validationError?.fieldErrors).toEqual ({
|
||||
'deerjikists.0.platform': ['プラットフォームを入力してください.'],
|
||||
})
|
||||
})
|
||||
|
||||
it ('does not treat 400 bad requests as form validation errors', async () => {
|
||||
api.isApiError.mockReturnValueOnce (true)
|
||||
|
||||
const { extractValidationError } = await import ('@/lib/apiErrors')
|
||||
const validationError = extractValidationError ({
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
type: 'bad_request',
|
||||
message: 'リクエストが不正です.',
|
||||
errors: {},
|
||||
base_errors: ['リクエストが不正です.'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect (validationError).toBeNull ()
|
||||
})
|
||||
|
||||
it ('ignores non-api errors', async () => {
|
||||
api.isApiError.mockReturnValueOnce (false)
|
||||
|
||||
const { extractValidationError } = await import ('@/lib/apiErrors')
|
||||
|
||||
expect (extractValidationError (new Error ('network'))).toBeNull ()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,3 @@
|
||||
import toCamel from 'camelcase-keys'
|
||||
|
||||
import { isApiError } from '@/lib/api'
|
||||
|
||||
export type FieldErrors<T extends string = string> = Partial<Record<T, string[]>>
|
||||
@@ -19,8 +17,13 @@ export const extractValidationError = <T extends string = string> (err: unknown)
|
||||
if (!(isApiError (err)) || err.response?.status !== 422)
|
||||
return null
|
||||
|
||||
const data = toCamel ((err.response.data ?? { }) as Record<string, unknown>,
|
||||
{ deep: true }) as RawValidationError
|
||||
const rawData = (err.response.data ?? { }) as Record<string, unknown>
|
||||
const data: RawValidationError = {
|
||||
type: rawData.type as string | undefined,
|
||||
message: rawData.message as string | undefined,
|
||||
errors: rawData.errors as Record<string, string[]> | undefined,
|
||||
baseErrors: rawData.base_errors as string[] | undefined,
|
||||
}
|
||||
|
||||
if (data.type !== 'validation_error' && !(data.errors))
|
||||
return null
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
||||
import { buildTag } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const tagsApi = vi.hoisted (() => ({
|
||||
fetchDeerjikistsByTag: vi.fn (),
|
||||
}))
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiPut: vi.fn (),
|
||||
isApiError: vi.fn (),
|
||||
}))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({
|
||||
toast: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/lib/tags', () => tagsApi)
|
||||
vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
|
||||
const renderPage = () =>
|
||||
renderWithProviders (
|
||||
<Routes>
|
||||
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
|
||||
</Routes>,
|
||||
{ route: '/tags/7/deerjikists' },
|
||||
)
|
||||
|
||||
describe ('DeerjikistDetailPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
})
|
||||
|
||||
it ('shows indexed validation errors returned for deerjikist rows', async () => {
|
||||
tagsApi.fetchDeerjikistsByTag.mockResolvedValueOnce ({
|
||||
tag: buildTag ({ id: 7, name: 'deerjika', category: 'deerjikist' }),
|
||||
deerjikists: [{ platform: null, code: 'abc' }],
|
||||
})
|
||||
api.isApiError.mockReturnValue (true)
|
||||
api.apiPut.mockRejectedValueOnce ({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
type: 'validation_error',
|
||||
message: '入力内容を確認してください.',
|
||||
errors: { 'deerjikists.0.platform': ['プラットフォームを入力してください.'] },
|
||||
base_errors: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
renderPage ()
|
||||
|
||||
await screen.findByDisplayValue ('abc')
|
||||
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (api.apiPut).toHaveBeenCalledWith (
|
||||
'/tags/7/deerjikists',
|
||||
[{ platform: null, code: 'abc' }],
|
||||
)
|
||||
})
|
||||
expect (await screen.findByText ('プラットフォームを入力してください.')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('combobox')).toHaveAttribute ('aria-invalid', 'true')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,13 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiPost: vi.fn (),
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
isApiError: vi.fn (),
|
||||
}))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({
|
||||
@@ -16,6 +18,12 @@ vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
|
||||
describe ('MaterialNewPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
api.isApiError.mockReturnValue (false)
|
||||
})
|
||||
|
||||
it ('initializes tag from query and submits form data', async () => {
|
||||
api.apiPost.mockResolvedValueOnce ({})
|
||||
|
||||
@@ -35,4 +43,30 @@ describe ('MaterialNewPage', () => {
|
||||
expect (formData.get ('url')).toBe ('https://example.com/ref')
|
||||
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '送信成功!' })
|
||||
})
|
||||
|
||||
it ('shows validation errors for file and url fields', async () => {
|
||||
api.isApiError.mockReturnValue (true)
|
||||
api.apiPost.mockRejectedValueOnce ({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
type: 'validation_error',
|
||||
message: '入力内容を確認してください.',
|
||||
errors: {
|
||||
file: ['ファイルまたは URL は必須です.'],
|
||||
url: ['ファイルまたは URL は必須です.'],
|
||||
},
|
||||
base_errors: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
renderWithProviders (<MaterialNewPage/>)
|
||||
|
||||
fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '虹夏' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
expect (await screen.findAllByText ('ファイルまたは URL は必須です.')).toHaveLength (2)
|
||||
expect (screen.getAllByRole ('textbox')[1]).toHaveAttribute ('aria-invalid', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostNewPage from '@/pages/posts/PostNewPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
apiGet: vi.fn (),
|
||||
apiPost: vi.fn (),
|
||||
isApiError: vi.fn (),
|
||||
}))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({
|
||||
@@ -18,6 +19,11 @@ vi.mock ('@/lib/api', () => api)
|
||||
vi.mock ('@/components/ui/use-toast', () => toastApi)
|
||||
|
||||
describe ('PostNewPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
})
|
||||
|
||||
it ('blocks guests', () => {
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'guest' })}/>)
|
||||
|
||||
@@ -55,4 +61,36 @@ describe ('PostNewPage', () => {
|
||||
expect (formData.get ('tags')).toBe ('tag1 tag2')
|
||||
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' })
|
||||
})
|
||||
|
||||
it ('shows 422 validation errors for post fields', async () => {
|
||||
api.apiGet.mockResolvedValue ([])
|
||||
api.isApiError.mockReturnValue (true)
|
||||
api.apiPost.mockRejectedValueOnce ({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
type: 'validation_error',
|
||||
message: '入力内容を確認してください.',
|
||||
errors: { tags: ['ニコニコ・タグは直接指定できません.'] },
|
||||
base_errors: ['投稿内容を確認してください.'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
renderWithProviders (<PostNewPage user={buildUser ({ role: 'member' })}/>)
|
||||
|
||||
const checkboxes = screen.getAllByRole ('checkbox', { name: '自動' })
|
||||
fireEvent.click (checkboxes[0])
|
||||
fireEvent.click (checkboxes[1])
|
||||
|
||||
const textboxes = screen.getAllByRole ('textbox')
|
||||
fireEvent.change (textboxes[0], { target: { value: 'https://example.com/post' } })
|
||||
fireEvent.change (textboxes[1], { target: { value: '投稿タイトル' } })
|
||||
fireEvent.change (textboxes[3], { target: { value: 'nico:nico_tag' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '追加' }))
|
||||
|
||||
expect (await screen.findByText ('投稿内容を確認してください.')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('ニコニコ・タグは直接指定できません.')).toBeInTheDocument ()
|
||||
expect (screen.getAllByRole ('textbox')[3]).toHaveAttribute ('aria-invalid', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import TagDetailPage from '@/pages/tags/TagDetailPage'
|
||||
import { buildTag } from '@/test/factories'
|
||||
@@ -11,7 +11,8 @@ const tagsApi = vi.hoisted (() => ({
|
||||
}))
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiPut: vi.fn (),
|
||||
apiPut: vi.fn (),
|
||||
isApiError: vi.fn (),
|
||||
}))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({
|
||||
@@ -28,9 +29,14 @@ const renderPage = () =>
|
||||
<Route path="/tags/:id" element={<TagDetailPage/>}/>
|
||||
</Routes>,
|
||||
{ route: '/tags/7' },
|
||||
)
|
||||
)
|
||||
|
||||
describe ('TagDetailPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
})
|
||||
|
||||
it ('loads and displays an editable tag', async () => {
|
||||
tagsApi.fetchTag.mockResolvedValueOnce (
|
||||
buildTag ({ id: 7, name: '虹夏', category: 'character', aliases: ['drums'] }),
|
||||
@@ -68,4 +74,28 @@ describe ('TagDetailPage', () => {
|
||||
|
||||
expect (await screen.findByRole ('button', { name: '更新' })).toBeDisabled ()
|
||||
})
|
||||
|
||||
it ('shows validation errors returned for tag fields', async () => {
|
||||
tagsApi.fetchTag.mockResolvedValueOnce (buildTag ({ id: 7, name: 'old' }))
|
||||
api.isApiError.mockReturnValue (true)
|
||||
api.apiPut.mockRejectedValueOnce ({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
type: 'validation_error',
|
||||
message: '入力内容を確認してください.',
|
||||
errors: { category: ['ニコタグは変更できません.'] },
|
||||
base_errors: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
renderPage ()
|
||||
|
||||
await screen.findByDisplayValue ('old')
|
||||
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
|
||||
|
||||
expect (await screen.findByText ('ニコタグは変更できません.')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('combobox')).toHaveAttribute ('aria-invalid', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import SettingPage from '@/pages/users/SettingPage'
|
||||
import { buildUser } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const api = vi.hoisted (() => ({
|
||||
apiPut: vi.fn (),
|
||||
apiPut: vi.fn (),
|
||||
isApiError: vi.fn (),
|
||||
}))
|
||||
|
||||
const toastApi = vi.hoisted (() => ({
|
||||
@@ -23,6 +24,11 @@ vi.mock ('@/components/users/InheritDialogue', () => ({
|
||||
}))
|
||||
|
||||
describe ('SettingPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
api.isApiError.mockReturnValue (false)
|
||||
})
|
||||
|
||||
it ('shows loading when user is absent', () => {
|
||||
renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>)
|
||||
|
||||
@@ -51,4 +57,28 @@ describe ('SettingPage', () => {
|
||||
expect (setUser).toHaveBeenCalled ()
|
||||
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' })
|
||||
})
|
||||
|
||||
it ('shows validation errors returned for the name field', async () => {
|
||||
const user = buildUser ({ id: 11, name: 'old' })
|
||||
api.isApiError.mockReturnValue (true)
|
||||
api.apiPut.mockRejectedValueOnce ({
|
||||
response: {
|
||||
status: 422,
|
||||
data: {
|
||||
type: 'validation_error',
|
||||
message: '入力内容を確認してください.',
|
||||
errors: { name: ['名前は必須です.'] },
|
||||
base_errors: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
renderWithProviders (<SettingPage user={user} setUser={vi.fn ()}/>)
|
||||
|
||||
fireEvent.change (screen.getByRole ('textbox'), { target: { value: '' } })
|
||||
fireEvent.click (screen.getByRole ('button', { name: '更新' }))
|
||||
|
||||
expect (await screen.findByText ('名前は必須です.')).toBeInTheDocument ()
|
||||
expect (screen.getByRole ('textbox')).toHaveAttribute ('aria-invalid', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
新しい課題から参照
ユーザをブロックする