From f362b915a5730d78ea02f56b7297bb61f72a61e2 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 16 Oct 2026 00:15:21 +0900 Subject: [PATCH] #431 --- backend/spec/db/wiki_removal_spec.rb | 23 +++ backend/spec/models/tag_group_spec.rb | 16 +- .../models/tag_name_sanitisation_rule_spec.rb | 60 +++++++ backend/spec/models/user_spec.rb | 25 +++ .../tag_description_wiki_write_spec.rb | 3 +- .../spec/requests/tag_group_locale_spec.rb | 122 ++++++++++++++ .../spec/requests/tag_group_markdown_spec.rb | 9 + backend/spec/requests/tag_group_wiki_spec.rb | 10 +- backend/spec/requests/tag_versions_spec.rb | 1 + backend/spec/requests/tags_spec.rb | 7 +- backend/spec/requests/wiki_conflict_spec.rb | 1 + backend/spec/requests/wiki_locale_spec.rb | 158 ++++++++++++++++++ backend/spec/requests/wiki_spec.rb | 18 +- backend/spec/support/ainu_locale_contract.rb | 22 +++ backend/spec/support/tag_group_records.rb | 11 +- backend/spec/support/wiki_records.rb | 4 +- 16 files changed, 458 insertions(+), 32 deletions(-) create mode 100644 backend/spec/db/wiki_removal_spec.rb create mode 100644 backend/spec/models/user_spec.rb create mode 100644 backend/spec/requests/tag_group_locale_spec.rb create mode 100644 backend/spec/requests/wiki_locale_spec.rb create mode 100644 backend/spec/support/ainu_locale_contract.rb diff --git a/backend/spec/db/wiki_removal_spec.rb b/backend/spec/db/wiki_removal_spec.rb new file mode 100644 index 0000000..33fe633 --- /dev/null +++ b/backend/spec/db/wiki_removal_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe 'Schema after Wiki removal' do + it 'has no legacy Wiki tables or name indirection columns' do + connection = ApplicationRecord.connection + ['wiki_pages', 'wiki_revisions', 'wiki_lines', 'wiki_revision_lines', 'wiki_versions'] + .each do |table| + expect(connection.table_exists?(table)).to be(false) + end + expect(connection.column_exists?(:tags, :tag_name_id)).to be(false) + expect(connection.column_exists?(:tag_names, :canonical_id)).to be(false) + end + + it 'requires TagName ownership while keeping the native resources and history' do + connection = ApplicationRecord.connection + expect(TagName.columns_hash.fetch('tag_id').null).to be(false) + expect(connection.foreign_key_exists?(:tag_names, :tags)).to be(true) + ['tag_descriptions', 'tag_description_versions', 'tag_groups', 'tag_group_versions'] + .each do |table| + expect(connection.table_exists?(table)).to be(true) + end + end +end diff --git a/backend/spec/models/tag_group_spec.rb b/backend/spec/models/tag_group_spec.rb index 67c1125..9e5be9d 100644 --- a/backend/spec/models/tag_group_spec.rb +++ b/backend/spec/models/tag_group_spec.rb @@ -4,7 +4,8 @@ RSpec.describe TagGroup, type: :model do let(:actor) { create_member_user! } def create_group name:, members: [], placements: [] - TagGroupOperation.create!(name:, description: nil, members:, placements:, actor:) + TagGroupOperation.create!( + Locale.nipponese, name:, description: nil, members:, placements:, actor:) end it 'retains version IDs and snapshots after destroying an empty group' do @@ -16,7 +17,8 @@ RSpec.describe TagGroup, type: :model do expect(TagGroupVersion.where(tag_group_id: group.id).order(:version_no).map(&:attributes)) .to eq(versions) - expect(TagGroupRepr.historical(TagGroupVersion.find(versions.first.fetch('id')))) + expect(TagGroupRepr.historical( + Locale.nipponese, TagGroupVersion.find(versions.first.fetch('id')))) .to include('id' => group.id, 'name' => 'history owner', 'members' => []) end @@ -26,14 +28,8 @@ RSpec.describe TagGroup, type: :model do parent = create_group( name: 'parent group', members: [{ 'tag_group_id' => child.id }], - placements: [{ - 'section_name' => 'section', - 'description' => nil, - 'children' => [{ - 'tag_group_id' => child.id, - 'children' => [] - }] - }]) + placements: [{ 'section_name' => 'section', 'description' => nil, + 'children' => [{ 'tag_group_id' => child.id, 'children' => [] }] }]) versions = parent.versions.order(:version_no).map(&:attributes) diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index cec475d..58bd168 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -34,6 +34,66 @@ RSpec.describe TagNameSanitisationRule, type: :model do described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo') end + [:primary, :alias].each do |source_kind| + it "keeps the primary identity when a #{ source_kind } collides within its Tag" do + tag = create(:tag, primary_name: source_kind == :primary ? 'temporary' : 'foobar') + primary = tag.tag_name('ja') + alias_name = create(:tag_name, :alias, tag:, + name: source_kind == :primary ? 'foobar' : 'temporary_alias') + source = source_kind == :primary ? primary : alias_name + source.update_columns(name: 'foo_bar') + + expect { described_class.apply! }.to change(TagName, :count).by(-1) + .and change(Tag, :count).by(0) + + expect(primary.reload).to have_attributes( + name: 'foobar', primary_flg: true, tag_id: tag.id) + expect(TagName.exists?(alias_name.id)).to be(false) + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) + .to contain_exactly(primary) + expect(tag.tag_versions.order(:version_no).last.names.fetch('ja')) + .to include('name' => 'foobar', 'aliases' => []) + end + end + + it 'deduplicates colliding aliases without changing the primary name' do + tag = create(:tag, primary_name: 'primary') + primary = tag.tag_name('ja') + kept = create(:tag_name, :alias, tag:, name: 'foobar') + removed = create(:tag_name, :alias, tag:, name: 'temporary_alias') + removed.update_columns(name: 'foo_bar') + + described_class.apply! + + expect(primary.reload).to have_attributes(name: 'primary', primary_flg: true) + expect(kept.reload.tag_id).to eq(tag.id) + expect(TagName.exists?(removed.id)).to be(false) + expect(tag.tag_versions.order(:version_no).last.names.fetch('ja').fetch('aliases')) + .to eq([{ 'name' => 'foobar', 'script_code' => 'Jpan' }]) + end + + it 'rolls back a collision merge when the source owns a description' do + user = create_member_user! + target = create(:tag, primary_name: 'foobar') + source = create(:tag, primary_name: 'temporary') + TagDescriptionOperation.create!( + Locale.nipponese, tag: source, body: 'retained', created_by_user: user) + source.tag_name('ja').update_columns(name: 'foo_bar') + post = create(:post) + PostTag.create!(post:, tag: source) + models = [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion, PostTag] + snapshots = models.map { |model| model.order(*Array(model.primary_key)).map(&:attributes) } + + expect { described_class.apply! }.to raise_error(ActiveRecord::RecordInvalid) + + after_state = models.map do |model| + model.order(*Array(model.primary_key)).map(&:attributes) + end + expect(after_state).to eq(snapshots) + expect(post.reload.tags).to contain_exactly(source) + expect(target.reload.name('ja')).to eq('foobar') + end + context 'when only another language has the sanitised name' do include_context 'English locale' diff --git a/backend/spec/models/user_spec.rb b/backend/spec/models/user_spec.rb new file mode 100644 index 0000000..692d682 --- /dev/null +++ b/backend/spec/models/user_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +RSpec.describe User, type: :model do + it 'nullifies description authorship without deleting native content or history' do + author = create_member_user! + editor = create_member_user! + tag = create(:tag) + description = TagDescriptionOperation.create!( + Locale.nipponese, tag:, body: 'initial', created_by_user: author) + TagDescriptionOperation.update!( + Locale.nipponese, tag:, body: 'edited', updated_by_user: editor) + versions = TagDescriptionVersion.where(tag_id: tag.id).order(:version_no) + snapshots = versions.map(&:attributes) + + expect(author.created_tag_descriptions).to contain_exactly(description) + expect(editor.updated_tag_descriptions).to contain_exactly(description) + expect { author.destroy! }.to change(TagDescription, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) + expect(description.reload).to have_attributes( + body: 'edited', created_by_user_id: nil, updated_by_user_id: editor.id) + expect { editor.destroy! }.not_to change(TagDescription, :count) + expect(description.reload.updated_by_user_id).to be_nil + expect(versions.reload.map(&:attributes)).to eq(snapshots) + end +end diff --git a/backend/spec/requests/tag_description_wiki_write_spec.rb b/backend/spec/requests/tag_description_wiki_write_spec.rb index 0658d7c..53b9675 100644 --- a/backend/spec/requests/tag_description_wiki_write_spec.rb +++ b/backend/spec/requests/tag_description_wiki_write_spec.rb @@ -26,7 +26,8 @@ RSpec.describe 'Tag description Wiki facade writes', type: :request do context 'with an existing description' do let!(:description) do - TagDescriptionOperation.create!(tag:, body: 'before', created_by_user: user) + TagDescriptionOperation.create!( + Locale.nipponese, tag:, body: 'before', created_by_user: user) end let!(:first_version) { current_description_version(description) } diff --git a/backend/spec/requests/tag_group_locale_spec.rb b/backend/spec/requests/tag_group_locale_spec.rb new file mode 100644 index 0000000..bc3088e --- /dev/null +++ b/backend/spec/requests/tag_group_locale_spec.rb @@ -0,0 +1,122 @@ +require 'rails_helper' + +RSpec.describe 'Tag group locale contracts', type: :request do + include TagGroupRecords + include_context 'Ainu locale contract' + + let!(:user) { create_member_user! } + let!(:tag) { create(:tag, primary_name: 'japanese_member') } + let!(:ainu_name) { add_ainu_name(tag, 'ainu_member') } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:members) { [{ 'tag_id' => tag.id }] } + let(:placements) do + [{ 'section_name' => 'section', 'description' => nil, 'children' => [ + { 'tag_id' => tag.id, 'children' => [ + { 'tag_id' => tag.id, 'children' => [] }] }] }] + end + let!(:group) do + TagVersioning.ensure_snapshot!(tag, created_by_user: user) + TagGroupOperation.create!( + ainu_locale, name: 'locale group', description: nil, members:, placements:, actor: user) + end + + it 'uses selected-language names in native collection and nested placements' do + ["/tag_groups/#{ group.id }", '/tag_groups'].each do |path| + get path, params: { locale: 'ain' } + expect(response).to have_http_status(:ok) + representation = json.is_a?(Array) ? json.sole : json + expect(representation.fetch('members').sole.fetch('tag').fetch('name')) + .to eq('ainu_member') + node = representation.fetch('placements').sole.fetch('children').sole + expect(node.fetch('tag').fetch('name')).to eq('ainu_member') + expect(node.fetch('children').sole.fetch('tag').fetch('name')).to eq('ainu_member') + expect(response.body).not_to include('japanese_member') + end + end + + it 'uses snapshot names recursively for history and current names for the latest view' do + version = group.versions.sole + ainu_name.update!(name: 'renamed_ainu') + get "/wiki/#{ group.id }", params: { locale: 'ain' } + expect(response).to have_http_status(:ok) + expect(json.fetch('body')).to include('renamed_ainu') + get "/wiki/#{ group.id }", params: { locale: 'ain', version: version.id } + expect(response).to have_http_status(:ok) + expect(json.fetch('body').scan('ainu_member').length).to eq(2) + expect(json.fetch('body')).not_to include('renamed_ainu', 'japanese_member') + get "/wiki/#{ group.id }/diff", params: { locale: 'ain', from: version.id } + expect(response).to have_http_status(:ok) + expect(json.fetch('diff').map { _1.fetch('type') }.uniq).to eq(['context']) + end + + ['タググループ:', 'tag_group:', 'TAG_GROUP:'].each do |prefix| + it "round trips #{ prefix } through create, lookup, search and update" do + post '/wiki', params: { + locale: 'ain', title: "#{ prefix }parent", body: '- tag_group:locale group' }, + headers: headers + expect(response).to have_http_status(:created) + id = json.fetch('id') + expect(json.fetch('body')).to include('タググループ:locale group') + path = "/wiki/title/#{ CGI.escapeURIComponent("#{ prefix }parent") }" + get path, params: { locale: 'ain' } + expect(response).to have_http_status(:ok) + expect(json.fetch('id')).to eq(id) + get "#{ path }/exists", params: { locale: 'ain' } + expect(response).to have_http_status(:no_content) + get '/wiki/search', params: { locale: 'ain', title: "#{ prefix }parent" } + expect(json.map { _1.fetch('id') }).to eq([id]) + put "/wiki/#{ id }", params: { + locale: 'ain', title: "#{ prefix }renamed", body: '- ainu_member' }, headers: headers + expect(response).to have_http_status(:ok) + get "/tag_groups/#{ id }", params: { locale: 'ain' } + expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member') + end + end + + it 'resolves Markdown names in the requested language and rolls back invalid input' do + parsed = TagGroupMarkdownParser.parse(ainu_locale, body: '- ainu_member') + expect(parsed.fetch(:members)).to eq(members) + expect do + TagGroupMarkdownParser.parse(ainu_locale, body: '- japanese_member') + end.to raise_error(TagGroupMarkdownParser::InvalidDocument) + before_state = group_state + put "/wiki/#{ group.id }", params: { + locale: 'ain', title: 'タググループ:partial rename', body: '- japanese_member' }, + headers: headers + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + + it 'passes locale into native validation and facade compatibility checks' do + post '/tag_groups', params: { + locale: 'ain', name: 'native', description: nil, members:, placements: }, + headers:, as: :json + expect(response).to have_http_status(:created) + id = json.fetch('id') + expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member') + get "/wiki/#{ id }", params: { locale: 'ain' } + expect(response).to have_http_status(:ok) + body = json.fetch('body') + put "/wiki/#{ id }", params: { locale: 'ain', title: 'tag_group:native', body: }, + headers: headers + expect(response).to have_http_status(:ok) + expect(TagGroup.find(id).version_no).to eq(1) + end + + it 'does not substitute current or Japanese names for a missing historical language' do + other = create(:tag, primary_name: 'historical_japanese') + TagVersioning.ensure_snapshot!(other, created_by_user: user) + old_group = TagGroupOperation.create!( + Locale.nipponese, name: 'old group', description: nil, + members: [{ 'tag_id' => other.id }], + placements: [{ 'tag_id' => other.id, 'children' => [] }], actor: user) + add_ainu_name(other, 'current_ainu') + version = old_group.versions.sole + representation = TagGroupRepr.historical(ainu_locale, version) + expect(representation.fetch('members').sole.fetch('tag').fetch('name')).to be_nil + expect(representation.fetch('placements').sole.fetch('tag').fetch('name')).to be_nil + get "/wiki/#{ old_group.id }", params: { locale: 'ain', version: version.id } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).not_to include('historical_japanese', 'current_ainu') + end +end diff --git a/backend/spec/requests/tag_group_markdown_spec.rb b/backend/spec/requests/tag_group_markdown_spec.rb index 68cc37b..2e12c9e 100644 --- a/backend/spec/requests/tag_group_markdown_spec.rb +++ b/backend/spec/requests/tag_group_markdown_spec.rb @@ -115,6 +115,15 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: : MARKDOWN end + it 'round trips a top-level list followed by heading Sections' do + check_round_trip( + members: [tag_member(series), tag_member(deer)], + placements: [tag_node(series), + { 'section_name' => '区分', 'description' => nil, + 'children' => [tag_node(deer)] }], + markdown: "- ぼざろクリーチャーシリーズ\n\n## 区分\n\n- 洗澡鹿") + end + it 'rejects mixed top-level heading Sections and Tags without losing structure' do id = seed_group( name: '混在構造', members: [tag_member(series)], diff --git a/backend/spec/requests/tag_group_wiki_spec.rb b/backend/spec/requests/tag_group_wiki_spec.rb index d24add8..73f1bad 100644 --- a/backend/spec/requests/tag_group_wiki_spec.rb +++ b/backend/spec/requests/tag_group_wiki_spec.rb @@ -10,8 +10,9 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do let!(:page) { create_markdown_group('読取り契約', '- 旧タグ') } let!(:first_revision) { page.versions.sole } let!(:last_revision) do - parsed = TagGroupMarkdownParser.parse(body: '- 新タグ') + parsed = TagGroupMarkdownParser.parse(Locale.nipponese, body: '- 新タグ') TagGroupOperation.update!( + Locale.nipponese, group: page, name: page.name, actor: user, **parsed.slice(:description, :members, :placements)) page.versions.order(:version_no).last @@ -67,7 +68,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do - TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user) + TagDescriptionOperation.create!( + Locale.nipponese, tag: new_tag, body: 'tag description', created_by_user: user) get '/wiki' expect(response).to have_http_status(:ok) expect(json.map { _1.fetch('title') }).to include( @@ -87,6 +89,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do it 'does not restore a stale namespaced Wiki page after a group rename' do group = create_markdown_group('旧名称', '正本') TagGroupOperation.update!( + Locale.nipponese, group:, name: '新名称', description: '正本', members: [], placements: [], actor: user) get group_title_path('旧名称') @@ -99,7 +102,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end it 'orders mixed resource history by timestamp rather than independent IDs' do - TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user) + TagDescriptionOperation.create!( + Locale.nipponese, tag: new_tag, body: 'description', created_by_user: user) TagGroupVersion.update_all(created_at: 3.days.ago) TagDescriptionVersion.update_all(created_at: 1.day.ago) diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index 7165a0f..f05bb0b 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -248,6 +248,7 @@ RSpec.describe 'TagVersions API', type: :request do message: 'init') TagDescriptionOperation.update!( + Locale.nipponese, tag: wiki_page.tag, body: 'after', updated_by_user: member, diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 897aed4..7a66889 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -1402,11 +1402,8 @@ RSpec.describe 'Tags API', type: :request do message: 'init') TagDescriptionOperation.update!( - tag: wiki_page.tag, - body: 'wiki body before', - updated_by_user: member_user, - message: 'init' - ) + Locale.nipponese, tag: wiki_page.tag, body: 'wiki body before', + updated_by_user: member_user, message: 'init') wiki_history = description_versions(wiki_page).map(&:attributes) diff --git a/backend/spec/requests/wiki_conflict_spec.rb b/backend/spec/requests/wiki_conflict_spec.rb index 5da7988..55c2f35 100644 --- a/backend/spec/requests/wiki_conflict_spec.rb +++ b/backend/spec/requests/wiki_conflict_spec.rb @@ -19,6 +19,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do stale_id = current_description_version(page).id TagDescriptionOperation.update!( + Locale.nipponese, tag: page.tag, body: 'second', updated_by_user: user, diff --git a/backend/spec/requests/wiki_locale_spec.rb b/backend/spec/requests/wiki_locale_spec.rb new file mode 100644 index 0000000..f8a653b --- /dev/null +++ b/backend/spec/requests/wiki_locale_spec.rb @@ -0,0 +1,158 @@ +require 'rails_helper' + +RSpec.describe 'Wiki and native API locale contracts', type: :request do + include WikiRecords + include TagGroupRecords + include_context 'Ainu locale contract' + + let!(:user) { create_member_user! } + let!(:tag) { create(:tag, primary_name: 'japanese_title') } + let!(:ainu_name) { add_ainu_name(tag, 'ainu_title') } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let!(:japanese) do + TagDescriptionOperation.create!( + Locale.nipponese, tag:, body: 'Japanese body', created_by_user: user) + end + let!(:ainu) do + TagDescriptionOperation.create!( + ainu_locale, tag:, body: 'Ainu body', created_by_user: user) + end + + ['/wiki', '/wiki/search'].each do |path| + it "filters #{ path } by the selected language" do + get path, params: { locale: 'ain' } + expect(response).to have_http_status(:ok) + expect(json.map { _1.fetch('id') }).to eq([ainu.id]) + expect(json.sole).to include('title' => 'ainu_title', 'body' => 'Ainu body') + + get path, params: { locale: 'ain', title: 'japanese' } + expect(response).to have_http_status(:ok) + expect(json).to eq([]) + end + end + + it 'uses the existing default locale when no locale is specified' do + get '/wiki' + expect(response).to have_http_status(:ok) + expect(json.map { _1.fetch('id') }).to eq([japanese.id]) + end + + it 'resolves titles and aliases only in the selected language in both APIs' do + ['/wiki/title/japanese_title', '/tags/name/japanese_title/description'].each do |path| + get path, params: { locale: 'ain' } + expect(response).to have_http_status(:not_found) + end + ['/wiki/title/ainu_title', '/tags/name/ainu_title/description'].each do |path| + get path, params: { locale: 'ain' } + expect(response).to have_http_status(:ok) + expect(json.fetch('body')).to eq('Ainu body') + end + get '/wiki/title/ainu_title/exists', params: { locale: 'ain' } + expect(response).to have_http_status(:no_content) + get '/wiki/title/japanese_title/exists', params: { locale: 'ain' } + expect(response).to have_http_status(:not_found) + end + + it 'does not disclose another language through ID, existence, diff or history' do + revision = current_description_version(japanese) + ["/wiki/#{ japanese.id }", "/wiki/#{ japanese.id }/exists", + "/wiki/#{ japanese.id }/diff"].each do |path| + get path, params: { locale: 'ain', from: revision.id } + expect(response).to have_http_status(:not_found) + expect(response.body).not_to include('Japanese body') + end + get '/wiki/changes', params: { locale: 'ain', id: japanese.id } + expect(response).to have_http_status(:ok) + expect(json).to eq([]) + get '/wiki/changes', params: { locale: 'ain' } + expect(json.map { _1.fetch('revision_id') }) + .to eq([current_description_version(ainu).id]) + end + + it 'does not update a description addressed through another language ID' do + snapshot = [japanese.attributes, ainu.attributes, ainu_name.attributes] + expect do + put "/wiki/#{ japanese.id }", params: { + locale: 'ain', title: 'renamed', body: 'wrong target' }, headers: headers + end.not_to change(TagDescriptionVersion, :count) + expect(response).to have_http_status(:not_found) + expect([japanese.reload.attributes, ainu.reload.attributes, ainu_name.reload.attributes]) + .to eq(snapshot) + end + + [:wiki, :native].each do |api| + it "creates the selected language through #{ api } without changing Japanese history" do + other = create(:tag, primary_name: 'other_japanese') + add_ainu_name(other, 'other_ainu') + original = TagDescriptionOperation.create!( + Locale.nipponese, tag: other, body: 'original', created_by_user: user) + snapshot = original.attributes + expect do + if api == :wiki + post '/wiki', params: { locale: 'ain', title: 'other_ainu', body: 'created' }, + headers: headers + else + put "/tags/#{ other.id }/description", params: { locale: 'ain', body: 'created' }, + headers: headers + end + end.to change(TagDescription, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + expect(response).to have_http_status(:created) + created = TagDescription.find_by!(tag: other, language_code: 'ain') + expect(created).to have_attributes(body: 'created', version_no: 1) + expect(current_description_version(created)).to have_attributes(language_code: 'ain') + expect(original.reload.attributes).to eq(snapshot) + end + end + + it 'shares selected-language edits and revision identity across both APIs' do + snapshot = japanese.attributes + first = current_description_version(ainu) + put "/tags/#{ tag.id }/description", params: { locale: 'ain', body: 'native edit' }, + headers: headers + expect(response).to have_http_status(:ok) + second = current_description_version(ainu) + expect(second).to have_attributes(language_code: 'ain', version_no: 2) + expect(second.id).not_to eq(first.id) + get "/wiki/#{ ainu.id }", params: { locale: 'ain' } + expect(json).to include('body' => 'native edit', 'revision_id' => second.id) + + put "/wiki/#{ ainu.id }", params: { + locale: 'ain', title: 'ainu_title', body: 'facade edit', + base_revision_id: second.id }, headers: headers + expect(response).to have_http_status(:ok) + get "/tags/#{ tag.id }/description", params: { locale: 'ain' } + expect(json).to include('body' => 'facade edit', 'language_code' => 'ain', 'version_no' => 3) + expect(japanese.reload.attributes).to eq(snapshot) + end + + it 'rejects a stale selected-language revision without a partial title or body edit' do + stale = current_description_version(ainu) + TagDescriptionOperation.update!( + ainu_locale, tag:, body: 'current', updated_by_user: user) + snapshot = [ainu.reload.attributes, ainu_name.attributes, japanese.attributes] + expect do + put "/wiki/#{ ainu.id }", params: { + locale: 'ain', title: 'conflicting_name', body: 'current', + base_revision_id: stale.id }, headers: headers + end.to change(TagDescriptionVersion, :count).by(0).and change(TagVersion, :count).by(0) + expect(response).to have_http_status(:conflict) + expect([ainu.reload.attributes, ainu_name.reload.attributes, japanese.reload.attributes]) + .to eq(snapshot) + end + + it 'scopes historical show and diff to the selected description' do + first = current_description_version(ainu) + TagDescriptionOperation.update!( + ainu_locale, tag:, body: 'later', updated_by_user: user) + get "/wiki/#{ ainu.id }", params: { locale: 'ain', version: first.id } + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'Ainu body', 'revision_id' => first.id) + get "/wiki/#{ ainu.id }/diff", params: { locale: 'ain', from: first.id } + expect(response).to have_http_status(:ok) + expect(json.fetch('newer_revision_id')).to eq(current_description_version(ainu).id) + get "/wiki/#{ ainu.id }/diff", params: { + locale: 'ain', from: current_description_version(japanese).id } + expect(response).to have_http_status(:not_found) + end +end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 80f9e28..12663c0 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -232,7 +232,9 @@ RSpec.describe 'Wiki API', type: :request do context 'when conflict' do it 'returns 409 when base_revision_id mismatches' do # 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める - TagDescriptionOperation.update!(tag: page.tag, body: "zzz", updated_by_user: member, message: 'other edit') + TagDescriptionOperation.update!( + Locale.nipponese, tag: page.tag, body: 'zzz', + updated_by_user: member, message: 'other edit') page.reload stale_id = description_versions(page).order(:id).first.id # わざと古い id @@ -314,12 +316,14 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/changes' do let!(:rev1) do - TagDescriptionOperation.update!(tag: page.tag, body: "a\nb", updated_by_user: user, message: 'r1') + TagDescriptionOperation.update!( + Locale.nipponese, tag: page.tag, body: "a\nb", updated_by_user: user, message: 'r1') current_description_version(page) end let!(:rev2) do - TagDescriptionOperation.update!(tag: page.tag, body: "a\nc", updated_by_user: user, message: 'r2') + TagDescriptionOperation.update!( + Locale.nipponese, tag: page.tag, body: "a\nc", updated_by_user: user, message: 'r2') current_description_version(page) end @@ -354,7 +358,7 @@ RSpec.describe 'Wiki API', type: :request do it 'returns empty array when page has no revisions and filtered by id' do # 別ページを作って revision 無し tn2 = create(:tag_name, name: 'spec_no_rev') - # 異常データ: revision 無し WikiPage を直接作る + # 異常データ: Version のない TagDescription を直接作る p2 = TagDescription.create!( tag: tn2.tag, language_code: 'ja', body: 'init', version_no: 1) @@ -392,12 +396,14 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/:id/diff' do let!(:rev_a) do - TagDescriptionOperation.update!(tag: page.tag, body: "a\nb\nc", updated_by_user: user, message: 'A') + TagDescriptionOperation.update!( + Locale.nipponese, tag: page.tag, body: "a\nb\nc", updated_by_user: user, message: 'A') current_description_version(page) end let!(:rev_b) do - TagDescriptionOperation.update!(tag: page.tag, body: "a\nx\nc", updated_by_user: user, message: 'B') + TagDescriptionOperation.update!( + Locale.nipponese, tag: page.tag, body: "a\nx\nc", updated_by_user: user, message: 'B') current_description_version(page) end diff --git a/backend/spec/support/ainu_locale_contract.rb b/backend/spec/support/ainu_locale_contract.rb new file mode 100644 index 0000000..83187cf --- /dev/null +++ b/backend/spec/support/ainu_locale_contract.rb @@ -0,0 +1,22 @@ +RSpec.shared_context 'Ainu locale contract' do + let(:ainu_locale) { instance_double(Locale, language_code: 'ain', script_code: 'Jpan') } + + before do + # Only the foreign-key reference is seeded; no Locale or enum is enabled. + Language.connection.execute(<<~SQL) + INSERT IGNORE INTO languages (code, name, created_at) + VALUES ('ain', 'Ainu', CURRENT_TIMESTAMP) + SQL + allow(Locale).to receive(:find_by).and_call_original + allow(Locale).to receive(:find_by).with(code: 'ain').and_return(ainu_locale) + end + + def add_ainu_name tag, name + TagName.insert_all!([{ + tag_id: tag.id, language_code: 'ain', script_code: 'Jpan', name:, + primary_flg: true, auto_generated: false, + created_at: Time.current, updated_at: Time.current }]) + tag.tag_names.reset + TagName.find_by!(tag_id: tag.id, language_code: 'ain', name:) + end +end diff --git a/backend/spec/support/tag_group_records.rb b/backend/spec/support/tag_group_records.rb index 9d370c7..82c1693 100644 --- a/backend/spec/support/tag_group_records.rb +++ b/backend/spec/support/tag_group_records.rb @@ -94,13 +94,14 @@ module TagGroupRecords tag.reload end - def create_markdown_group name, body - parsed = TagGroupMarkdownParser.parse(body:) - TagGroupOperation.create!(name:, actor: user, **parsed.slice(:description, :members, :placements)) + def create_markdown_group name, body, locale: Locale.nipponese + parsed = TagGroupMarkdownParser.parse(locale, body:) + TagGroupOperation.create!( + locale, name:, actor: user, **parsed.slice(:description, :members, :placements)) end - def group_markdown group - TagGroupMarkdownRenderer.render(TagGroupRepr.one(group.reload)) + def group_markdown group, locale: Locale.nipponese + TagGroupMarkdownRenderer.render(TagGroupRepr.one(locale, group.reload)) end def seed_group name:, placements: [], members: [], description: nil, id: nil diff --git a/backend/spec/support/wiki_records.rb b/backend/spec/support/wiki_records.rb index 4dbcfb1..ff06bb1 100644 --- a/backend/spec/support/wiki_records.rb +++ b/backend/spec/support/wiki_records.rb @@ -1,6 +1,6 @@ module WikiRecords - def seed_description(tag_name:, body:, created_by_user:, message: nil) - TagDescriptionOperation.create!(tag: tag_name.tag, body:, created_by_user:, message:) + def seed_description(tag_name:, body:, created_by_user:, message: nil, locale: Locale.nipponese) + TagDescriptionOperation.create!(locale, tag: tag_name.tag, body:, created_by_user:, message:) end def description_versions(description)