From bf0a2977fc6d5a5031c6ed745394ebf8e103b5e8 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 4 Oct 2026 14:53:40 +0900 Subject: [PATCH] =?UTF-8?q?#422=20Spec=20=E6=95=B4=E5=82=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/db/tag_description_migration_spec.rb | 34 +++ .../requests/tag_description_wiki_spec.rb | 276 ++++++++++++++++++ .../tag_description_wiki_write_spec.rb | 162 ++++++++++ .../requests/wiki_unmigrated_contract_spec.rb | 96 ++++++ 4 files changed, 568 insertions(+) create mode 100644 backend/spec/db/tag_description_migration_spec.rb create mode 100644 backend/spec/requests/tag_description_wiki_spec.rb create mode 100644 backend/spec/requests/tag_description_wiki_write_spec.rb create mode 100644 backend/spec/requests/wiki_unmigrated_contract_spec.rb diff --git a/backend/spec/db/tag_description_migration_spec.rb b/backend/spec/db/tag_description_migration_spec.rb new file mode 100644 index 0000000..012bd29 --- /dev/null +++ b/backend/spec/db/tag_description_migration_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' +require_relative '../../db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages' + +RSpec.describe 'Tag description migration identity' do + it 'preserves every revision ID and repeated body in an independent sequence' do + user = create_member_user! + tag_name = create(:tag_name, name: 'description_migration') + page = Wiki::Commit.create_content!( + tag_name:, body: 'A', created_by_user: user) + revisions = [page.current_revision] + ['A', 'B', 'B', 'A'].each do |body| + revisions << Wiki::Commit.content!( + page:, body:, created_user: user, base_revision_id: revisions.last.id) + end + tag = create(:tag, primary_tag_name: tag_name) + legacy_state = [page.reload.attributes, revisions.map(&:attributes)] + expect(page.wiki_versions.count).to eq(3) + + migration = MigrateTagDescriptionsFromWikiPages.new + migration.suppress_messages { migration.up } + + versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: 'ja') + .order(:version_no) + expect(versions.pluck(:id)).to eq(revisions.map(&:id)) + expect(versions.pluck(:version_no)).to eq([1, 2, 3, 4, 5]) + expect(versions.pluck(:body)).to eq(['A', 'A', 'B', 'B', 'A']) + expect(versions.pluck(:created_by_user_id)).to eq([user.id] * 5) + expect(versions.pluck(:created_at)).to eq(revisions.map(&:created_at)) + expect(TagDescription.find(page.id)).to have_attributes( + tag_id: tag.id, language_code: 'ja', version_no: 5, body: 'A') + expect([page.reload.attributes, revisions.map { |revision| revision.reload.attributes }]) + .to eq(legacy_state) + end +end diff --git a/backend/spec/requests/tag_description_wiki_spec.rb b/backend/spec/requests/tag_description_wiki_spec.rb new file mode 100644 index 0000000..3b9b192 --- /dev/null +++ b/backend/spec/requests/tag_description_wiki_spec.rb @@ -0,0 +1,276 @@ +require 'rails_helper' + +RSpec.describe 'Tag description Wiki compatibility', type: :request do + let!(:user) { create_member_user! } + let!(:tag_name) { create(:tag_name, name: 'description_contract') } + let!(:page) do + Wiki::Commit.create_content!( + tag_name:, body: 'old-revision', created_by_user: user) + end + let!(:revisions) do + first = page.current_revision + second = Wiki::Commit.content!(page:, body: 'old-second', created_user: user) + third = Wiki::Commit.content!(page:, body: 'old-third', created_user: user) + [first, second, third] + end + let!(:tag) { create(:tag, primary_tag_name: tag_name) } + let!(:versions) do + revisions.each_with_index.map do |revision, index| + TagDescriptionVersion.create!( + id: revision.id, tag_id: tag.id, language_code: 'ja', + version_no: index + 1, event_type: index.zero? ? 'create' : 'update', + body: index == 2 ? 'new-version' : 'A', + created_by_user_id: user.id, created_at: revision.created_at) + end + end + let!(:description) do + TagDescription.create!( + id: page.id, tag:, language_code: 'ja', version_no: 3, body: 'new', + created_by_user_id: user.id, updated_by_user_id: user.id) + end + + before { page.update_columns(body: 'old') } + + # Observe real SELECTs, including cached queries, only during the request. + def without_legacy_reads + queries = [] + subscriber = lambda do |*args| + sql = args.last.fetch(:sql) + if sql.match?(/\bSELECT\b/i) && sql.match?(/\bwiki_(pages|revisions|versions)\b/i) + queries << sql + end + end + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield } + expect(queries).to be_empty + end + + describe 'show' do + it 'uses current content and revision identity without legacy reads' do + without_legacy_reads { get "/wiki/#{ description.id }" } + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'new', 'revision_id' => versions.last.id, + 'pred' => versions.second.id, 'succ' => nil) + end + + [0, 1, 2].each do |index| + it "navigates displayed version #{ index + 1 }, preserving repeated bodies" do + without_legacy_reads do + get "/wiki/#{ description.id }", params: { version: versions[index].id } + end + expect(response).to have_http_status(:ok) + expect(json).to include( + 'body' => index == 2 ? 'new' : 'A', + 'revision_id' => versions[index].id, + 'pred' => index.zero? ? nil : versions[index - 1].id, + 'succ' => index == 2 ? nil : versions[index + 1].id) + end + end + + it 'does not fall back to a revision absent from new history' do + versions.first.destroy! + without_legacy_reads do + get "/wiki/#{ description.id }", params: { version: revisions.first.id } + end + expect(response).to have_http_status(:not_found) + end + + it 'does not fall back when the new current revision is missing' do + versions.last.destroy! + without_legacy_reads { get "/wiki/#{ description.id }" } + expect(response).to have_http_status(:not_found) + end + end + + describe 'revision resource scope' do + let!(:other_tag) { create(:tag, primary_name: 'other_description_contract') } + let!(:other_version) do + TagDescriptionVersion.create!( + tag_id: other_tag.id, language_code: 'ja', version_no: 1, + event_type: 'create', body: 'other tag history', + created_by_user_id: user.id, created_at: Time.current) + end + let!(:other_description) do + TagDescription.create!( + tag: other_tag, language_code: 'ja', version_no: 1, body: 'other tag history', + created_by_user_id: user.id, updated_by_user_id: user.id) + end + + it 'returns 404 when show specifies another tag description version' do + get "/wiki/#{ description.id }", params: { version: other_version.id } + expect(response).to have_http_status(:not_found) + expect(response.body).not_to include(other_version.body) + end + + it 'returns 404 when diff from belongs to another tag description' do + get "/wiki/#{ description.id }/diff", + params: { from: other_version.id, to: versions.last.id } + expect(response).to have_http_status(:not_found) + expect(response.body).not_to include(other_version.body) + end + + it 'returns 404 when diff to belongs to another tag description' do + get "/wiki/#{ description.id }/diff", + params: { from: versions.first.id, to: other_version.id } + expect(response).to have_http_status(:not_found) + expect(response.body).not_to include(other_version.body) + end + end + + describe 'show_by_title' do + it 'resolves TagName and reads current content from new tables' do + without_legacy_reads { get "/wiki/title/#{ tag_name.name }" } + expect(response).to have_http_status(:ok) + expect(json).to include('id' => description.id, 'body' => 'new', + 'revision_id' => versions.last.id) + end + + it 'uses historical content and navigation from the new history' do + get "/wiki/title/#{ tag_name.name }", params: { version: versions.second.id } + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'A', 'revision_id' => versions.second.id, + 'pred' => versions.first.id, 'succ' => versions.last.id) + end + + it 'ignores changes to same-title legacy content and revision metadata' do + get "/wiki/title/#{ tag_name.name }" + original = json + page.update_columns(body: 'changed legacy body') + revisions.last.update_columns(message: 'legacy message', created_at: 1.day.ago) + revisions.last.wiki_lines.update_all(body: 'changed legacy revision') + get "/wiki/title/#{ tag_name.name }" + expect(json).to eq(original) + expect(json.fetch('body')).to eq('new') + end + end + + describe 'exists' do + it 'recognises an ID without reading legacy tables' do + without_legacy_reads { get "/wiki/#{ description.id }/exists" } + expect(response).to have_http_status(:no_content) + end + + it 'recognises a title without reading legacy tables' do + without_legacy_reads { get "/wiki/title/#{ tag_name.name }/exists" } + expect(response).to have_http_status(:no_content) + end + + context 'without a legacy page' do + before do + WikiVersion.where(wiki_page_id: page.id).delete_all + page.destroy! + end + + it 'recognises the description ID alone' do + get "/wiki/#{ description.id }/exists" + expect(response).to have_http_status(:no_content) + end + + it 'recognises the TagName alone' do + get "/wiki/title/#{ tag_name.name }/exists" + expect(response).to have_http_status(:no_content) + end + + it 'shows the description by title' do + get "/wiki/title/#{ tag_name.name }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'new', 'revision_id' => versions.last.id) + end + end + end + + describe 'diff' do + [false, true].each do |current| + it "uses new bodies with #{ current ? 'implicit current' : 'explicit to' }" do + params = { from: versions.first.id } + params[:to] = versions.last.id unless current + get "/wiki/#{ description.id }/diff", params: params + expect(response).to have_http_status(:ok) + expect(json).to include( + 'wiki_page_id' => description.id, + 'older_revision_id' => versions.first.id, + 'newer_revision_id' => versions.last.id, + 'diff' => [{ 'type' => 'removed', 'content' => 'A' }, + { 'type' => 'added', 'content' => 'new-version' }]) + end + end + + it 'does not query legacy tables for diff' do + without_legacy_reads do + get "/wiki/#{ description.id }/diff", + params: { from: versions.first.id, to: versions.last.id } + end + expect(response).to have_http_status(:ok) + end + + it 'keeps distinct revision IDs for identical consecutive bodies' do + get "/wiki/#{ description.id }/diff", + params: { from: versions.first.id, to: versions.second.id } + expect(response).to have_http_status(:ok) + expect(json).to include( + 'older_revision_id' => versions.first.id, + 'newer_revision_id' => versions.second.id, + 'diff' => [{ 'type' => 'context', 'content' => 'A' }]) + end + end + + describe 'changes' do + it 'lists every new version including consecutive identical bodies' do + without_legacy_reads { get '/wiki/changes', params: { id: description.id } } + expect(response).to have_http_status(:ok) + expect(json.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id)) + expect(json.first.keys).to match_array( + ['revision_id', 'pred', 'succ', 'wiki_page', 'user', 'kind', 'message', 'timestamp']) + expect(json.first).to include( + 'pred' => versions.second.id, 'succ' => nil, 'kind' => 'content', + 'timestamp' => versions.last.created_at.iso8601(3), + 'user' => { 'id' => user.id, 'name' => user.name }) + expect(json.first.fetch('wiki_page')).to include('id' => description.id, + 'title' => tag_name.name) + end + + it 'ignores legacy-only revisions and metadata changes' do + get '/wiki/changes', params: { id: description.id } + original = json + revisions.first.update_columns(message: 'legacy changed', created_at: 1.day.ago) + WikiRevision.create!( + wiki_page: page, created_user: user, kind: :content, + tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0) + get '/wiki/changes', params: { id: description.id } + expect(json).to eq(original) + expect(json.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id)) + end + end + + describe 'mixed changes' do + it 'includes unmigrated history and ignores migrated legacy-only revisions' do + legacy_page = Wiki::Commit.create_content!( + tag_name: create(:tag_name, name: 'Help_mixed'), + body: 'help', created_by_user: user) + legacy_revision = legacy_page.current_revision + extra = WikiRevision.create!( + wiki_page: page, created_user: user, kind: :content, + tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0) + + get '/wiki/changes' + expect(response).to have_http_status(:ok) + migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id } + expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id)) + expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id) + expect(json).to include(include( + 'revision_id' => legacy_revision.id, + 'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed'))) + end + end + + describe 'index' do + [nil, 'description_contract'].each do |title| + it "returns new content once in #{ title ? 'filtered' : 'unfiltered' } index" do + get '/wiki', params: { title: } + expect(response).to have_http_status(:ok) + rows = json.select { |row| row.fetch('id') == description.id } + expect(rows.size).to eq(1) + expect(rows.first).to include('body' => 'new', 'title' => tag_name.name) + end + end + end +end diff --git a/backend/spec/requests/tag_description_wiki_write_spec.rb b/backend/spec/requests/tag_description_wiki_write_spec.rb new file mode 100644 index 0000000..6d83347 --- /dev/null +++ b/backend/spec/requests/tag_description_wiki_write_spec.rb @@ -0,0 +1,162 @@ +require 'rails_helper' + +RSpec.describe 'Tag description Wiki dual writes', type: :request do + let!(:user) { create_member_user! } + let!(:tag_name) { create(:tag_name, name: 'description_write') } + let(:tag) { create(:tag, primary_tag_name: tag_name) } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + + def stored_state + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion, + TagName, TagVersion].map do |model| + model.order(:id).map(&:attributes) + end + end + + # Fail after a real INSERT/UPDATE, exercising rollback rather than a mock write. + def fail_save model + failure = Class.new(StandardError) + callback = ->(_record) { raise failure, 'injected persistence failure' } + model.set_callback(:save, :after, callback) + expect { yield }.to raise_error(failure) + ensure + model.skip_callback(:save, :after, callback) + end + + describe 'POST /wiki' do + before { tag } + + def create_description + post '/wiki', params: { title: tag_name.name, body: 'initial', message: 'create' }, + headers: headers + end + + it 'creates both current records and histories with a shared revision ID' do + expect { create_description } + .to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change(TagDescription, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + expect(response).to have_http_status(:created) + + page = WikiPage.find(json.fetch('id')) + description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') + version = TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja') + expect(description).to have_attributes(id: page.id, body: 'initial', version_no: 1) + expect(version).to have_attributes( + id: page.current_revision.id, body: 'initial', version_no: 1, + event_type: 'create', created_by_user_id: user.id) + expect(page.body).to eq(description.body) + expect(page.current_revision.body).to eq(version.body) + expect(page.wiki_versions.last.body).to eq(version.body) + end + + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + it "rolls back both systems when #{ model.name } persistence fails" do + tag_name + before_state = stored_state + aggregate_failures do + fail_save(model) { create_description } + expect(stored_state).to eq(before_state) + end + end + end + end + + describe 'PUT /wiki/:id' do + let!(:page) do + Wiki::Commit.create_content!( + tag_name:, body: 'before', created_by_user: user) + end + let!(:first_revision) { page.current_revision } + let!(:current_revision) do + # Repeated bodies create revisions, but no extra WikiVersion snapshot. + Wiki::Commit.content!(page:, body: 'before', created_user: user, + base_revision_id: first_revision.id) + end + let!(:description) do + TagDescription.create!( + id: page.id, tag:, language_code: 'ja', body: 'before', version_no: 2, + created_by_user_id: user.id, updated_by_user_id: user.id) + end + + before do + [first_revision, current_revision].each_with_index do |revision, index| + TagDescriptionVersion.create!( + id: revision.id, tag_id: tag.id, language_code: 'ja', version_no: index + 1, + body: 'before', event_type: index.zero? ? 'create' : 'update', + created_by_user_id: user.id, created_at: revision.created_at) + end + end + + def update_description body: 'after', title: tag_name.name, base: current_revision.id + put "/wiki/#{ page.id }", + params: { title:, body:, message: 'edit', base_revision_id: base }, + headers: headers + end + + it 'increments the independent description sequence and shares revision identity' do + expect(page.reload.version_no).to eq(1) + expect(description.version_no).to eq(2) + expect { update_description } + .to change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + expect(response).to have_http_status(:ok) + + revision = page.reload.current_revision + version = TagDescriptionVersion.find(revision.id) + expect(description.reload).to have_attributes(body: 'after', version_no: 3) + expect(version).to have_attributes( + body: 'after', version_no: 3, event_type: 'update', created_by_user_id: user.id) + expect(page).to have_attributes(body: 'after', version_no: 2) + expect(revision.body).to eq(version.body) + expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body) + end + + it 'mirrors the Wiki revision for a title-only update despite identical bodies' do + expect { update_description(body: 'before', title: 'description_renamed') } + .to change(WikiRevision, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + expect(response).to have_http_status(:ok) + expect(tag_name.reload.name).to eq('description_renamed') + expect(description.reload).to have_attributes(body: 'before', version_no: 3) + + revision = page.reload.current_revision + version = TagDescriptionVersion.find(revision.id) + expect(revision.body).to eq('before') + expect(version).to have_attributes( + tag_id: tag.id, language_code: 'ja', body: 'before', version_no: 3, + event_type: 'update', created_by_user_id: user.id) + expect(TagDescriptionVersion.where(tag_id: tag.id, language_code: 'ja').order(:id).ids) + .to eq(page.wiki_revisions.order(:id).ids) + end + + it 'does not add a description version for a TagName rename through tags' do + expect do + patch "/tags/#{ tag.id }", params: { name: 'description_tag_renamed' }, headers: headers + end.not_to change(TagDescriptionVersion, :count) + expect(response).to have_http_status(:ok) + expect(tag_name.reload.name).to eq('description_tag_renamed') + expect(description.reload).to have_attributes(body: 'before', version_no: 2) + end + + it 'keeps both systems and the title unchanged on a stale revision conflict' do + before_state = stored_state + update_description(title: 'conflicting_rename', base: first_revision.id) + expect(response).to have_http_status(:conflict) + expect(stored_state).to eq(before_state) + end + + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + it "rolls back both systems when #{ model.name } persistence fails" do + before_state = stored_state + aggregate_failures do + fail_save(model) { update_description(title: 'rollback_rename') } + expect(stored_state).to eq(before_state) + end + end + end + end +end diff --git a/backend/spec/requests/wiki_unmigrated_contract_spec.rb b/backend/spec/requests/wiki_unmigrated_contract_spec.rb new file mode 100644 index 0000000..1029b0f --- /dev/null +++ b/backend/spec/requests/wiki_unmigrated_contract_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +RSpec.describe 'Unmigrated Wiki compatibility', type: :request do + let!(:user) { create_member_user! } + let!(:tag_name) { create(:tag_name, name: 'Help_contract') } + let!(:page) do + Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: user) + end + let!(:first_revision) { page.current_revision } + let!(:last_revision) do + Wiki::Commit.content!(page:, body: 'after', created_user: user, + message: 'legacy edit', base_revision_id: first_revision.id) + end + + before { expect(TagDescription.exists?(id: page.id)).to be(false) } + + it 'shows legacy current and historical content by ID' do + get "/wiki/#{ page.id }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'after', 'revision_id' => last_revision.id, + 'pred' => first_revision.id, 'succ' => nil) + get "/wiki/#{ page.id }", params: { version: first_revision.id } + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'before', 'revision_id' => first_revision.id, + 'pred' => nil, 'succ' => last_revision.id) + end + + it 'shows legacy current and historical content by title' do + get "/wiki/title/#{ tag_name.name }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'after', 'revision_id' => last_revision.id) + get "/wiki/title/#{ tag_name.name }", params: { version: first_revision.id } + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'before', 'revision_id' => first_revision.id) + end + + it 'recognises legacy IDs' do + get "/wiki/#{ page.id }/exists" + expect(response).to have_http_status(:no_content) + end + + it 'recognises legacy titles' do + get "/wiki/title/#{ tag_name.name }/exists" + expect(response).to have_http_status(:no_content) + end + + it 'diffs legacy revisions with explicit and current target' do + [last_revision.id, nil].each do |to| + get "/wiki/#{ page.id }/diff", params: { from: first_revision.id, to: } + expect(response).to have_http_status(:ok) + expect(json).to include( + 'wiki_page_id' => page.id, 'title' => tag_name.name, + 'older_revision_id' => first_revision.id, 'newer_revision_id' => last_revision.id, + 'diff' => [{ 'type' => 'removed', 'content' => 'before' }, + { 'type' => 'added', 'content' => 'after' }]) + end + end + + describe 'revision resource scope' do + let!(:other_page) do + Wiki::Commit.create_content!( + tag_name: create(:tag_name, name: 'Help_other_contract'), + body: 'other page history', created_by_user: user) + end + let(:other_revision) { other_page.current_revision } + + it 'returns 404 when show specifies another page revision' do + get "/wiki/#{ page.id }", params: { version: other_revision.id } + expect(response).to have_http_status(:not_found) + end + + it 'returns 404 when diff from belongs to another page' do + get "/wiki/#{ page.id }/diff", + params: { from: other_revision.id, to: last_revision.id } + expect(response).to have_http_status(:not_found) + end + + it 'returns 404 when diff to belongs to another page' do + get "/wiki/#{ page.id }/diff", + params: { from: first_revision.id, to: other_revision.id } + expect(response).to have_http_status(:not_found) + end + end + + it 'retains legacy history metadata and ordering' do + get '/wiki/changes', params: { id: page.id } + expect(response).to have_http_status(:ok) + expect(json.map { |row| row.fetch('revision_id') }).to eq( + [last_revision.id, first_revision.id]) + expect(json.first).to include( + 'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content', + 'message' => 'legacy edit', 'timestamp' => last_revision.created_at.iso8601(3), + 'wiki_page' => { 'id' => page.id, 'title' => tag_name.name, 'deprecated_at' => nil }, + 'user' => { 'id' => user.id, 'name' => user.name }) + end +end