require 'rails_helper' RSpec.describe PostBulkCreator do include_context 'Ainu locale' it 'limits workers to two and keeps failures in their request slots' do locale = Locale.nipponese actor = instance_double(User, id: 123) allow(User).to receive(:find).with(123) { instance_double(User, id: 123) } mutex = Mutex.new active = 0 maximum_active = 0 creators = [] allow(PostCreatePreflight).to receive(:new) do |attributes:, **| preflight = instance_double(PostCreatePreflight) allow(preflight).to receive(:run) do mutex.synchronize do active += 1 maximum_active = [maximum_active, active].max end sleep 0.02 mutex.synchronize { active -= 1 } attributes.symbolize_keys.merge( existing_post_id: nil, field_warnings: { }, base_warnings: []) end preflight end allow(PostCreator).to receive(:new) do |attributes:, **| creator = instance_double(PostCreator) mutex.synchronize { creators << creator } if attributes[:title] == 'broken' allow(creator).to receive(:create!).and_raise(StandardError, 'broken') else post = instance_double(Post, id: attributes[:title].delete_prefix('post ').to_i) allow(creator).to receive(:create!).and_return(post) end creator end posts = [ { 'title' => 'post 1', 'url' => 'https://example.com/1' }, { 'title' => 'broken', 'url' => 'https://example.com/2' }, { 'title' => 'post 3', 'url' => 'https://example.com/3' }, { 'title' => 'post 4', 'url' => 'https://example.com/4' }] results = described_class.new( actor:, posts:, thumbnails: { }).run(locale).fetch(:results) expect(creators.length).to eq(posts.length) creators.each do |creator| expect(creator).to have_received(:create!).with(locale).once end expect(maximum_active).to eq(2) expect(results.length).to eq(posts.length) expect(results.map { _1[:status] }).to eq( ['created', 'failed', 'created', 'created']) expect(results[0].dig(:post, :id)).to eq(1) expect(results[1]).to include(status: 'failed', recoverable: false) expect(results[2].dig(:post, :id)).to eq(3) expect(results[3].dig(:post, :id)).to eq(4) end it 'passes a future Ainu locale to preflight and creation in every worker' do locale actor = instance_double(User, id: 123) allow(User).to receive(:find).with(123).and_return(actor) preflight = instance_double(PostCreatePreflight) creator = instance_double(PostCreator) post = instance_double(Post, id: 1) allow(PostCreatePreflight).to receive(:new).and_return(preflight) allow(preflight).to receive(:run).with(locale).and_return(existing_post_id: nil) allow(PostCreator).to receive(:new).and_return(creator) allow(creator).to receive(:create!).with(locale).and_return(post) results = described_class.new( actor:, posts: [{ title: 'future post' }], thumbnails: { }).run(locale) expect(results.fetch(:results)).to contain_exactly( hash_including(status: 'created', post: { id: 1 })) expect(preflight).to have_received(:run).with(locale).once expect(creator).to have_received(:create!).with(locale).once end end RSpec.describe 'PostBulkCreator event grouping', type: :model do self.use_transactional_tests = false before do expect(Rails.env.test?).to be(true) expect(ApplicationRecord.connection.open_transactions).to eq(0) @prefix = "bulk_event_#{ SecureRandom.hex(8) }" @actor = create(:user, :member) allow(PostCreatePreflight).to receive(:new) do |attributes:, **| specs = [{ name: attributes.fetch(:title), category: :general }] instance_double(PostCreatePreflight, run: attributes.merge( existing_post_id: nil, snapshot_tag_specs: specs, post_tag_specs: specs, normalised_parent_post_ids: [], tag_sections: { }, video_ms: nil)) end end after do if @actor posts = Post.where(uploaded_user: @actor) PostTagSection.where(post_id: posts.select(:id)).delete_all PostTag.where(post_id: posts.select(:id)).delete_all PostVersion.where(post_id: posts.select(:id)).delete_all posts.delete_all tag_ids = TagName.where(name: ["#{ @prefix }_1", "#{ @prefix }_2"]).pluck(:tag_id) TagVersion.where(tag_id: tag_ids).delete_all TagName.where(tag_id: tag_ids).delete_all Tag.where(id: tag_ids).delete_all EventRecords::EventRow.where(created_by_user_id: @actor.id).delete_all @actor.delete end end it 'shares one creation event between real workers and their tag histories' do posts = [1, 2].map do |number| { title: "#{ @prefix }_#{ number }", url: "https://example.com/#{ @prefix }/#{ number }" } end result = nil expect { result = PostBulkCreator.new(actor: @actor, posts:, thumbnails: { }) .run(Locale.nipponese) }.to change { event_rows.length }.by(1) expect(result.fetch(:results).map { _1.fetch(:status) }).to eq(['created', 'created']) event = event_rows.last expect(event).to include( 'event_type' => 'posts.create', 'created_by_user_id' => @actor.id) versions = PostVersion.where(created_by_user_id: @actor.id) expect(versions.count).to eq(2) expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) tag_versions = TagVersion.where(created_by_user_id: @actor.id) expect(tag_versions.count).to eq(2) expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) end it 'does not leave an event when every row fails validation' do preflight = instance_double(PostCreatePreflight) allow(PostCreatePreflight).to receive(:new).and_return(preflight) allow(preflight).to receive(:run).and_raise( PostCreatePreflight::ValidationFailed.new(fields: { url: ['invalid'] })) events = event_rows result = PostBulkCreator.new( actor: @actor, posts: [{ title: 'invalid' }], thumbnails: { }).run(Locale.nipponese) expect(result.fetch(:results).map { _1.fetch(:status) }).to eq(['failed']) expect(event_rows).to eq(events) end it 'does not leave an event when every row is skipped' do preflight = instance_double(PostCreatePreflight, run: { existing_post_id: 123 }) allow(PostCreatePreflight).to receive(:new).and_return(preflight) events = event_rows result = PostBulkCreator.new( actor: @actor, posts: [{ title: 'existing' }], thumbnails: { }).run(Locale.nipponese) expect(result.fetch(:results).map { _1.fetch(:status) }).to eq(['skipped']) expect(event_rows).to eq(events) end end