3980e9651e
Reviewed-on: #357 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
526 行
15 KiB
Ruby
526 行
15 KiB
Ruby
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!(:youtube_post) do
|
|
Post.create!(
|
|
title: 'youtube post',
|
|
url: 'https://www.youtube.com/watch?v=yt123'
|
|
)
|
|
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, youtube_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 only a YouTube post is eligible' do
|
|
before do
|
|
niconico_post.destroy!
|
|
second_niconico_post.destroy!
|
|
theatre.update!(host_user: member)
|
|
sign_in_as(member)
|
|
end
|
|
|
|
it 'sets current_post to the YouTube post' do
|
|
do_request
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
expect(theatre.reload.current_post_id).to eq(youtube_post.id)
|
|
end
|
|
end
|
|
|
|
context 'when current user is host and no eligible post exists' do
|
|
before do
|
|
niconico_post.destroy!
|
|
second_niconico_post.destroy!
|
|
youtube_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", params: { post_id: requested_post_id }
|
|
end
|
|
|
|
let(:third_user) { create(:user, :member, name: 'third user') }
|
|
let(:requested_post_id) { niconico_post.id }
|
|
|
|
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 '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)
|
|
|
|
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([second_niconico_post.id, youtube_post.id]).to include(json['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
|
|
|
|
it 'does not record a vote when requested post is no longer current' do
|
|
theatre.update!(current_post: second_niconico_post)
|
|
sign_in_as(member)
|
|
|
|
expect { do_request }.not_to change(TheatreSkipVote, :count)
|
|
|
|
expect(response).to have_http_status(:conflict)
|
|
expect(json['post_id']).to eq(second_niconico_post.id)
|
|
expect(json['skip_vote']).to include(
|
|
'votes_count' => 0,
|
|
'voted' => false
|
|
)
|
|
end
|
|
end
|
|
|
|
describe 'DELETE /theatres/:id/skip_vote' do
|
|
let(:requested_post_id) { niconico_post.id }
|
|
|
|
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", params: { post_id: requested_post_id }
|
|
}.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
|
|
|
|
it 'does not remove a vote when requested post is no longer current' do
|
|
theatre.update!(current_post: second_niconico_post)
|
|
|
|
expect {
|
|
delete "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id }
|
|
}.not_to change(TheatreSkipVote, :count)
|
|
|
|
expect(response).to have_http_status(:conflict)
|
|
expect(json['post_id']).to eq(second_niconico_post.id)
|
|
end
|
|
end
|
|
|
|
describe 'GET /theatres/:id/skip_events' do
|
|
before do
|
|
sign_in_as(member)
|
|
end
|
|
|
|
it 'does not expose skip voters' do
|
|
event = TheatreSkipEvent.create!(
|
|
theatre:,
|
|
post: niconico_post,
|
|
skipped_by_user: member,
|
|
created_at: Time.current
|
|
)
|
|
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: member)
|
|
|
|
get "/theatres/#{theatre.id}/skip_events"
|
|
|
|
expect(response).to have_http_status(:ok)
|
|
expect(json.first).to include(
|
|
'id' => event.id,
|
|
'theatre_id' => theatre.id
|
|
)
|
|
expect(json.first).not_to have_key('voters')
|
|
expect(json.first).not_to have_key('skipped_by_user')
|
|
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
|