From 5abb6cca98a608299857910559ff0e000dd52145 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 26 Sep 2026 20:23:11 +0900 Subject: [PATCH] #55 --- backend/app/controllers/posts_controller.rb | 4 +- backend/app/services/post_bulk_creator.rb | 8 +- backend/app/services/post_creator.rb | 2 +- backend/db/schema.rb | 42 ++- backend/spec/factories/tag_names.rb | 18 +- backend/spec/factories/tags.rb | 5 + backend/spec/models/locale_spec.rb | 69 +++++ .../spec/models/material_export_item_spec.rb | 2 +- backend/spec/models/post_version_spec.rb | 4 +- .../models/tag_name_sanitisation_rule_spec.rb | 52 +++- backend/spec/models/tag_name_spec.rb | 73 +++++ backend/spec/models/tag_spec.rb | 200 +++++++++---- .../spec/requests/gekanator_learning_spec.rb | 11 +- backend/spec/requests/gekanator_posts_spec.rb | 11 +- .../spec/requests/locale_write_paths_spec.rb | 232 +++++++++++++++ backend/spec/requests/materials_spec.rb | 34 +-- backend/spec/requests/nico_tags_spec.rb | 8 +- backend/spec/requests/posts_spec.rb | 111 ++++--- backend/spec/requests/tag_versions_spec.rb | 4 +- .../tag_wiki_history_integrity_spec.rb | 4 +- backend/spec/requests/tags_spec.rb | 279 ++++++++---------- backend/spec/requests/wiki_conflict_spec.rb | 2 +- .../requests/wiki_history_integrity_spec.rb | 2 +- backend/spec/requests/wiki_spec.rb | 29 +- .../requests/wiki_title_collision_spec.rb | 2 +- .../services/material_sync_importer_spec.rb | 2 +- .../nico_tag_version_recorder_spec.rb | 2 +- .../spec/services/post_bulk_creator_spec.rb | 11 +- .../spec/services/post_create_plan_spec.rb | 64 +++- backend/spec/services/post_creator_spec.rb | 34 ++- .../services/post_import_previewer_spec.rb | 2 +- .../services/wiki/commit_integrity_spec.rb | 10 +- backend/spec/services/wiki/commit_spec.rb | 10 +- .../services/wiki_version_recorder_spec.rb | 2 +- backend/spec/services/youtube/sync_spec.rb | 6 +- backend/spec/support/locale_context.rb | 13 + backend/spec/tasks/nico_sync_spec.rb | 2 +- .../spec/tasks/post_similarity_calc_spec.rb | 8 +- .../spec/tasks/tag_similarity_calc_spec.rb | 8 +- 39 files changed, 1005 insertions(+), 377 deletions(-) create mode 100644 backend/spec/models/locale_spec.rb create mode 100644 backend/spec/models/tag_name_spec.rb create mode 100644 backend/spec/requests/locale_write_paths_spec.rb create mode 100644 backend/spec/support/locale_context.rb diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 73710f4..228cb7b 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -213,6 +213,8 @@ class PostsController < ApplicationController return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? + locale = resolve_locale! + preflight = PostCreatePreflight.new( attributes: post_create_attributes, thumbnail: params[:thumbnail], @@ -242,7 +244,7 @@ class PostsController < ApplicationController :post_tag_specs, :tag_sections, :normalised_parent_post_ids).symbolize_keys).merge( - thumbnail: params[:thumbnail])).create! + thumbnail: params[:thumbnail])).create!(locale) post.reload render json: PostRepr.base(post), status: :created diff --git a/backend/app/services/post_bulk_creator.rb b/backend/app/services/post_bulk_creator.rb index 1e75e24..e525c57 100644 --- a/backend/app/services/post_bulk_creator.rb +++ b/backend/app/services/post_bulk_creator.rb @@ -6,7 +6,7 @@ class PostBulkCreator @host = host end - def run + def run locale results = Array.new(@posts.length) mutex = Mutex.new next_index = 0 @@ -27,7 +27,7 @@ class PostBulkCreator break if index >= @posts.length attributes = @posts[index] - results[index] = create_row(actor, attributes, index) + results[index] = create_row(locale, actor, attributes, index) rescue StandardError => e Rails.logger.error( "post_bulk_creator_worker_failure #{ { error: e.class.name, @@ -61,7 +61,7 @@ class PostBulkCreator private - def create_row actor, attributes, index + def create_row locale, actor, attributes, index preflight = PostCreatePreflight.new( attributes: attributes, @@ -76,7 +76,7 @@ class PostBulkCreator post = PostCreator.new( actor: actor, - attributes: normalised_attributes(attributes, preflight, index)).create! + attributes: normalised_attributes(attributes, preflight, index)).create!(locale) result = { status: 'created', post: { id: post.id } } diff --git a/backend/app/services/post_creator.rb b/backend/app/services/post_creator.rb index fd834b1..dbe4c89 100644 --- a/backend/app/services/post_creator.rb +++ b/backend/app/services/post_creator.rb @@ -9,7 +9,7 @@ class PostCreator @field_warnings = { } end - def create!(locale) + def create! locale thumbnail_attachment = prepare_thumbnail_attachment post = Post.new(title: @attributes[:title].presence, url: @attributes[:url], diff --git a/backend/db/schema.rb b/backend/db/schema.rb index c4ce56a..c6b886b 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do +ActiveRecord::Schema[8.0].define(version: 2026_09_22_030000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -138,6 +138,24 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true end + create_table "languages", primary_key: "code", id: { type: :string, limit: 16 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_languages_on_deprecated_at" + end + + create_table "locales", primary_key: "code", id: { type: :string, limit: 32 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "language_code", limit: 16, null: false + t.string "script_code", limit: 4, null: false + t.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_locales_on_deprecated_at" + t.index ["language_code"], name: "index_locales_on_language_code" + t.index ["script_code"], name: "fk_rails_0b74ce96a8" + end + create_table "material_export_items", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "material_id", null: false t.string "profile", default: "legacy_drive", null: false @@ -388,6 +406,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end + create_table "scripts", primary_key: "code", id: { type: :string, limit: 4 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "name", null: false + t.datetime "deprecated_at" + t.datetime "created_at", null: false + t.index ["deprecated_at"], name: "index_scripts_on_deprecated_at" + end + create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "user_id", null: false t.datetime "created_at", null: false @@ -420,12 +445,20 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do end create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id" + t.string "language_code", limit: 16, null: false t.string "name", null: false + t.string "script_code", limit: 4, null: false + t.boolean "primary_flg", null: false t.bigint "canonical_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)" t.index ["canonical_id"], name: "index_tag_names_on_canonical_id" - t.index ["name"], name: "index_tag_names_on_name", unique: true + t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true + t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true + t.index ["script_code"], name: "fk_rails_dd783b3d1c" + t.index ["tag_id"], name: "index_tag_names_on_tag_id" end create_table "tag_similarities", primary_key: ["tag_id", "target_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -701,6 +734,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do add_foreign_key "gekanator_question_suggestions", "users" add_foreign_key "gekanator_questions", "gekanator_question_suggestions" add_foreign_key "gekanator_questions", "users", column: "created_by_id" + add_foreign_key "locales", "languages", column: "language_code", primary_key: "code" + add_foreign_key "locales", "scripts", column: "script_code", primary_key: "code" add_foreign_key "material_export_items", "materials" add_foreign_key "material_export_items", "users", column: "created_by_user_id" add_foreign_key "material_import_blocks", "users", column: "created_by_user_id" @@ -734,7 +769,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do add_foreign_key "settings", "users" add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags", column: "parent_tag_id" + add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code" + add_foreign_key "tag_names", "scripts", column: "script_code", primary_key: "code" add_foreign_key "tag_names", "tag_names", column: "canonical_id" + add_foreign_key "tag_names", "tags" add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags", column: "target_tag_id" add_foreign_key "tag_versions", "users", column: "created_by_user_id" diff --git a/backend/spec/factories/tag_names.rb b/backend/spec/factories/tag_names.rb index a33be1e..4af5bcf 100644 --- a/backend/spec/factories/tag_names.rb +++ b/backend/spec/factories/tag_names.rb @@ -1,5 +1,21 @@ FactoryBot.define do factory :tag_name do - name { "tag-#{SecureRandom.hex(4)}" } + language_code { 'ja' } + script_code { 'Jpan' } + primary_flg { true } + sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" } + + trait :alias do + primary_flg { false } + + after(:create) do |tag_name| + owner = tag_name.canonical&.tag + raise ArgumentError, 'alias requires a canonical tag' unless owner + + # The alias validation still rejects tag_id; its contract is tested separately. + tag_name.update_columns(tag_id: owner.id) + tag_name.association(:tag).reset + end + end end end diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index 55db316..a9df735 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -6,10 +6,15 @@ FactoryBot.define do category { :general } post_count { 0 } + association :tag_name after(:build) do |tag, evaluator| tag.name = evaluator.name if evaluator.name.present? end + + after(:create) do |tag| + tag.tag_name.update!(tag:) if tag.tag_name.tag_id != tag.id + end end end diff --git a/backend/spec/models/locale_spec.rb b/backend/spec/models/locale_spec.rb new file mode 100644 index 0000000..d87bb56 --- /dev/null +++ b/backend/spec/models/locale_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +RSpec.describe Locale, type: :model do + def prepare_french_reference! + Language.find_or_create_by!(code: 'fr') { _1.name = 'French' } + Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } + end + + def create_french_locale! + prepare_french_reference! + described_class.create!(code: 'fr', language_code: 'fr', + script_code: 'Latn', name: 'French') + end + + it 'generates a primary name for every existing tag using the name generator' do + first = create(:tag, name: 'first_existing_tag') + second = create(:tag, name: 'second_existing_tag') + allow(TagName).to receive(:generate_name).and_call_original + allow(TagName).to receive(:generate_name) + .with(kind_of(described_class), first, first.name) + .and_return('name_from_generator') + + locale = create_french_locale! + + expect(TagName).to have_received(:generate_name).with(locale, first, first.name) + expect(TagName).to have_received(:generate_name).with(locale, second, second.name) + expect(first.tag_names.find_by!(language_code: 'fr', primary_flg: true)) + .to have_attributes(name: 'name_from_generator', tag_id: first.id, + language_code: 'fr', script_code: 'Latn', + primary_flg: true, canonical_id: nil) + expect(second.tag_names.find_by!(language_code: 'fr', primary_flg: true)) + .to have_attributes(tag_id: second.id, language_code: 'fr', + script_code: 'Latn', primary_flg: true, canonical_id: nil) + expect(TagName.where(language_code: 'fr', primary_flg: true, + tag_id: [first.id, second.id]).count).to eq(2) + end + + it 'does not duplicate an existing primary name in the new language' do + prepare_french_reference! + tag = create(:tag, name: 'already_named') + existing = create(:tag_name, name: 'nom_existant', tag:, + language_code: 'fr', script_code: 'Latn') + + create_french_locale! + + expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true)) + .to contain_exactly(existing) + expect(existing.reload).to have_attributes( + tag_id: tag.id, language_code: 'fr', script_code: 'Latn', primary_flg: true) + end + + it 'creates a primary name when the language has only an alias' do + prepare_french_reference! + tag = create(:tag, name: 'alias_only_tag') + alias_name = create(:tag_name, :alias, name: 'alias_fr', + canonical: tag.tag_name, + language_code: 'fr', script_code: 'Latn') + + create_french_locale! + + expect(alias_name.reload).to have_attributes( + canonical_id: tag.tag_name_id, tag_id: tag.id, + language_code: 'fr', script_code: 'Latn', primary_flg: false) + generated = TagName.find_by!(tag_id: tag.id, language_code: 'fr', primary_flg: true) + expect(generated).to have_attributes(tag_id: tag.id, language_code: 'fr', + script_code: 'Latn', primary_flg: true) + expect(TagName.where(tag_id: tag.id, language_code: 'fr', primary_flg: true).count).to eq(1) + end +end diff --git a/backend/spec/models/material_export_item_spec.rb b/backend/spec/models/material_export_item_spec.rb index d98e225..07e08f3 100644 --- a/backend/spec/models/material_export_item_spec.rb +++ b/backend/spec/models/material_export_item_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe MaterialExportItem, type: :model do let(:user) { create(:user, :member) } - let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'export_item'), category: :material) } + let(:tag) { create(:tag, name: 'export_item', category: :material) } let(:material) do Material.create!(tag:, url: 'https://example.com/material', created_by_user: user, updated_by_user: user) diff --git a/backend/spec/models/post_version_spec.rb b/backend/spec/models/post_version_spec.rb index 4cc0e82..ccbb20f 100644 --- a/backend/spec/models/post_version_spec.rb +++ b/backend/spec/models/post_version_spec.rb @@ -1,8 +1,8 @@ require 'rails_helper' RSpec.describe PostVersion, type: :model do - let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') } - let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } + let!(:tag_name) { create(:tag_name, name: 'post_version_spec_tag') } + let!(:tag) { create(:tag, tag_name: tag_name, category: :general) } let!(:post_record) do Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post| diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index b82f9fa..6fb9929 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -34,9 +34,41 @@ RSpec.describe TagNameSanitisationRule, type: :model do described_class.create!(priority: 10, source_pattern: '_', replacement: '') end + context 'when only another language has the sanitised name' do + include_context 'English locale' + + it 'keeps both names when sanitisation produces a cross-language match' do + japanese = create(:tag_name, name: 'foobar') + english = create(:tag_name, name: 'temporary', + language_code: 'en', script_code: 'Latn') + english.update_columns(name: 'foo_bar') + + expect { described_class.apply! }.not_to change(TagName, :count) + + expect(english.reload).to have_attributes(name: 'foobar', language_code: 'en') + expect(japanese.reload).to have_attributes(name: 'foobar', language_code: 'ja') + end + + it 'renames without merging tags or deleting either language identity' do + japanese = create(:tag, name: 'foobar') + english_name = create(:tag_name, name: 'temporary', + language_code: 'en', script_code: 'Latn') + english = create(:tag, tag_name: english_name) + english_name.update_columns(name: 'foo_bar') + + described_class.apply! + + expect(english_name.reload).to have_attributes( + name: 'foobar', language_code: 'en', tag_id: english.id) + expect(japanese.reload.tag_name).to have_attributes( + name: 'foobar', language_code: 'ja', tag_id: japanese.id) + expect(english.reload.tag_name_id).to eq(english_name.id) + end + end + context 'when no conflicting tag_name exists' do let!(:tag_name) do - TagName.create!(name: 'tmp').tap do |tn| + create(:tag_name, name: 'tmp').tap do |tn| tn.update_columns(name: 'foo_bar', updated_at: Time.current) end end @@ -48,9 +80,9 @@ RSpec.describe TagNameSanitisationRule, type: :model do end context 'when a conflicting canonical tag_name exists' do - let!(:existing) { TagName.create!(name: 'foobar') } + let!(:existing) { create(:tag_name, name: 'foobar') } let!(:source) do - TagName.create!(name: 'tmp').tap do |tn| + create(:tag_name, name: 'tmp').tap do |tn| tn.update_columns(name: 'foo_bar', updated_at: Time.current) end end @@ -63,7 +95,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do end context 'when the source tag_name has a tag and the existing one has no tag' do - let!(:existing) { TagName.create!(name: 'foobar') } + let!(:existing) { create(:tag_name, name: 'foobar') } let!(:source_tag) { create(:tag, name: 'tmp', category: :general) } let!(:source_tag_name_id) { source_tag.tag_name_id } @@ -82,10 +114,10 @@ RSpec.describe TagNameSanitisationRule, type: :model do context 'when the sanitised name is an alias of an existing tag' do let!(:existing_tag) { create(:tag) } let!(:alias_name) do - TagName.create!(name: 'foobar', canonical: existing_tag.tag_name) + create(:tag_name, :alias, name: 'foobar', canonical: existing_tag.tag_name) end let!(:source) do - TagName.create!(name: 'tmp').tap do |tn| + create(:tag_name, name: 'tmp').tap do |tn| tn.update_columns(name: 'foo_bar', updated_at: Time.current) end end @@ -100,11 +132,11 @@ RSpec.describe TagNameSanitisationRule, type: :model do end context 'when both source and existing tag_names have tags' do - let!(:existing_tn) { TagName.create!(name: 'foobar') } - let!(:existing_tag) { Tag.create!(tag_name: existing_tn, category: :general) } + let!(:existing_tn) { create(:tag_name, name: 'foobar') } + let!(:existing_tag) { create(:tag, tag_name: existing_tn, category: :general) } - let!(:source_tn) { TagName.create!(name: 'tmp') } - let!(:source_tag) { Tag.create!(tag_name: source_tn, category: :general) } + let!(:source_tn) { create(:tag_name, name: 'tmp') } + let!(:source_tag) { create(:tag, tag_name: source_tn, category: :general) } let!(:source_tag_name_id) { source_tn.id } before do diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb new file mode 100644 index 0000000..66aa120 --- /dev/null +++ b/backend/spec/models/tag_name_spec.rb @@ -0,0 +1,73 @@ +require 'rails_helper' + +RSpec.describe TagName, type: :model do + include_context 'English locale' + + it 'allows the same name in different languages' do + create(:tag_name, name: 'shared_name') + english = build(:tag_name, name: 'shared_name', + language_code: 'en', script_code: 'Latn') + + expect(english).to be_valid + expect { english.save! }.to change(described_class, :count).by(1) + end + + it 'rejects a duplicate name within the same language' do + create(:tag_name, name: 'shared_name') + duplicate = build(:tag_name, name: 'shared_name') + + expect(duplicate).to be_invalid + expect(duplicate.errors.of_kind?(:name, :taken)).to be(true) + end + + it 'creates Japanese factory names without requiring a tag' do + name = create(:tag_name) + + expect(name.reload).to have_attributes( + language_code: 'ja', script_code: 'Jpan', primary_flg: true, tag_id: nil) + end + + it 'persists both sides of the representative tag factory association' do + tag = create(:tag) + + expect(tag.reload.tag_name.reload.tag_id).to eq(tag.id) + expect(tag.tag_name.tag).to eq(tag) + end + + it 'accepts an alias owned by the canonical tag' do + tag = create(:tag) + alias_name = build(:tag_name, name: 'valid_alias', canonical: tag.tag_name, + tag:, primary_flg: false) + + expect(alias_name).to have_attributes( + canonical_id: tag.tag_name_id, tag_id: tag.id, + primary_flg: false, language_code: 'ja') + expect(alias_name).to be_valid + end + + describe '.canonicalise' do + it 'resolves only aliases in the requested language' do + japanese = create(:tag, name: 'japanese_canonical') + english_name = create(:tag_name, name: 'english_canonical', + language_code: 'en', script_code: 'Latn') + english = create(:tag, tag_name: english_name) + japanese_alias = create(:tag_name, :alias, name: 'shared_alias', + canonical: japanese.tag_name) + english_alias = create(:tag_name, :alias, name: 'temporary_alias', + canonical: english_name, + language_code: 'en', script_code: 'Latn') + english_alias.update_columns(name: 'shared_alias') + + expect(japanese_alias.reload).to have_attributes( + canonical_id: japanese.tag_name_id, tag_id: japanese.id, + primary_flg: false, language_code: 'ja') + expect(english_alias.reload).to have_attributes( + canonical_id: english_name.id, tag_id: english.id, + primary_flg: false, language_code: 'en') + expect(described_class.canonicalise(locale, ['shared_alias'])) + .to eq(['english_canonical']) + expect(described_class.canonicalise(Locale.nipponese, ['shared_alias'])) + .to eq(['japanese_canonical']) + end + end +end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index d63be45..e9683dd 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Tag, type: :model do expect(tag).to be_invalid expect(tag.errors[:name]).to be_present expect { - described_class.normalise_tags!([name]) + described_class.normalise_tags!(Locale.nipponese, [name]) }.to raise_error(Tag::NicoTagNormalisationError) end end @@ -25,19 +25,49 @@ RSpec.describe Tag, type: :model do end describe '.normalise_tags!' do + it 'canonicalises aliases in the supplied language only' do + Language.find_or_create_by!(code: 'en') { _1.name = 'English' } + Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } + Locale.insert_all!([ + { code: 'en', language_code: 'en', script_code: 'Latn', + name: 'English', created_at: Time.current }]) unless Locale.exists?(code: 'en') + english_locale = Locale.find('en') + japanese = create(:tag, name: 'japanese_canonical') + english_name = create(:tag_name, name: 'english_canonical', + language_code: 'en', script_code: 'Latn') + english = create(:tag, tag_name: english_name) + japanese_alias = create(:tag_name, :alias, name: 'shared_alias', + canonical: japanese.tag_name) + english_alias = create(:tag_name, :alias, name: 'temporary_alias', + canonical: english_name, + language_code: 'en', script_code: 'Latn') + english_alias.update_columns(name: 'shared_alias') + + expect(japanese_alias.reload).to have_attributes( + canonical_id: japanese.tag_name_id, tag_id: japanese.id, + primary_flg: false, language_code: 'ja') + expect(english_alias.reload).to have_attributes( + canonical_id: english_name.id, tag_id: english.id, + primary_flg: false, language_code: 'en') + expect(described_class.normalise_tags!( + english_locale, ['shared_alias'], + with_tagme: false, with_no_deerjikist: false)).to eq([english]) + expect(described_class.normalise_tags!( + Locale.nipponese, ['shared_alias'], + with_tagme: false, with_no_deerjikist: false)).to eq([japanese]) + end + it 'rejects deprecated tags when deny_deprecated is enabled' do - tag_name = TagName.create!(name: 'normalise deprecated tag') - deprecated_tag = Tag.create!( - tag_name:, - category: :general, - deprecated_at: 1.day.from_now - ) + tag_name = create(:tag_name, name: 'normalise deprecated tag') + deprecated_tag = create(:tag, + tag_name:, + category: :general, + deprecated_at: 1.day.from_now) expect { - described_class.normalise_tags!( - [deprecated_tag.name], - deny_deprecated: true - ) + described_class.normalise_tags!(Locale.nipponese, + [deprecated_tag.name], + deny_deprecated: true) }.to raise_error(Tag::DeprecatedTagNormalisationError) { |error| expect(error.tag_names).to eq([deprecated_tag.name]) } @@ -45,88 +75,79 @@ RSpec.describe Tag, type: :model do it 'rejects invalid section literals instead of treating them as zero' do expect { - described_class.normalise_tags!( - ['normalise_invalid_section[1:aa-2:00]'], - with_sections: true - ) + described_class.normalise_tags!(Locale.nipponese, + ['normalise_invalid_section[1:aa-2:00]'], + with_sections: true) }.to raise_error(Tag::SectionLiteralParseError) end it 'parses open-ended section literals' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[1:00-]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[1:00-]'], + with_sections: true) tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) end it 'parses omitted begin as zero' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[-1:00]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[-1:00]'], + with_sections: true) tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } expect(result.fetch(:sections).fetch(tag.id)).to eq([[0, 60_000]]) end it 'treats fully open section literals as plain tags' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[-]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[-]'], + with_sections: true) tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } expect(result.fetch(:sections)[tag.id]).to be_nil end it 'treats [0:00-] as a plain tag' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[0:00-]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[0:00-]'], + with_sections: true) tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } expect(result.fetch(:sections)[tag.id]).to be_nil end it 'expands zero-width sections to one millisecond' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[1:00-1:00]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[1:00-1:00]'], + with_sections: true) tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 60_001]]) end it 'swaps reversed section boundaries' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[2:00-1:00]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[2:00-1:00]'], + with_sections: true) tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, 120_000]]) end it 'merges open-ended sections over later bounded sections' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[1:00-][2:00-3:00]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[1:00-][2:00-3:00]'], + with_sections: true) tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) end it 'merges adjacent bounded and open-ended sections' do - result = described_class.normalise_tags!( - ['伊地知ニジカ[1:00-3:00][3:00-]'], - with_sections: true - ) + result = described_class.normalise_tags!(Locale.nipponese, + ['伊地知ニジカ[1:00-3:00][3:00-]'], + with_sections: true) tag = result.fetch(:tags).find { _1.name == '伊地知ニジカ' } expect(result.fetch(:sections).fetch(tag.id)).to eq([[60_000, nil]]) @@ -183,11 +204,65 @@ RSpec.describe Tag, type: :model do end describe '.find_or_create_by_tag_name!' do + context 'with an explicit locale' do + include_context 'English locale' + + it 'creates the representative name with locale attributes and ownership' do + tag = described_class.find_or_create_by_tag_name!( + locale, 'english_name', category: :character) + + expect(tag.tag_name.reload).to have_attributes( + language_code: 'en', script_code: 'Latn', + primary_flg: true, tag_id: tag.id) + expect(tag.category).to eq('character') + end + + it 'creates separate tag identities for the same name in different languages' do + japanese = described_class.find_or_create_by_tag_name!( + Locale.nipponese, 'same_name', category: :general) + english = described_class.find_or_create_by_tag_name!( + locale, 'same_name', category: :character) + + expect(english).not_to eq(japanese) + expect(english.tag_name.language_code).to eq('en') + expect(japanese.tag_name.language_code).to eq('ja') + expect(described_class.find_or_create_by_tag_name!( + locale, 'same_name', category: :general)).to eq(english) + expect(described_class.find_or_create_by_tag_name!( + Locale.nipponese, 'same_name', category: :general)).to eq(japanese) + end + + it 'preserves the V1 representative when looking up another primary language' do + tag = create(:tag, name: '日本語代表名') + representative_id = tag.tag_name_id + english = create(:tag_name, name: 'english_primary', tag:, + language_code: 'en', script_code: 'Latn') + + found = described_class.find_or_create_by_tag_name!( + locale, english.name, category: :general) + + expect(found).to eq(tag) + expect(tag.reload.tag_name_id).to eq(representative_id) + expect(english.reload.tag_id).to eq(tag.id) + end + + it 'normalises names using the supplied locale' do + tags = described_class.normalise_tags!( + locale, ['character:normalised_english'], + with_tagme: false, with_no_deerjikist: false) + + expect(tags.length).to eq(1) + expect(tags.first.tag_name).to have_attributes( + name: 'normalised_english', language_code: 'en', script_code: 'Latn', + primary_flg: true, tag_id: tags.first.id) + end + end + it 'creates a tag and name with the requested category after stripping whitespace' do tag = nil expect { - tag = described_class.find_or_create_by_tag_name!( + tag = described_class.find_or_create_by_tag_name!(Locale.nipponese, ' lookup_new ', category: :character) }.to change(Tag, :count).by(1).and change(TagName, :count).by(1) @@ -197,27 +272,38 @@ RSpec.describe Tag, type: :model do it 'reuses the canonical tag for an alias without changing its category' do tag = create(:tag, category: :character) - alias_name = TagName.create!(name: 'lookup_alias', canonical: tag.tag_name) + representative_id = tag.tag_name_id + alias_name = create(:tag_name, :alias, name: 'lookup_alias', canonical: tag.tag_name) + expect(alias_name.reload).to have_attributes( + canonical_id: tag.tag_name_id, tag_id: tag.id, + primary_flg: false, language_code: 'ja') found = nil expect { - found = described_class.find_or_create_by_tag_name!( + found = described_class.find_or_create_by_tag_name!(Locale.nipponese, alias_name.name, category: :general) }.to change(Tag, :count).by(0).and change(TagName, :count).by(0) expect(found).to eq(tag) expect(found.category).to eq('character') + expect(tag.reload.tag_name_id).to eq(representative_id) end - it 'creates a tag for an existing canonical name reached through an alias' do - canonical = create(:tag_name) - alias_name = TagName.create!(name: 'lookup_alias', canonical:) - tag = nil + it 'reuses the canonical tag through another alias' do + tag = create(:tag) + canonical = tag.tag_name + alias_name = create(:tag_name, :alias, name: 'lookup_alias', canonical:) + other_alias = create(:tag_name, :alias, name: 'lookup_other_alias', canonical:) + + expect([alias_name.reload, other_alias.reload]).to all(have_attributes( + canonical_id: canonical.id, tag_id: tag.id, + primary_flg: false, language_code: 'ja')) expect { - tag = described_class.find_or_create_by_tag_name!( + found = described_class.find_or_create_by_tag_name!(Locale.nipponese, alias_name.name, category: :general) - }.to change(Tag, :count).by(1).and change(TagName, :count).by(0) + expect(found).to eq(tag) + }.to change(Tag, :count).by(0).and change(TagName, :count).by(0) expect(tag.tag_name).to eq(canonical) end @@ -277,7 +363,7 @@ RSpec.describe Tag, type: :model do it 'keeps source history and records the new target alias after deleting the source' do user = create_member_user! source_name = source_tag.name - source_alias = TagName.create!(name: 'merge_alias', canonical: source_tag_name) + source_alias = create(:tag_name, :alias, name: 'merge_alias', canonical: source_tag_name) TagVersioning.ensure_snapshot!(source_tag, created_by_user: user) original_version = source_tag.tag_versions.first diff --git a/backend/spec/requests/gekanator_learning_spec.rb b/backend/spec/requests/gekanator_learning_spec.rb index 26f271b..855bb75 100644 --- a/backend/spec/requests/gekanator_learning_spec.rb +++ b/backend/spec/requests/gekanator_learning_spec.rb @@ -898,12 +898,11 @@ RSpec.describe 'Gekanator learning API', type: :request do describe 'GET /gekanator/questions' do it 'omits questions for deprecated tags' do - active_tag = Tag.create!(name: 'active_question_tag', category: :general) - deprecated_tag = Tag.create!( - name: 'deprecated_question_tag', - category: :general, - deprecated_at: Time.current - ) + active_tag = create(:tag, name: 'active_question_tag', category: :general) + deprecated_tag = create(:tag, + name: 'deprecated_question_tag', + category: :general, + deprecated_at: Time.current) [active_tag, deprecated_tag].each do |question_tag| GekanatorQuestion.create!( diff --git a/backend/spec/requests/gekanator_posts_spec.rb b/backend/spec/requests/gekanator_posts_spec.rb index b21e414..7fe627a 100644 --- a/backend/spec/requests/gekanator_posts_spec.rb +++ b/backend/spec/requests/gekanator_posts_spec.rb @@ -4,12 +4,11 @@ require 'rails_helper' RSpec.describe 'Gekanator posts API', type: :request do describe 'GET /gekanator/posts' do it 'omits deprecated tags and returns the stored similarity cosine' do - active_tag = Tag.create!(name: 'active tag', category: :general) - deprecated_tag = Tag.create!( - name: 'deprecated tag', - category: :general, - deprecated_at: Time.current - ) + active_tag = create(:tag, name: 'active tag', category: :general) + deprecated_tag = create(:tag, + name: 'deprecated tag', + category: :general, + deprecated_at: Time.current) post_record = Post.create!(title: 'source', url: 'https://example.com/source') target_post = Post.create!(title: 'target', url: 'https://example.com/target') diff --git a/backend/spec/requests/locale_write_paths_spec.rb b/backend/spec/requests/locale_write_paths_spec.rb new file mode 100644 index 0000000..f90a8c3 --- /dev/null +++ b/backend/spec/requests/locale_write_paths_spec.rb @@ -0,0 +1,232 @@ +require 'rails_helper' + +RSpec.describe 'Locale propagation on write paths', type: :request do + include_context 'English locale' + + let(:member) { create(:user, :member) } + + before do + sign_in_as(member) + end + + def expect_localised_tag(name, expected_locale) + tag_name = TagName.find_by!(name:, language_code: expected_locale.language_code) + expect(tag_name).to have_attributes( + script_code: expected_locale.script_code, primary_flg: true) + expect(tag_name.tag_id).to be_present + expect(tag_name.tag.tag_name_id).to eq(tag_name.id) + tag_name.tag + end + + ['en', nil].each do |requested_locale| + context "with locale #{ requested_locale.inspect }" do + let(:locale_params) { requested_locale ? { locale: requested_locale } : { } } + let(:expected_locale) { requested_locale ? locale : Locale.nipponese } + + it 'creates post tags in the requested language or Japanese fallback' do + post '/posts', params: locale_params.merge( + title: 'Locale post', url: 'https://example.com/locale-post', + tags: 'locale_post_tag', parent_post_ids: '') + + expect(response).to have_http_status(:created), response.body + tag = expect_localised_tag('locale_post_tag', expected_locale) + expect(Post.find(json.fetch('id')).tags).to include(tag) + end + + it 'updates post tags in the requested language or Japanese fallback' do + record = create(:post) + PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member) + + put "/posts/#{ record.id }", params: locale_params.merge( + title: record.title, tags: 'locale_updated_tag', parent_post_ids: '', + base_version_no: record.reload.version_no) + + expect(response).to have_http_status(:ok), response.body + tag = expect_localised_tag('locale_updated_tag', expected_locale) + expect(record.reload.tags).to include(tag) + end + + it 'passes the resolved locale to bulk creation' do + # Worker propagation is exercised in post_bulk_creator_spec. + expect_any_instance_of(PostBulkCreator).to receive(:run) + .with(expected_locale).and_return(results: []) + manifest = [{ title: 'Bulk locale', url: 'https://example.com/bulk-locale', + tags: 'bulk_locale_tag', parent_post_ids: '' }] + + post '/posts/bulk', params: locale_params.merge(posts: JSON.generate(manifest)), + headers: { 'CONTENT_TYPE' => 'multipart/form-data' } + + expect(response).to have_http_status(:ok), response.body + end + + it 'creates materials with tags in the resolved locale' do + post '/materials', params: locale_params.merge( + tag: 'locale_material', url: 'https://example.com/material') + + expect(response).to have_http_status(:created), response.body + tag = expect_localised_tag('locale_material', expected_locale) + expect(Material.find(json.fetch('id')).tag).to eq(tag) + end + + it 'updates materials with tags in the resolved locale' do + tag = create(:tag, category: :material) + material = Material.create!(tag:, url: 'https://example.com/material') + + put "/materials/#{ material.id }", params: locale_params.merge( + tag: 'locale_material_updated', url: material.url) + + expect(response).to have_http_status(:ok), response.body + resolved_tag = expect_localised_tag('locale_material_updated', expected_locale) + expect(material.reload.tag).to eq(resolved_tag) + end + + it 'links Nico external tags to internal tags in the resolved locale' do + external = create(:external_tag) + NicoTagVersionRecorder.record!( + external_tag: external, event_type: :create, created_by_user: member) + + put "/tags/nico/#{ external.id }", params: locale_params.merge(tags: 'locale_link') + + expect(response).to have_http_status(:ok), response.body + tag = expect_localised_tag('locale_link', expected_locale) + expect(external.reload.linked_tags).to contain_exactly(tag) + end + + it 'creates parent tags in the resolved locale' do + tag = create(:tag) + + put "/tags/#{ tag.id }", params: locale_params.merge( + name: tag.name, category: tag.category, deprecated: false, + aliases: '', parent_tags: 'locale_parent') + + expect(response).to have_http_status(:ok), response.body + parent = expect_localised_tag('locale_parent', expected_locale) + expect(TagImplication.where(tag:).pluck(:parent_tag_id)).to eq([parent.id]) + end + end + end + + context 'with existing names in both languages' do + let!(:english_name) do + create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn') + end + let!(:english_tag) { create(:tag, tag_name: english_name, category: :material) } + let!(:japanese_tag) do + create(:tag, name: 'temporary_japanese', category: :general).tap do |tag| + # Isolate lookup from the separately tested uniqueness validation. + tag.tag_name.update_columns(name: 'shared_name') + end + end + + it 'plans and creates the post using the English identity and category' do + post '/posts', params: { + locale: 'en', title: 'English identity', + url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' } + + expect(response).to have_http_status(:created), response.body + record = Post.find(json.fetch('id')) + expect(record.tags).to include(english_tag) + expect(record.tags).not_to include(japanese_tag) + expect(english_tag.reload.category).to eq('material') + expect(japanese_tag.reload.category).to eq('general') + end + + it 'updates the post using the English identity' do + record = create(:post) + PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member) + + put "/posts/#{ record.id }", params: { + locale: 'en', title: record.title, tags: 'shared_name', parent_post_ids: '', + base_version_no: record.reload.version_no } + + expect(response).to have_http_status(:ok), response.body + expect(record.reload.tags).to include(english_tag) + expect(record.tags).not_to include(japanese_tag) + end + + it 'resolves the English material tag' do + post '/materials', params: { + locale: 'en', tag: 'shared_name', url: 'https://example.com/english-material' } + + expect(response).to have_http_status(:created), response.body + expect(Material.find(json.fetch('id')).tag).to eq(english_tag) + expect(Material.where(tag: japanese_tag)).to be_empty + end + + it 'resolves the English parent tag' do + child = create(:tag) + + put "/tags/#{ child.id }", params: { + locale: 'en', name: child.name, category: child.category, deprecated: false, + aliases: '', parent_tags: 'shared_name' } + + expect(response).to have_http_status(:ok), response.body + expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([english_tag.id]) + end + + it 'resolves the English internal tag for a Nico link' do + external = create(:external_tag) + NicoTagVersionRecorder.record!( + external_tag: external, event_type: :create, created_by_user: member) + + put "/tags/nico/#{ external.id }", params: { locale: 'en', tags: 'shared_name' } + + expect(response).to have_http_status(:ok), response.body + expect(external.reload.linked_tags).to contain_exactly(english_tag) + end + end + + context 'when renaming or updating aliases in English' do + let!(:english_name) do + create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn') + end + let!(:tag) { create(:tag, tag_name: english_name) } + + it 'allows a rename to a name already used in Japanese' do + japanese = create(:tag, name: 'rename_target') + + patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' } + + expect(response).to have_http_status(:ok), response.body + expect(tag.reload.tag_name).to have_attributes( + name: 'rename_target', language_code: 'en', tag_id: tag.id) + expect(japanese.reload.tag_name).to have_attributes( + name: 'rename_target', language_code: 'ja', tag_id: japanese.id) + end + + it 'creates aliases in the requested language' do + put "/tags/#{ tag.id }", params: { + locale: 'en', name: tag.name, category: tag.category, deprecated: false, + aliases: 'english_alias', parent_tags: '' } + + expect(response).to have_http_status(:ok), response.body + expect(TagName.find_by!(language_code: 'en', name: 'english_alias')) + .to have_attributes( + script_code: 'Latn', canonical_id: english_name.id, + tag_id: tag.id, primary_flg: false, language_code: 'en') + expect(tag.reload.tag_name_id).to eq(english_name.id) + end + + it 'does not take an alias from another language' do + japanese = create(:tag, name: 'japanese_owner') + japanese_alias = create(:tag_name, :alias, name: 'shared_alias', + canonical: japanese.tag_name) + expect(japanese_alias.reload).to have_attributes( + canonical_id: japanese.tag_name_id, tag_id: japanese.id, + primary_flg: false, language_code: 'ja') + + put "/tags/#{ tag.id }", params: { + locale: 'en', name: tag.name, category: tag.category, deprecated: false, + aliases: 'shared_alias', parent_tags: '' } + + expect(response).to have_http_status(:ok), response.body + expect(japanese_alias.reload).to have_attributes( + canonical_id: japanese.tag_name_id, tag_id: japanese.id, + primary_flg: false, language_code: 'ja') + expect(TagName.find_by!(language_code: 'en', name: 'shared_alias')) + .to have_attributes( + canonical_id: english_name.id, tag_id: tag.id, + primary_flg: false, language_code: 'en') + end + end +end diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index c1ffdf4..17ff0a9 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -26,10 +26,10 @@ RSpec.describe 'Materials API', type: :request do describe 'GET /materials' do let!(:tag_a) do - Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) + create(:tag, name: 'material_index_a', category: :material) end let!(:tag_b) do - Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) + create(:tag, name: 'material_index_b', category: :material) end let!(:material_a) do @@ -116,20 +116,20 @@ RSpec.describe 'Materials API', type: :request do it 'filters by descendant tags and returns stable parent tag groups' do root = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_root'), + create(:tag, name: 'material_scope_root', category: :material) child_b = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_b'), + create(:tag, name: 'material_scope_b', category: :material) child_a = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_a'), + create(:tag, name: 'material_scope_a', category: :material) deprecated = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_old'), + create(:tag, name: 'material_scope_old', category: :material, deprecated_at: Time.current) grandchild = - Tag.create!(tag_name: TagName.create!(name: 'material_scope_grandchild'), + create(:tag, name: 'material_scope_grandchild', category: :material) root_material = build_material(tag: root, user: member_user, @@ -179,7 +179,7 @@ RSpec.describe 'Materials API', type: :request do describe 'GET /materials/:id' do let!(:tag) do - Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) + create(:tag, name: 'material_show', category: :material) end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png')) @@ -323,8 +323,8 @@ RSpec.describe 'Materials API', type: :request do end it 'returns 422 when the existing tag is not material/character' do - general_tag_name = TagName.create!(name: 'material_create_general_tag') - Tag.create!(tag_name: general_tag_name, category: :general) + general_tag_name = create(:tag_name, name: 'material_create_general_tag') + create(:tag, tag_name: general_tag_name, category: :general) post '/materials', params: { tag: 'material_create_general_tag', @@ -384,7 +384,7 @@ RSpec.describe 'Materials API', type: :request do describe 'PUT /materials/:id' do let!(:tag) do - Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) + create(:tag, name: 'material_update_old', category: :material) end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png')) @@ -508,7 +508,7 @@ RSpec.describe 'Materials API', type: :request do it 'backfills a create tag_version for an existing material tag without history' do existing_tag = - Tag.create!(tag_name: TagName.create!(name: 'material_update_existing_no_history'), + create(:tag, name: 'material_update_existing_no_history', category: :material) expect(existing_tag.tag_versions).to be_empty @@ -531,7 +531,7 @@ RSpec.describe 'Materials API', type: :request do it 'backfills a create tag_version for an existing character tag without history' do existing_tag = - Tag.create!(tag_name: TagName.create!(name: 'material_update_character_no_history'), + create(:tag, name: 'material_update_character_no_history', category: :character) expect(existing_tag.tag_versions).to be_empty @@ -651,8 +651,8 @@ RSpec.describe 'Materials API', type: :request do end describe 'GET /materials/download.zip' do - let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'zip_a'), category: :material) } - let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'zip_b'), category: :material) } + let!(:tag_a) { create(:tag, name: 'zip_a', category: :material) } + let!(:tag_b) { create(:tag, name: 'zip_b', category: :material) } let!(:material_a) do build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png', body: 'zip-a')) @@ -701,7 +701,7 @@ RSpec.describe 'Materials API', type: :request do describe 'GET /materials/versions' do let!(:tag) do - Tag.create!(tag_name: TagName.create!(name: 'material_history'), category: :material) + create(:tag, name: 'material_history', category: :material) end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'history.png')) @@ -776,7 +776,7 @@ RSpec.describe 'Materials API', type: :request do describe 'DELETE /materials/:id' do let!(:tag) do - Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) + create(:tag, name: 'material_destroy', category: :material) end let!(:material) do build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png')) diff --git a/backend/spec/requests/nico_tags_spec.rb b/backend/spec/requests/nico_tags_spec.rb index 243cbd3..f800afb 100644 --- a/backend/spec/requests/nico_tags_spec.rb +++ b/backend/spec/requests/nico_tags_spec.rb @@ -162,11 +162,11 @@ RSpec.describe 'NicoTags', type: :request do nico_tag = create(:external_tag, name: 'nico_tags_spec_source') - linked_a_name = TagName.create!(name: 'nico_linked_a') - linked_a = Tag.create!(tag_name: linked_a_name, category: :general) + linked_a_name = create(:tag_name, name: 'nico_linked_a') + linked_a = create(:tag, tag_name: linked_a_name, category: :general) - linked_b_name = TagName.create!(name: 'nico_linked_b') - linked_b = Tag.create!(tag_name: linked_b_name, category: :general) + linked_b_name = create(:tag_name, name: 'nico_linked_b') + linked_b = create(:tag, tag_name: linked_b_name, category: :general) NicoTagVersionRecorder.record!(external_tag: nico_tag, event_type: :create, created_by_user: admin) diff --git a/backend/spec/requests/posts_spec.rb b/backend/spec/requests/posts_spec.rb index 8660634..585dacb 100644 --- a/backend/spec/requests/posts_spec.rb +++ b/backend/spec/requests/posts_spec.rb @@ -113,8 +113,8 @@ RSpec.describe 'Posts API', type: :request do 'sections' => []) end - let!(:tag_name) { TagName.create!(name: 'spec_tag') } - let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } + let!(:tag_name) { create(:tag_name, name: 'spec_tag') } + let!(:tag) { create(:tag, tag_name: tag_name, category: :general) } let!(:post_record) do Post.create!(title: 'spec post', url: 'https://example.com/spec').tap do |p| @@ -125,11 +125,11 @@ RSpec.describe 'Posts API', type: :request do describe 'GET /posts' do let!(:user) { create_member_user! } - let!(:tag_name) { TagName.create!(name: 'spec_tag') } - let!(:tag) { Tag.create!(tag_name:, category: :general) } - let!(:tag_name2) { TagName.create!(name: 'unko') } - let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) } - let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } + let!(:tag_name) { create(:tag_name, name: 'spec_tag') } + let!(:tag) { create(:tag, tag_name:, category: :general) } + let!(:tag_name2) { create(:tag_name, name: 'unko') } + let!(:tag2) { create(:tag, tag_name: tag_name2, category: :deerjikist) } + let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', canonical: tag_name) } let!(:hit_post) do Post.create!(uploaded_user: user, title: 'hello spec world', @@ -341,17 +341,17 @@ RSpec.describe 'Posts API', type: :request do end context 'when tags contain not:' do - let!(:foo_tag_name) { TagName.create!(name: 'not_spec_foo') } - let!(:foo_tag) { Tag.create!(tag_name: foo_tag_name, category: :general) } + let!(:foo_tag_name) { create(:tag_name, name: 'not_spec_foo') } + let!(:foo_tag) { create(:tag, tag_name: foo_tag_name, category: :general) } - let!(:bar_tag_name) { TagName.create!(name: 'not_spec_bar') } - let!(:bar_tag) { Tag.create!(tag_name: bar_tag_name, category: :general) } + let!(:bar_tag_name) { create(:tag_name, name: 'not_spec_bar') } + let!(:bar_tag) { create(:tag, tag_name: bar_tag_name, category: :general) } - let!(:baz_tag_name) { TagName.create!(name: 'not_spec_baz') } - let!(:baz_tag) { Tag.create!(tag_name: baz_tag_name, category: :general) } + let!(:baz_tag_name) { create(:tag_name, name: 'not_spec_baz') } + let!(:baz_tag) { create(:tag, tag_name: baz_tag_name, category: :general) } let!(:foo_alias_tag_name) do - TagName.create!(name: 'not_spec_foo_alias', canonical: foo_tag_name) + create(:tag_name, :alias, name: 'not_spec_foo_alias', canonical: foo_tag_name) end let!(:foo_only_post) do @@ -772,11 +772,10 @@ RSpec.describe 'Posts API', type: :request do end it 'omits deprecated tags' do - deprecated_tag = Tag.create!( - name: 'deprecated_post_tag', - category: :general, - deprecated_at: Time.current - ) + deprecated_tag = create(:tag, + name: 'deprecated_post_tag', + category: :general, + deprecated_at: Time.current) PostTag.create!(post: post_record, tag: deprecated_tag) request @@ -853,9 +852,9 @@ RSpec.describe 'Posts API', type: :request do tags = 15.times.map do |i| - tag_name = TagName.create!(name: "show_query_tag_#{ i }") - tag = Tag.create!(tag_name:, category: :general) - TagName.create!(name: "show_query_alias_#{ i }", canonical: tag_name) + tag_name = create(:tag_name, name: "show_query_tag_#{ i }") + tag = create(:tag, tag_name:, category: :general) + create(:tag_name, :alias, name: "show_query_alias_#{ i }", canonical: tag_name) PostTag.create!(post: post_record, tag:) tag end @@ -1000,7 +999,7 @@ RSpec.describe 'Posts API', type: :request do describe 'POST /posts' do let(:member) { create(:user, :member) } - let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) } + let!(:alias_tag_name) { create(:tag_name, :alias, name: 'manko', canonical: tag_name) } it '401 when not logged in' do sign_out @@ -1157,11 +1156,10 @@ RSpec.describe 'Posts API', type: :request do end it 'rejects a deprecated tag specified directly' do - Tag.create!( - name: 'deprecated_direct_tag', - category: :general, - deprecated_at: Time.current - ) + create(:tag, + name: 'deprecated_direct_tag', + category: :general, + deprecated_at: Time.current) sign_in_as(member) post '/posts', params: post_write_params( @@ -1178,18 +1176,16 @@ RSpec.describe 'Posts API', type: :request do end it 'expands through multiple deprecated parent tags and saves active ancestors' do - child = Tag.create!(name: 'active_child', category: :general) - deprecated_parent = Tag.create!( - name: 'deprecated_parent', - category: :general, - deprecated_at: Time.current - ) - deprecated_grandparent = Tag.create!( - name: 'deprecated_grandparent', - category: :general, - deprecated_at: Time.current - ) - active_grandparent = Tag.create!(name: 'active_grandparent', category: :general) + child = create(:tag, name: 'active_child', category: :general) + deprecated_parent = create(:tag, + name: 'deprecated_parent', + category: :general, + deprecated_at: Time.current) + deprecated_grandparent = create(:tag, + name: 'deprecated_grandparent', + category: :general, + deprecated_at: Time.current) + active_grandparent = create(:tag, name: 'active_grandparent', category: :general) TagImplication.create!(tag: child, parent_tag: deprecated_parent) TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent) TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_grandparent) @@ -1622,6 +1618,7 @@ RSpec.describe 'Posts API', type: :request do expect(arguments[:thumbnails].keys).to eq([0]) expect(arguments[:host]).to eq('http://www.example.com') end + expect(creator).to have_received(:run).with(Locale.nipponese) end it 'rejects malformed manifests as a request-level error' do @@ -1658,8 +1655,8 @@ RSpec.describe 'Posts API', type: :request do create(:post_tag_section, post: post_record, tag:, begin_ms: 1000, end_ms: 2000) - tn2 = TagName.create!(name: 'spec_tag_2') - replacement_tag = Tag.create!(tag_name: tn2, category: :general) + tn2 = create(:tag_name, name: 'spec_tag_2') + replacement_tag = create(:tag, tag_name: tn2, category: :general) put "/posts/#{post_record.id}", params: post_update_params( post_record, @@ -1707,11 +1704,10 @@ RSpec.describe 'Posts API', type: :request do end it 'rejects a deprecated tag specified directly' do - Tag.create!( - name: 'deprecated_update_tag', - category: :general, - deprecated_at: Time.current - ) + create(:tag, + name: 'deprecated_update_tag', + category: :general, + deprecated_at: Time.current) sign_in_as(member) put "/posts/#{ post_record.id }", params: post_update_params( @@ -1986,7 +1982,8 @@ RSpec.describe 'Posts API', type: :request do base_version = create_post_version_for!(post_record.reload) - current_tag = Tag.find_or_create_by_tag_name!('current_added_tag', category: :general) + current_tag = Tag.find_or_create_by_tag_name!( + Locale.nipponese, 'current_added_tag', category: :general) PostTag.create!(post: post_record, tag: current_tag, created_user: member) PostVersionRecorder.record!( @@ -2015,7 +2012,8 @@ RSpec.describe 'Posts API', type: :request do base_version = create_post_version_for!(post_record.reload) - current_tag = Tag.find_or_create_by_tag_name!('current_merge_tag', category: :general) + current_tag = Tag.find_or_create_by_tag_name!( + Locale.nipponese, 'current_merge_tag', category: :general) PostTag.create!(post: post_record, tag: current_tag, created_user: member) PostVersionRecorder.record!( @@ -2102,7 +2100,8 @@ RSpec.describe 'Posts API', type: :request do sign_in_as(member) nico_tag = create_nico_tag!('nico:relation_source') - linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general) + linked_tag = Tag.find_or_create_by_tag_name!( + Locale.nipponese, 'relation_linked_tag', category: :general) NicoTagRelation.create!(nico_tag:, tag: linked_tag) PostExternalTag.create!(post: post_record, external_tag: nico_tag) @@ -2191,8 +2190,8 @@ RSpec.describe 'Posts API', type: :request do let(:oc_from) { Time.zone.local(2019, 12, 31, 0, 0, 0) } let(:oc_before) { Time.zone.local(2020, 1, 1, 0, 0, 0) } - let!(:tag_name2) { TagName.create!(name: 'spec_tag_2') } - let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :general) } + let!(:tag_name2) { create(:tag_name, name: 'spec_tag_2') } + let!(:tag2) { create(:tag, tag_name: tag_name2, category: :general) } def snapshot_tags(post) post.snapshot_tag_names.join(' ') @@ -2550,8 +2549,8 @@ RSpec.describe 'Posts API', type: :request do sign_in_as(member) base_version = create_post_version_for!(post_record) - tag_name2 = TagName.create!(name: 'spec_tag_2') - Tag.create!(tag_name: tag_name2, category: :general) + tag_name2 = create(:tag_name, name: 'spec_tag_2') + create(:tag, tag_name: tag_name2, category: :general) expect do put "/posts/#{post_record.id}", params: post_write_params( @@ -2715,8 +2714,8 @@ RSpec.describe 'Posts API', type: :request do base_version = create_post_version_for!(post_record.reload) - tag_name2 = TagName.create!(name: 'spec_tag_2') - tag2 = Tag.create!(tag_name: tag_name2, category: :general) + tag_name2 = create(:tag_name, name: 'spec_tag_2') + tag2 = create(:tag, tag_name: tag_name2, category: :general) expect { put "/posts/#{post_record.id}", params: post_write_params( diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index 62afeb8..f9802a4 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -232,8 +232,8 @@ RSpec.describe 'TagVersions API', type: :request do end it 'does not create tag versions by wiki updates when tag has no versions yet' do - wiki_tag_name = TagName.create!(name: 'tag_versions_from_wiki') - wiki_tag = Tag.create!(tag_name: wiki_tag_name, category: :general) + wiki_tag_name = create(:tag_name, name: 'tag_versions_from_wiki') + wiki_tag = create(:tag, tag_name: wiki_tag_name, category: :general) wiki_page = Wiki::Commit.create_content!( diff --git a/backend/spec/requests/tag_wiki_history_integrity_spec.rb b/backend/spec/requests/tag_wiki_history_integrity_spec.rb index 4914cf4..33401e9 100644 --- a/backend/spec/requests/tag_wiki_history_integrity_spec.rb +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -8,8 +8,8 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do end def create_tag! name:, category: :general - tag_name = TagName.create!(name:) - Tag.create!(tag_name:, category:) + tag_name = create(:tag_name, name:) + create(:tag, tag_name:, category:) end def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 3c1088c..26c1b52 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -2,13 +2,13 @@ require 'cgi' require 'rails_helper' RSpec.describe 'Tags API', type: :request do - let!(:tn) { TagName.create!(name: 'spec_tag') } - let!(:tag) { Tag.create!(tag_name: tn, category: :general) } - let!(:alias_tn) { TagName.create!(name: 'unko', canonical: tn) } + let!(:tn) { create(:tag_name, name: 'spec_tag') } + let!(:tag) { create(:tag, tag_name: tn, category: :general) } + let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', canonical: tn) } let!(:post) { Post.create!(url: 'https://example.com/unkounkounko') } let!(:post_tag) { PostTag.create!(post:, tag:) } - let!(:tn2) { TagName.create!(name: 'unknown') } - let!(:tag2) { Tag.create!(tag_name: tn2, category: :general) } + let!(:tn2) { create(:tag_name, name: 'unknown') } + let!(:tag2) { create(:tag, tag_name: tn2, category: :general) } def response_tags json.fetch('tags') @@ -91,7 +91,7 @@ RSpec.describe 'Tags API', type: :request do end it 'filters tags by category' do - meme = Tag.create!(tag_name: TagName.create!(name: 'meme_only'), category: :meme) + meme = create(:tag, name: 'meme_only', category: :meme) get '/tags', params: { category: 'meme' } @@ -101,12 +101,11 @@ RSpec.describe 'Tags API', type: :request do end it 'filters tags by deprecated state' do - deprecated_tag = Tag.create!( - name: 'deprecated_filter', - category: :general, - deprecated_at: 1.day.from_now - ) - active_tag = Tag.create!(name: 'active_filter', category: :general) + deprecated_tag = create(:tag, + name: 'deprecated_filter', + category: :general, + deprecated_at: 1.day.from_now) + active_tag = create(:tag, name: 'active_filter', category: :general) get '/tags', params: { name: '_filter', deprecated: '1' } @@ -122,9 +121,9 @@ RSpec.describe 'Tags API', type: :request do end it 'filters tags by post_count range' do - low = Tag.create!(tag_name: TagName.create!(name: 'pc_low'), category: :general) - mid = Tag.create!(tag_name: TagName.create!(name: 'pc_mid'), category: :general) - high = Tag.create!(tag_name: TagName.create!(name: 'pc_high'), category: :general) + low = create(:tag, name: 'pc_low', category: :general) + mid = create(:tag, name: 'pc_mid', category: :general) + high = create(:tag, name: 'pc_high', category: :general) low.update_columns(post_count: 1) mid.update_columns(post_count: 3) @@ -142,8 +141,8 @@ RSpec.describe 'Tags API', type: :request do end it 'filters tags by created_at range' do - old_tag = Tag.create!(tag_name: TagName.create!(name: 'created_old'), category: :general) - new_tag = Tag.create!(tag_name: TagName.create!(name: 'created_new'), category: :general) + old_tag = create(:tag, name: 'created_old', category: :general) + new_tag = create(:tag, name: 'created_new', category: :general) old_time = Time.zone.local(2024, 1, 1, 0, 0, 0) new_time = Time.zone.local(2024, 2, 1, 0, 0, 0) @@ -162,8 +161,8 @@ RSpec.describe 'Tags API', type: :request do end it 'filters tags by updated_at range' do - old_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_old'), category: :general) - new_tag = Tag.create!(tag_name: TagName.create!(name: 'updated_new'), category: :general) + old_tag = create(:tag, name: 'updated_old', category: :general) + new_tag = create(:tag, name: 'updated_new', category: :general) old_time = Time.zone.local(2024, 3, 1, 0, 0, 0) new_time = Time.zone.local(2024, 4, 1, 0, 0, 0) @@ -182,12 +181,12 @@ RSpec.describe 'Tags API', type: :request do end it 'orders tags by custom category order' do - Tag.create!(tag_name: TagName.create!(name: 'cat_deerjikist'), category: :deerjikist) - Tag.create!(tag_name: TagName.create!(name: 'cat_meme'), category: :meme) - Tag.create!(tag_name: TagName.create!(name: 'cat_character'), category: :character) - Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general) - Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material) - Tag.create!(tag_name: TagName.create!(name: 'cat_meta'), category: :meta) + create(:tag, name: 'cat_deerjikist', category: :deerjikist) + create(:tag, name: 'cat_meme', category: :meme) + create(:tag, name: 'cat_character', category: :character) + create(:tag, name: 'cat_general', category: :general) + create(:tag, name: 'cat_material', category: :material) + create(:tag, name: 'cat_meta', category: :meta) create(:external_tag, name: 'cat_nico') get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 } @@ -202,20 +201,20 @@ RSpec.describe 'Tags API', type: :request do context 'with mixed legacy pagination' do let!(:first_tag) do create(:tag, - tag_name: create(:tag_name, name: 'a_mixed_page'), - category: :meme) + tag_name: create(:tag_name, name: 'a_mixed_page'), + category: :meme) end let!(:middle_tag) do create(:tag, - tag_name: create(:tag_name, name: 'm_mixed_page'), - category: :meta) + tag_name: create(:tag_name, name: 'm_mixed_page'), + category: :meta) end let!(:last_tag) do create(:tag, - tag_name: create(:tag_name, name: 'z_mixed_page'), - category: :general) + tag_name: create(:tag_name, name: 'z_mixed_page'), + category: :general) end let!(:first_external) do @@ -265,7 +264,7 @@ RSpec.describe 'Tags API', type: :request do it 'paginates and keeps total count' do %w[pag_a pag_b pag_c].each do |name| - Tag.create!(tag_name: TagName.create!(name:), category: :general) + create(:tag, tag_name: create(:tag_name, name:), category: :general) end get '/tags', params: { name: 'pag_', order: 'name:asc', page: 2, limit: 2 } @@ -276,8 +275,8 @@ RSpec.describe 'Tags API', type: :request do end it 'falls back to default ordering when order is invalid' do - low = Tag.create!(tag_name: TagName.create!(name: 'fallback_low'), category: :general) - high = Tag.create!(tag_name: TagName.create!(name: 'fallback_high'), category: :general) + low = create(:tag, name: 'fallback_low', category: :general) + high = create(:tag, name: 'fallback_high', category: :general) low.update_columns(post_count: 1) high.update_columns(post_count: 9) @@ -290,7 +289,7 @@ RSpec.describe 'Tags API', type: :request do it 'normalises invalid page and limit' do %w[norm_a norm_b].each do |name| - Tag.create!(tag_name: TagName.create!(name:), category: :general) + create(:tag, tag_name: create(:tag_name, name:), category: :general) end get '/tags', params: { name: 'norm_', order: 'name:asc', page: 0, limit: 0 } @@ -302,10 +301,9 @@ RSpec.describe 'Tags API', type: :request do end it 'returns aliases and parent tags' do - parent_tag = Tag.create!( - tag_name: TagName.create!(name: 'index_parent_tag'), - category: :meme - ) + parent_tag = create(:tag, + tag_name: create(:tag_name, name: 'index_parent_tag'), + category: :meme) TagImplication.create!(tag:, parent_tag:) get '/tags', params: { name: 'spec_tag' } @@ -349,10 +347,9 @@ RSpec.describe 'Tags API', type: :request do end it 'returns aliases and parent tags' do - parent_tag = Tag.create!( - tag_name: TagName.create!(name: 'show_parent_tag'), - category: :character - ) + parent_tag = create(:tag, + tag_name: create(:tag_name, name: 'show_parent_tag'), + category: :character) TagImplication.create!(tag:, parent_tag:) request @@ -416,7 +413,7 @@ RSpec.describe 'Tags API', type: :request do describe 'GET /tags/autocomplete' do it 'combines internal and external matches without conflating equal ids' do - internal = Tag.create!(category: :general, name: 'mixed_internal', post_count: 2) + internal = create(:tag, category: :general, name: 'mixed_internal', post_count: 2) external = create(:external_tag, id: internal.id, name: 'mixed_external', post_count: 3) @@ -455,7 +452,7 @@ RSpec.describe 'Tags API', type: :request do it 'limits the combined results to 20 and sorts ties by displayed name' do 11.times do |i| name = "combined_#{ i.to_s.rjust(2, '0') }" - Tag.create!(category: :general, name:, post_count: 1) + create(:tag, category: :general, name:, post_count: 1) create(:external_tag, name:, post_count: 1) end @@ -467,9 +464,9 @@ RSpec.describe 'Tags API', type: :request do end it 'excludes external tags but preserves internal alias matches when nico is false' do - internal = Tag.create!(category: :general, name: 'switch_internal', post_count: 1) - alias_target = Tag.create!(category: :general, name: 'alias_target', post_count: 1) - TagName.create!(name: 'switch_alias', canonical: alias_target.tag_name) + internal = create(:tag, category: :general, name: 'switch_internal', post_count: 1) + alias_target = create(:tag, category: :general, name: 'alias_target', post_count: 1) + create(:tag_name, :alias, name: 'switch_alias', canonical: alias_target.tag_name) create(:external_tag, name: 'switch_external', post_count: 1) get '/tags/autocomplete', params: { q: 'switch', nico: '0' } @@ -484,11 +481,13 @@ RSpec.describe 'Tags API', type: :request do ['%', '_'].each do |wildcard| it "treats #{ wildcard } literally for canonical, alias, and external names" do literal = "literal#{ wildcard }match" - Tag.create!(category: :general, name: literal, post_count: 1) - alias_target = Tag.create!(category: :general, name: 'literal_alias_target', post_count: 1) - TagName.create!(name: "#{ literal }_alias", canonical: alias_target.tag_name) + create(:tag, category: :general, name: literal, post_count: 1) + alias_target = create( + :tag, category: :general, name: 'literal_alias_target', post_count: 1) + create(:tag_name, :alias, name: "#{ literal }_alias", + canonical: alias_target.tag_name) create(:external_tag, name: literal, post_count: 1) - Tag.create!(category: :general, name: 'literalXmatch', post_count: 2) + create(:tag, category: :general, name: 'literalXmatch', post_count: 2) create(:external_tag, name: 'literalXmatch', post_count: 2) get '/tags/autocomplete', params: { q: "literal#{ wildcard }" } @@ -523,11 +522,10 @@ RSpec.describe 'Tags API', type: :request do end it 'omits deprecated tags' do - deprecated_tag = Tag.create!( - name: 'spec_deprecated', - category: :general, - deprecated_at: Time.current - ) + deprecated_tag = create(:tag, + name: 'spec_deprecated', + category: :general, + deprecated_at: Time.current) deprecated_tag.update_columns(post_count: 1) get '/tags/autocomplete', params: { q: 'spec_', present: '0' } @@ -820,27 +818,27 @@ RSpec.describe 'Tags API', type: :request do describe 'GET /tags/with-depth' do let!(:root_meme) do - Tag.create!(tag_name: TagName.create!(name: 'depth_a_root_meme'), category: :meme) + create(:tag, name: 'depth_a_root_meme', category: :meme) end let!(:root_material) do - Tag.create!(tag_name: TagName.create!(name: 'depth_b_root_material'), category: :material) + create(:tag, name: 'depth_b_root_material', category: :material) end let!(:hidden_general_root) do - Tag.create!(tag_name: TagName.create!(name: 'depth_hidden_general_root'), category: :general) + create(:tag, name: 'depth_hidden_general_root', category: :general) end let!(:child_character) do - Tag.create!(tag_name: TagName.create!(name: 'depth_child_character'), category: :character) + create(:tag, name: 'depth_child_character', category: :character) end let!(:grandchild_material) do - Tag.create!(tag_name: TagName.create!(name: 'depth_grandchild_material'), category: :material) + create(:tag, name: 'depth_grandchild_material', category: :material) end let!(:child_general) do - Tag.create!(tag_name: TagName.create!(name: 'depth_child_general'), category: :general) + create(:tag, name: 'depth_child_general', category: :general) end before do @@ -883,15 +881,13 @@ RSpec.describe 'Tags API', type: :request do end it 'passes through deprecated tags when finding children' do - deprecated_middle = Tag.create!( - name: 'depth_deprecated_middle', - category: :character, - deprecated_at: Time.current - ) - visible_descendant = Tag.create!( - name: 'depth_visible_descendant', - category: :material - ) + deprecated_middle = create(:tag, + name: 'depth_deprecated_middle', + category: :character, + deprecated_at: Time.current) + visible_descendant = create(:tag, + name: 'depth_visible_descendant', + category: :material) TagImplication.create!(parent_tag: root_material, tag: deprecated_middle) TagImplication.create!(parent_tag: deprecated_middle, tag: visible_descendant) @@ -903,24 +899,20 @@ RSpec.describe 'Tags API', type: :request do end it 'passes through multiple deprecated tags for roots and has_children' do - active_child = Tag.create!( - name: 'depth_active_child_below_deprecated', - category: :character - ) - deprecated_parent = Tag.create!( - name: 'depth_deprecated_parent', - category: :character, - deprecated_at: Time.current - ) - deprecated_grandparent = Tag.create!( - name: 'depth_deprecated_grandparent', - category: :material, - deprecated_at: Time.current - ) - active_ancestor = Tag.create!( - name: 'depth_active_ancestor', - category: :meme - ) + active_child = create(:tag, + name: 'depth_active_child_below_deprecated', + category: :character) + deprecated_parent = create(:tag, + name: 'depth_deprecated_parent', + category: :character, + deprecated_at: Time.current) + deprecated_grandparent = create(:tag, + name: 'depth_deprecated_grandparent', + category: :material, + deprecated_at: Time.current) + active_ancestor = create(:tag, + name: 'depth_active_ancestor', + category: :meme) TagImplication.create!(tag: active_child, parent_tag: deprecated_parent) TagImplication.create!(tag: deprecated_parent, parent_tag: deprecated_grandparent) TagImplication.create!(tag: deprecated_grandparent, parent_tag: active_ancestor) @@ -945,15 +937,13 @@ RSpec.describe 'Tags API', type: :request do end it 'treats an active tag with only deprecated ancestors as a root' do - active_child = Tag.create!( - name: 'depth_root_below_deprecated', - category: :character - ) - deprecated_parent = Tag.create!( - name: 'depth_root_deprecated_parent', - category: :material, - deprecated_at: Time.current - ) + active_child = create(:tag, + name: 'depth_root_below_deprecated', + category: :character) + deprecated_parent = create(:tag, + name: 'depth_root_deprecated_parent', + category: :material, + deprecated_at: Time.current) TagImplication.create!(tag: active_child, parent_tag: deprecated_parent) get '/tags/with-depth' @@ -967,16 +957,14 @@ RSpec.describe 'Tags API', type: :request do end it 'terminates when deprecated implications contain a cycle' do - first = Tag.create!( - name: 'depth_cycle_first', - category: :character, - deprecated_at: Time.current - ) - second = Tag.create!( - name: 'depth_cycle_second', - category: :material, - deprecated_at: Time.current - ) + first = create(:tag, + name: 'depth_cycle_first', + category: :character, + deprecated_at: Time.current) + second = create(:tag, + name: 'depth_cycle_second', + category: :material, + deprecated_at: Time.current) TagImplication.create!(tag: first, parent_tag: root_material) TagImplication.create!(tag: second, parent_tag: first) now = Time.current @@ -1002,19 +990,19 @@ RSpec.describe 'Tags API', type: :request do let!(:material_user) { create_member_user! } let!(:root_tag) do - Tag.create!(tag_name: TagName.create!(name: 'materials_root'), category: :material) + create(:tag, name: 'materials_root', category: :material) end let!(:child_a_tag) do - Tag.create!(tag_name: TagName.create!(name: 'materials_child_a'), category: :material) + create(:tag, name: 'materials_child_a', category: :material) end let!(:child_b_tag) do - Tag.create!(tag_name: TagName.create!(name: 'materials_child_b'), category: :character) + create(:tag, name: 'materials_child_b', category: :character) end let!(:grandchild_tag) do - Tag.create!(tag_name: TagName.create!(name: 'materials_grandchild'), category: :material) + create(:tag, name: 'materials_grandchild', category: :material) end let!(:root_material) do @@ -1158,14 +1146,12 @@ RSpec.describe 'Tags API', type: :request do end it 'name, category, aliases, parent tags をまとめて更新できる' do - old_parent = Tag.create!( - tag_name: TagName.create!(name: 'put_old_parent'), - category: :general - ) - kept_parent = Tag.create!( - tag_name: TagName.create!(name: 'put_kept_parent'), - category: :general - ) + old_parent = create(:tag, + tag_name: create(:tag_name, name: 'put_old_parent'), + category: :general) + kept_parent = create(:tag, + tag_name: create(:tag_name, name: 'put_kept_parent'), + category: :general) TagImplication.create!(tag:, parent_tag: old_parent) TagImplication.create!(tag:, parent_tag: kept_parent) @@ -1310,14 +1296,12 @@ RSpec.describe 'Tags API', type: :request do end it 'parent tag の snapshot も作成する' do - old_parent = Tag.create!( - tag_name: TagName.create!(name: 'put_snapshot_old_parent'), - category: :general - ) - new_parent = Tag.create!( - tag_name: TagName.create!(name: 'put_snapshot_new_parent'), - category: :general - ) + old_parent = create(:tag, + tag_name: create(:tag_name, name: 'put_snapshot_old_parent'), + category: :general) + new_parent = create(:tag, + tag_name: create(:tag_name, name: 'put_snapshot_new_parent'), + category: :general) TagImplication.create!(tag:, parent_tag: old_parent) put "/tags/#{ tag.id }", params: { @@ -1454,14 +1438,12 @@ RSpec.describe 'Tags API', type: :request do end it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do - old_owner = Tag.create!( - tag_name: TagName.create!(name: 'put_alias_old_owner'), - category: :general - ) - stolen_alias = TagName.create!( - name: 'put_stolen_alias', - canonical: old_owner.tag_name - ) + old_owner = create(:tag, + tag_name: create(:tag_name, name: 'put_alias_old_owner'), + category: :general) + stolen_alias = create(:tag_name, :alias, + name: 'put_stolen_alias', + canonical: old_owner.tag_name) expect(old_owner.tag_name.aliases.map(&:name)).to include('put_stolen_alias') @@ -1492,14 +1474,12 @@ RSpec.describe 'Tags API', type: :request do end it '別 tag の alias 名を rename で奪へる' do - old_owner = Tag.create!( - tag_name: TagName.create!(name: 'put_alias_collision_owner'), - category: :general - ) - stolen_alias = TagName.create!( - name: 'put_alias_collision_name', - canonical: old_owner.tag_name - ) + old_owner = create(:tag, + tag_name: create(:tag_name, name: 'put_alias_collision_owner'), + category: :general) + stolen_alias = create(:tag_name, :alias, + name: 'put_alias_collision_name', + canonical: old_owner.tag_name) wiki_page = Wiki::Commit.create_content!( tag_name: tag.tag_name, @@ -1515,7 +1495,7 @@ RSpec.describe 'Tags API', type: :request do deprecated: '0', } - expect(response).to have_http_status(:ok) + expect(response).to have_http_status(:ok), response.body tag.reload old_owner.reload @@ -1534,10 +1514,9 @@ RSpec.describe 'Tags API', type: :request do end it 'parent_tags に指定すると循環する tag は 422 にする' do - child = Tag.create!( - tag_name: TagName.create!(name: 'put_cycle_child'), - category: :general - ) + child = create(:tag, + tag_name: create(:tag_name, name: 'put_cycle_child'), + category: :general) TagImplication.create!(tag: child, parent_tag: tag) diff --git a/backend/spec/requests/wiki_conflict_spec.rb b/backend/spec/requests/wiki_conflict_spec.rb index 9ae9052..39c5458 100644 --- a/backend/spec/requests/wiki_conflict_spec.rb +++ b/backend/spec/requests/wiki_conflict_spec.rb @@ -10,7 +10,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do it 'returns 409 when base_revision_id is stale' do page = Wiki::Commit.create_content!( - tag_name: TagName.create!(name: 'wiki_conflict_request'), + tag_name: create(:tag_name, name: 'wiki_conflict_request'), body: 'first', created_by_user: user, message: 'init') diff --git a/backend/spec/requests/wiki_history_integrity_spec.rb b/backend/spec/requests/wiki_history_integrity_spec.rb index 7f2ccce..db365c6 100644 --- a/backend/spec/requests/wiki_history_integrity_spec.rb +++ b/backend/spec/requests/wiki_history_integrity_spec.rb @@ -10,7 +10,7 @@ RSpec.describe 'Wiki history integrity', type: :request do def create_wiki_page title:, body: 'body', message: 'init', user: self.user Wiki::Commit.create_content!( - tag_name: TagName.create!(name: title), + tag_name: create(:tag_name, name: title), body:, created_by_user: user, message:) diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 614180e..7bf7c49 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -10,7 +10,7 @@ RSpec.describe 'Wiki API', type: :request do let!(:user) { create_member_user! } - let!(:tn) { TagName.create!(name: 'spec_wiki_title') } + let!(:tn) { create(:tag_name, name: 'spec_wiki_title') } let!(:page) do Wiki::Commit.create_content!( tag_name: tn, @@ -19,11 +19,10 @@ RSpec.describe 'Wiki API', type: :request do message: 'init') end let!(:tag) do - Tag.create!( - tag_name: tn, - category: :general, - deprecated_at: Time.zone.local(2026, 6, 1) - ) + create(:tag, + tag_name: tn, + category: :general, + deprecated_at: Time.zone.local(2026, 6, 1)) end describe 'GET /wiki' do @@ -208,7 +207,7 @@ RSpec.describe 'Wiki API', type: :request do { 'X-Transfer-Code' => user.inheritance_code } end - let!(:test_tag_name) { TagName.create!(name: 'TestPage') } + let!(:test_tag_name) { create(:tag_name, name: 'TestPage') } let!(:page) do Wiki::Commit.create_content!( @@ -275,8 +274,8 @@ RSpec.describe 'Wiki API', type: :request do end it 'wiki body だけを変更しても tag version は作成しない' do - linked_tag_name = TagName.create!(name: 'wiki_body_only_tag') - linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) + linked_tag_name = create(:tag_name, name: 'wiki_body_only_tag') + linked_tag = create(:tag, tag_name: linked_tag_name, category: :general) TagVersionRecorder.record!( tag: linked_tag, @@ -359,13 +358,13 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/search' do before do Wiki::Commit.create_content!( - tag_name: TagName.create!(name: 'spec_wiki_title_2'), + tag_name: create(:tag_name, name: 'spec_wiki_title_2'), body: 'search body 2', created_by_user: user, message: 'init') Wiki::Commit.create_content!( - tag_name: TagName.create!(name: 'unrelated_title'), + tag_name: create(:tag_name, name: 'unrelated_title'), body: 'unrelated body', created_by_user: user, message: 'init') @@ -434,7 +433,7 @@ RSpec.describe 'Wiki API', type: :request do it 'returns empty array when page has no revisions and filtered by id' do # 別ページを作って revision 無し - tn2 = TagName.create!(name: 'spec_no_rev') + tn2 = create(:tag_name, name: 'spec_no_rev') # 異常データ: revision 無し WikiPage を直接作る p2 = WikiPage.create!( tag_name: tn2, @@ -514,7 +513,7 @@ RSpec.describe 'Wiki API', type: :request do describe 'Wiki::Commit.redirect!' do it 'raises because redirect revisions are deprecated' do - target_tag_name = TagName.create!(name: 'redirect_deprecated_target') + target_tag_name = create(:tag_name, name: 'redirect_deprecated_target') target = Wiki::Commit.create_content!( tag_name: target_tag_name, @@ -535,8 +534,8 @@ RSpec.describe 'Wiki API', type: :request do end it 'wiki title を変更すると対応する tag の version を作成する' do - linked_tag_name = TagName.create!(name: 'wiki_linked_tag_for_version') - linked_tag = Tag.create!(tag_name: linked_tag_name, category: :general) + linked_tag_name = create(:tag_name, name: 'wiki_linked_tag_for_version') + linked_tag = create(:tag, tag_name: linked_tag_name, category: :general) linked_page = Wiki::Commit.create_content!( diff --git a/backend/spec/requests/wiki_title_collision_spec.rb b/backend/spec/requests/wiki_title_collision_spec.rb index ad02dfa..f78f987 100644 --- a/backend/spec/requests/wiki_title_collision_spec.rb +++ b/backend/spec/requests/wiki_title_collision_spec.rb @@ -9,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do def create_wiki_page title:, body: Wiki::Commit.create_content!( - tag_name: TagName.create!(name: title), + tag_name: create(:tag_name, name: title), body:, created_by_user: user, message: 'init') diff --git a/backend/spec/services/material_sync_importer_spec.rb b/backend/spec/services/material_sync_importer_spec.rb index 20c69fd..7d34dc6 100644 --- a/backend/spec/services/material_sync_importer_spec.rb +++ b/backend/spec/services/material_sync_importer_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe MaterialSyncImporter do let(:user) { create(:user, :member) } - let(:tag) { Tag.create!(tag_name: TagName.create!(name: 'sync_tag'), category: :material) } + let(:tag) { create(:tag, name: 'sync_tag', category: :material) } def tempfile_for body Tempfile.new(['material-sync-importer', '.png']).tap do |file| diff --git a/backend/spec/services/nico_tag_version_recorder_spec.rb b/backend/spec/services/nico_tag_version_recorder_spec.rb index e90d1e6..b7811c4 100644 --- a/backend/spec/services/nico_tag_version_recorder_spec.rb +++ b/backend/spec/services/nico_tag_version_recorder_spec.rb @@ -65,7 +65,7 @@ RSpec.describe NicoTagVersionRecorder do end it 'records sorted linked internal names and later link removal' do - tags = ['z_link', 'a_link'].map { |name| Tag.create!(name:, category: :general) } + tags = ['z_link', 'a_link'].map { |name| create(:tag, name:, category: :general) } tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) } expect(record(:create).linked_tags).to eq('a_link z_link') diff --git a/backend/spec/services/post_bulk_creator_spec.rb b/backend/spec/services/post_bulk_creator_spec.rb index 0779661..9cd5d7b 100644 --- a/backend/spec/services/post_bulk_creator_spec.rb +++ b/backend/spec/services/post_bulk_creator_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe PostBulkCreator do + include_context 'English locale' + it 'limits workers to two and keeps failures in their request slots' do actor = instance_double(User, id: 123) allow(User).to receive(:find).with(123) { @@ -9,6 +11,7 @@ RSpec.describe PostBulkCreator do mutex = Mutex.new active = 0 maximum_active = 0 + creators = [] allow(PostCreatePreflight).to receive(:new) do |attributes:, **| preflight = instance_double(PostCreatePreflight) @@ -28,6 +31,7 @@ RSpec.describe PostBulkCreator do 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 @@ -45,7 +49,12 @@ RSpec.describe PostBulkCreator do results = described_class.new( actor:, posts:, - thumbnails: { }).run.fetch(:results) + 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) diff --git a/backend/spec/services/post_create_plan_spec.rb b/backend/spec/services/post_create_plan_spec.rb index 71a19c1..e87b492 100644 --- a/backend/spec/services/post_create_plan_spec.rb +++ b/backend/spec/services/post_create_plan_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe PostCreatePlan do def create_tag! name, category - Tag.create!(name:, category:) + create(:tag, name:, category:) end before do @@ -10,6 +10,56 @@ RSpec.describe PostCreatePlan do create_tag!('ニジラー情報不詳', :meta) end + context 'with an explicit locale' do + include_context 'English locale' + + it 'resolves the matching language identity and its parents' do + english_name = create(:tag_name, name: 'shared_name', + language_code: 'en', script_code: 'Latn') + english = create(:tag, tag_name: english_name, category: :character) + japanese = create(:tag, name: 'temporary_japanese', category: :general) + # The DB permits this identity; validation has its own contract spec. + japanese.tag_name.update_columns(name: 'shared_name') + english_parent = create(:tag, name: 'english_parent', category: :material) + japanese_parent = create(:tag, name: 'japanese_parent', category: :general) + TagImplication.create!(tag: english, parent_tag: english_parent) + TagImplication.create!(tag: japanese, parent_tag: japanese_parent) + + plan = described_class.new(attributes: { tags: 'shared_name' }).build!(locale) + + expect(plan[:direct_tag_specs]).to eq( + [{ name: 'shared_name', category: :character }]) + expect(plan[:post_tag_specs]).to include( + { name: english_parent.name, category: :material }) + expect(plan[:post_tag_specs].pluck(:name)).not_to include(japanese_parent.name) + end + + it 'resolves aliases within the requested language' do + japanese = create(:tag, name: 'japanese_canonical') + english_name = create(:tag_name, name: 'english_canonical', + language_code: 'en', script_code: 'Latn') + english = create(:tag, tag_name: english_name, category: :character) + japanese_alias = create(:tag_name, :alias, name: 'shared_alias', + canonical: japanese.tag_name) + english_alias = create(:tag_name, :alias, name: 'temporary_alias', + canonical: english_name, + language_code: 'en', script_code: 'Latn') + english_alias.update_columns(name: 'shared_alias') + + expect(japanese_alias.reload).to have_attributes( + canonical_id: japanese.tag_name_id, tag_id: japanese.id, + primary_flg: false, language_code: 'ja') + expect(english_alias.reload).to have_attributes( + canonical_id: english_name.id, tag_id: english.id, + primary_flg: false, language_code: 'en') + + plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale) + + expect(plan[:direct_tag_specs]).to eq( + [{ name: english.name, category: :character }]) + end + end + it 'plans direct and existing default tags without persisting records' do counts = [TagName.count, Tag.count] @@ -18,7 +68,7 @@ RSpec.describe PostCreatePlan do url: 'https://example.com/post', title: 'title', tags: 'character:new_character', - parent_post_ids: '' }).build! + parent_post_ids: '' }).build!(Locale.nipponese) expect(plan[:tags]).to eq('new_character') expect(plan[:direct_tag_specs]).to eq( @@ -31,7 +81,7 @@ RSpec.describe PostCreatePlan do it 'resolves aliases and keeps tag sections separate from canonical names' do canonical = create_tag!('虹夏', :character) - TagName.create!(name: 'にじか', canonical: canonical.tag_name) + create(:tag_name, :alias, name: 'にじか', canonical: canonical.tag_name) create_tag!('動画', :meta) plan = described_class.new( @@ -40,7 +90,7 @@ RSpec.describe PostCreatePlan do title: 'video', tags: '動画 にじか[0:10-0:20]', duration: '1:00', - parent_post_ids: '' }).build! + parent_post_ids: '' }).build!(Locale.nipponese) expect(plan[:tags].split).to include('動画', '虹夏[0:10-0:20]') expect(plan[:display_tags]).to include( @@ -55,7 +105,7 @@ RSpec.describe PostCreatePlan do counts = [Tag.count, TagName.count, ExternalTag.count] expect { - described_class.new(attributes: { tags: 'NiCo:reserved' }).build! + described_class.new(attributes: { tags: 'NiCo:reserved' }).build!(Locale.nipponese) }.to raise_error(ActiveRecord::RecordInvalid) { |error| expect(error.record.errors[:tags]).to be_present } @@ -73,7 +123,7 @@ RSpec.describe PostCreatePlan do url: 'https://example.com/post', title: 'title', tags: long_name, - parent_post_ids: '' }).build! + parent_post_ids: '' }).build!(Locale.nipponese) }.to raise_error(ActiveRecord::RecordInvalid) { |error| expect(error.record.errors[:tags]).not_to be_empty } @@ -87,7 +137,7 @@ RSpec.describe PostCreatePlan do title: 'title', tags: 'ordinary_tag', duration: 'invalid', - parent_post_ids: '' }).build! + parent_post_ids: '' }).build!(Locale.nipponese) expect(plan[:duration]).to eq('invalid') expect(plan[:video_ms]).to be_nil diff --git a/backend/spec/services/post_creator_spec.rb b/backend/spec/services/post_creator_spec.rb index cadf8a1..2fa36cc 100644 --- a/backend/spec/services/post_creator_spec.rb +++ b/backend/spec/services/post_creator_spec.rb @@ -14,7 +14,6 @@ RSpec.describe PostCreator do end before do - allow(Tag).to receive(:normalise_tags!).and_return({ tags: [], sections: {} }) allow(TagVersioning).to receive(:record_tag_snapshots!) allow(Tag).to receive(:expand_parent_tags).and_return([]) allow(PostVersionRecorder).to receive(:record!) @@ -34,7 +33,7 @@ RSpec.describe PostCreator do url: 'https://example.com/post', thumbnail: real_thumbnail_upload, thumbnail_base: 'https://example.com/thumb.jpg', - tags: '' }).create! + tags: '' }).create!(Locale.nipponese) expect(post.thumbnail).to be_attached expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg') @@ -54,7 +53,7 @@ RSpec.describe PostCreator do title: 'title', url: 'https://example.com/post', thumbnail_base: 'https://example.com/thumb.jpg', - tags: '' }).create! + tags: '' }).create!(Locale.nipponese) expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg') expect(post.thumbnail).to be_attached @@ -72,7 +71,34 @@ RSpec.describe PostCreator do tags: '' }) post_count = Post.count - expect { creator.create! }.to raise_error(Post::RemoteThumbnailFetchFailed) + expect { creator.create!(Locale.nipponese) } + .to raise_error(Post::RemoteThumbnailFetchFailed) expect(Post.count).to eq(post_count) end + + context 'with an explicit locale' do + include_context 'English locale' + + [false, true].each do |planned| + it "passes locale to tag creation with planned attributes: #{ planned }" do + attributes = { + title: 'Locale propagation', + url: 'https://example.com/locale-post', + tags: 'character:locale_character' } + if planned + specs = [{ name: 'locale_character', category: :character }] + attributes.merge!(snapshot_tag_specs: specs, post_tag_specs: specs) + end + allow(Tag).to receive(:find_or_create_by_tag_name!).and_call_original + + post = described_class.new(actor:, attributes:).create!(locale) + + expect(Tag).to have_received(:find_or_create_by_tag_name!) + .with(locale, 'locale_character', category: :character).at_least(:once) + tag_name = TagName.find_by!(language_code: 'en', name: 'locale_character') + expect(tag_name).to have_attributes(script_code: 'Latn', primary_flg: true) + expect(post.tags).to include(tag_name.tag) + end + end + end end diff --git a/backend/spec/services/post_import_previewer_spec.rb b/backend/spec/services/post_import_previewer_spec.rb index bd6e267..5bc7bcc 100644 --- a/backend/spec/services/post_import_previewer_spec.rb +++ b/backend/spec/services/post_import_previewer_spec.rb @@ -64,7 +64,7 @@ RSpec.describe PostImportPreviewer do end it 'applies metadata to automatic fields and recognises metadata tags' do - Tag.create!(name: 'known-tag', category: :general) + create(:tag, name: 'known-tag', category: :general) allow(PostMetadataFetcher).to receive(:fetch).and_return( title: 'metadata title', thumbnail_base: 'https://example.com/thumb.jpg', diff --git a/backend/spec/services/wiki/commit_integrity_spec.rb b/backend/spec/services/wiki/commit_integrity_spec.rb index 4a08de5..9351d75 100644 --- a/backend/spec/services/wiki/commit_integrity_spec.rb +++ b/backend/spec/services/wiki/commit_integrity_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Wiki::Commit do def create_page title:, body: 'initial body' described_class.create_content!( - tag_name: TagName.create!(name: title), + tag_name: create(:tag_name, name: title), body:, created_by_user: user, message: 'init') @@ -16,7 +16,7 @@ RSpec.describe Wiki::Commit do it 'creates page, revision, and version with normalised body' do expect { described_class.create_content!( - tag_name: TagName.create!(name: 'commit_integrity_create'), + tag_name: create(:tag_name, name: 'commit_integrity_create'), body: "a\r\nb\r\n\r\n", created_by_user: user, message: 'init') @@ -36,7 +36,7 @@ RSpec.describe Wiki::Commit do end it 'rejects body that becomes blank after normalisation' do - tag_name = TagName.create!(name: 'commit_integrity_blank') + tag_name = create(:tag_name, name: 'commit_integrity_blank') expect { described_class.create_content!( @@ -76,8 +76,8 @@ RSpec.describe Wiki::Commit do end it 'does not record tag_version on body-only wiki update' do - tag_name = TagName.create!(name: 'commit_integrity_linked_tag') - tag = Tag.create!(tag_name:, category: :general) + tag_name = create(:tag_name, name: 'commit_integrity_linked_tag') + tag = create(:tag, tag_name:, category: :general) page = described_class.create_content!( diff --git a/backend/spec/services/wiki/commit_spec.rb b/backend/spec/services/wiki/commit_spec.rb index 2e564cf..5d82b34 100644 --- a/backend/spec/services/wiki/commit_spec.rb +++ b/backend/spec/services/wiki/commit_spec.rb @@ -4,7 +4,7 @@ RSpec.describe Wiki::Commit do let(:user) { create_member_user! } def create_page(title: 'commit_spec_page', body: 'initial body') - tag_name = TagName.create!(name: title) + tag_name = create(:tag_name, name: title) Wiki::Commit.create_content!( tag_name:, @@ -80,8 +80,8 @@ RSpec.describe Wiki::Commit do end it 'does not record tag version when corresponding tag has no versions' do - tag_name = TagName.create!(name: 'commit_linked_tag_without_versions') - tag = Tag.create!(tag_name:, category: :general) + tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions') + tag = create(:tag, tag_name:, category: :general) page = described_class.create_content!( @@ -107,8 +107,8 @@ RSpec.describe Wiki::Commit do end it 'does not record tag version when corresponding tag has no versions' do - tag_name = TagName.create!(name: 'commit_linked_tag_without_versions') - tag = Tag.create!(tag_name:, category: :general) + tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions') + tag = create(:tag, tag_name:, category: :general) page = described_class.create_content!( diff --git a/backend/spec/services/wiki_version_recorder_spec.rb b/backend/spec/services/wiki_version_recorder_spec.rb index 9f1c6dd..c8c4dfa 100644 --- a/backend/spec/services/wiki_version_recorder_spec.rb +++ b/backend/spec/services/wiki_version_recorder_spec.rb @@ -5,7 +5,7 @@ RSpec.describe WikiVersionRecorder do def create_page title:, body: 'body' Wiki::Commit.create_content!( - tag_name: TagName.create!(name: title), + tag_name: create(:tag_name, name: title), body:, created_by_user: user, message: 'init') diff --git a/backend/spec/services/youtube/sync_spec.rb b/backend/spec/services/youtube/sync_spec.rb index c24c6a1..e8d7a24 100644 --- a/backend/spec/services/youtube/sync_spec.rb +++ b/backend/spec/services/youtube/sync_spec.rb @@ -172,7 +172,8 @@ RSpec.describe Youtube::Sync do Tag.video Tag.no_deerjikist - deerjikist_tag = Tag.find_or_create_by_tag_name!('テスト投稿者', category: :deerjikist) + deerjikist_tag = Tag.find_or_create_by_tag_name!( + Locale.nipponese, 'テスト投稿者', category: :deerjikist) Deerjikist.create!( platform: 'youtube', code: 'UC_MAPPED', @@ -226,7 +227,8 @@ RSpec.describe Youtube::Sync do ) PostTag.create!(post:, tag: Tag.no_deerjikist) - deerjikist_tag = Tag.find_or_create_by_tag_name!('後から判明した投稿者', category: :deerjikist) + deerjikist_tag = Tag.find_or_create_by_tag_name!( + Locale.nipponese, '後から判明した投稿者', category: :deerjikist) Deerjikist.create!( platform: 'youtube', code: 'UC_MAPPED_LATER', diff --git a/backend/spec/support/locale_context.rb b/backend/spec/support/locale_context.rb new file mode 100644 index 0000000..fa873ef --- /dev/null +++ b/backend/spec/support/locale_context.rb @@ -0,0 +1,13 @@ +RSpec.shared_context 'English locale' do + let!(:locale) do + Language.find_or_create_by!(code: 'en') { _1.name = 'English' } + Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' } + unless Locale.exists?(code: 'en') + # Reference data only: do not generate names for unrelated existing tags. + Locale.insert_all!([ + { code: 'en', language_code: 'en', script_code: 'Latn', + name: 'English', created_at: Time.current }]) + end + Locale.find('en') + end +end diff --git a/backend/spec/tasks/nico_sync_spec.rb b/backend/spec/tasks/nico_sync_spec.rb index b3c053f..e62a8f3 100644 --- a/backend/spec/tasks/nico_sync_spec.rb +++ b/backend/spec/tasks/nico_sync_spec.rb @@ -8,7 +8,7 @@ RSpec.describe 'nico:sync' do end def create_tag!(name, category:) - Tag.find_or_create_by_tag_name!(name, category:) + Tag.find_or_create_by_tag_name!(Locale.nipponese, name, category:) end def link_nico_to_tag!(nico_tag, tag) diff --git a/backend/spec/tasks/post_similarity_calc_spec.rb b/backend/spec/tasks/post_similarity_calc_spec.rb index 20dd575..1934f66 100644 --- a/backend/spec/tasks/post_similarity_calc_spec.rb +++ b/backend/spec/tasks/post_similarity_calc_spec.rb @@ -6,10 +6,10 @@ RSpec.describe 'post_similarity:calc' do it 'calculates similarities from active tags only' do # 必要最低限のデータ - t1 = Tag.create!(name: "t1") - t2 = Tag.create!(name: "t2") - t3 = Tag.create!(name: "t3") - deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current) + t1 = create(:tag, name: 't1') + t2 = create(:tag, name: 't2') + t3 = create(:tag, name: 't3') + deprecated_tag = create(:tag, name: 'deprecated', deprecated_at: Time.current) p1 = Post.create!(url: "https://example.com/1") p2 = Post.create!(url: "https://example.com/2") diff --git a/backend/spec/tasks/tag_similarity_calc_spec.rb b/backend/spec/tasks/tag_similarity_calc_spec.rb index 3063010..fe293a6 100644 --- a/backend/spec/tasks/tag_similarity_calc_spec.rb +++ b/backend/spec/tasks/tag_similarity_calc_spec.rb @@ -6,10 +6,10 @@ RSpec.describe 'tag_similarity:calc' do it 'calculates similarities for active tags only' do # 必要最低限のデータ - t1 = Tag.create!(name: "t1") - t2 = Tag.create!(name: "t2") - t3 = Tag.create!(name: "t3") - deprecated_tag = Tag.create!(name: 'deprecated', deprecated_at: Time.current) + t1 = create(:tag, name: 't1') + t2 = create(:tag, name: 't2') + t3 = create(:tag, name: 't3') + deprecated_tag = create(:tag, name: 'deprecated', deprecated_at: Time.current) p1 = Post.create!(url: "https://example.com/1") p2 = Post.create!(url: "https://example.com/2")