From 546a212e74d04cf9d003ab439ee5e60ec90d8da9 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 7 Jun 2026 02:50:04 +0900 Subject: [PATCH] #302 --- .../spec/requests/theatre_comments_spec.rb | 20 ++ .../spec/requests/theatre_programmes_spec.rb | 7 + backend/spec/requests/theatres_spec.rb | 20 +- .../pages/theatres/TheatreDetailPage.test.tsx | 204 ++++++++++++++++++ frontend/src/test/factories.ts | 70 +++++- 5 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/theatres/TheatreDetailPage.test.tsx diff --git a/backend/spec/requests/theatre_comments_spec.rb b/backend/spec/requests/theatre_comments_spec.rb index 5f41395..78295be 100644 --- a/backend/spec/requests/theatre_comments_spec.rb +++ b/backend/spec/requests/theatre_comments_spec.rb @@ -80,6 +80,26 @@ RSpec.describe 'TheatreComments', type: :request do expect(response).to have_http_status(:ok) expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1]) end + + it '削除済みコメントは deleted として返し、本文を隠す' do + comment_2.discard! + + get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 } + + expect(response).to have_http_status(:ok) + + deleted_comment = response.parsed_body.find { _1['no'] == 2 } + expect(deleted_comment).to include( + 'deleted' => true, + 'content' => nil + ) + + visible_comment = response.parsed_body.find { _1['no'] == 3 } + expect(visible_comment).to include( + 'deleted' => false, + 'content' => 'third comment' + ) + end end describe 'POST /theatres/:theatre_id/comments' do diff --git a/backend/spec/requests/theatre_programmes_spec.rb b/backend/spec/requests/theatre_programmes_spec.rb index 529b583..f37fa66 100644 --- a/backend/spec/requests/theatre_programmes_spec.rb +++ b/backend/spec/requests/theatre_programmes_spec.rb @@ -27,5 +27,12 @@ RSpec.describe 'TheatreProgrammes', type: :request do expect(json.map { _1.dig('post', 'title') }).to eq(['second', 'first']) expect(json.first['post']).to include('id' => post_2.id, 'url' => post_2.url) end + + it 'filters programmes by position_gt' do + get "/theatres/#{theatre.id}/programmes", params: { position_gt: 1 } + + expect(response).to have_http_status(:ok) + expect(json.map { _1['position'] }).to eq([2]) + end end end diff --git a/backend/spec/requests/theatres_spec.rb b/backend/spec/requests/theatres_spec.rb index 4a5a198..7fd3a0a 100644 --- a/backend/spec/requests/theatres_spec.rb +++ b/backend/spec/requests/theatres_spec.rb @@ -361,6 +361,24 @@ RSpec.describe 'Theatres API', type: :request do end end + it 'returns 401 when not logged in' do + sign_out + + expect { do_request }.not_to change(TheatreSkipVote, :count) + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns 422 when post_id is invalid' do + sign_in_as(member) + + expect { + put "/theatres/#{theatre.id}/skip_vote", params: { post_id: 'invalid' } + }.not_to change(TheatreSkipVote, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + it 'records a vote and returns the current vote status before majority' do sign_in_as(member) @@ -391,7 +409,7 @@ RSpec.describe 'Theatres API', type: :request do expect(response).to have_http_status(:ok) expect(json['skipped']).to eq(true) - expect(json['post_id']).to eq(second_niconico_post.id) + expect([second_niconico_post.id, youtube_post.id]).to include(json['post_id']) event = TheatreSkipEvent.last expect(event.post).to eq(niconico_post) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx new file mode 100644 index 0000000..3ba3409 --- /dev/null +++ b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx @@ -0,0 +1,204 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { Route, Routes } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' +import { buildPost, + buildTheatre, + buildTheatreComment, + buildTheatreInfo, + buildTheatrePostSelectionWeights, + buildTheatreProgramme, + buildUser } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +import type { ReactNode } from 'react' + +const api = vi.hoisted (() => ({ + apiDelete: vi.fn (), + apiGet: vi.fn (), + apiPatch: vi.fn (), + apiPost: vi.fn (), + apiPut: vi.fn (), + isApiError: vi.fn (() => false), +})) + +const postsApi = vi.hoisted (() => ({ + fetchPost: vi.fn (), +})) + +const dialogue = vi.hoisted (() => ({ + confirm: vi.fn (), +})) + +vi.mock ('@/lib/api', () => api) +vi.mock ('@/lib/posts', () => postsApi) +vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + useDialogue: () => dialogue, +})) +vi.mock ('@/components/PostEmbed', () => ({ + default: ({ post }: { + post: { title: string | null; url: string } + }) =>
Embed:{post.title || post.url}
, +})) +vi.mock ('@/components/PostEditForm', () => ({ + default: () =>
Post edit form
, +})) +vi.mock ('framer-motion', () => ({ + motion: { + aside: ({ children }: { children?: ReactNode }) => , + div: ({ children }: { children?: ReactNode }) =>
{children}
, + main: ({ children }: { children?: ReactNode }) =>
{children}
, + }, +})) + +const currentPost = buildPost ({ + id: 10, + title: '上映中の投稿', + url: 'https://www.nicovideo.jp/watch/sm10', +}) +const theatre = buildTheatre ({ id: 7, name: '上映室' }) +const programme = buildTheatreProgramme ({ + theatreId: 7, + position: 3, + post: currentPost, +}) +const weights = buildTheatrePostSelectionWeights ({ + lightestPosts: [{ + post: currentPost, + penalty: 2, + weight: 0.5, + tags: [], + }], +}) + +const renderPage = (user = buildUser ({ id: 1, role: 'member' })) => + renderWithProviders ( + + }/> + , + { route: '/theatres/7' }, + ) + +const mockDefaultApi = () => { + api.apiGet.mockImplementation ((path: string) => { + switch (path) + { + case '/theatres/7': + return Promise.resolve (theatre) + + case '/theatres/7/comments': + return Promise.resolve ([ + buildTheatreComment ({ + theatreId: 7, + no: 2, + user: { id: 1, name: 'tester' }, + content: '視聴コメント', + }), + ]) + + case '/theatres/7/programmes': + return Promise.resolve ([programme]) + + case '/theatres/7/post_selection_weights': + return Promise.resolve (weights) + + default: + return Promise.reject (new Error (`Unexpected GET ${ path }`)) + } + }) + + api.apiPut.mockImplementation ((path: string) => { + switch (path) + { + case '/theatres/7/watching': + return Promise.resolve (buildTheatreInfo ({ + postId: currentPost.id, + postStartedAt: '2026-01-02T03:04:05.000Z', + postElapsedMs: 1_000, + watchingUsers: [{ id: 1, name: 'tester' }], + skipVote: { + votesCount: 0, + requiredCount: 2, + watchingUsersCount: 1, + voted: false, + }, + })) + + case '/theatres/7/skip_vote': + return Promise.resolve (buildTheatreInfo ({ + postId: currentPost.id, + postStartedAt: '2026-01-02T03:04:05.000Z', + postElapsedMs: 2_000, + watchingUsers: [{ id: 1, name: 'tester' }], + skipVote: { + votesCount: 1, + requiredCount: 2, + watchingUsersCount: 1, + voted: true, + }, + })) + + default: + return Promise.reject (new Error (`Unexpected PUT ${ path }`)) + } + }) + + api.apiDelete.mockResolvedValue (undefined) + api.apiPatch.mockResolvedValue (undefined) + api.apiPost.mockResolvedValue (undefined) + postsApi.fetchPost.mockResolvedValue (currentPost) + dialogue.confirm.mockResolvedValue (true) +} + +describe ('TheatreDetailPage', () => { + beforeEach (() => { + vi.clearAllMocks () + mockDefaultApi () + }) + + it ('loads theatre state, comments, current post, programme history, and weights', async () => { + renderPage () + + expect (await screen.findByText ('上映会場『上映室』')).toBeInTheDocument () + expect (await screen.findByText ('Embed:上映中の投稿')).toBeInTheDocument () + expect (screen.getAllByText ('視聴コメント')[0]).toBeInTheDocument () + expect (screen.getAllByText ('上映中の投稿')[0]).toBeInTheDocument () + expect (screen.getByText ('penalty 2')).toBeInTheDocument () + + await waitFor (() => { + expect (postsApi.fetchPost).toHaveBeenCalledWith ('10') + }) + }) + + it ('votes to skip the current post', async () => { + renderPage () + + await screen.findByText ('Embed:上映中の投稿') + + fireEvent.click (screen.getByRole ('button', { name: 'スキップ 0 / 2' })) + + await waitFor (() => { + expect (api.apiPut).toHaveBeenCalledWith ( + '/theatres/7/skip_vote', + { post_id: 10 }, + ) + }) + expect (await screen.findByRole ('button', { name: 'スキップ取消 1 / 2' })) + .toBeInTheDocument () + }) + + it ('deletes an owned comment after confirmation', async () => { + renderPage () + + fireEvent.click ((await screen.findAllByLabelText ('コメントを削除'))[0]) + + await waitFor (() => { + expect (dialogue.confirm).toHaveBeenCalled () + }) + await waitFor (() => { + expect (api.apiDelete).toHaveBeenCalledWith ('/theatres/7/comments/2') + }) + expect (await screen.findAllByText ('削除されました.')).toHaveLength (2) + }) +}) diff --git a/frontend/src/test/factories.ts b/frontend/src/test/factories.ts index 2092619..94a09ed 100644 --- a/frontend/src/test/factories.ts +++ b/frontend/src/test/factories.ts @@ -1,4 +1,13 @@ -import type { Material, Post, Tag, User, WikiPage } from '@/types' +import type { Material, + Post, + Tag, + Theatre, + TheatreComment, + TheatreInfo, + TheatrePostSelectionWeights, + TheatreProgramme, + User, + WikiPage } from '@/types' export const buildTag = (overrides: Partial = {}): Tag => ({ id: 1, @@ -72,3 +81,62 @@ export const buildMaterial = (overrides: Partial = {}): Material => ({ updatedByUser: { id: 2, name: 'updater' }, ...overrides, }) + +export const buildTheatre = (overrides: Partial = {}): Theatre => ({ + id: 1, + name: 'テスト劇場', + opensAt: '2026-01-02T03:04:05.000Z', + closesAt: null, + createdByUser: { id: 1, name: 'creator' }, + createdAt: '2026-01-02T03:04:05.000Z', + updatedAt: '2026-01-03T03:04:05.000Z', + ...overrides, +}) + +export const buildTheatreInfo = ( + overrides: Partial = {}, +): TheatreInfo => ({ + hostFlg: false, + postId: null, + postStartedAt: null, + postElapsedMs: null, + watchingUsers: [], + skipVote: { + votesCount: 0, + requiredCount: 1, + watchingUsersCount: 0, + voted: false, + }, + ...overrides, +}) + +export const buildTheatreComment = ( + overrides: Partial = {}, +): TheatreComment => ({ + theatreId: 1, + no: 1, + deleted: false, + user: { id: 1, name: 'tester' }, + content: 'テストコメント', + createdAt: '2026-01-02T03:04:05.000Z', + ...overrides, +} as TheatreComment) + +export const buildTheatreProgramme = ( + overrides: Partial = {}, +): TheatreProgramme => ({ + theatreId: 1, + position: 1, + post: buildPost (), + createdAt: '2026-01-02T03:04:05.000Z', + ...overrides, +}) + +export const buildTheatrePostSelectionWeights = ( + overrides: Partial = {}, +): TheatrePostSelectionWeights => ({ + tagPenalties: [], + lightestPosts: [], + heaviestPosts: [], + ...overrides, +})