フォームのバリデーションとニコ連携の画面変更 (#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
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
+18 -8
ファイルの表示
@@ -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
+4 -2
ファイルの表示
@@ -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
+16 -4
ファイルの表示
@@ -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
+24
ファイルの表示
@@ -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
+4 -2
ファイルの表示
@@ -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
+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'
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
+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 { 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 (() => ({
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')
})
})
+39 -1
ファイルの表示
@@ -1,5 +1,5 @@
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'
@@ -8,6 +8,7 @@ import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
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')
})
})
+31 -1
ファイルの表示
@@ -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'
@@ -12,6 +12,7 @@ const tagsApi = vi.hoisted (() => ({
const api = vi.hoisted (() => ({
apiPut: vi.fn (),
isApiError: vi.fn (),
}))
const toastApi = vi.hoisted (() => ({
@@ -31,6 +32,11 @@ const renderPage = () =>
)
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')
})
})
+31 -1
ファイルの表示
@@ -1,5 +1,5 @@
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'
@@ -7,6 +7,7 @@ import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
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')
})
})