diff --git a/backend/spec/requests/list_limits_spec.rb b/backend/spec/requests/list_limits_spec.rb new file mode 100644 index 0000000..ec0a0ef --- /dev/null +++ b/backend/spec/requests/list_limits_spec.rb @@ -0,0 +1,174 @@ +require 'rails_helper' + +RSpec.describe 'List API limits', type: :request do + shared_examples 'a capped list' do |default_count, minimum_count, paginated| + def response_rows + body = json + body.is_a?(Array) ? body : body.fetch(collection_key) + end + + it 'caps oversized limits at 100 and preserves the 100 boundary' do + get path, params: { locale: 'ja', limit: 100 } + expect(response).to have_http_status(:ok) + expect(response_rows.size).to eq(100) + boundary_rows = response_rows + + [101, 10_000].each do |limit| + get path, params: { locale: 'ja', limit: } + expect(response).to have_http_status(:ok) + expect(response_rows).to eq(boundary_rows) + end + end + + it 'preserves the default, nonpositive and small positive limits' do + get path, params: { locale: 'ja' } + expect(response).to have_http_status(:ok) + expect(response_rows.size).to eq(default_count) + + [0, -1].each do |limit| + get path, params: { locale: 'ja', limit: } + expect(response).to have_http_status(:ok) + expect(response_rows.size).to eq(minimum_count) + end + + get path, params: { locale: 'ja', limit: 2 } + expect(response).to have_http_status(:ok) + expect(response_rows.size).to eq(2) + end + + if paginated + it 'uses the capped limit for page offsets and preserves total count' do + get path, params: { locale: 'ja', limit: 100 } + first_page = response_rows + expect(response).to have_http_status(:ok) + expect(json.fetch('count')).to eq(101) + + get path, params: { locale: 'ja', limit: 100, page: 2 } + expect(response).to have_http_status(:ok) + expect(response_rows.size).to eq(1) + second_page = response_rows + expect(first_page & second_page).to be_empty + + get path, params: { locale: 'ja', limit: 101, page: 2 } + expect(response).to have_http_status(:ok) + expect(response_rows).to eq(second_page) + expect(json.fetch('count')).to eq(101) + end + end + end + + context 'posts' do + let(:path) { '/posts' } + let(:collection_key) { 'posts' } + before { create_list(:post, 101) } + + include_examples 'a capped list', 20, 1, true + end + + context 'tags' do + let(:path) { '/tags' } + let(:collection_key) { 'tags' } + before { create_list(:tag, 101) } + + include_examples 'a capped list', 20, 1, true + end + + context 'nico tags' do + let(:path) { '/tags/nico' } + let(:collection_key) { 'tags' } + before { create_list(:external_tag, 101) } + + include_examples 'a capped list', 20, 1, true + end + + context 'materials' do + let(:path) { '/materials' } + let(:collection_key) { 'materials' } + before do + 101.times { |index| Material.create!(url: "https://example.com/material-#{ index }") } + end + + include_examples 'a capped list', 20, 1, true + end + + context 'post versions' do + let(:path) { '/posts/versions' } + let(:collection_key) { 'versions' } + let(:post) { create(:post) } + before do + 101.times do |index| + PostVersion.create!(post:, version_no: index + 1, event_type: 'update', + url: post.url, tags: '', tags_json: [], parent_post_ids: '') + end + end + + include_examples 'a capped list', 20, 1, true + end + + context 'tag versions' do + let(:path) { '/tags/versions' } + let(:collection_key) { 'versions' } + let(:tag) { create(:tag) } + before do + 101.times do |index| + TagVersion.create!(tag:, version_no: index + 1, event_type: 'update', + name: 'limit tag', category: 'general', aliases: '', + parent_tag_ids: '', names: { + ja: { name: 'limit tag', script_code: 'Jpan', + auto_generated: false, aliases: [] } }) + end + end + + include_examples 'a capped list', 20, 1, true + end + + context 'material versions' do + let(:path) { '/materials/versions' } + let(:collection_key) { 'versions' } + let(:material) { Material.create!(url: 'https://example.com/material') } + before do + 101.times do |index| + MaterialVersion.create!(material:, version_no: index + 1, + event_type: 'update', url: material.url) + end + end + + include_examples 'a capped list', 20, 1, true + end + + context 'theatre lists' do + let(:theatre) { create(:theatre) } + let(:post) { create(:post) } + + context 'comments' do + let(:path) { "/theatres/#{ theatre.id }/comments" } + before { create_list(:theatre_comment, 101, theatre:, user: theatre.created_by_user) } + + include_examples 'a capped list', 20, 20, false + end + + context 'programmes' do + let(:path) { "/theatres/#{ theatre.id }/programmes" } + before do + 101.times do |index| + TheatreProgramme.create!(theatre:, post:, position: index + 1) + end + end + + include_examples 'a capped list', 100, 100, false + end + + context 'skip events' do + let(:path) { "/theatres/#{ theatre.id }/skip_events" } + before do + 101.times do |index| + TheatreSkipEvent.create!(theatre:, post:, + skipped_by_user: theatre.created_by_user, + created_at: Time.utc(2026, 1, 1) + index.seconds) + end + end + + include_examples 'a capped list', 50, 50, false + end + end +end