フォームのバリデーションとニコ連携の画面変更 (#090) #355

マージ済み
みてるぞ が 11 個のコミットを feature/090 から main へマージ 2026-06-05 01:59:46 +09:00
13個のファイルの変更385行の追加49行の削除
コミット d68bcc8c5b の変更だけを表示してゐます - すべてのコミットを表示
+19 -19
ファイルの表示
@@ -2,20 +2,18 @@ require 'rails_helper'
RSpec.describe 'error responses', type: :request do RSpec.describe 'error responses', type: :request do
describe 'manual input errors' do describe 'manual input errors' do
it 'returns a stable errors array for bad requests' do it 'returns a stable payload for bad requests' do
user = create(:user) get '/tags/name/%20/deerjikists'
sign_in_as(user)
put "/users/#{ user.id }", params: { name: ' ' }
expect(response).to have_http_status(:bad_request) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to contain_exactly( expect(json).to include(
include('code' => 'bad_request', 'type' => 'bad_request',
'field' => 'name', 'message' => be_present,
'message' => be_present)) 'errors' => {},
'base_errors' => [be_present])
end 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) member = create(:user, :member)
tag = create(:tag, :general, name: 'error_response_tag') tag = create(:tag, :general, name: 'error_response_tag')
sign_in_as(member) sign_in_as(member)
@@ -23,25 +21,27 @@ RSpec.describe 'error responses', type: :request do
patch "/tags/#{ tag.id }", params: { category: 'nico' } patch "/tags/#{ tag.id }", params: { category: 'nico' }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to contain_exactly( expect(json).to include(
include('code' => 'invalid', 'type' => 'validation_error',
'field' => 'category', 'message' => '入力内容を確認してください.',
'message' => be_present)) 'base_errors' => [])
expect(json.fetch('errors')).to include(
'category' => ['ニコタグは変更できません.'])
end end
end end
describe 'model validation errors' do 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) user = create(:user)
sign_in_as(user) sign_in_as(user)
put "/users/#{ user.id }", params: { name: 'a' * 256 } put "/users/#{ user.id }", params: { name: 'a' * 256 }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include( expect(json).to include(
include('code' => 'too_long', 'type' => 'validation_error',
'field' => 'name', 'message' => '入力内容を確認してください.')
'message' => be_present)) expect(json.fetch('errors').fetch('name')).to include(be_present)
end end
end end
end end
+18 -8
ファイルの表示
@@ -141,16 +141,21 @@ RSpec.describe 'Materials API', type: :request do
context 'when logged in' do context 'when logged in' do
before { sign_in_as(guest_user) } 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 } 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 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' } 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 end
it 'creates a material with an attached file' do 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) expect(response).to have_http_status(:not_found)
end end
it 'returns 400 when tag is blank' do it 'returns 422 when tag is blank' do
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
tag: ' ', tag: ' ',
file: dummy_upload 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 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: { put "/materials/#{ material.id }", params: {
tag: 'material_update_no_payload' 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 end
it 'updates tag, url, file, and updated_by_user' do it 'updates tag, url, file, and updated_by_user' do
+4 -2
ファイルの表示
@@ -75,7 +75,7 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.last.created_by_user_id).to eq(admin.id) expect(versions.last.created_by_user_id).to eq(admin.id)
end 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) sign_in_as(member)
other_nico = create(:tag, :nico, name: 'nico:linked_ng') 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' } patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.not_to change(NicoTagVersion, :count) }.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 end
end end
+16 -4
ファイルの表示
@@ -704,7 +704,7 @@ RSpec.describe 'Posts API', type: :request do
category: :nico) category: :nico)
end end
it 'return 400' do it 'returns 422 with tag field errors' do
sign_in_as(member) sign_in_as(member)
post '/posts', params: post_write_params( post '/posts', params: post_write_params(
@@ -714,7 +714,13 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload 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
end end
@@ -931,7 +937,7 @@ RSpec.describe 'Posts API', type: :request do
category: :nico) category: :nico)
end end
it 'return 400' do it 'returns 422 with tag field errors' do
sign_in_as(member) sign_in_as(member)
put "/posts/#{post_record.id}", params: post_update_params( put "/posts/#{post_record.id}", params: post_update_params(
@@ -939,7 +945,13 @@ RSpec.describe 'Posts API', type: :request do
title: 'updated title', title: 'updated title',
tags: 'nico:nico_tag') 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
end end
+24
ファイルの表示
@@ -275,6 +275,30 @@ RSpec.describe 'Tags deerjikists API', type: :request do
end end
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 context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' } let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do let(:payload) do
+4 -2
ファイルの表示
@@ -90,12 +90,14 @@ RSpec.describe 'Users', type: :request do
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end
it 'returns 400 when name is blank' do it 'returns 422 when name is blank' do
put "/users/#{user.id}", put "/users/#{user.id}",
params: { name: ' ' }, params: { name: ' ' },
headers: auth_headers(user) 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 end
it 'updates name and returns user slice' do it 'updates name and returns user slice' do
+79
ファイルの表示
@@ -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 ()
})
})
+7 -4
ファイルの表示
@@ -1,5 +1,3 @@
import toCamel from 'camelcase-keys'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
export type FieldErrors<T extends string = string> = Partial<Record<T, string[]>> 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) if (!(isApiError (err)) || err.response?.status !== 422)
return null return null
const data = toCamel ((err.response.data ?? { }) as Record<string, unknown>, const rawData = (err.response.data ?? { }) as Record<string, unknown>
{ deep: true }) as RawValidationError 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)) if (data.type !== 'validation_error' && !(data.errors))
return null return null
+72
ファイルの表示
@@ -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')
})
})
+35 -1
ファイルの表示
@@ -1,11 +1,13 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' 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 MaterialNewPage from '@/pages/materials/MaterialNewPage'
import { renderWithProviders } from '@/test/render' import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (),
apiPost: vi.fn (), apiPost: vi.fn (),
isApiError: vi.fn (),
})) }))
const toastApi = vi.hoisted (() => ({ const toastApi = vi.hoisted (() => ({
@@ -16,6 +18,12 @@ vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
describe ('MaterialNewPage', () => { describe ('MaterialNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.apiGet.mockResolvedValue ([])
api.isApiError.mockReturnValue (false)
})
it ('initializes tag from query and submits form data', async () => { it ('initializes tag from query and submits form data', async () => {
api.apiPost.mockResolvedValueOnce ({}) api.apiPost.mockResolvedValueOnce ({})
@@ -35,4 +43,30 @@ describe ('MaterialNewPage', () => {
expect (formData.get ('url')).toBe ('https://example.com/ref') expect (formData.get ('url')).toBe ('https://example.com/ref')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '送信成功!' }) 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')
})
}) })
+39 -1
ファイルの表示
@@ -1,5 +1,5 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' 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 PostNewPage from '@/pages/posts/PostNewPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
@@ -8,6 +8,7 @@ import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiGet: vi.fn (), apiGet: vi.fn (),
apiPost: vi.fn (), apiPost: vi.fn (),
isApiError: vi.fn (),
})) }))
const toastApi = vi.hoisted (() => ({ const toastApi = vi.hoisted (() => ({
@@ -18,6 +19,11 @@ vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi) vi.mock ('@/components/ui/use-toast', () => toastApi)
describe ('PostNewPage', () => { describe ('PostNewPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('blocks guests', () => { it ('blocks guests', () => {
renderWithProviders (<PostNewPage user={buildUser ({ role: 'guest' })}/>) renderWithProviders (<PostNewPage user={buildUser ({ role: 'guest' })}/>)
@@ -55,4 +61,36 @@ describe ('PostNewPage', () => {
expect (formData.get ('tags')).toBe ('tag1 tag2') expect (formData.get ('tags')).toBe ('tag1 tag2')
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '投稿成功!' }) 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')
})
}) })
+32 -2
ファイルの表示
@@ -1,6 +1,6 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom' 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 TagDetailPage from '@/pages/tags/TagDetailPage'
import { buildTag } from '@/test/factories' import { buildTag } from '@/test/factories'
@@ -12,6 +12,7 @@ const tagsApi = vi.hoisted (() => ({
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiPut: vi.fn (), apiPut: vi.fn (),
isApiError: vi.fn (),
})) }))
const toastApi = vi.hoisted (() => ({ const toastApi = vi.hoisted (() => ({
@@ -28,9 +29,14 @@ const renderPage = () =>
<Route path="/tags/:id" element={<TagDetailPage/>}/> <Route path="/tags/:id" element={<TagDetailPage/>}/>
</Routes>, </Routes>,
{ route: '/tags/7' }, { route: '/tags/7' },
) )
describe ('TagDetailPage', () => { describe ('TagDetailPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('loads and displays an editable tag', async () => { it ('loads and displays an editable tag', async () => {
tagsApi.fetchTag.mockResolvedValueOnce ( tagsApi.fetchTag.mockResolvedValueOnce (
buildTag ({ id: 7, name: '虹夏', category: 'character', aliases: ['drums'] }), buildTag ({ id: 7, name: '虹夏', category: 'character', aliases: ['drums'] }),
@@ -68,4 +74,28 @@ describe ('TagDetailPage', () => {
expect (await screen.findByRole ('button', { name: '更新' })).toBeDisabled () 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')
})
}) })
+31 -1
ファイルの表示
@@ -1,5 +1,5 @@
import { fireEvent, screen, waitFor } from '@testing-library/react' 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 SettingPage from '@/pages/users/SettingPage'
import { buildUser } from '@/test/factories' import { buildUser } from '@/test/factories'
@@ -7,6 +7,7 @@ import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({
apiPut: vi.fn (), apiPut: vi.fn (),
isApiError: vi.fn (),
})) }))
const toastApi = vi.hoisted (() => ({ const toastApi = vi.hoisted (() => ({
@@ -23,6 +24,11 @@ vi.mock ('@/components/users/InheritDialogue', () => ({
})) }))
describe ('SettingPage', () => { describe ('SettingPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
api.isApiError.mockReturnValue (false)
})
it ('shows loading when user is absent', () => { it ('shows loading when user is absent', () => {
renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>) renderWithProviders (<SettingPage user={null} setUser={vi.fn ()}/>)
@@ -51,4 +57,28 @@ describe ('SettingPage', () => {
expect (setUser).toHaveBeenCalled () expect (setUser).toHaveBeenCalled ()
expect (toastApi.toast).toHaveBeenCalledWith ({ title: '設定を更新しました.' }) 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')
})
}) })