require 'rails_helper' require 'active_support/testing/time_helpers' RSpec.describe 'Theatres API', type: :request do include ActiveSupport::Testing::TimeHelpers around do |example| travel_to(Time.zone.parse('2026-03-18 21:00:00')) do example.run end end let(:member) { create(:user, :member, name: 'member user') } let(:other_user) { create(:user, :member, name: 'other user') } let!(:niconico_post) do Post.create!( title: 'niconico post', url: 'https://www.nicovideo.jp/watch/sm123' ) end let!(:second_niconico_post) do Post.create!( title: 'second niconico post', url: 'https://www.nicovideo.jp/watch/sm456' ) end let!(:other_post) do Post.create!( title: 'other post', url: 'https://example.com/posts/1' ) end let!(:theatre) do Theatre.create!( name: 'spec theatre', opens_at: Time.zone.parse('2026-03-18 20:00:00'), kind: 0, created_by_user: member ) end describe 'GET /theatres/:id' do subject(:do_request) do get "/theatres/#{theatre_id}" end context 'when theatre exists' do let(:theatre_id) { theatre.id } it 'returns theatre json' do do_request expect(response).to have_http_status(:ok) expect(json).to include( 'id' => theatre.id, 'name' => 'spec theatre' ) expect(json).to have_key('opens_at') expect(json).to have_key('closes_at') expect(json).to have_key('created_at') expect(json).to have_key('updated_at') expect(json['created_by_user']).to include( 'id' => member.id, 'name' => 'member user' ) end end context 'when theatre does not exist' do let(:theatre_id) { 999_999_999 } it 'returns 404' do do_request expect(response).to have_http_status(:not_found) end end end describe 'PUT /theatres/:id/watching' do subject(:do_request) do put "/theatres/#{theatre_id}/watching" end let(:theatre_id) { theatre.id } context 'when not logged in' do it 'returns 401' do sign_out do_request expect(response).to have_http_status(:unauthorized) end end context 'when theatre does not exist' do let(:theatre_id) { 999_999_999 } it 'returns 404' do sign_in_as(member) do_request expect(response).to have_http_status(:not_found) end end context 'when theatre has no host yet' do before do sign_in_as(member) end it 'creates watching row, assigns current user as host, and returns current theatre info' do expect { do_request } .to change { TheatreWatchingUser.count }.by(1) expect(response).to have_http_status(:ok) theatre.reload watch = TheatreWatchingUser.find_by!(theatre: theatre, user: member) expect(theatre.host_user_id).to eq(member.id) expect(watch.expires_at).to be_within(1.second).of(30.seconds.from_now) expect(json).to include( 'host_flg' => true, 'post_id' => nil, 'post_started_at' => nil, 'post_elapsed_ms' => nil ) expect(json.fetch('watching_users')).to contain_exactly( { 'id' => member.id, 'name' => 'member user' } ) end end context 'when current user is already watching' do let!(:watching_row) do TheatreWatchingUser.create!( theatre: theatre, user: member, expires_at: 5.seconds.from_now ) end before do sign_in_as(member) end it 'refreshes expires_at without creating another row' do expect { do_request } .not_to change { TheatreWatchingUser.count } expect(response).to have_http_status(:ok) expect(watching_row.reload.expires_at) .to be_within(1.second).of(30.seconds.from_now) end end context 'when another active host exists' do before do TheatreWatchingUser.create!( theatre: theatre, user: other_user, expires_at: 10.minutes.from_now ) theatre.update!(host_user: other_user) sign_in_as(member) end it 'does not steal host and returns host_flg false' do expect { do_request } .to change { TheatreWatchingUser.count }.by(1) expect(response).to have_http_status(:ok) expect(theatre.reload.host_user_id).to eq(other_user.id) expect(json).to include( 'host_flg' => false, 'post_id' => nil, 'post_started_at' => nil, 'post_elapsed_ms' => nil ) expect(json.fetch('watching_users')).to contain_exactly( { 'id' => member.id, 'name' => 'member user' }, { 'id' => other_user.id, 'name' => 'other user' } ) end end context 'when host is set but no longer actively watching' do let(:started_at) { 2.minutes.ago } before do TheatreWatchingUser.create!( theatre: theatre, user: other_user, expires_at: 1.second.ago ) theatre.update!( host_user: other_user, current_post: niconico_post, current_post_started_at: started_at ) sign_in_as(member) end it 'reassigns host to current user and returns current post info' do expect { do_request } .to change { TheatreWatchingUser.count }.by(1) expect(response).to have_http_status(:ok) theatre.reload expect(theatre.host_user_id).to eq(member.id) expect(json['host_flg']).to eq(true) expect(json['post_id']).to eq(niconico_post.id) expect(Time.zone.parse(json['post_started_at'])) .to be_within(1.second).of(started_at) expect(json['post_elapsed_ms']) .to be_within(1_000).of(120_000) end end end describe 'PATCH /theatres/:id/next_post' do subject(:do_request) do patch "/theatres/#{theatre_id}/next_post" end let(:theatre_id) { theatre.id } context 'when not logged in' do it 'returns 401' do sign_out do_request expect(response).to have_http_status(:unauthorized) end end context 'when theatre does not exist' do let(:theatre_id) { 999_999_999 } it 'returns 404' do sign_in_as(member) do_request expect(response).to have_http_status(:not_found) end end context 'when logged in but not host' do before do theatre.update!(host_user: other_user) sign_in_as(member) end it 'returns 403' do do_request expect(response).to have_http_status(:forbidden) end end context 'when current user is host' do before do theatre.update!(host_user: member) sign_in_as(member) end it 'sets current_post to an eligible post and updates current_post_started_at' do expect { do_request } .to change { theatre.reload.current_post_id } expect(response).to have_http_status(:no_content) expect([niconico_post.id, second_niconico_post.id]) .to include(theatre.reload.current_post_id) expect(theatre.reload.current_post_started_at) .to be_within(1.second).of(Time.current) expect(theatre.programmes.count).to eq(1) end end context 'when current user is host and no eligible post exists' do before do niconico_post.destroy! second_niconico_post.destroy! theatre.update!( host_user: member, current_post: other_post, current_post_started_at: 1.hour.ago ) sign_in_as(member) end it 'still returns 204 and clears current_post' do do_request expect(response).to have_http_status(:no_content) theatre.reload expect(theatre.current_post_id).to be_nil expect(theatre.current_post_started_at).to be_nil end end end describe 'PUT /theatres/:id/skip_vote' do subject(:do_request) do put "/theatres/#{theatre.id}/skip_vote" end let(:third_user) { create(:user, :member, name: 'third user') } before do theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago) [member, other_user, third_user].each do |user| TheatreWatchingUser.create!( theatre:, user:, expires_at: 10.seconds.from_now ) end end it 'records a vote and returns the current vote status before majority' do sign_in_as(member) expect { do_request }.to change(TheatreSkipVote, :count).by(1) expect(response).to have_http_status(:ok) expect(json['skipped']).to eq(false) expect(json['post_id']).to eq(niconico_post.id) expect(json['skip_vote']).to include( 'votes_count' => 1, 'required_count' => 2, 'watching_users_count' => 3, 'voted' => true ) end it 'finalizes skip when votes reach majority and stores voters and tag snapshots' do tag = create(:tag, name: 'skip-target') PostTag.create!(post: niconico_post, tag:) TheatreSkipVote.create!(theatre:, post: niconico_post, user: member) sign_in_as(other_user) expect { do_request } .to change(TheatreSkipEvent, :count).by(1) .and change(TheatreSkipEventVoter, :count).by(2) .and change(TheatreSkipEventTag, :count).by(1) expect(response).to have_http_status(:ok) expect(json['skipped']).to eq(true) expect(json['post_id']).to eq(second_niconico_post.id) event = TheatreSkipEvent.last expect(event.post).to eq(niconico_post) expect(event.users).to contain_exactly(member, other_user) expect(event.tags).to contain_exactly(tag) expect(TheatreSkipVote.where(theatre:, post: niconico_post)).to be_empty end end describe 'DELETE /theatres/:id/skip_vote' do before do theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago) TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now) TheatreSkipVote.create!(theatre:, post: niconico_post, user: member) sign_in_as(member) end it 'removes the current user vote' do expect { delete "/theatres/#{theatre.id}/skip_vote" }.to change(TheatreSkipVote, :count).by(-1) expect(response).to have_http_status(:ok) expect(json['skip_vote']).to include( 'votes_count' => 0, 'required_count' => 1, 'watching_users_count' => 1, 'voted' => false ) end end describe 'GET /theatres/:id/post_selection_weights' do before do theatre.update!(current_post: niconico_post) TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now) sign_in_as(member) end it 'returns tag penalties and candidate weights for the current watchers' do tag = create(:tag, name: 'heavy-tag') PostTag.create!(post: second_niconico_post, tag:) event = TheatreSkipEvent.create!( theatre:, post: niconico_post, skipped_by_user: member, created_at: Time.current ) TheatreSkipEventVoter.create!(theatre_skip_event: event, user: member) TheatreSkipEventTag.create!(theatre_skip_event: event, tag:) get "/theatres/#{theatre.id}/post_selection_weights" expect(response).to have_http_status(:ok) expect(json['tag_penalties'].first['penalty']).to eq(1) expect(json['lightest_posts'].first['post']['id']).to eq(second_niconico_post.id) expect(json['lightest_posts'].first['penalty']).to eq(1) end end end