From d68bcc8c5b4bf6896f8e76de540ae0d2bfedd561 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Wed, 3 Jun 2026 23:56:52 +0900 Subject: [PATCH] #90 --- backend/spec/requests/error_responses_spec.rb | 38 ++++----- backend/spec/requests/materials_spec.rb | 26 ++++-- backend/spec/requests/nico_tags_spec.rb | 6 +- backend/spec/requests/posts_spec.rb | 20 ++++- .../spec/requests/tags_deerjikists_spec.rb | 24 ++++++ backend/spec/requests/users_spec.rb | 6 +- frontend/src/lib/apiErrors.test.ts | 79 +++++++++++++++++++ frontend/src/lib/apiErrors.ts | 11 ++- .../deerjikists/DeerjikistDetailPage.test.tsx | 72 +++++++++++++++++ .../pages/materials/MaterialNewPage.test.tsx | 38 ++++++++- frontend/src/pages/posts/PostNewPage.test.tsx | 44 ++++++++++- .../src/pages/tags/TagDetailPage.test.tsx | 36 ++++++++- frontend/src/pages/users/SettingPage.test.tsx | 34 +++++++- 13 files changed, 385 insertions(+), 49 deletions(-) create mode 100644 frontend/src/lib/apiErrors.test.ts create mode 100644 frontend/src/pages/deerjikists/DeerjikistDetailPage.test.tsx diff --git a/backend/spec/requests/error_responses_spec.rb b/backend/spec/requests/error_responses_spec.rb index 4fdc4d6..a7b5191 100644 --- a/backend/spec/requests/error_responses_spec.rb +++ b/backend/spec/requests/error_responses_spec.rb @@ -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 diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index f2cc27e..d4085ec 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -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 diff --git a/backend/spec/requests/nico_tags_spec.rb b/backend/spec/requests/nico_tags_spec.rb index 26d5de0..5b3b27c 100644 --- a/backend/spec/requests/nico_tags_spec.rb +++ b/backend/spec/requests/nico_tags_spec.rb @@ -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 diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 08329d7..2914220 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -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 diff --git a/backend/spec/requests/tags_deerjikists_spec.rb b/backend/spec/requests/tags_deerjikists_spec.rb index c599f17..7bbf224 100644 --- a/backend/spec/requests/tags_deerjikists_spec.rb +++ b/backend/spec/requests/tags_deerjikists_spec.rb @@ -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 diff --git a/backend/spec/requests/users_spec.rb b/backend/spec/requests/users_spec.rb index 67c556f..c58f614 100644 --- a/backend/spec/requests/users_spec.rb +++ b/backend/spec/requests/users_spec.rb @@ -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 diff --git a/frontend/src/lib/apiErrors.test.ts b/frontend/src/lib/apiErrors.test.ts new file mode 100644 index 0000000..32d6250 --- /dev/null +++ b/frontend/src/lib/apiErrors.test.ts @@ -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 () + }) +}) diff --git a/frontend/src/lib/apiErrors.ts b/frontend/src/lib/apiErrors.ts index 5c57d8d..72496d1 100644 --- a/frontend/src/lib/apiErrors.ts +++ b/frontend/src/lib/apiErrors.ts @@ -1,5 +1,3 @@ -import toCamel from 'camelcase-keys' - import { isApiError } from '@/lib/api' export type FieldErrors = Partial> @@ -19,8 +17,13 @@ export const extractValidationError = (err: unknown) if (!(isApiError (err)) || err.response?.status !== 422) return null - const data = toCamel ((err.response.data ?? { }) as Record, - { deep: true }) as RawValidationError + const rawData = (err.response.data ?? { }) as Record + const data: RawValidationError = { + type: rawData.type as string | undefined, + message: rawData.message as string | undefined, + errors: rawData.errors as Record | undefined, + baseErrors: rawData.base_errors as string[] | undefined, + } if (data.type !== 'validation_error' && !(data.errors)) return null diff --git a/frontend/src/pages/deerjikists/DeerjikistDetailPage.test.tsx b/frontend/src/pages/deerjikists/DeerjikistDetailPage.test.tsx new file mode 100644 index 0000000..ae60500 --- /dev/null +++ b/frontend/src/pages/deerjikists/DeerjikistDetailPage.test.tsx @@ -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 ( + + }/> + , + { 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') + }) +}) diff --git a/frontend/src/pages/materials/MaterialNewPage.test.tsx b/frontend/src/pages/materials/MaterialNewPage.test.tsx index c33a5dc..c67e55b 100644 --- a/frontend/src/pages/materials/MaterialNewPage.test.tsx +++ b/frontend/src/pages/materials/MaterialNewPage.test.tsx @@ -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 () + + 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') + }) }) diff --git a/frontend/src/pages/posts/PostNewPage.test.tsx b/frontend/src/pages/posts/PostNewPage.test.tsx index f14b318..b8c909e 100644 --- a/frontend/src/pages/posts/PostNewPage.test.tsx +++ b/frontend/src/pages/posts/PostNewPage.test.tsx @@ -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 () @@ -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 () + + 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') + }) }) diff --git a/frontend/src/pages/tags/TagDetailPage.test.tsx b/frontend/src/pages/tags/TagDetailPage.test.tsx index e5380cc..9b9ca2c 100644 --- a/frontend/src/pages/tags/TagDetailPage.test.tsx +++ b/frontend/src/pages/tags/TagDetailPage.test.tsx @@ -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: '/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') + }) }) diff --git a/frontend/src/pages/users/SettingPage.test.tsx b/frontend/src/pages/users/SettingPage.test.tsx index 3e1a232..0595642 100644 --- a/frontend/src/pages/users/SettingPage.test.tsx +++ b/frontend/src/pages/users/SettingPage.test.tsx @@ -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 () @@ -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 () + + 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') + }) })