From ad04c03e14df683a8864dfe9ebd4f43cbf8e5891 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 3 Nov 2026 13:37:09 +0900 Subject: [PATCH] #414 --- backend/spec/db/tag_group_constraints_spec.rb | 1 + backend/spec/db/tag_version_names_spec.rb | 1 + backend/spec/models/history_access_spec.rb | 28 +++++-- backend/spec/models/locale_activation_spec.rb | 25 +++++- backend/spec/models/post_version_spec.rb | 28 +++---- .../models/tag_name_sanitisation_rule_spec.rb | 27 ++++++ backend/spec/models/tag_spec.rb | 28 +++---- backend/spec/models/version_record_spec.rb | 46 +++++----- backend/spec/rails_helper.rb | 1 + backend/spec/requests/counts_download_spec.rb | 4 +- .../counts_history_boundaries_spec.rb | 4 +- backend/spec/requests/counts_spec.rb | 8 +- backend/spec/requests/counts_table_spec.rb | 36 ++++++-- backend/spec/requests/materials_spec.rb | 41 ++++++++- backend/spec/requests/nico_tags_spec.rb | 5 ++ backend/spec/requests/posts_spec.rb | 55 ++++++++---- backend/spec/requests/sitemap_entries_spec.rb | 4 +- backend/spec/requests/tag_children_spec.rb | 10 +++ .../requests/tag_description_wiki_spec.rb | 2 + .../spec/requests/tag_descriptions_spec.rb | 14 +++- backend/spec/requests/tag_groups_spec.rb | 6 +- backend/spec/requests/tag_versions_spec.rb | 1 + backend/spec/requests/tags_spec.rb | 10 +++ backend/spec/services/counts_export_spec.rb | 4 +- .../services/counts_pivot_security_spec.rb | 28 +++++-- .../services/material_sync_importer_spec.rb | 2 + .../services/material_sync_runner_spec.rb | 41 +++++++++ .../spec/services/post_bulk_creator_spec.rb | 83 +++++++++++++++++++ ...shared_resource_id_allocator_mysql_spec.rb | 10 +++ .../shared_resource_id_allocator_spec.rb | 3 + backend/spec/services/youtube/sync_spec.rb | 35 ++++++++ backend/spec/support/event_records.rb | 15 ++++ backend/spec/support/tag_group_records.rb | 5 +- backend/spec/tasks/nico_sync_spec.rb | 57 +++++++++---- 34 files changed, 554 insertions(+), 114 deletions(-) create mode 100644 backend/spec/support/event_records.rb diff --git a/backend/spec/db/tag_group_constraints_spec.rb b/backend/spec/db/tag_group_constraints_spec.rb index bdbe417..08472b5 100644 --- a/backend/spec/db/tag_group_constraints_spec.rb +++ b/backend/spec/db/tag_group_constraints_spec.rb @@ -102,6 +102,7 @@ RSpec.describe 'Tag group persistence constraints' do 6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] } reject_statement do insert_group_row('tag_group_versions', { + event_id: create_event!(event_type: 'unknown').id, tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認', members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json, placements: nodes.to_json, created_at: Time.current }) diff --git a/backend/spec/db/tag_version_names_spec.rb b/backend/spec/db/tag_version_names_spec.rb index 3c351b4..b90bd84 100644 --- a/backend/spec/db/tag_version_names_spec.rb +++ b/backend/spec/db/tag_version_names_spec.rb @@ -10,6 +10,7 @@ RSpec.describe 'TagVersion names database constraint' do def insert_snapshot!(snapshot) TagVersion.insert_all!([{ tag_id: tag.id, version_no: 1, event_type: 'create', + event_id: create_event!(event_type: 'unknown').id, name: 'snapshot_name', category: 'general', aliases: '', parent_tag_ids: '', names: snapshot, created_at: Time.current }]) diff --git a/backend/spec/models/history_access_spec.rb b/backend/spec/models/history_access_spec.rb index c087c35..9c382fa 100644 --- a/backend/spec/models/history_access_spec.rb +++ b/backend/spec/models/history_access_spec.rb @@ -9,7 +9,9 @@ RSpec.describe HistoryAccess do locale rows = ['ja', 'ain'].flat_map do |language| ['create', 'discard', 'restore'].each_with_index.map do |event, index| - TagDescriptionVersion.create!(tag_id: tag.id, language_code: language, + TagDescriptionVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + tag_id: tag.id, language_code: language, version_no: index + 1, event_type: language == 'ain' ? 'update' : event, body: "#{ language } #{ index }", created_at: Time.utc(2024)) end @@ -28,7 +30,9 @@ RSpec.describe HistoryAccess do public_metadata = described_class.set(user: nil) { described_class.authority } admin = create(:user, :admin) admin_metadata = described_class.set(user: admin) { described_class.authority } - TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private', + TagVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + tag:, version_no: 1, event_type: 'discard', name: 'private', category: 'general', names: { ja: { name: 'private', script_code: 'Jpan', auto_generated: false, aliases: [] } }, @@ -52,7 +56,9 @@ RSpec.describe HistoryAccess do blob = ActiveStorage::Blob.create_before_direct_upload!(filename: 'private.png', byte_size: 1, checksum: Base64.strict_encode64('checksum'), content_type: 'image/png') ActiveStorage::Attachment.create!(name: 'thumbnail', record: post, blob:) - PostVersion.create!(post:, version_no: 1, event_type: 'discard', url: post.url, + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post:, version_no: 1, event_type: 'discard', url: post.url, title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024)) variant = ActiveStorage::VariantRecord.create!(blob:, variation_digest: 'spec-variation') derived = ActiveStorage::Blob.create_before_direct_upload!(filename: 'derived.png', @@ -78,7 +84,9 @@ RSpec.describe 'Counts artifact policy and external references' do it 'does not expose a private linked tag name through an external history snapshot' do tag = create(:tag, primary_name: 'private-linked-name') - TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name', + TagVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name', category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024), names: { ja: { name: 'private-linked-name', script_code: 'Jpan', auto_generated: false, aliases: [] } }) @@ -87,7 +95,9 @@ RSpec.describe 'Counts artifact policy and external references' do name: 'nico:public-external', linked_tags: 'private-linked-name', created_at: Time.utc(2024)) post = create(:post) - snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create', + snapshot = PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post:, version_no: 1, event_type: 'create', title: 'external reference', url: post.url, tags: '', tags_json: [{ external_tag_id: external.id }], parent_post_ids: '', created_at: Time.utc(2024, 2)) @@ -112,13 +122,17 @@ RSpec.describe 'Counts artifact policy and external references' do child = create(:tag, primary_name: 'referencing child') [[parent, 'discard', '', 'private parent'], [child, 'create', parent.id.to_s, 'referencing child']].each do |tag, event, parents, name| - TagVersion.create!(tag:, version_no: 1, event_type: event, name:, + TagVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + tag:, version_no: 1, event_type: event, name:, category: 'general', aliases: '', parent_tag_ids: parents, created_at: Time.utc(2023), names: { ja: { name:, script_code: 'Jpan', auto_generated: false, aliases: [] } }) end post = create(:post) - row = PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'snapshot', + row = PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post:, version_no: 1, event_type: 'create', title: 'snapshot', url: post.url, tags: '', parent_post_ids: '', tags_json: [{ tag_id: child.id, version_no: 1, name: 'referencing child', sections: [] }], created_at: Time.utc(2024)) diff --git a/backend/spec/models/locale_activation_spec.rb b/backend/spec/models/locale_activation_spec.rb index e7d80d5..ac14678 100644 --- a/backend/spec/models/locale_activation_spec.rb +++ b/backend/spec/models/locale_activation_spec.rb @@ -23,9 +23,22 @@ RSpec.describe 'Locale activation', type: :model do .and_raise('backfill failed') end + it 'groups all tag histories from locale creation into one event' do + previous_ids = TagVersion.pluck(:id) + + expect { build_locale.save! }.to change { event_rows.length }.by(1) + + event = event_rows.last + expect(event).to include('event_type' => 'locales.create', 'created_by_user_id' => nil) + versions = TagVersion.where.not(id: previous_ids) + expect(versions.pluck(:tag_id)).to include(first.id, second.id) + expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) + end + it 'rolls back a newly created locale and the preceding generated names' do locale = build_locale fail_second_generation! + events = event_rows expect { locale.save! }.to raise_error('backfill failed') @@ -33,6 +46,7 @@ RSpec.describe 'Locale activation', type: :model do expect(generated_names).to be_empty expect(first.reload.name('ja')).to eq('activation_first') expect(second.reload.name('ja')).to eq('activation_second') + expect(event_rows).to eq(events) end it 'does not backfill while the locale is inactive' do @@ -45,7 +59,14 @@ RSpec.describe 'Locale activation', type: :model do locale = build_locale(deprecated_at: Time.current) locale.save! - locale.update!(deprecated_at: nil) + previous_ids = TagVersion.pluck(:id) + expect { locale.update!(deprecated_at: nil) }.to change { event_rows.length }.by(1) + + event = event_rows.last + expect(event).to include('event_type' => 'locales.restore', 'created_by_user_id' => nil) + versions = TagVersion.where.not(id: previous_ids) + expect(versions.pluck(:tag_id)).to include(first.id, second.id) + expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id) expect(generated_names).to all(have_attributes( @@ -57,11 +78,13 @@ RSpec.describe 'Locale activation', type: :model do locale.save! previous_deprecated_at = locale.reload.deprecated_at fail_second_generation! + events = event_rows expect { locale.update!(deprecated_at: nil) }.to raise_error('backfill failed') expect(locale.reload.deprecated_at).to eq(previous_deprecated_at) expect(generated_names).to be_empty + expect(event_rows).to eq(events) end it 'preserves a human primary through deactivation and repeated activation' do diff --git a/backend/spec/models/post_version_spec.rb b/backend/spec/models/post_version_spec.rb index f29b530..e7b4031 100644 --- a/backend/spec/models/post_version_spec.rb +++ b/backend/spec/models/post_version_spec.rb @@ -12,20 +12,20 @@ RSpec.describe PostVersion, type: :model do let!(:post_version) do PostVersion.create!( - post: post_record, - version_no: 1, - event_type: 'create', - title: post_record.title, - url: post_record.url, - thumbnail_base: post_record.thumbnail_base, - tags: post_record.snapshot_tag_names.join(' '), - tags_json: post_record.snapshot_tags_json, - parent_post_ids: post_record.snapshot_parent_post_ids.join(' '), - original_created_from: post_record.original_created_from, - original_created_before: post_record.original_created_before, - created_at: Time.current, - created_by_user: nil - ) + event_id: create_event!(event_type: 'unknown').id, + post: post_record, + version_no: 1, + event_type: 'create', + title: post_record.title, + url: post_record.url, + thumbnail_base: post_record.thumbnail_base, + tags: post_record.snapshot_tag_names.join(' '), + tags_json: post_record.snapshot_tags_json, + parent_post_ids: post_record.snapshot_parent_post_ids.join(' '), + original_created_from: post_record.original_created_from, + original_created_before: post_record.original_created_before, + created_at: Time.current, + created_by_user: nil) end it 'is read only after create' do diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index ab77c84..a0344f3 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -34,6 +34,31 @@ RSpec.describe TagNameSanitisationRule, type: :model do described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo') end + it 'groups renames, merges and affected post histories into one event' do + target = create(:tag, primary_name: 'foobar') + source = create(:tag, primary_name: 'temporary_source') + renamed = create(:tag, primary_name: 'temporary_rename') + source.tag_name('ja').update_columns(name: 'foo_bar') + renamed.tag_name('ja').update_columns(name: 'foo_baz') + post = create(:post) + PostTag.create!(post:, tag: source) + previous_tag_ids = TagVersion.pluck(:id) + previous_post_ids = PostVersion.pluck(:id) + + expect { described_class.apply! }.to change { event_rows.length }.by(1) + + event = event_rows.last + expect(event).to include( + 'event_type' => 'tag_name_sanitisation_rules.apply', 'created_by_user_id' => nil) + tag_versions = TagVersion.where.not(id: previous_tag_ids) + expect(tag_versions.pluck(:tag_id).uniq).to contain_exactly(target.id, source.id, renamed.id) + expect(tag_versions.pluck(:event_type)).to include('discard', 'update') + expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) + post_versions = PostVersion.where.not(id: previous_post_ids) + expect(post_versions.pluck(:post_id).uniq).to eq([post.id]) + expect(post_versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) + end + [:primary, :alias].each do |source_kind| it "keeps the primary identity when a #{ source_kind } collides within its Tag" do tag = create(:tag, primary_name: source_kind == :primary ? 'temporary' : 'foobar') @@ -83,6 +108,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do PostTag.create!(post:, tag: source) models = [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion, PostTag] snapshots = models.map { |model| model.order(*Array(model.primary_key)).map(&:attributes) } + events = event_rows expect { described_class.apply! }.to raise_error(ActiveRecord::RecordInvalid) @@ -90,6 +116,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do model.order(*Array(model.primary_key)).map(&:attributes) end expect(after_state).to eq(snapshots) + expect(event_rows).to eq(events) expect(post.reload.tags).to contain_exactly(source) expect(target.reload.name('ja')).to eq('foobar') end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index a247f93..b27f07e 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -665,20 +665,20 @@ RSpec.describe Tag, type: :model do def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil) PostVersion.create!( - post: post, - version_no: version_no, - event_type: event_type, - title: post.title, - url: post.url, - thumbnail_base: post.thumbnail_base, - tags: snapshot_tags(post), - tags_json: post.snapshot_tags_json, - parent_post_ids: post.snapshot_parent_post_ids.join(' '), - original_created_from: post.original_created_from, - original_created_before: post.original_created_before, - created_at: Time.current, - created_by_user: created_by_user - ) + event_id: create_event!(event_type: 'unknown').id, + post: post, + version_no: version_no, + event_type: event_type, + title: post.title, + url: post.url, + thumbnail_base: post.thumbnail_base, + tags: snapshot_tags(post), + tags_json: post.snapshot_tags_json, + parent_post_ids: post.snapshot_parent_post_ids.join(' '), + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + created_at: Time.current, + created_by_user: created_by_user) end context 'when post versions are enabled' do diff --git a/backend/spec/models/version_record_spec.rb b/backend/spec/models/version_record_spec.rb index 561c19b..148859b 100644 --- a/backend/spec/models/version_record_spec.rb +++ b/backend/spec/models/version_record_spec.rb @@ -6,17 +6,18 @@ RSpec.describe VersionRecord, type: :model do it 'makes TagVersion read only after create' do version = TagVersion.create!( - tag: tag, - version_no: 1, - event_type: 'create', - name: primary_tag_name_for(tag, 'ja').name, - category: tag.category, - aliases: '', - names: { ja: { name: primary_tag_name_for(tag, 'ja').name, - script_code: 'Jpan', auto_generated: false, aliases: [] } }, - parent_tag_ids: '', - created_at: Time.current, - created_by_user: nil) + event_id: create_event!(event_type: 'unknown').id, + tag: tag, + version_no: 1, + event_type: 'create', + name: primary_tag_name_for(tag, 'ja').name, + category: tag.category, + aliases: '', + names: { ja: { name: primary_tag_name_for(tag, 'ja').name, + script_code: 'Jpan', auto_generated: false, aliases: [] } }, + parent_tag_ids: '', + created_at: Time.current, + created_by_user: nil) expect { version.update!(name: 'changed') @@ -25,17 +26,18 @@ RSpec.describe VersionRecord, type: :model do it 'prevents TagVersion destroy' do version = TagVersion.create!( - tag: tag, - version_no: 1, - event_type: 'create', - name: primary_tag_name_for(tag, 'ja').name, - category: tag.category, - aliases: '', - names: { ja: { name: primary_tag_name_for(tag, 'ja').name, - script_code: 'Jpan', auto_generated: false, aliases: [] } }, - parent_tag_ids: '', - created_at: Time.current, - created_by_user: nil) + event_id: create_event!(event_type: 'unknown').id, + tag: tag, + version_no: 1, + event_type: 'create', + name: primary_tag_name_for(tag, 'ja').name, + category: tag.category, + aliases: '', + names: { ja: { name: primary_tag_name_for(tag, 'ja').name, + script_code: 'Jpan', auto_generated: false, aliases: [] } }, + parent_tag_ids: '', + created_at: Time.current, + created_by_user: nil) expect { version.destroy! diff --git a/backend/spec/rails_helper.rb b/backend/spec/rails_helper.rb index 8fae8f1..0b2f5c7 100644 --- a/backend/spec/rails_helper.rb +++ b/backend/spec/rails_helper.rb @@ -44,6 +44,7 @@ RSpec.configure do |config| end config.include TestRecords + config.include EventRecords config.include RakeTaskHelper # FactoryBot の create / create_list を使へるやぅにする diff --git a/backend/spec/requests/counts_download_spec.rb b/backend/spec/requests/counts_download_spec.rb index 5734869..7f0c224 100644 --- a/backend/spec/requests/counts_download_spec.rb +++ b/backend/spec/requests/counts_download_spec.rb @@ -84,7 +84,9 @@ RSpec.describe 'Counts native GET acknowledgement', type: :request do expect(response).to have_http_status(:not_found) job, file, attempt = ready_job('json') record = create(:post) - PostVersion.create!(post: record, version_no: 1, event_type: 'discard', title: 'private', + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post: record, version_no: 1, event_type: 'discard', title: 'private', url: record.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024)) get "/counts/export/files/#{ file }", params: { job:, attempt: } expect(response).to have_http_status(:not_found) diff --git a/backend/spec/requests/counts_history_boundaries_spec.rb b/backend/spec/requests/counts_history_boundaries_spec.rb index 0bdb514..0335ff8 100644 --- a/backend/spec/requests/counts_history_boundaries_spec.rb +++ b/backend/spec/requests/counts_history_boundaries_spec.rb @@ -8,7 +8,9 @@ RSpec.describe 'History boundaries shared with Counts', type: :request do before do ['create', 'discard'].each_with_index do |event, index| - TagDescriptionVersion.create!(tag_id: tag.id, language_code: 'ja', + TagDescriptionVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + tag_id: tag.id, language_code: 'ja', version_no: index + 1, event_type: event, body: 'private body', created_at: Time.utc(2024)) end diff --git a/backend/spec/requests/counts_spec.rb b/backend/spec/requests/counts_spec.rb index 0026d49..7dd78ff 100644 --- a/backend/spec/requests/counts_spec.rb +++ b/backend/spec/requests/counts_spec.rb @@ -10,12 +10,16 @@ RSpec.describe 'Legacy Counts API', type: :request do category: 'general', sections: [] } [1, 2, 3].each do |number| time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0) - PostVersion.create!(post:, version_no: number, + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post:, version_no: number, event_type: number == 1 ? 'create' : 'update', tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '', tags_json: number == 1 ? [snapshot] : [], created_by_user: number == 3 ? nil : actor, created_at: time) - TagVersion.create!(tag:, version_no: number, + TagVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + tag:, version_no: number, event_type: number == 1 ? 'create' : 'update', name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '', names: { ja: { name: 'counts tag', script_code: 'Jpan', diff --git a/backend/spec/requests/counts_table_spec.rb b/backend/spec/requests/counts_table_spec.rb index 5e13aac..e5f9b26 100644 --- a/backend/spec/requests/counts_table_spec.rb +++ b/backend/spec/requests/counts_table_spec.rb @@ -14,7 +14,9 @@ RSpec.describe 'Counts table API', type: :request do end before do - TagVersion.create!(tag:, version_no: 1, event_type: 'create', name: 'alpha', + TagVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + tag:, version_no: 1, event_type: 'create', name: 'alpha', category: 'general', aliases: '', parent_tag_ids: '', names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } }, created_at: Time.utc(2023)) @@ -24,7 +26,9 @@ RSpec.describe 'Counts table API', type: :request do { tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] }, { external_tag_id: external.id }] [1, 2, 3].each do |number| - PostVersion.create!(post: record, version_no: number, + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post: record, version_no: number, event_type: number == 1 ? 'create' : 'update', url: record.url, title: "snapshot #{ number }", tags: '', parent_post_ids: '', tags_json: snapshots.first(number == 1 ? 2 : (number == 2 ? 1 : 0)), @@ -134,7 +138,9 @@ RSpec.describe 'Counts table API', type: :request do end it 'checks the current latest flag beyond the execution timestamp' do - PostVersion.create!(post: record, version_no: 4, event_type: 'update', + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post: record, version_no: 4, event_type: 'update', url: record.url, title: 'after execution', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2025, 2, 1)) result = query @@ -188,12 +194,16 @@ RSpec.describe 'Counts table API', type: :request do tag.tag_names.find_by!(language_code: 'ja', primary_flg: true) .update!(name: 'renamed-alpha') create(:tag_name, :alias, tag:, name: 'alpha-alias') - TagVersion.create!(tag:, version_no: 2, event_type: 'update', name: 'renamed-alpha', + TagVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + tag:, version_no: 2, event_type: 'update', name: 'renamed-alpha', category: 'general', aliases: 'alpha-alias', parent_tag_ids: '', names: { ja: { name: 'renamed-alpha', script_code: 'Jpan', auto_generated: false, aliases: [{ name: 'alpha-alias', script_code: 'Jpan' }] } }, created_at: Time.utc(2024, 2, 1)) - PostVersion.create!(post: record, version_no: 4, event_type: 'update', + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post: record, version_no: 4, event_type: 'update', url: record.url, title: 'after rename', tags: '', parent_post_ids: '', tags_json: [{ tag_id: tag.id, version_no: 2, name: 'renamed-alpha', category: 'general', sections: [] }], @@ -257,7 +267,9 @@ RSpec.describe 'Counts table API', type: :request do it 'reports column overflow separately from malformed input' do 101.times do |index| - PostVersion.create!(post: record, version_no: index + 4, + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post: record, version_no: index + 4, event_type: 'update', url: record.url, title: "column #{ index }", tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3)) end @@ -301,7 +313,9 @@ RSpec.describe 'Counts table API', type: :request do sign_in_as(create(:user, role)) if role # Equal timestamps deliberately require version ordering, not a date comparison. ['discard', 'restore', 'discard', 'restore'].each_with_index do |event, index| - PostVersion.create!(post: record, version_no: index + 4, event_type: event, + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post: record, version_no: index + 4, event_type: event, url: record.url, title: event == 'restore' ? 'restored' : 'private snapshot', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3)) end @@ -329,7 +343,9 @@ RSpec.describe 'Counts table API', type: :request do end it 'does not resolve private condition names even when the result set is empty' do - TagVersion.create!(tag:, version_no: 2, event_type: 'discard', name: 'secret tag', + TagVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + tag:, version_no: 2, event_type: 'discard', name: 'secret tag', category: 'general', aliases: '', parent_tag_ids: '', names: { ja: { name: 'secret tag', script_code: 'Jpan', auto_generated: false, aliases: [] } }, created_at: Time.utc(2024, 2)) @@ -345,7 +361,9 @@ RSpec.describe 'Counts table API', type: :request do end end it 'includes private history in the public timeline without an exclusion count' do - PostVersion.create!(post: record, version_no: 4, event_type: 'discard', url: record.url, + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post: record, version_no: 4, event_type: 'discard', url: record.url, title: 'hidden title', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3)) result = query(mode: 'summary', rows: 'created_at:month', cols: 'event_type') diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index 093a8f1..3a7f0cd 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -314,6 +314,7 @@ RSpec.describe 'Materials API', type: :request do .and change(Tag, :count).by(1) .and change(TagName, :count).by(1) .and change(MaterialVersion, :count).by(1) + .and change { event_rows.length }.by(1) expect(response).to have_http_status(:created) @@ -325,6 +326,12 @@ RSpec.describe 'Materials API', type: :request do expect(material.file.attached?).to be(true) expect(material.version_no).to eq(1) expect(material.material_versions.first.event_type).to eq('create') + event = event_rows.last + expect(event).to include( + 'event_type' => 'materials.create', + 'created_by_user_id' => member_user.id) + expect(material.material_versions.first.event_id).to eq(event.fetch('id')) + expect(material.tag.tag_versions.pluck(:event_id)).to eq([event.fetch('id')]) expect(material.material_export_items.first.export_path).to eq('伊地知ニジカ/created.png') expect(material.material_versions.first.export_paths_json).to eq( 'legacy_drive' => '伊地知ニジカ/created.png' @@ -509,6 +516,7 @@ RSpec.describe 'Materials API', type: :request do export_paths: { legacy_drive: '伊地知ニジカ/updated.jpg' } } end.to change(MaterialVersion, :count).by(2) + .and change { event_rows.length }.by(1) expect(response).to have_http_status(:ok) @@ -517,6 +525,12 @@ RSpec.describe 'Materials API', type: :request do expect(material.tag.category).to eq('material') expect(material.url).to eq('https://example.com/updated-source') expect(material.updated_by_user).to eq(member_user) + event = event_rows.last + expect(event).to include( + 'event_type' => 'materials.update', + 'created_by_user_id' => member_user.id) + expect(material.material_versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) + expect(material.tag.tag_versions.pluck(:event_id)).to eq([event.fetch('id')]) expect(material.file.attached?).to be(true) expect(material.file.blob.id).not_to eq(old_blob_id) expect(ActiveStorage::Blob.where(id: old_blob_id).exists?).to be(true) @@ -632,6 +646,7 @@ RSpec.describe 'Materials API', type: :request do MaterialVersionRecorder.record!(material:, event_type: :create, created_by_user: member_user) TagVersioning.ensure_snapshot!(tag, created_by_user: member_user) + events = event_rows expect do put "/materials/#{ material.id }", params: { @@ -643,6 +658,7 @@ RSpec.describe 'Materials API', type: :request do expect(response).to have_http_status(:ok) expect(material.reload.version_no).to eq(1) + expect(event_rows).to eq(events) end it 'records update version when only export_path changes' do @@ -807,6 +823,12 @@ RSpec.describe 'Materials API', type: :request do it 'creates source suppression as member' do sign_in_as(member_user) + materials = ['a', 'b'].map do |name| + Material.create!( + source_kind: 'google_drive_file', source_file_id: "blocked-#{ name }", + source_path: "伊地知ニジカ/危険素材/#{ name }.png", + url: "https://example.com/blocked-#{ name }", created_by_user: member_user) + end expect do post '/materials/suppressions', params: { @@ -815,6 +837,7 @@ RSpec.describe 'Materials API', type: :request do reason: 'copyright_high_risk' } end.to change(MaterialSyncSuppression, :count).by(1) + .and change { event_rows.length }.by(1) expect(response).to have_http_status(:created) expect(json).to include( @@ -822,6 +845,14 @@ RSpec.describe 'Materials API', type: :request do 'drive_path' => '伊地知ニジカ/危険素材', 'normalized_source_key' => 'google_drive_path_prefix:伊地知ニジカ/危険素材' ) + event = event_rows.last + expect(event).to include( + 'event_type' => 'materials.block', 'created_by_user_id' => member_user.id) + expect(materials.map { _1.reload.discarded? }).to eq([true, true]) + versions = MaterialVersion.where(material_id: materials.map(&:id)) + expect(versions.where(event_type: 'discard').pluck(:material_id)) + .to match_array(materials.map(&:id)) + expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) end end @@ -860,11 +891,19 @@ RSpec.describe 'Materials API', type: :request do end it 'discards the material and returns 204' do - delete "/materials/#{ material.id }" + expect { delete "/materials/#{ material.id }" } + .to change { event_rows.length }.by(1) expect(response).to have_http_status(:no_content) expect(Material.find_by(id: material.id)).to be_nil expect(Material.with_discarded.find(material.id)).to be_discarded + event = event_rows.last + expect(event).to include( + 'event_type' => 'materials.destroy', + 'created_by_user_id' => member_user.id) + versions = MaterialVersion.where(material_id: material.id) + expect(versions.order(:version_no).last.event_type).to eq('discard') + expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) end end end diff --git a/backend/spec/requests/nico_tags_spec.rb b/backend/spec/requests/nico_tags_spec.rb index a554d26..fb6366a 100644 --- a/backend/spec/requests/nico_tags_spec.rb +++ b/backend/spec/requests/nico_tags_spec.rb @@ -177,6 +177,7 @@ RSpec.describe 'NicoTags', type: :request do } }.to change(TagVersion, :count).by(2) .and change(NicoTagVersion, :count).by(1) + .and change { event_rows.length }.by(1) expect(response).to have_http_status(:ok) @@ -186,6 +187,10 @@ RSpec.describe 'NicoTags', type: :request do linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id) expect(linked_versions.map(&:event_type)).to eq(['create', 'create']) expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id)) + event = event_rows.last + expect(event).to include( + 'event_type' => 'tags.update', 'created_by_user_id' => admin.id) + expect(linked_versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) versions = nico_tag.reload.nico_tag_versions.order(:version_no) expect(versions.map(&:event_type)).to eq(['create', 'update']) diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index f0d0ab7..bb05526 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -50,6 +50,8 @@ RSpec.describe 'Posts API', type: :request do post:, version_no: 1, event_type: 'create', + event_id: create_event!( + event_type: 'posts.create', created_by_user: post.uploaded_user).id, title: post.title, url: post.url, thumbnail_base: post.thumbnail_base, @@ -1027,7 +1029,7 @@ RSpec.describe 'Posts API', type: :request do it 'dry-runs without persisting posts or new tags' do sign_in_as(member) - counts = [Post.count, Tag.count, TagName.count] + counts = [Post.count, Tag.count, TagName.count, event_rows.length] post '/posts?dry=1', params: post_write_params( title: 'dry-run post', @@ -1043,7 +1045,7 @@ RSpec.describe 'Posts API', type: :request do [{ 'name' => 'new_dry_run_tag', 'category' => 'character', 'section_literals' => [] }]) - expect([Post.count, Tag.count, TagName.count]).to eq(counts) + expect([Post.count, Tag.count, TagName.count, event_rows.length]).to eq(counts) end it 'dry-runs an existing URL without validating its upload' do @@ -2218,20 +2220,20 @@ RSpec.describe 'Posts API', type: :request do def create_post_version! post, version_no:, event_type:, created_by_user:, created_at: PostVersion.create!( - post:, - version_no:, - event_type:, - title: post.title, - url: post.url, - thumbnail_base: post.thumbnail_base, - tags: snapshot_tags(post), - tags_json: post.snapshot_tags_json, - parent_post_ids: post.snapshot_parent_post_ids.join(' '), - original_created_from: post.original_created_from, - original_created_before: post.original_created_before, - created_at:, - created_by_user: - ) + event_id: create_event!(event_type: 'unknown').id, + post:, + version_no:, + event_type:, + title: post.title, + url: post.url, + thumbnail_base: post.thumbnail_base, + tags: snapshot_tags(post), + tags_json: post.snapshot_tags_json, + parent_post_ids: post.snapshot_parent_post_ids.join(' '), + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + created_at:, + created_by_user:) end let!(:v1) do @@ -2559,6 +2561,7 @@ RSpec.describe 'Posts API', type: :request do tags: 'spec_tag', thumbnail: dummy_upload) end.to change(PostVersion, :count).by(1) + .and change { event_rows.length }.by(1) expect(response).to have_http_status(:created) @@ -2570,6 +2573,13 @@ RSpec.describe 'Posts API', type: :request do expect(version.url).to eq('https://example.com/versioned-post') expect(version.created_by_user_id).to eq(member.id) expect(version.tags).to eq(snapshot_tags(created_post)) + event = event_rows.last + expect(event).to include( + 'event_type' => 'posts.create', 'created_by_user_id' => member.id) + expect(version.event_id).to eq(event.fetch('id')) + tag_versions = TagVersion.where(tag_id: created_post.tags.select(:id)) + expect(tag_versions).not_to be_empty + expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) end it 'creates next version on PUT /posts/:id when snapshot changes' do @@ -2585,6 +2595,7 @@ RSpec.describe 'Posts API', type: :request do title: 'updated title', tags: 'spec_tag_2') end.to change(PostVersion, :count).by(1) + .and change { event_rows.length }.by(1) expect(response).to have_http_status(:ok) @@ -2594,6 +2605,12 @@ RSpec.describe 'Posts API', type: :request do expect(version.title).to eq('updated title') expect(version.created_by_user_id).to eq(member.id) expect(version.tags).to eq(snapshot_tags(post_record.reload)) + event = event_rows.last + expect(event).to include( + 'event_type' => 'posts.update', 'created_by_user_id' => member.id) + expect(version.event_id).to eq(event.fetch('id')) + tag_version = TagVersion.find_by!(tag_id: tag_name2.reload.tag_id, version_no: 1) + expect(tag_version.event_id).to eq(event.fetch('id')) end it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do @@ -2602,6 +2619,7 @@ RSpec.describe 'Posts API', type: :request do PostTag.create!(post: post_record, tag: Tag.no_deerjikist) base_version = create_post_version_for!(post_record.reload) + events = event_rows expect { put "/posts/#{post_record.id}", params: post_write_params( base_version_no: base_version.version_no, @@ -2615,10 +2633,12 @@ RSpec.describe 'Posts API', type: :request do expect(version.version_no).to eq(1) expect(version.event_type).to eq('create') expect(version.tags).to eq(snapshot_tags(post_record)) + expect(event_rows).to eq(events) end it 'does not create a version when POST /posts is invalid' do sign_in_as(member) + events = event_rows expect do post '/posts', params: post_write_params( @@ -2629,11 +2649,13 @@ RSpec.describe 'Posts API', type: :request do end.not_to change(PostVersion, :count) expect(response).to have_http_status(:unprocessable_entity) + expect(event_rows).to eq(events) end it 'does not create a version when PUT /posts/:id is invalid' do sign_in_as(member) base_version = create_post_version_for!(post_record) + events = event_rows expect do put "/posts/#{post_record.id}", params: post_write_params( @@ -2645,6 +2667,7 @@ RSpec.describe 'Posts API', type: :request do end.not_to change(PostVersion, :count) expect(response).to have_http_status(:unprocessable_entity) + expect(event_rows).to eq(events) end it 'rejects second-bearing original created timestamps on POST /posts' do diff --git a/backend/spec/requests/sitemap_entries_spec.rb b/backend/spec/requests/sitemap_entries_spec.rb index 26d63cf..65ef340 100644 --- a/backend/spec/requests/sitemap_entries_spec.rb +++ b/backend/spec/requests/sitemap_entries_spec.rb @@ -119,7 +119,9 @@ RSpec.describe 'Sitemap entries API', type: :request do # Legacy empty bodies can exist even though the write service rejects them. empty = TagDescription.create!(tag: empty_tag, language_code: 'ain', body: '', version_no: 1) - TagDescriptionVersion.create!(tag_id: empty_tag.id, language_code: 'ain', + TagDescriptionVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + tag_id: empty_tag.id, language_code: 'ain', body: '', version_no: 1, event_type: 'create', created_by_user_id: user.id, created_at: Time.current) diff --git a/backend/spec/requests/tag_children_spec.rb b/backend/spec/requests/tag_children_spec.rb index dd22144..22365b4 100644 --- a/backend/spec/requests/tag_children_spec.rb +++ b/backend/spec/requests/tag_children_spec.rb @@ -53,8 +53,13 @@ RSpec.describe "TagChildren", type: :request do expect { do_request } .to change { parent.reload.children.ids.include?(child.id) } .from(false).to(true) + .and change { event_rows.length }.by(1) expect(response).to have_http_status(:no_content) + event = event_rows.last + expect(event).to include( + 'event_type' => 'tags.update', 'created_by_user_id' => admin.id) + expect(child.tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) end it 'returns 422 and does not create relation when the new link makes a cycle' do @@ -132,6 +137,7 @@ RSpec.describe "TagChildren", type: :request do expect { do_request }.to change(TagVersion, :count).by(2) + .and change { event_rows.length }.by(1) expect(response).to have_http_status(:no_content) @@ -140,6 +146,10 @@ RSpec.describe "TagChildren", type: :request do expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s) expect(versions.second.parent_tag_ids).to eq('') expect(versions.second.created_by_user_id).to eq(admin.id) + event = event_rows.last + expect(event).to include( + 'event_type' => 'tags.update', 'created_by_user_id' => admin.id) + expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) end end diff --git a/backend/spec/requests/tag_description_wiki_spec.rb b/backend/spec/requests/tag_description_wiki_spec.rb index 19b0e19..0b19cb0 100644 --- a/backend/spec/requests/tag_description_wiki_spec.rb +++ b/backend/spec/requests/tag_description_wiki_spec.rb @@ -8,6 +8,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do let!(:versions) do ['A', 'A', 'new-version'].each_with_index.map do |body, index| TagDescriptionVersion.create!( + event_id: create_event!(event_type: 'unknown').id, tag_id: tag.id, language_code: 'ja', version_no: index + 1, event_type: index.zero? ? 'create' : 'update', body:, created_by_user_id: user.id, created_at: Time.current) @@ -73,6 +74,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do let!(:other_tag) { create(:tag, primary_name: 'other_description_contract') } let!(:other_version) do TagDescriptionVersion.create!( + event_id: create_event!(event_type: 'unknown').id, tag_id: other_tag.id, language_code: 'ja', version_no: 1, event_type: 'create', body: 'other tag history', created_by_user_id: user.id, created_at: Time.current) diff --git a/backend/spec/requests/tag_descriptions_spec.rb b/backend/spec/requests/tag_descriptions_spec.rb index ceff8fd..ebd6ebe 100644 --- a/backend/spec/requests/tag_descriptions_spec.rb +++ b/backend/spec/requests/tag_descriptions_spec.rb @@ -13,7 +13,7 @@ RSpec.describe 'Tag description native API', type: :request do def stored_state [TagDescription, TagDescriptionVersion, TagName, TagVersion].map do |model| model.order(:id).map(&:attributes) - end + end + [event_rows] end def fail_save model @@ -91,6 +91,8 @@ RSpec.describe 'Tag description native API', type: :request do let!(:version) do TagDescriptionVersion.create!( tag_id: tag.id, language_code: 'ja', body: 'native version', version_no: 1, + event_id: create_event!( + event_type: 'tag_descriptions.create', created_by_user: author).id, event_type: 'create', created_by_user_id: author.id, created_at: Time.current) end let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" } @@ -185,6 +187,7 @@ RSpec.describe 'Tag description native API', type: :request do expect { native_write(body: 'created natively') } .to change(TagDescription, :count).by(1) .and change(TagDescriptionVersion, :count).by(1) + .and change { event_rows.length }.by(1) expect(response).to have_http_status(:created) description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') expect_current(description) @@ -192,6 +195,10 @@ RSpec.describe 'Tag description native API', type: :request do expect(version).to have_attributes( body: 'created natively', version_no: 1, event_type: 'create', created_by_user_id: editor.id) + event = event_rows.last + expect(event).to include( + 'event_type' => 'tag_descriptions.create', 'created_by_user_id' => editor.id) + expect(version.event_id).to eq(event.fetch('id')) get "/wiki/#{ description.id }" expect(response).to have_http_status(:ok) expect(json).to include('body' => description.body, 'revision_id' => version.id) @@ -223,6 +230,7 @@ RSpec.describe 'Tag description native API', type: :request do expect(description.version_no).to eq(1) expect { native_write(body: 'after') } .to change(TagDescriptionVersion, :count).by(1) + .and change { event_rows.length }.by(1) expect(response).to have_http_status(:ok) expect(description.reload).to have_attributes( body: 'after', version_no: 2, @@ -234,6 +242,10 @@ RSpec.describe 'Tag description native API', type: :request do expect(version).to have_attributes( tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update', body: 'after', created_by_user_id: editor.id) + event = event_rows.last + expect(event).to include( + 'event_type' => 'tag_descriptions.update', 'created_by_user_id' => editor.id) + expect(version.event_id).to eq(event.fetch('id')) expect(revision.body).to eq(version.body) expect(description_versions(page).order(:version_no).last.body).to eq(version.body) get "/wiki/#{ description.id }" diff --git a/backend/spec/requests/tag_groups_spec.rb b/backend/spec/requests/tag_groups_spec.rb index 091d919..62b7152 100644 --- a/backend/spec/requests/tag_groups_spec.rb +++ b/backend/spec/requests/tag_groups_spec.rb @@ -121,10 +121,14 @@ RSpec.describe 'Tag group native API', type: :request do [:create, :update].each do |operation| it "#{ operation }s structured data and keeps unplaced membership" do previous = group_version(group_id, 1) - native_write(operation) + expect { native_write(operation) }.to change { event_rows.length }.by(1) expect(response).to have_http_status(operation == :create ? :created : :ok) id = operation == :create ? json.fetch('id') : group_id number = operation == :create ? 1 : 2 + event = event_rows.last + event_type = operation == :create ? 'tag_groups.create' : 'tag_groups.update' + expect(event).to include('event_type' => event_type, 'created_by_user_id' => user.id) + expect(group_version(id, number).fetch('event_id')).to eq(event.fetch('id')) expect_group_snapshot( id, number:, members: snapshot_members, placements:, description: '全体の説明') expect(group_version(group_id, 1)).to eq(previous) diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index f05bb0b..9eb88e9 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -30,6 +30,7 @@ RSpec.describe 'TagVersions API', type: :request do ) version = TagVersion.create!( + event_id: create_event!(event_type: 'unknown').id, tag: tag, version_no: version_no, event_type: event_type, diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 7a66889..7588257 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -705,6 +705,7 @@ RSpec.describe 'Tags API', type: :request do expect { patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' } }.to change(TagVersion, :count).by(2) + .and change { event_rows.length }.by(1) expect(response).to have_http_status(:ok) @@ -719,6 +720,10 @@ RSpec.describe 'Tags API', type: :request do expect(version_primary_name(versions.second, 'ja')).to eq('new_tag_name') expect(versions.second.category).to eq('meme') expect(versions.second.created_by_user_id).to eq(member_user.id) + event = event_rows.last + expect(event).to include( + 'event_type' => 'tags.update', 'created_by_user_id' => member_user.id) + expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) end it 'updates deprecated state and records it in tag versions' do @@ -1275,6 +1280,7 @@ RSpec.describe 'Tags API', type: :request do deprecated: '0', } }.to change(TagVersion, :count).by(2) + .and change { event_rows.length }.by(1) expect(response).to have_http_status(:ok) @@ -1290,6 +1296,10 @@ RSpec.describe 'Tags API', type: :request do expect(versions.second.category).to eq('meta') expect(version_alias_names(versions.second, 'ja')).to include('spec_tag') expect(versions.second.created_by_user_id).to eq(member_user.id) + event = event_rows.last + expect(event).to include( + 'event_type' => 'tags.update', 'created_by_user_id' => member_user.id) + expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) end it '同じ tag の旧 alias へ戻しても rename できる' do diff --git a/backend/spec/services/counts_export_spec.rb b/backend/spec/services/counts_export_spec.rb index de205d1..3e96153 100644 --- a/backend/spec/services/counts_export_spec.rb +++ b/backend/spec/services/counts_export_spec.rb @@ -143,7 +143,9 @@ RSpec.describe 'Counts export contracts' do expect(CountsExportDownload.find(token, 'owner')).not_to be_nil expect(CountsExportDownload.find(token, 'other')).to be_nil post = create(:post) - PostVersion.create!(post:, version_no: 1, event_type: 'discard', url: post.url, + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post:, version_no: 1, event_type: 'discard', url: post.url, title: 'private', tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024)) expect(CountsExportDownload.find(token, 'owner')).to be_nil end diff --git a/backend/spec/services/counts_pivot_security_spec.rb b/backend/spec/services/counts_pivot_security_spec.rb index 337d7a5..7245e43 100644 --- a/backend/spec/services/counts_pivot_security_spec.rb +++ b/backend/spec/services/counts_pivot_security_spec.rb @@ -13,7 +13,9 @@ RSpec.describe 'Counts masked pivot contracts' do let!(:tags) do ['A', 'B', 'C'].map do |name| tag = create(:tag, primary_name: name) - TagVersion.create!(tag:, version_no: 1, event_type: 'create', name:, + TagVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + tag:, version_no: 1, event_type: 'create', name:, category: 'general', aliases: '', parent_tag_ids: '', names: { ja: { name:, script_code: 'Jpan', auto_generated: false, aliases: [] } }, created_at: Time.utc(2023)) @@ -25,12 +27,16 @@ RSpec.describe 'Counts masked pivot contracts' do [[0, 1], [0], [1, 2]].each_with_index do |indexes, index| post = create(:post) snapshot = indexes.map { { tag_id: tags[_1].id, version_no: 1, sections: [] } } - PostVersion.create!(post:, version_no: 1, event_type: 'create', + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post:, version_no: 1, event_type: 'create', title: "hidden-title-#{ index }", url: post.url, tags: '', tags_json: snapshot, parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 1)) next if index == 2 - PostVersion.create!(post:, version_no: 2, event_type: 'discard', + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post:, version_no: 2, event_type: 'discard', title: "private-discard-#{ index }", url: post.url, tags: '', tags_json: [], parent_post_ids: '', created_by_user: member, created_at: Time.utc(2024, 2)) end @@ -98,12 +104,16 @@ RSpec.describe 'Counts masked pivot contracts' do it 'uses changed-record membership for totals and hides changes to private predecessors' do post = PostVersion.order(:id).last.post - PostVersion.create!(post:, version_no: 2, event_type: 'update', title: 'changed-public-title', + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post:, version_no: 2, event_type: 'update', title: 'changed-public-title', url: post.url, tags: '', tags_json: [{ tag_id: tags.last.id, version_no: 1, sections: [] }], parent_post_ids: '', created_at: Time.utc(2024, 3)) restored = PostVersion.order(:id).first.post - PostVersion.create!(post: restored, version_no: 3, event_type: 'restore', + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post: restored, version_no: 3, event_type: 'restore', title: 'restored-public-title', url: restored.url, tags: '', tags_json: [{ tag_id: tags.first.id, version_no: 1, sections: [] }], parent_post_ids: '', created_at: Time.utc(2024, 4)) @@ -134,7 +144,9 @@ RSpec.describe 'Counts masked pivot contracts' do it 'keeps NULL as a public data value rather than treating it as a rollup' do post = create(:post) - PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'public bot', + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post:, version_no: 1, event_type: 'create', title: 'public bot', url: post.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 5)) page = result(nil, 'rows' => 'user', 'cols' => 'event_type') expect(page[:cells].select { _1[:values].first.nil? }.sum { _1[:count] }).to eq(1) @@ -145,7 +157,9 @@ RSpec.describe 'Counts masked pivot contracts' do it 'sorts public numeric values numerically alongside a masked group' do post = PostVersion.order(:id).last.post [2, 10].each do |number| - PostVersion.create!(post:, version_no: number, event_type: 'update', + PostVersion.create!( + event_id: create_event!(event_type: 'unknown').id, + post:, version_no: number, event_type: 'update', title: 'public numeric sort', url: post.url, tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 5)) end diff --git a/backend/spec/services/material_sync_importer_spec.rb b/backend/spec/services/material_sync_importer_spec.rb index b2734e3..bf1a774 100644 --- a/backend/spec/services/material_sync_importer_spec.rb +++ b/backend/spec/services/material_sync_importer_spec.rb @@ -56,6 +56,7 @@ RSpec.describe MaterialSyncImporter do profile: 'legacy_drive', export_path: '素材/a.png', created_by_user: user) + events = event_rows result = described_class.import!( @@ -73,6 +74,7 @@ RSpec.describe MaterialSyncImporter do expect(result.action).to eq(:unchanged) expect(material.reload.tag).to eq(tag) expect(material.url).to eq('https://example.com/human') + expect(event_rows).to eq(events) end it 'suppresses a Google Drive candidate by relative path prefix' do diff --git a/backend/spec/services/material_sync_runner_spec.rb b/backend/spec/services/material_sync_runner_spec.rb index ace4f45..810830a 100644 --- a/backend/spec/services/material_sync_runner_spec.rb +++ b/backend/spec/services/material_sync_runner_spec.rb @@ -14,6 +14,47 @@ RSpec.describe MaterialSyncRunner do let(:drive_client) { instance_double(GoogleDrive::ApiClient) } describe '#sync!' do + it 'groups created and updated materials across sync sources into one event' do + existing = Material.create!( + source_kind: 'google_drive_file', source_file_id: 'existing-file', + source_path: 'old.png', url: 'https://example.com/existing', + created_by_user: user, updated_by_user: user) + source + MaterialSyncSource.create!( + name: 'Second Drive source', source_kind: 'google_drive_path', + source_file_id: 'folder-456', profile: 'legacy_drive', + created_by_user: user, updated_by_user: user) + allow(GoogleDrive::ApiClient).to receive(:new).and_return(drive_client) + allow(drive_client).to receive(:each_material_file_under_folder) do |folder_id, &block| + id = folder_id == 'folder-123' ? 'existing-file' : 'new-file' + block.call(id:, name: "#{ id }.png", mime_type: 'image/png', + relative_path: "素材/#{ id }.png", sha256_checksum: nil, + web_view_link: "https://drive.google.com/file/d/#{ id }/view") + end + allow(drive_client).to receive(:download_to_tempfile) do |id, **| + Tempfile.new(['sync-event', '.png']).tap do |file| + file.binmode + file.write(id) + file.rewind + end + end + allow(MaterialThumbnailGenerator).to receive(:generate!) + previous_ids = MaterialVersion.pluck(:id) + + result = nil + expect { result = described_class.sync_enabled! } + .to change { event_rows.length }.by(1) + + expect(result).to have_attributes(imported: 1, updated: 1, failed: 0) + event = event_rows.last + expect(event.fetch('event_type')).to eq('materials.sync') + created = Material.find_by!(source_file_id: 'new-file') + versions = MaterialVersion.where.not(id: previous_ids) + expect(versions.pluck(:material_id).uniq).to contain_exactly(existing.id, created.id) + expect(versions.pluck(:event_type)).to include('create', 'update') + expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) + end + it 'imports google drive path candidates as they are yielded' do first_entry = { id: 'file-1', name: 'a.png', diff --git a/backend/spec/services/post_bulk_creator_spec.rb b/backend/spec/services/post_bulk_creator_spec.rb index 2976143..570e610 100644 --- a/backend/spec/services/post_bulk_creator_spec.rb +++ b/backend/spec/services/post_bulk_creator_spec.rb @@ -88,3 +88,86 @@ RSpec.describe PostBulkCreator do 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 diff --git a/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb b/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb index 8487c30..e501c4e 100644 --- a/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb +++ b/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb @@ -16,6 +16,7 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do @tag = create(:tag, primary_name: "#{ @prefix }_owner") @group = TagGroup.create!(name: "#{ @prefix }_owner", version_no: 1) @tag_ids = Queue.new + @event_ids = Queue.new @threads = [] @release = Queue.new end @@ -42,6 +43,9 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do ids << @tag.id if @tag TagName.where(tag_id: ids).delete_all Tag.where(id: ids).delete_all + event_ids = [] + event_ids << @event_ids.pop until @event_ids.empty? if @event_ids + EventRecords::EventRow.where(id: event_ids).delete_all end end @@ -72,11 +76,17 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do when :description TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1) when :description_version + event = create_event!(event_type: 'unknown') + @event_ids << event.id TagDescriptionVersion.create!( + event_id: event.id, tag_id: @tag.id, language_code: 'ja', body: 'body', version_no: 1, event_type: 'create', created_at: Time.current) when :group_version + event = create_event!(event_type: 'unknown') + @event_ids << event.id TagGroupVersion.create!( + event_id: event.id, tag_group: @group, name: @group.name, version_no: 1, event_type: 'create', members: [], placements: [], created_at: Time.current) when :group diff --git a/backend/spec/services/shared_resource_id_allocator_spec.rb b/backend/spec/services/shared_resource_id_allocator_spec.rb index 1fa3f18..773a453 100644 --- a/backend/spec/services/shared_resource_id_allocator_spec.rb +++ b/backend/spec/services/shared_resource_id_allocator_spec.rb @@ -15,10 +15,12 @@ RSpec.describe SharedResourceIdAllocator do TagGroup.new(id:, name: "group #{ number }", version_no: number) when 'tag_description_versions' TagDescriptionVersion.new( + event_id: create_event!(event_type: 'unknown').id, id:, tag_id: tag.id, language_code: 'ja', body: 'body', version_no: number, event_type: 'create', created_at: Time.current) when 'tag_group_versions' TagGroupVersion.new( + event_id: create_event!(event_type: 'unknown').id, id:, tag_group_id: group.id, name: group.name, version_no: number, event_type: 'create', members: [], placements: [], created_at: Time.current) end @@ -82,6 +84,7 @@ RSpec.describe SharedResourceIdAllocator do it 'reserves a Tag ID referenced only by remaining description history' do high = [Tag.maximum(:id).to_i, ExternalTag.maximum(:id).to_i].max + 1000 TagDescriptionVersion.create!( + event_id: create_event!(event_type: 'unknown').id, tag_id: high, language_code: 'ja', version_no: 1, body: 'history', event_type: 'create', created_at: Time.current) expect(create(:tag).id).to be > high diff --git a/backend/spec/services/youtube/sync_spec.rb b/backend/spec/services/youtube/sync_spec.rb index e8d7a24..0ed81e7 100644 --- a/backend/spec/services/youtube/sync_spec.rb +++ b/backend/spec/services/youtube/sync_spec.rb @@ -42,6 +42,41 @@ RSpec.describe Youtube::Sync do end describe '#sync!' do + it 'groups created and updated post histories into one sync event' do + allow(PostVersionRecorder).to receive(:record!).and_call_original + allow(PostVersionRecorder).to receive(:ensure_snapshot!).and_call_original + existing = create(:post, url: 'https://youtu.be/video-1', title: 'old title') + allow(sync).to receive(:query_terms).and_return([]) + allow(sync).to receive(:playlist_ids).and_return(['PL123']) + allow(client).to receive(:playlist_items).with(playlist_id: 'PL123', page_token: nil) + .and_return('items' => [ + { 'contentDetails' => { 'videoId' => 'video-1' } }, + { 'contentDetails' => { 'videoId' => 'video-2' } }]) + allow(client).to receive(:videos).with(['video-1', 'video-2']) + .and_return('items' => [ + youtube_video_item(id: 'video-1', title: 'updated', channel_id: 'UC_NONE'), + youtube_video_item(id: 'video-2', title: 'created', channel_id: 'UC_NONE')]) + previous_post_ids = PostVersion.pluck(:id) + previous_tag_ids = TagVersion.pluck(:id) + + expect { sync.sync! }.to change { event_rows.length }.by(1) + + event = event_rows.last + expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil) + versions = PostVersion.where.not(id: previous_post_ids) + created = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-2') + expect(versions.pluck(:post_id).uniq).to contain_exactly(existing.id, created.id) + expect(versions.pluck(:event_type)).to include('create', 'update') + expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) + tag_versions = TagVersion.where.not(id: previous_tag_ids) + expect(tag_versions).not_to be_empty + expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) + + events = event_rows + expect { sync.sync! }.not_to change(PostVersion, :count) + expect(event_rows).to eq(events) + end + it 'returns without fetching video details when no video ids are discovered' do allow(sync).to receive(:query_terms).and_return([]) allow(sync).to receive(:playlist_ids).and_return([]) diff --git a/backend/spec/support/event_records.rb b/backend/spec/support/event_records.rb new file mode 100644 index 0000000..f68b151 --- /dev/null +++ b/backend/spec/support/event_records.rb @@ -0,0 +1,15 @@ +module EventRecords + # Read the database contract without requiring an application Event model. + class EventRow < ActiveRecord::Base + self.table_name = 'events' + end + + def event_rows + EventRow.order(:id).map(&:attributes) + end + + def create_event! event_type:, created_by_user: nil + EventRow.create!(event_type:, created_by_user_id: created_by_user&.id, + created_at: Time.current) + end +end diff --git a/backend/spec/support/tag_group_records.rb b/backend/spec/support/tag_group_records.rb index 82c1693..9023907 100644 --- a/backend/spec/support/tag_group_records.rb +++ b/backend/spec/support/tag_group_records.rb @@ -3,11 +3,13 @@ require 'cgi' # SQL fixtures use the migration contract without inventing application models. # Include explicitly; these helpers must not affect unrelated examples. module TagGroupRecords + include EventRecords + GROUP_TABLES = [ 'tag_groups', 'tag_group_tags', 'tag_group_groups', 'tag_group_placements', 'tag_group_versions'].freeze WRITE_TABLES = GROUP_TABLES + [ - 'tag_names', 'tag_description_versions', + 'events', 'tag_names', 'tag_description_versions', 'tag_descriptions'] def group_connection = ApplicationRecord.connection @@ -114,6 +116,7 @@ module TagGroupRecords group_id = insert_group_row('tag_groups', attributes) insert_group_row('tag_group_versions', { tag_group_id: group_id, version_no: 1, event_type: 'create', + event_id: create_event!(event_type: 'tag_groups.create', created_by_user: user).id, name:, description:, members: members.to_json, placements: placements.to_json, created_by_user_id: user.id, created_at: now }) members.each do |member| diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index db6d55a..ab3be13 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -174,20 +174,20 @@ RSpec.describe 'nico:sync' do def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil) PostVersion.create!( - post: post, - version_no: version_no, - event_type: event_type, - title: post.title, - url: post.url, - thumbnail_base: post.thumbnail_base, - tags: snapshot_tags(post), - tags_json: post.snapshot_tags_json, - parent_post_ids: post.snapshot_parent_post_ids.join(' '), - original_created_from: post.original_created_from, - original_created_before: post.original_created_before, - created_at: Time.current, - created_by_user: created_by_user - ) + event_id: create_event!(event_type: 'unknown').id, + post: post, + version_no: version_no, + event_type: event_type, + title: post.title, + url: post.url, + thumbnail_base: post.thumbnail_base, + tags: snapshot_tags(post), + tags_json: post.snapshot_tags_json, + parent_post_ids: post.snapshot_parent_post_ids.join(' '), + original_created_from: post.original_created_from, + original_created_before: post.original_created_before, + created_at: Time.current, + created_by_user: created_by_user) end it '新規 post 作成時に version 1 を作る' do @@ -209,6 +209,7 @@ RSpec.describe 'nico:sync' do expect { run_rake_task('nico:sync') }.to change(PostVersion, :count).by(1) + .and change { event_rows.length }.by(1) post = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm9') version = post.post_versions.order(:version_no).last @@ -217,6 +218,32 @@ RSpec.describe 'nico:sync' do expect(version.event_type).to eq('create') expect(version.created_by_user).to be_nil expect(version.tags).to eq(snapshot_tags(post.reload)) + event = event_rows.last + expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil) + expect(version.event_id).to eq(event.fetch('id')) + end + + it 'groups created and updated post histories into one sync event' do + existing = create(:post, title: 'old', url: 'https://www.nicovideo.jp/watch/sm9') + stub_python([ + { 'code' => 'sm9', 'title' => 'updated', 'tags' => [] }, + { 'code' => 'sm10', 'title' => 'created', 'tags' => [] }]) + allow(URI).to receive(:open).and_return(StringIO.new('')) + previous_post_ids = PostVersion.pluck(:id) + previous_tag_ids = TagVersion.pluck(:id) + + expect { run_rake_task('nico:sync') }.to change { event_rows.length }.by(1) + + event = event_rows.last + expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil) + created = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm10') + versions = PostVersion.where.not(id: previous_post_ids) + expect(versions.pluck(:post_id).uniq).to contain_exactly(existing.id, created.id) + expect(versions.pluck(:event_type)).to include('create', 'update') + expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) + tag_versions = TagVersion.where.not(id: previous_tag_ids) + expect(tag_versions).not_to be_empty + expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')]) end it '既存 post の内容または tags が変わったとき update version を作る' do @@ -282,6 +309,7 @@ RSpec.describe 'nico:sync' do }]) allow(URI).to receive(:open).and_return(StringIO.new('')) + events = event_rows expect { run_rake_task('nico:sync') @@ -291,6 +319,7 @@ RSpec.describe 'nico:sync' do expect(version.version_no).to eq(1) expect(version.event_type).to eq('create') expect(version.tags).to eq(snapshot_tags(post.reload)) + expect(event_rows).to eq(events) end it '新規 nico tag に nico tag version を作る' do