フォームのバリデーションとニコ連携の画面変更 (#090) #355
@@ -2,20 +2,18 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe 'error responses', type: :request do
|
||||
describe 'manual input errors' do
|
||||
it 'returns a stable errors array for bad requests' do
|
||||
user = create(:user)
|
||||
sign_in_as(user)
|
||||
|
||||
put "/users/#{ user.id }", params: { name: ' ' }
|
||||
it 'returns a stable payload for bad requests' do
|
||||
get '/tags/name/%20/deerjikists'
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(json.fetch('errors')).to contain_exactly(
|
||||
include('code' => 'bad_request',
|
||||
'field' => 'name',
|
||||
'message' => be_present))
|
||||
expect(json).to include(
|
||||
'type' => 'bad_request',
|
||||
'message' => be_present,
|
||||
'errors' => {},
|
||||
'base_errors' => [be_present])
|
||||
end
|
||||
|
||||
it 'returns a stable errors array for unprocessable requests' do
|
||||
it 'returns a stable field-error payload for unprocessable requests' do
|
||||
member = create(:user, :member)
|
||||
tag = create(:tag, :general, name: 'error_response_tag')
|
||||
sign_in_as(member)
|
||||
@@ -23,25 +21,27 @@ RSpec.describe 'error responses', type: :request do
|
||||
patch "/tags/#{ tag.id }", params: { category: 'nico' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to contain_exactly(
|
||||
include('code' => 'invalid',
|
||||
'field' => 'category',
|
||||
'message' => be_present))
|
||||
expect(json).to include(
|
||||
'type' => 'validation_error',
|
||||
'message' => '入力内容を確認してください.',
|
||||
'base_errors' => [])
|
||||
expect(json.fetch('errors')).to include(
|
||||
'category' => ['ニコタグは変更できません.'])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'model validation errors' do
|
||||
it 'returns field, code, and message for model errors' do
|
||||
it 'returns field messages for model errors' do
|
||||
user = create(:user)
|
||||
sign_in_as(user)
|
||||
|
||||
put "/users/#{ user.id }", params: { name: 'a' * 256 }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
include('code' => 'too_long',
|
||||
'field' => 'name',
|
||||
'message' => be_present))
|
||||
expect(json).to include(
|
||||
'type' => 'validation_error',
|
||||
'message' => '入力内容を確認してください.')
|
||||
expect(json.fetch('errors').fetch('name')).to include(be_present)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -141,16 +141,21 @@ RSpec.describe 'Materials API', type: :request do
|
||||
context 'when logged in' do
|
||||
before { sign_in_as(guest_user) }
|
||||
|
||||
it 'returns 400 when tag is blank' do
|
||||
it 'returns 422 when tag is blank' do
|
||||
post '/materials', params: { tag: ' ', file: dummy_upload }
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'tag' => ['タグは必須です.'])
|
||||
end
|
||||
|
||||
it 'returns 400 when both file and url are blank' do
|
||||
it 'returns 422 when both file and url are blank' do
|
||||
post '/materials', params: { tag: 'material_create_blank' }
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'file' => ['ファイルまたは URL は必須です.'],
|
||||
'url' => ['ファイルまたは URL は必須です.'])
|
||||
end
|
||||
|
||||
it 'creates a material with an attached file' do
|
||||
@@ -261,21 +266,26 @@ RSpec.describe 'Materials API', type: :request do
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns 400 when tag is blank' do
|
||||
it 'returns 422 when tag is blank' do
|
||||
put "/materials/#{ material.id }", params: {
|
||||
tag: ' ',
|
||||
file: dummy_upload
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'tag' => ['タグは必須です.'])
|
||||
end
|
||||
|
||||
it 'returns 400 when both file and url are blank' do
|
||||
it 'returns 422 when both file and url are blank' do
|
||||
put "/materials/#{ material.id }", params: {
|
||||
tag: 'material_update_no_payload'
|
||||
}
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'file' => ['ファイルまたは URL は必須です.'],
|
||||
'url' => ['ファイルまたは URL は必須です.'])
|
||||
end
|
||||
|
||||
it 'updates tag, url, file, and updated_by_user' do
|
||||
|
||||
@@ -75,7 +75,7 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
expect(versions.last.created_by_user_id).to eq(admin.id)
|
||||
end
|
||||
|
||||
it '400 when linked tag normalises to nico tag' do
|
||||
it 'returns 422 when linked tag normalises to nico tag' do
|
||||
sign_in_as(member)
|
||||
|
||||
other_nico = create(:tag, :nico, name: 'nico:linked_ng')
|
||||
@@ -87,7 +87,9 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
|
||||
}.not_to change(NicoTagVersion, :count)
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'tags' => ['ニコニコ・タグ同士は連携できません.'])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -704,7 +704,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
category: :nico)
|
||||
end
|
||||
|
||||
it 'return 400' do
|
||||
it 'returns 422 with tag field errors' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts', params: post_write_params(
|
||||
@@ -714,7 +714,13 @@ RSpec.describe 'Posts API', type: :request do
|
||||
thumbnail: dummy_upload
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:bad_request), response.body
|
||||
expect(response).to have_http_status(:unprocessable_entity), response.body
|
||||
expect(json).to include(
|
||||
'type' => 'validation_error',
|
||||
'message' => '入力内容を確認してください.',
|
||||
'base_errors' => [])
|
||||
expect(json.fetch('errors')).to include(
|
||||
'tags' => ['ニコニコ・タグは直接指定できません.'])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -931,7 +937,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
category: :nico)
|
||||
end
|
||||
|
||||
it 'return 400' do
|
||||
it 'returns 422 with tag field errors' do
|
||||
sign_in_as(member)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_update_params(
|
||||
@@ -939,7 +945,13 @@ RSpec.describe 'Posts API', type: :request do
|
||||
title: 'updated title',
|
||||
tags: 'nico:nico_tag')
|
||||
|
||||
expect(response).to have_http_status(:bad_request), response.body
|
||||
expect(response).to have_http_status(:unprocessable_entity), response.body
|
||||
expect(json).to include(
|
||||
'type' => 'validation_error',
|
||||
'message' => '入力内容を確認してください.',
|
||||
'base_errors' => [])
|
||||
expect(json.fetch('errors')).to include(
|
||||
'tags' => ['ニコニコ・タグは直接指定できません.'])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -275,6 +275,30 @@ RSpec.describe 'Tags deerjikists API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a row is invalid' do
|
||||
let(:payload) do
|
||||
[
|
||||
{ platform: '', code: code1 },
|
||||
]
|
||||
end
|
||||
|
||||
it 'returns 422 with indexed field errors and does not replace existing deerjikists' do
|
||||
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
|
||||
|
||||
expect {
|
||||
do_request
|
||||
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json).to include(
|
||||
'type' => 'validation_error',
|
||||
'message' => '入力内容を確認してください.',
|
||||
'base_errors' => [])
|
||||
expect(json.fetch('errors')).to include(
|
||||
'deerjikists.0.platform' => [be_present])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when youtube code is handle' do
|
||||
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
|
||||
let(:payload) do
|
||||
|
||||
@@ -90,12 +90,14 @@ RSpec.describe 'Users', type: :request do
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns 400 when name is blank' do
|
||||
it 'returns 422 when name is blank' do
|
||||
put "/users/#{user.id}",
|
||||
params: { name: ' ' },
|
||||
headers: auth_headers(user)
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'name' => ['名前は必須です.'])
|
||||
end
|
||||
|
||||
it 'updates name and returns user slice' do
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
新しい課題から参照
ユーザをブロックする