From 11eedc242d455338c29bcc4ed3fa2cdccfcda99f Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 4 Oct 2026 13:45:23 +0900 Subject: [PATCH 01/11] #422 --- .../tag_descriptions_controller.rb | 0 .../app/controllers/wiki_pages_controller.rb | 120 ++++++++++++++++-- backend/app/models/tag_description.rb | 3 + backend/app/models/tag_description_version.rb | 3 + backend/app/models/wiki_page.rb | 11 +- backend/config/routes.rb | 2 +- .../20261003000000_create_tag_descriptions.rb | 63 +++++++++ ...igrate_tag_descriptions_from_wiki_pages.rb | 104 +++++++++++++++ backend/db/schema.rb | 67 +++++++--- 9 files changed, 343 insertions(+), 30 deletions(-) create mode 100644 backend/app/controllers/tag_descriptions_controller.rb create mode 100644 backend/app/models/tag_description.rb create mode 100644 backend/app/models/tag_description_version.rb create mode 100644 backend/db/migrate/20261003000000_create_tag_descriptions.rb create mode 100644 backend/db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages.rb diff --git a/backend/app/controllers/tag_descriptions_controller.rb b/backend/app/controllers/tag_descriptions_controller.rb new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index b0d0b8c..9a0c310 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -2,21 +2,129 @@ class WikiPagesController < ApplicationController rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict def index + # 互換性のための化石リソース + # TODO: ヘルプおよびタグ・グループ追加後に, + # TODO: タグ説明とヘルプ、タグ・グループから取得する作りに変更する. + title = params[:title].to_s.strip if title.blank? - return render json: WikiPageRepr.base( - WikiPage.joins(:tag_name).includes(tag_name: :tag)) + tag_descriptions = TagDescription.where(language_code: :ja).map do |td| + { id: td.id, + tag_name_id: td.tag.tag_name(:ja).id, + body: td.body, + created_user_id: td.created_by_user_id, + updated_user_id: td.updated_by_user_id, + created_at: td.created_at, + updated_at: td.updated_at, + discarded_at: nil, + version_no: td.version_no, + title: td.tag.name(:ja), + deprecated_at: td.tag.deprecated_at } + end + # TODO: ヘルプ移行したらここに追記 + # TODO: タグ・グループ追加したらここに追記 + + # TODO: ヘルプ、タグ・グループ移行したらそれらもタイトルから除外に含める. + # てかこれ何順? + wiki_pages = + WikiPageRepr.base(WikiPage.joins(:tag_name).includes(tag_name: :tag)) + .reject { |wp| tag_descriptions.any? { |td| td.title == wp.title } } + + json = tag_descriptions + wiki_pages + + return render json: end - q = WikiPage.joins(:tag_name).includes(tag_name: :tag) + q = + TagDescription.joins(tag: :tag_names) + .where(language_code: :ja) + .where(tag: { tag_names: { language_code: :ja, primary_flg: true } }) + .where('tag_names.name LIKE ?', "%#{ ApplicationRecord.sanitize_sql_like(title) }%") + tag_descriptions = q.limit(20).map do |td| + { id: td.id, + tag_name_id: td.tag.tag_name(:ja).id, + body: td.body, + created_user_id: td.created_by_user_id, + updated_user_id: td.updated_by_user_id, + created_at: td.created_at, + updated_at: td.updated_at, + discarded_at: nil, + version_no: td.version_no, + title: td.tag.name(:ja), + deprecated_at: td.tag.deprecated_at } + end + + q = + WikiPage.joins(:tag_name).includes(tag_name: :tag) .where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") - render json: WikiPageRepr.base(q.limit(20)) + # TODO: ヘルプ、タグ・グループ移行したらそれらもタイトルから除外に含める. + wiki_pages = + WikiPageRepr.base(q.limit(20)) + .reject { |wp| tag_descriptions.any? { |td| td.title == wp.title } } + + json = (tag_descriptions + wiki_pages)[...20] + + render json: end def show + tag_description = TagDescription.find_by(id: params[:id]) + + if tag_description + rev = + if params[:version].present? + TagDescriptionVersion.find_by(id: params[:version]) + else + TagDescriptionVersion.find_by(tag_id: tag_description.tag_id, + language_code: :ja, + version_no: tag_description.version_no) + end + + if rev + body = + if rev.version_no == tag_description.version_no + tag_description.body + else + rev.body + end + revision_id = rev.id + pred = TagDescriptionVersion.find_by(tag_id: tag_description.tag_id, + language_code: :ja, + version_no: tag_description.version_no - 1)&.id + succ = TagDescriptionVersion.find_by(tag_id: tag_description.tag_id, + language_code: :ja, + version_no: tag_description.version_no + 1)&.id + updated_at = + if rev.version_no == tag_description.version_no + tag_description.updated_at + else + rev.created_at + end + + json = + { id: tag_description.id, + tag_name_id: tag_description.tag.tag_name(:ja).id, + body:, + created_user_id: tag_description.created_by_user_id, + updated_user_id: tag_description.updated_by_user_id, + created_at: tag_description.created_at, + updated_at:, + discarded_at: nil, + version_no: tag_description.version_no, + title: tag_description.tag.name(:ja), + deprecated_at: tag_description.tag.deprecated_at, + revision_id:, + pred:, + succ: } + + return render json: + end + end + page = WikiPage.joins(:tag_name) .includes(tag_name: :tag) .find_by(id: params[:id]) + render_wiki_page_or_404 page end @@ -156,10 +264,6 @@ class WikiPagesController < ApplicationController head :ok end - def search - index - end - def changes id = params[:id].presence q = WikiRevision.joins(wiki_page: :tag_name) diff --git a/backend/app/models/tag_description.rb b/backend/app/models/tag_description.rb new file mode 100644 index 0000000..2c5e226 --- /dev/null +++ b/backend/app/models/tag_description.rb @@ -0,0 +1,3 @@ +class TagDescription < ApplicationRecord + belongs_to :tag +end diff --git a/backend/app/models/tag_description_version.rb b/backend/app/models/tag_description_version.rb new file mode 100644 index 0000000..69f31ac --- /dev/null +++ b/backend/app/models/tag_description_version.rb @@ -0,0 +1,3 @@ +class TagDescriptionVersion < ApplicationRecord + ; +end diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb index 68928f1..caee630 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -48,8 +48,11 @@ class WikiPage < ApplicationRecord page end - def pred_revision_id(revision_id) = - wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id) - def succ_revision_id(revision_id) = - wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id) + def pred_revision_id revision_id + wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id) + end + + def succ_revision_id revision_id + wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id) + end end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 0f721d1..e44f5b5 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -35,7 +35,7 @@ Rails.application.routes.draw do resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do collection do - get :search + get :search, to: redirect('/') get :changes scope :title do diff --git a/backend/db/migrate/20261003000000_create_tag_descriptions.rb b/backend/db/migrate/20261003000000_create_tag_descriptions.rb new file mode 100644 index 0000000..961a4ad --- /dev/null +++ b/backend/db/migrate/20261003000000_create_tag_descriptions.rb @@ -0,0 +1,63 @@ +class CreateTagDescriptions < ActiveRecord::Migration[8.0] + def change + create_table :tag_description_versions do |t| + t.references :tag, null: false, foreign_key: false + t.string :language_code, limit: 16, null: false + t.integer :version_no, null: false + t.string :event_type, null: false + t.text :body, null: false + t.string :source_language_code, limit: 16 + t.integer :source_version_no + t.references :created_by_user + t.datetime :created_at, null: false + + t.index [:tag_id, :language_code, :version_no], unique: true + t.foreign_key :tag_description_versions, + column: [:tag_id, :source_language_code, :source_version_no], + primary_key: [:tag_id, :language_code, :version_no] + + t.check_constraint 'version_no > 0', + name: 'chk_tag_description_versions_version_no_positive' + + t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')", + name: 'tag_description_versions_event_type_valid' + + t.check_constraint ('(source_language_code IS NULL AND source_version_no IS NULL) OR ' + + '(source_language_code IS NOT NULL AND source_version_no IS NOT NULL)'), + name: 'chk_tag_description_versions_source_pair' + + t.check_constraint 'source_language_code IS NULL OR source_language_code <> language_code', + name: 'chk_tag_description_versions_source_language_code_different' + end + + create_table :tag_descriptions do |t| + t.references :tag, null: false, foreign_key: true + t.string :language_code, limit: 16, null: false, index: true + t.integer :version_no, null: false + t.text :body, null: false + t.string :source_language_code, limit: 16 + t.integer :source_version_no + t.references :created_by_user, foreign_key: { to_table: :users } + t.references :updated_by_user, foreign_key: { to_table: :users } + t.timestamps + + t.foreign_key :languages, column: :language_code, primary_key: :code + t.foreign_key :tag_description_versions, + column: [:tag_id, :source_language_code, :source_version_no], + primary_key: [:tag_id, :language_code, :version_no] + + t.index [:tag_id, :language_code], unique: true + t.index [:source_language_code, :source_version_no] + + t.check_constraint 'version_no > 0', + name: 'chk_tag_descriptions_version_no_positive' + + t.check_constraint ('(source_language_code IS NULL AND source_version_no IS NULL) OR ' + + '(source_language_code IS NOT NULL AND source_version_no IS NOT NULL)'), + name: 'chk_tag_descriptions_source_pair' + + t.check_constraint 'source_language_code IS NULL OR source_language_code <> language_code', + name: 'chk_tag_descriptions_source_language_code_different' + end + end +end diff --git a/backend/db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages.rb b/backend/db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages.rb new file mode 100644 index 0000000..4d51db9 --- /dev/null +++ b/backend/db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages.rb @@ -0,0 +1,104 @@ +class MigrateTagDescriptionsFromWikiPages < ActiveRecord::Migration[8.0] + class WikiPage < ActiveRecord::Base + self.table_name = 'wiki_pages' + end + + class WikiRevision < ActiveRecord::Base + self.table_name = 'wiki_revisions' + end + + class WikiRevisionLine < ActiveRecord::Base + self.table_name = 'wiki_revision_lines' + end + + class TagName < ActiveRecord::Base + self.table_name = 'tag_names' + end + + class TagDescription < ActiveRecord::Base + self.table_name = 'tag_descriptions' + end + + class TagDescriptionVersion < ActiveRecord::Base + self.table_name = 'tag_description_versions' + end + + def up + WikiPage.find_each do |page| + tag_name = TagName.find_by(id: page.tag_name_id, language_code: 'ja') + next unless tag_name&.tag_id + + revisions = WikiRevision.where(wiki_page_id: page.id).order(:id).to_a + + revisions.each_with_index do |revision, index| + version_no = index + 1 + + tag_id = tag_name[:tag_id] + + unless tag_id + raise ( + 'tag_id is nil: ' + + "wiki_page_id=#{ page.id }, " + + "tag_name_id=#{ tag_name.id }, " + + "tag_name=#{ tag_name.attributes.inspect}") + end + + revision_id = revision.id + + say ( + "wiki_page_id=#{ page.id }, " + + "tag_name_id=#{ tag_name.id }, " + + "tag_id=#{ tag_id }, " + + "revision_id=#{ revision_id }") + + version = TagDescriptionVersion.new( + id: revision_id, + tag_id:, + language_code: 'ja', + version_no:, + event_type: version_no == 1 ? 'create' : 'update', + body: revision_body(revision_id), + source_language_code: nil, + source_version_no: nil, + created_by_user_id: revision.created_user_id, + created_at: revision.created_at) + + say version.attributes.inspect + + version.save! + end + + next if page.discarded_at.present? + next if revisions.empty? + + TagDescription.create!( + id: page.id, + tag_id: tag_name.tag_id, + language_code: 'ja', + version_no: revisions.length, + body: page.body, + source_language_code: nil, + source_version_no: nil, + created_by_user_id: page.created_user_id, + updated_by_user_id: page.updated_user_id, + created_at: page.created_at, + updated_at: page.updated_at) + end + end + + def down + raise ActiveRecord::IrreversibleMigration, '戻せません.' + end + + private + + def revision_body revision_id + WikiRevisionLine + .joins('INNER JOIN wiki_lines ' + + 'ON wiki_lines.id = wiki_revision_lines.wiki_line_id') + .where(wiki_revision_id: revision_id) + .order(:position) + .pluck('wiki_lines.body') + .join("\n") + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index eee4568..914edcb 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_22_040000) do +ActiveRecord::Schema[8.0].define(version: 2026_10_03_010000) 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 @@ -440,6 +440,49 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do t.index ["user_id"], name: "index_settings_on_user_id", unique: true end + create_table "tag_description_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id", null: false + t.string "language_code", limit: 16, null: false + t.integer "version_no", null: false + t.string "event_type", null: false + t.text "body", null: false + t.string "source_language_code", limit: 16 + t.integer "source_version_no" + t.bigint "created_by_user_id" + t.datetime "created_at", null: false + t.index ["created_by_user_id"], name: "index_tag_description_versions_on_created_by_user_id" + t.index ["tag_id", "language_code", "version_no"], name: "idx_on_tag_id_language_code_version_no_ced77e57e6", unique: true + t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_4f13094dbc" + t.index ["tag_id"], name: "index_tag_description_versions_on_tag_id" + t.check_constraint "((`source_language_code` is null) and (`source_version_no` is null)) or ((`source_language_code` is not null) and (`source_version_no` is not null))", name: "chk_tag_description_versions_source_pair" + t.check_constraint "(`source_language_code` is null) or (`source_language_code` <> `language_code`)", name: "chk_tag_description_versions_source_language_code_different" + t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "tag_description_versions_event_type_valid" + t.check_constraint "`version_no` > 0", name: "chk_tag_description_versions_version_no_positive" + end + + create_table "tag_descriptions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id", null: false + t.string "language_code", limit: 16, null: false + t.integer "version_no", null: false + t.text "body", null: false + t.string "source_language_code", limit: 16 + t.integer "source_version_no" + t.bigint "created_by_user_id" + t.bigint "updated_by_user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_tag_descriptions_on_created_by_user_id" + t.index ["language_code"], name: "index_tag_descriptions_on_language_code" + t.index ["source_language_code", "source_version_no"], name: "idx_on_source_language_code_source_version_no_00117fe02a" + t.index ["tag_id", "language_code"], name: "index_tag_descriptions_on_tag_id_and_language_code", unique: true + t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_d1be0a7696" + t.index ["tag_id"], name: "index_tag_descriptions_on_tag_id" + t.index ["updated_by_user_id"], name: "index_tag_descriptions_on_updated_by_user_id" + t.check_constraint "((`source_language_code` is null) and (`source_version_no` is null)) or ((`source_language_code` is not null) and (`source_version_no` is not null))", name: "chk_tag_descriptions_source_pair" + t.check_constraint "(`source_language_code` is null) or (`source_language_code` <> `language_code`)", name: "chk_tag_descriptions_source_language_code_different" + t.check_constraint "`version_no` > 0", name: "chk_tag_descriptions_version_no_positive" + end + create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_id", null: false t.bigint "parent_tag_id", null: false @@ -656,19 +699,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do t.index ["banned_at"], name: "index_users_on_banned_at" end - create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "wiki_page_id", null: false - t.integer "no", null: false - t.string "alt_text" - t.binary "sha256", limit: 32, null: false - t.bigint "created_by_user_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id" - t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true - t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true - end - create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "sha256", limit: 64, null: false t.text "body", null: false @@ -685,7 +715,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" - t.integer "next_asset_no", default: 1, null: false t.integer "version_no", null: false t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" @@ -788,6 +817,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do add_foreign_key "post_versions", "users", column: "created_by_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "settings", "users" + add_foreign_key "tag_description_versions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"] + add_foreign_key "tag_descriptions", "languages", column: "language_code", primary_key: "code" + add_foreign_key "tag_descriptions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"] + add_foreign_key "tag_descriptions", "tags" + add_foreign_key "tag_descriptions", "users", column: "created_by_user_id" + add_foreign_key "tag_descriptions", "users", column: "updated_by_user_id" 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" @@ -822,8 +857,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "users" add_foreign_key "user_theme_slots", "users" - add_foreign_key "wiki_assets", "users", column: "created_by_user_id" - add_foreign_key "wiki_assets", "wiki_pages" add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id" -- 2.53.0 From bf0a2977fc6d5a5031c6ed745394ebf8e103b5e8 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 4 Oct 2026 14:53:40 +0900 Subject: [PATCH 02/11] =?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 -- 2.53.0 From 93e2f483553868f594a1eca966b9410afb711069 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 4 Oct 2026 15:24:27 +0900 Subject: [PATCH 03/11] #422 --- .../app/controllers/wiki_pages_controller.rb | 295 +++++++++--------- backend/app/representations/wiki_page_repr.rb | 15 + backend/app/services/wiki/commit.rb | 26 +- backend/config/routes.rb | 2 +- 4 files changed, 179 insertions(+), 159 deletions(-) diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 9a0c310..3973e48 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -2,124 +2,29 @@ class WikiPagesController < ApplicationController rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict def index - # 互換性のための化石リソース - # TODO: ヘルプおよびタグ・グループ追加後に, - # TODO: タグ説明とヘルプ、タグ・グループから取得する作りに変更する. - title = params[:title].to_s.strip - if title.blank? - tag_descriptions = TagDescription.where(language_code: :ja).map do |td| - { id: td.id, - tag_name_id: td.tag.tag_name(:ja).id, - body: td.body, - created_user_id: td.created_by_user_id, - updated_user_id: td.updated_by_user_id, - created_at: td.created_at, - updated_at: td.updated_at, - discarded_at: nil, - version_no: td.version_no, - title: td.tag.name(:ja), - deprecated_at: td.tag.deprecated_at } - end - # TODO: ヘルプ移行したらここに追記 - # TODO: タグ・グループ追加したらここに追記 + descriptions = TagDescription.where(language_code: 'ja') + .includes(tag: :tag_names) + pages = unmigrated_wiki_pages.includes(tag_name: :tag) - # TODO: ヘルプ、タグ・グループ移行したらそれらもタイトルから除外に含める. - # てかこれ何順? - wiki_pages = - WikiPageRepr.base(WikiPage.joins(:tag_name).includes(tag_name: :tag)) - .reject { |wp| tag_descriptions.any? { |td| td.title == wp.title } } - - json = tag_descriptions + wiki_pages - - return render json: + if title.present? + pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" + descriptions = descriptions.joins(tag: :tag_names) + .where(tag_names: { language_code: 'ja', primary_flg: true }) + .where('tag_names.name LIKE ?', pattern).limit(20) + pages = pages.where('tag_names.name LIKE ?', pattern).limit(20) end - q = - TagDescription.joins(tag: :tag_names) - .where(language_code: :ja) - .where(tag: { tag_names: { language_code: :ja, primary_flg: true } }) - .where('tag_names.name LIKE ?', "%#{ ApplicationRecord.sanitize_sql_like(title) }%") - tag_descriptions = q.limit(20).map do |td| - { id: td.id, - tag_name_id: td.tag.tag_name(:ja).id, - body: td.body, - created_user_id: td.created_by_user_id, - updated_user_id: td.updated_by_user_id, - created_at: td.created_at, - updated_at: td.updated_at, - discarded_at: nil, - version_no: td.version_no, - title: td.tag.name(:ja), - deprecated_at: td.tag.deprecated_at } - end - - q = - WikiPage.joins(:tag_name).includes(tag_name: :tag) - .where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") - # TODO: ヘルプ、タグ・グループ移行したらそれらもタイトルから除外に含める. - wiki_pages = - WikiPageRepr.base(q.limit(20)) - .reject { |wp| tag_descriptions.any? { |td| td.title == wp.title } } - - json = (tag_descriptions + wiki_pages)[...20] - + json = descriptions.map { |description| WikiPageRepr.tag_description(description) } + json.concat(WikiPageRepr.base(pages)) + json = json.first(20) if title.present? render json: end def show tag_description = TagDescription.find_by(id: params[:id]) - if tag_description - rev = - if params[:version].present? - TagDescriptionVersion.find_by(id: params[:version]) - else - TagDescriptionVersion.find_by(tag_id: tag_description.tag_id, - language_code: :ja, - version_no: tag_description.version_no) - end - - if rev - body = - if rev.version_no == tag_description.version_no - tag_description.body - else - rev.body - end - revision_id = rev.id - pred = TagDescriptionVersion.find_by(tag_id: tag_description.tag_id, - language_code: :ja, - version_no: tag_description.version_no - 1)&.id - succ = TagDescriptionVersion.find_by(tag_id: tag_description.tag_id, - language_code: :ja, - version_no: tag_description.version_no + 1)&.id - updated_at = - if rev.version_no == tag_description.version_no - tag_description.updated_at - else - rev.created_at - end - - json = - { id: tag_description.id, - tag_name_id: tag_description.tag.tag_name(:ja).id, - body:, - created_user_id: tag_description.created_by_user_id, - updated_user_id: tag_description.updated_by_user_id, - created_at: tag_description.created_at, - updated_at:, - discarded_at: nil, - version_no: tag_description.version_no, - title: tag_description.tag.name(:ja), - deprecated_at: tag_description.tag.deprecated_at, - revision_id:, - pred:, - succ: } - - return render json: - end - end + return render_wiki_page_by_tag_description tag_description if tag_description page = WikiPage.joins(:tag_name) .includes(tag_name: :tag) @@ -130,27 +35,30 @@ class WikiPagesController < ApplicationController def show_by_title title = params[:title].to_s.strip + + tag_description = tag_description_by_title(title) + + return render_wiki_page_by_tag_description tag_description if tag_description + page = WikiPage.joins(:tag_name) .includes(tag_name: :tag) .find_by(tag_name: { name: title }) + render_wiki_page_or_404 page end def exists - if WikiPage.exists?(id: params[:id]) - head :no_content - else - head :not_found - end + return head :no_content if TagDescription.exists?(id: params[:id]) + + head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found end def exists_by_title title = params[:title].to_s.strip - if WikiPage.joins(:tag_name).exists?(tag_names: { name: title }) - head :no_content - else - head :not_found - end + return head :no_content if tag_description_by_title(title) + + exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title }) + head exists ? :no_content : :not_found end def diff @@ -160,12 +68,20 @@ class WikiPagesController < ApplicationController from = params[:from].presence to = params[:to].presence - page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id) - - from_rev = from && page.wiki_revisions.find(from) - to_rev = to ? page.wiki_revisions.find(to) : page.current_revision - if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?)) - return render_unprocessable_entity('差分を表示できない版です.') + description = TagDescription.find_by(id:) + if description + versions = description_versions(description) + from_rev = from && versions.find(from) + to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no) + resource = WikiPageRepr.tag_description(description) + else + page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id) + from_rev = from && page.wiki_revisions.find(from) + to_rev = to ? page.wiki_revisions.find(to) : page.current_revision + if ((from_rev && !from_rev.content?) || !to_rev&.content?) + return render_unprocessable_entity('差分を表示できない版です.') + end + resource = WikiPageRepr.base(page) end diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines) @@ -183,9 +99,9 @@ class WikiPagesController < ApplicationController end }.flatten.compact - render json: { wiki_page_id: page.id, - title: page.title, - deprecated_at: page.deprecated_at, + render json: { wiki_page_id: resource.fetch('id'), + title: resource.fetch('title'), + deprecated_at: resource.fetch('deprecated_at'), older_revision_id: from_rev&.id, newer_revision_id: to_rev.id, diff: diff_json } @@ -202,18 +118,16 @@ class WikiPagesController < ApplicationController return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? - tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| - name.script_code = 'Jpan' - name.primary_flg = true - name.auto_generated = false - end + page = ApplicationRecord.transaction do + tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| + name.script_code = 'Jpan' + name.primary_flg = true + name.auto_generated = false + end - page = - Wiki::Commit.create_content!( - tag_name:, - body:, - created_by_user: current_user, - message:) + Wiki::Commit.create_content!( + tag_name:, body:, created_by_user: current_user, message:) + end render json: WikiPageRepr.base(page), status: :created rescue ActiveRecord::RecordInvalid => e @@ -240,7 +154,7 @@ class WikiPagesController < ApplicationController old_title = page.title - tag = Tag.find_by(tag_name_id: page.tag_name_id) + tag = page.tag_name.tag if tag && title != old_title TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) @@ -266,23 +180,33 @@ class WikiPagesController < ApplicationController def changes id = params[:id].presence - q = WikiRevision.joins(wiki_page: :tag_name) - .includes(:created_user, wiki_page: { tag_name: :tag }) - .order(id: :desc) - q = q.where(wiki_page_id: id) if id + description = id && TagDescription.find_by(id:) + if description + return render json: description_changes(TagDescription.where(id: description.id)) + end - render json: q.limit(200).map { |rev| - { revision_id: rev.id, - pred: rev.base_revision_id, - succ: nil, - wiki_page: { id: rev.wiki_page_id, - title: rev.wiki_page.title, - deprecated_at: rev.wiki_page.deprecated_at }, - user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name }, - kind: rev.kind, - message: rev.message, - timestamp: rev.created_at } - }.compact + pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages + revisions = WikiRevision.joins(wiki_page: :tag_name) + .where(wiki_page_id: pages.select(:id)) + .includes(:created_user, wiki_page: { tag_name: :tag }) + .order(id: :desc).limit(200) + json = revisions.map { |rev| + { revision_id: rev.id, + pred: rev.base_revision_id, + succ: nil, + wiki_page: { id: rev.wiki_page_id, + title: rev.wiki_page.title, + deprecated_at: rev.wiki_page.deprecated_at }, + user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name }, + kind: rev.kind, + message: rev.message, + timestamp: rev.created_at } + } + unless id + json.concat(description_changes(TagDescription.where(language_code: 'ja'))) + json.sort_by! { |row| -row[:revision_id] } + end + render json: json.first(200) end private @@ -308,6 +232,67 @@ class WikiPagesController < ApplicationController render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:) end + def render_wiki_page_by_tag_description description + versions = description_versions(description) + historical = params[:version].present? + rev = historical ? versions.find_by(id: params[:version]) : + versions.find_by(version_no: description.version_no) + return head :not_found unless rev + + pred = versions.find_by(version_no: rev.version_no - 1)&.id + succ = versions.find_by(version_no: rev.version_no + 1)&.id + body = historical ? rev.body : description.body + updated_at = historical ? rev.created_at : description.updated_at + render json: WikiPageRepr.tag_description(description).merge( + body:, revision_id: rev.id, pred:, succ:, updated_at:) + end + + def description_versions description + TagDescriptionVersion.where(tag_id: description.tag_id, + language_code: description.language_code) + end + + def tag_description_by_title title + TagDescription.joins(tag: :tag_names) + .find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title }) + end + + def unmigrated_wiki_pages + descriptions = TagDescription.where(language_code: 'ja') + WikiPage.joins(:tag_name).where.not(id: descriptions.select(:id)) + .where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)', + descriptions.select(:tag_id)) + end + + def description_changes descriptions + versions = TagDescriptionVersion.where(language_code: 'ja', + tag_id: descriptions.select(:tag_id)) + .order(id: :desc).limit(200).to_a + return [] if versions.empty? + + by_tag = descriptions.where(tag_id: versions.map(&:tag_id)) + .includes(tag: :tag_names).index_by(&:tag_id) + users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id) + predecessors = TagDescriptionVersion.where( + language_code: 'ja', tag_id: by_tag.keys, + version_no: versions.map { |version| version.version_no - 1 }) + .pluck(:tag_id, :version_no, :id) + .to_h { |tag_id, version_no, id| [[tag_id, version_no], id] } + versions.map do |version| + description = by_tag.fetch(version.tag_id) + user = users[version.created_by_user_id] + { revision_id: version.id, + pred: predecessors[[version.tag_id, version.version_no - 1]], + succ: nil, + wiki_page: { id: description.id, title: description.tag.name('ja'), + deprecated_at: description.tag.deprecated_at }, + user: user && { id: user.id, name: user.name }, + kind: 'content', + message: nil, + timestamp: version.created_at } + end + end + def find_revision page if params[:version].present? page.wiki_revisions.find_by(id: params[:version]) diff --git a/backend/app/representations/wiki_page_repr.rb b/backend/app/representations/wiki_page_repr.rb index ee09de3..a217341 100644 --- a/backend/app/representations/wiki_page_repr.rb +++ b/backend/app/representations/wiki_page_repr.rb @@ -10,6 +10,21 @@ module WikiPageRepr wiki_page.as_json(BASE) end + def tag_description description + tag = description.tag + { 'id' => description.id, + 'tag_name_id' => tag.tag_name('ja')&.id, + 'body' => description.body, + 'created_user_id' => description.created_by_user_id, + 'updated_user_id' => description.updated_by_user_id, + 'created_at' => description.created_at, + 'updated_at' => description.updated_at, + 'discarded_at' => nil, + 'version_no' => description.version_no, + 'title' => tag.name('ja'), + 'deprecated_at' => tag.deprecated_at } + end + def many wiki_pages wiki_pages.map { |p| base(p) } end diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb index bd6fb7b..14afa7b 100644 --- a/backend/app/services/wiki/commit.rb +++ b/backend/app/services/wiki/commit.rb @@ -57,9 +57,6 @@ module Wiki line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } tree_sha = Digest::SHA256.hexdigest(line_shas.join(',')) - line_id_by_sha = upsert_lines!(lines, line_shas) - line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) } - ActiveRecord::Base.transaction do @page.lock! @@ -72,6 +69,8 @@ module Wiki end end + line_id_by_sha = upsert_lines!(lines, line_shas) + line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) } @page.update!(body: normalised) WikiVersionRecorder.record!( @@ -94,6 +93,7 @@ module Wiki { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } end WikiRevisionLine.insert_all!(rows) if rows.any? + record_description!(rev, normalised) rev end @@ -110,6 +110,26 @@ module Wiki private + def record_description! revision, body + tag = @page.tag_name.tag + return unless tag && @page.tag_name.language_code == 'ja' + + tag.lock! + description = TagDescription.lock.find_by(tag_id: tag.id, language_code: 'ja') + version_no = description ? description.version_no + 1 : 1 + TagDescriptionVersion.create!( + id: revision.id, tag_id: tag.id, language_code: 'ja', version_no:, + event_type: description ? 'update' : 'create', body:, + created_by_user_id: @created_user.id, created_at: revision.created_at) + if description + description.update!(body:, version_no:, updated_by_user_id: @created_user.id) + else + TagDescription.create!( + id: @page.id, tag_id: tag.id, language_code: 'ja', version_no:, body:, + created_by_user_id: @created_user.id, updated_by_user_id: @created_user.id) + end + end + def split_lines(body) = body.split("\n") def upsert_lines! lines, line_shas diff --git a/backend/config/routes.rb b/backend/config/routes.rb index e44f5b5..e109460 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -35,7 +35,7 @@ Rails.application.routes.draw do resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do collection do - get :search, to: redirect('/') + get :search, action: :index get :changes scope :title do -- 2.53.0 From 593e867fdd8564402b97876e7328edb7a9406ce5 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 4 Oct 2026 16:18:33 +0900 Subject: [PATCH 04/11] q --- .../spec/requests/tag_descriptions_spec.rb | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 backend/spec/requests/tag_descriptions_spec.rb diff --git a/backend/spec/requests/tag_descriptions_spec.rb b/backend/spec/requests/tag_descriptions_spec.rb new file mode 100644 index 0000000..104ef56 --- /dev/null +++ b/backend/spec/requests/tag_descriptions_spec.rb @@ -0,0 +1,361 @@ +require 'rails_helper' + +RSpec.describe 'Tag description native API', type: :request do + let!(:author) { create_member_user! } + let(:editor) { create(:user, :member, name: 'description editor') } + let(:tag_name) { create(:tag_name, name: 'native_description') } + let(:tag) { create(:tag, primary_tag_name: tag_name) } + let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } } + let(:endpoint) { "/tags/#{ tag.id }/description" } + + def stored_state + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion, + WikiLine, WikiRevisionLine, TagName, TagVersion].map do |model| + model.order(:id).map(&:attributes) + end + end + + 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 + + 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 + + def native_get path, action: 'show' + get path + # A missing route must not make the resource-not-found examples pass. + expect(request.path_parameters).to include(controller: 'tag_descriptions', action:) + end + + def native_write method, body:, request_headers: headers + public_send(method, endpoint, params: { body: }, headers: request_headers) + action = method == :post ? 'create' : 'update' + expect(request.path_parameters).to include(controller: 'tag_descriptions', action:) + end + + def expect_current description + expect(json).to include( + 'tag_id' => description.tag_id, + 'language_code' => 'ja', 'body' => description.body, + 'version_no' => description.version_no, + 'created_by_user_id' => description.created_by_user_id, + 'updated_by_user_id' => description.updated_by_user_id, + 'created_at' => description.created_at.iso8601(3), + 'updated_at' => description.updated_at.iso8601(3)) + expect(json.keys).not_to include('title', 'name', 'wiki_page', 'tag_name_id') + end + + describe 'routing', type: :routing do + it 'routes GET by Tag ID to show' do + expect(get: '/tags/123/description').to route_to( + controller: 'tag_descriptions', action: 'show', id: '123') + end + + it 'routes GET by TagName to show_by_title' do + expect(get: '/tags/name/native_description/description').to route_to( + controller: 'tag_descriptions', action: 'show_by_title', name: 'native_description') + end + + it 'routes POST by Tag ID to create' do + expect(post: '/tags/123/description').to route_to( + controller: 'tag_descriptions', action: 'create', id: '123') + end + + it 'routes PUT by Tag ID to update' do + expect(put: '/tags/123/description').to route_to( + controller: 'tag_descriptions', action: 'update', id: '123') + end + end + + describe 'native reads' do + let!(:description) do + TagDescription.create!( + id: tag.id + 1_000_000, tag:, language_code: 'ja', body: 'native current', + version_no: 1, created_by_user_id: author.id, updated_by_user_id: editor.id) + end + let!(:version) do + TagDescriptionVersion.create!( + tag_id: tag.id, language_code: 'ja', body: 'native version', version_no: 1, + event_type: 'create', created_by_user_id: author.id, created_at: Time.current) + end + let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" } + + it 'reads the current resource by Tag ID without a WikiPage' do + expect(description.id).not_to eq(tag.id) + expect(WikiPage.exists?(id: description.id)).to be(false) + without_legacy_reads { native_get(endpoint) } + expect(response).to have_http_status(:ok) + expect_current(description) + end + + it 'does not interpret a compatibility handle as a Tag ID' do + other_tag = create(:tag, id: description.id, primary_name: 'handle_collision') + other = TagDescription.create!( + tag: other_tag, language_code: 'ja', body: 'other current', version_no: 1, + created_by_user_id: author.id, updated_by_user_id: author.id) + without_legacy_reads { native_get("/tags/#{ other_tag.id }/description") } + expect(response).to have_http_status(:ok) + expect_current(other) + end + + context 'with another language for the same Tag' do + before do + # Seed only the reference row; V1 does not enable an English locale. + Language.connection.execute(<<~SQL) + INSERT IGNORE INTO languages (code, name, created_at) + VALUES ('en', 'English', CURRENT_TIMESTAMP) + SQL + end + + it 'selects the Japanese resource' do + TagDescription.create!( + tag:, language_code: 'en', body: 'English current', version_no: 4, + created_by_user_id: author.id, updated_by_user_id: author.id) + without_legacy_reads { native_get(endpoint) } + expect(response).to have_http_status(:ok) + expect_current(description) + end + + it 'returns 404 if only the English description exists' do + description.update!(language_code: 'en') + without_legacy_reads { native_get(endpoint) } + expect(response).to have_http_status(:not_found) + end + end + + it 'resolves the primary Japanese TagName without a WikiPage' do + without_legacy_reads { native_get(name_endpoint, action: 'show_by_title') } + expect(response).to have_http_status(:ok) + expect_current(description) + end + + it 'resolves an alias through the existing TagName canonicalisation semantics' do + alias_name = create(:tag_name, :alias, tag:, name: 'native_alias') + expect(TagName.canonicalise(Locale.nipponese, [alias_name.name])).to eq([tag_name.name]) + without_legacy_reads do + native_get("/tags/name/#{ alias_name.name }/description", action: 'show_by_title') + end + expect(response).to have_http_status(:ok) + expect_current(description) + end + + [:id, :name].each do |lookup| + it "ignores divergent legacy body and history for #{ lookup } lookup" do + page = WikiPage.create!( + id: description.id, tag_name:, body: 'old current', + created_user: author, updated_user: author) + revision = WikiRevision.create!( + wiki_page: page, created_user: author, kind: :content, + lines_count: 1, tree_sha256: Digest::SHA256.hexdigest('old revision')) + line = WikiLine.create!(body: 'old revision', sha256: revision.tree_sha256) + WikiRevisionLine.create!(wiki_revision: revision, wiki_line: line, position: 0) + WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: author) + path = lookup == :id ? endpoint : name_endpoint + action = lookup == :id ? 'show' : 'show_by_title' + + without_legacy_reads { native_get(path, action:) } + expect(response).to have_http_status(:ok) + expect_current(description) + original = json + page.update_columns(body: 'changed old current') + line.update_columns(body: 'changed old revision') + revision.update_columns(message: 'changed old history', created_at: 1.day.ago) + without_legacy_reads { native_get(path, action:) } + expect(response).to have_http_status(:ok) + expect(json).to eq(original) + end + end + end + + describe 'missing native resources' do + it 'returns 404 for a Tag without a description' do + without_legacy_reads { native_get(endpoint) } + expect(response).to have_http_status(:not_found) + end + + it 'returns 404 for a primary name without a description' do + tag + without_legacy_reads do + native_get("/tags/name/#{ tag_name.name }/description", action: 'show_by_title') + end + expect(response).to have_http_status(:not_found) + end + + it 'returns 404 for a missing Tag' do + missing_id = Tag.maximum(:id).to_i + 1 + without_legacy_reads { native_get("/tags/#{ missing_id }/description") } + expect(response).to have_http_status(:not_found) + end + end + + describe 'POST /tags/:id/description' do + before { tag } + + it 'creates both current resources and histories with matching compatibility IDs' do + expect { native_write(:post, body: 'created natively') } + .to change(TagDescription, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + .and change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + expect(response).to have_http_status(:created) + description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') + page = WikiPage.find(description.id) + revision = page.current_revision + version = TagDescriptionVersion.find(revision.id) + expect(description).to have_attributes( + body: 'created natively', version_no: 1, + created_by_user_id: editor.id, updated_by_user_id: editor.id) + expect(version).to have_attributes( + tag_id: tag.id, language_code: 'ja', body: description.body, + version_no: 1, event_type: 'create', created_by_user_id: editor.id) + expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body) + expect(revision.body).to eq(version.body) + expect(page.wiki_versions.last.body).to eq(version.body) + expect_current(description) + + get "/wiki/#{ description.id }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => description.body, 'revision_id' => version.id) + end + + it 'returns the existing validation error format on duplicate creation' do + existing = TagDescription.create!( + tag:, language_code: 'ja', body: 'existing', version_no: 1, + created_by_user_id: author.id, updated_by_user_id: author.id) + before_state = stored_state + native_write(:post, body: 'duplicate') + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include('type' => 'validation_error', 'errors' => a_kind_of(Hash), + 'base_errors' => a_kind_of(Array), 'message' => a_kind_of(String)) + expect(stored_state).to eq(before_state) + expect(existing.reload.body).to eq('existing') + end + + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + it "rolls back native create when #{ model.name } persistence fails" do + headers + before_state = stored_state + aggregate_failures do + fail_save(model) { native_write(:post, body: 'rollback create') } + expect(stored_state).to eq(before_state) + end + end + end + end + + describe 'PUT /tags/:id/description' do + let!(:page) do + tag + Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author) + end + let!(:second_revision) do + Wiki::Commit.content!(page:, body: 'before', created_user: author) + end + let!(:description) do + TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') + end + + ['after', 'before'].each do |body| + it "appends matching revisions for body '#{ body }'" do + history = TagDescriptionVersion.where(tag_id: tag.id).order(:version_no).map(&:attributes) + expect(page.reload.version_no).to eq(1) + expect(description.version_no).to eq(2) + expect { native_write(:put, body:) } + .to change(WikiRevision, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + expect(response).to have_http_status(:ok) + expect(description.reload).to have_attributes( + body:, version_no: 3, created_by_user_id: author.id, updated_by_user_id: editor.id) + expect(page.reload.body).to eq(body) + revision = page.current_revision + version = TagDescriptionVersion.find(revision.id) + expect(version).to have_attributes( + tag_id: tag.id, language_code: 'ja', version_no: 3, event_type: 'update', + body:, created_by_user_id: editor.id) + expect(revision.body).to eq(body) + expect(page.wiki_versions.order(:version_no).last.body).to eq(body) + expect(TagDescriptionVersion.where(id: history.pluck('id')).order(:version_no) + .map(&:attributes)).to eq(history) + + get "/wiki/#{ description.id }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => body, 'revision_id' => version.id) + end + end + + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + it "rolls back native update when #{ model.name } persistence fails" do + headers + before_state = stored_state + aggregate_failures do + fail_save(model) { native_write(:put, body: 'rollback update') } + expect(stored_state).to eq(before_state) + end + end + end + end + + describe 'write access and validation' do + [:post, :put].each do |method| + context "with #{ method.upcase }" do + before do + tag + if method == :put + Wiki::Commit.create_content!(tag_name:, body: 'existing', created_by_user: author) + end + end + + it 'requires authentication without changing either system' do + before_state = stored_state + native_write(method, body: 'unauthorised', request_headers: { }) + expect(response).to have_http_status(:unauthorized) + expect(stored_state).to eq(before_state) + end + + it 'rejects a guest without changing either system' do + guest = create(:user, :guest) + before_state = stored_state + native_write(method, body: 'forbidden', + request_headers: { 'X-Transfer-Code' => guest.inheritance_code }) + expect(response).to have_http_status(:forbidden) + expect(stored_state).to eq(before_state) + end + + it 'rejects a banned member without changing either system' do + banned = create(:user, :member, :banned) + before_state = stored_state + native_write(method, body: 'forbidden', + request_headers: { 'X-Transfer-Code' => banned.inheritance_code }) + expect(response).to have_http_status(:forbidden) + expect(stored_state).to eq(before_state) + end + + it 'rejects blank body using the existing validation error format' do + headers + before_state = stored_state + native_write(method, body: '') + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include('type' => 'validation_error', + 'errors' => include('body' => a_kind_of(Array))) + expect(stored_state).to eq(before_state) + end + end + end + end +end -- 2.53.0 From 6f2f56b7b4d34d5f5278880c4feb618012458c35 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 4 Oct 2026 17:23:44 +0900 Subject: [PATCH 05/11] #422 --- .../tag_descriptions_controller.rb | 74 +++++++++++++++++++ .../app/services/tag_description_operation.rb | 51 +++++++++++++ backend/app/services/wiki/commit.rb | 45 +++++------ backend/config/routes.rb | 8 ++ 4 files changed, 156 insertions(+), 22 deletions(-) create mode 100644 backend/app/services/tag_description_operation.rb diff --git a/backend/app/controllers/tag_descriptions_controller.rb b/backend/app/controllers/tag_descriptions_controller.rb index e69de29..de2685d 100644 --- a/backend/app/controllers/tag_descriptions_controller.rb +++ b/backend/app/controllers/tag_descriptions_controller.rb @@ -0,0 +1,74 @@ +class TagDescriptionsController < ApplicationController + def show + description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja') + return head :not_found unless description + + render json: representation(description) + end + + def show_by_title + tag_name = TagName.find_by(language_code: 'ja', name: params[:name].to_s.strip) + description = tag_name&.tag && TagDescription.find_by(tag: tag_name.tag, language_code: 'ja') + return head :not_found unless description + + render json: representation(description) + end + + def create + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + body = params[:body].to_s + return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? + + tag = Tag.find_by(id: params[:id]) + return head :not_found unless tag + + if TagDescription.exists?(tag:, language_code: 'ja') + return render_unprocessable_entity('説明はすでに存在します.') + end + + TagDescriptionOperation.create!(tag:, body:, created_by_user: current_user) + description = TagDescription.find_by!(tag:, language_code: 'ja') + + render json: representation(description), status: :created + rescue ActiveRecord::RecordInvalid => e + render_validation_error e.record + rescue ActiveRecord::RecordNotUnique + render_record_not_unique + end + + def update + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + body = params[:body].to_s + return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? + + description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja') + return head :not_found unless description + + page = WikiPage.find(description.id) + TagDescriptionOperation.update!( + description:, page:, body:, updated_by_user: current_user) + description.reload + + render json: representation(description) + rescue ActiveRecord::RecordInvalid => e + render_validation_error e.record + end + + private + + def representation description + description.as_json(only: [ + :tag_id, + :language_code, + :body, + :version_no, + :created_by_user_id, + :updated_by_user_id, + :created_at, + :updated_at]) + end +end diff --git a/backend/app/services/tag_description_operation.rb b/backend/app/services/tag_description_operation.rb new file mode 100644 index 0000000..342cefb --- /dev/null +++ b/backend/app/services/tag_description_operation.rb @@ -0,0 +1,51 @@ +class TagDescriptionOperation + LANGUAGE_CODE = 'ja' + + def self.create!(tag:, body:, created_by_user:, message: nil) + ApplicationRecord.transaction do + tag.lock! + raise ActiveRecord::RecordNotUnique if TagDescription.exists?( + tag_id: tag.id, language_code: LANGUAGE_CODE) + + page = Wiki::Commit.create_legacy_content!( + tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:) + revision = page.wiki_revisions.order(id: :desc).first + record_version!(tag:, revision:, body:, user: created_by_user, + description: nil) + page + end + end + + def self.update!(description:, page:, body:, updated_by_user:, message: nil, + base_revision_id: nil) + ApplicationRecord.transaction do + description&.lock! + page.lock! + revision = Wiki::Commit.legacy_content!( + page:, body:, created_user: updated_by_user, message:, + base_revision_id:) + record_version!(tag: page.tag_name.tag, revision:, body: revision.body, + user: updated_by_user, description:) + description || TagDescription.find_by!( + tag_id: page.tag_name.tag_id, language_code: LANGUAGE_CODE) + end + end + + def self.record_version! tag:, revision:, body:, user:, description: + version_no = description ? description.version_no + 1 : 1 + TagDescriptionVersion.create!( + id: revision.id, tag_id: tag.id, language_code: LANGUAGE_CODE, + version_no:, event_type: description ? 'update' : 'create', body:, + created_by_user_id: user.id, created_at: revision.created_at) + if description + description.update!(body:, version_no:, updated_by_user_id: user.id) + else + TagDescription.create!( + id: revision.wiki_page_id, tag_id: tag.id, + language_code: LANGUAGE_CODE, version_no:, body:, + created_by_user_id: user.id, updated_by_user_id: user.id) + end + end + + private_class_method :record_version! +end diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb index 14afa7b..80913f1 100644 --- a/backend/app/services/wiki/commit.rb +++ b/backend/app/services/wiki/commit.rb @@ -8,6 +8,15 @@ module Wiki end def self.create_content! tag_name:, body:, created_by_user:, message: nil + if tag_name.tag && tag_name.language_code == 'ja' + return TagDescriptionOperation.create!( + tag: tag_name.tag, body:, created_by_user:, message:) + end + + create_legacy_content!(tag_name:, body:, created_by_user:, message:) + end + + def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil normalised = normalise_body(body) page = WikiPage.new(tag_name:, @@ -33,6 +42,20 @@ module Wiki end def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil + tag = page.tag_name.tag + description = tag && page.tag_name.language_code == 'ja' && + TagDescription.find_by(tag_id: tag.id, language_code: 'ja') + if tag && page.tag_name.language_code == 'ja' + return TagDescriptionOperation.update!( + description:, page:, body:, updated_by_user: created_user, + message:, base_revision_id:) + end + + legacy_content!(page:, body:, created_user:, message:, base_revision_id:) + end + + def self.legacy_content! page:, body:, created_user:, message: nil, + base_revision_id: nil new(page:, created_user:).content!(body:, message:, base_revision_id:) end @@ -93,8 +116,6 @@ module Wiki { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } end WikiRevisionLine.insert_all!(rows) if rows.any? - record_description!(rev, normalised) - rev end end @@ -110,26 +131,6 @@ module Wiki private - def record_description! revision, body - tag = @page.tag_name.tag - return unless tag && @page.tag_name.language_code == 'ja' - - tag.lock! - description = TagDescription.lock.find_by(tag_id: tag.id, language_code: 'ja') - version_no = description ? description.version_no + 1 : 1 - TagDescriptionVersion.create!( - id: revision.id, tag_id: tag.id, language_code: 'ja', version_no:, - event_type: description ? 'update' : 'create', body:, - created_by_user_id: @created_user.id, created_at: revision.created_at) - if description - description.update!(body:, version_no:, updated_by_user_id: @created_user.id) - else - TagDescription.create!( - id: @page.id, tag_id: tag.id, language_code: 'ja', version_no:, body:, - created_by_user_id: @created_user.id, updated_by_user_id: @created_user.id) - end - end - def split_lines(body) = body.split("\n") def upsert_lines! lines, line_shas diff --git a/backend/config/routes.rb b/backend/config/routes.rb index e109460..c6664bc 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,4 +1,12 @@ Rails.application.routes.draw do + get 'tags/name/:name/description', to: 'tag_descriptions#show_by_title' + + scope 'tags/:id/description', controller: :tag_descriptions do + get '', action: :show + post '', action: :create + put '', action: :update + end + resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] scope 'tags/:parent_id/children', controller: :tag_children do -- 2.53.0 From d5b5fc16461a4606aca933edf3dc76286044878c Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 4 Oct 2026 17:57:46 +0900 Subject: [PATCH 06/11] #422 --- .../tag_description_wiki_write_spec.rb | 129 ++++++++++++------ .../spec/requests/tag_descriptions_spec.rb | 63 +++++---- 2 files changed, 124 insertions(+), 68 deletions(-) diff --git a/backend/spec/requests/tag_description_wiki_write_spec.rb b/backend/spec/requests/tag_description_wiki_write_spec.rb index 6d83347..0b86adb 100644 --- a/backend/spec/requests/tag_description_wiki_write_spec.rb +++ b/backend/spec/requests/tag_description_wiki_write_spec.rb @@ -70,27 +70,12 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do 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) + TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') 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 + def update_description body: 'after', title: tag_name.name, base: first_revision.id put "/wiki/#{ page.id }", params: { title:, body:, message: 'edit', base_revision_id: base }, headers: headers @@ -98,7 +83,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do 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(description.version_no).to eq(1) expect { update_description } .to change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(1) @@ -107,56 +92,116 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do revision = page.reload.current_revision version = TagDescriptionVersion.find(revision.id) - expect(description.reload).to have_attributes(body: 'after', version_no: 3) + expect(description.reload).to have_attributes(body: 'after', version_no: 2) expect(version).to have_attributes( - body: 'after', version_no: 3, event_type: 'update', created_by_user_id: user.id) + body: 'after', version_no: 2, 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) + it 'does not add body history for a title-only update' do + revision_count = WikiRevision.count + wiki_version_count = WikiVersion.count + description_version_count = TagDescriptionVersion.count + wiki_version_no = page.reload.version_no + description_version_no = description.version_no + current_revision_id = page.current_revision.id + page_state = page.attributes + description_state = description.attributes + + update_description(body: 'before', title: 'description_renamed') + 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) + expect(WikiRevision.count).to eq(revision_count) + expect(WikiVersion.count).to eq(wiki_version_count) + expect(TagDescriptionVersion.count).to eq(description_version_count) + expect(page.reload).to have_attributes(body: 'before', version_no: wiki_version_no) + expect(page.current_revision.id).to eq(current_revision_id) + expect(page.reload.attributes).to eq(page_state) + expect(description.reload).to have_attributes( + body: 'before', version_no: description_version_no) + expect(description.reload.attributes).to eq(description_state) 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) + it 'does not add body history for a TagName rename through tags' do + revision_count = WikiRevision.count + wiki_version_count = WikiVersion.count + description_version_count = TagDescriptionVersion.count + page_version_no = page.reload.version_no + current_revision_id = page.current_revision.id + + patch "/tags/#{ tag.id }", + params: { name: 'description_tag_renamed' }, + headers: + 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) + expect(WikiRevision.count).to eq(revision_count) + expect(WikiVersion.count).to eq(wiki_version_count) + expect(TagDescriptionVersion.count).to eq(description_version_count) + expect(page.reload.version_no).to eq(page_version_no) + expect(page.current_revision.id).to eq(current_revision_id) + expect(description.reload).to have_attributes( + body: 'before', version_no: 1) end it 'keeps both systems and the title unchanged on a stale revision conflict' do + update_description(body: 'current body', base: first_revision.id) + expect(response).to have_http_status(:ok) + + stale_revision_id = first_revision.id before_state = stored_state - update_description(title: 'conflicting_rename', base: first_revision.id) + + update_description( + body: 'conflicting body', + title: 'conflicting_rename', + base: stale_revision_id) + expect(response).to have_http_status(:conflict) expect(stored_state).to eq(before_state) + expect(tag_name.reload.name).not_to eq('conflicting_rename') 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') } + fail_save(model) do + update_description(body: 'rollback body', title: 'rollback_rename') + end expect(stored_state).to eq(before_state) end end end + + [['identical body', 'before'], + ['normalised-equivalent body', "before\n\n"]].each do |label, body| + it "does not write histories for #{ label }" do + before_state = stored_state + page_state = page.reload.attributes + description_state = description.reload.attributes + current_revision_id = page.current_revision.id + wiki_lines = WikiLine.order(:id).map(&:attributes) + revision_lines = WikiRevisionLine.order(:id).map(&:attributes) + page_version_no = page.version_no + description_version_no = description.version_no + + update_description(body:) + + expect(response).to have_http_status(:ok) + expect(WikiRevision.count).to eq(before_state[1].length) + expect(WikiVersion.count).to eq(before_state[2].length) + expect(TagDescriptionVersion.count).to eq(before_state[4].length) + expect(page.reload.version_no).to eq(page_version_no) + expect(description.reload.version_no).to eq(description_version_no) + expect(page.current_revision.id).to eq(current_revision_id) + expect(page.reload.attributes).to eq(page_state) + expect(description.reload.attributes).to eq(description_state) + expect(WikiLine.order(:id).map(&:attributes)).to eq(wiki_lines) + expect(WikiRevisionLine.order(:id).map(&:attributes)).to eq(revision_lines) + end + end end end diff --git a/backend/spec/requests/tag_descriptions_spec.rb b/backend/spec/requests/tag_descriptions_spec.rb index 104ef56..88d1a83 100644 --- a/backend/spec/requests/tag_descriptions_spec.rb +++ b/backend/spec/requests/tag_descriptions_spec.rb @@ -264,38 +264,49 @@ RSpec.describe 'Tag description native API', type: :request do tag Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author) end - let!(:second_revision) do - Wiki::Commit.content!(page:, body: 'before', created_user: author) - end let!(:description) do TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') end - ['after', 'before'].each do |body| - it "appends matching revisions for body '#{ body }'" do - history = TagDescriptionVersion.where(tag_id: tag.id).order(:version_no).map(&:attributes) - expect(page.reload.version_no).to eq(1) - expect(description.version_no).to eq(2) - expect { native_write(:put, body:) } - .to change(WikiRevision, :count).by(1) - .and change(TagDescriptionVersion, :count).by(1) - expect(response).to have_http_status(:ok) - expect(description.reload).to have_attributes( - body:, version_no: 3, created_by_user_id: author.id, updated_by_user_id: editor.id) - expect(page.reload.body).to eq(body) - revision = page.current_revision - version = TagDescriptionVersion.find(revision.id) - expect(version).to have_attributes( - tag_id: tag.id, language_code: 'ja', version_no: 3, event_type: 'update', - body:, created_by_user_id: editor.id) - expect(revision.body).to eq(body) - expect(page.wiki_versions.order(:version_no).last.body).to eq(body) - expect(TagDescriptionVersion.where(id: history.pluck('id')).order(:version_no) - .map(&:attributes)).to eq(history) + it 'writes matching histories when the body changes' do + expect(page.reload.version_no).to eq(1) + expect(description.version_no).to eq(1) + expect { native_write(:put, body: 'after') } + .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) + expect(description.reload).to have_attributes( + body: 'after', version_no: 2, + created_by_user_id: author.id, updated_by_user_id: editor.id) + expect(page.reload).to have_attributes(body: 'after', version_no: 2) + + revision = page.current_revision + version = TagDescriptionVersion.find(revision.id) + expect(version).to have_attributes( + tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update', + body: 'after', created_by_user_id: editor.id) + expect(revision.body).to eq(version.body) + expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body) + get "/wiki/#{ description.id }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'after', 'revision_id' => version.id) + end + + ['before', "before\n\n"].each do |body| + it 'does not write histories when the normalised body is unchanged' do + before_state = stored_state + page_state = page.reload.attributes + description_state = description.reload.attributes + current_revision_id = page.current_revision.id + + native_write(:put, body:) - get "/wiki/#{ description.id }" expect(response).to have_http_status(:ok) - expect(json).to include('body' => body, 'revision_id' => version.id) + expect(stored_state).to eq(before_state) + expect(page.reload.attributes).to eq(page_state) + expect(description.reload.attributes).to eq(description_state) + expect(page.current_revision.id).to eq(current_revision_id) end end -- 2.53.0 From 6cd1fd43ee5fb4949b03090963365c9da4ac715b Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 4 Oct 2026 17:57:58 +0900 Subject: [PATCH 07/11] #422 --- .../tag_descriptions_controller.rb | 3 +- .../app/services/tag_description_operation.rb | 21 +++++++----- backend/app/services/wiki/commit.rb | 4 +-- .../app/services/wiki/compatibility_writer.rb | 33 +++++++++++++++++++ 4 files changed, 48 insertions(+), 13 deletions(-) create mode 100644 backend/app/services/wiki/compatibility_writer.rb diff --git a/backend/app/controllers/tag_descriptions_controller.rb b/backend/app/controllers/tag_descriptions_controller.rb index de2685d..52859ac 100644 --- a/backend/app/controllers/tag_descriptions_controller.rb +++ b/backend/app/controllers/tag_descriptions_controller.rb @@ -48,9 +48,8 @@ class TagDescriptionsController < ApplicationController description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja') return head :not_found unless description - page = WikiPage.find(description.id) TagDescriptionOperation.update!( - description:, page:, body:, updated_by_user: current_user) + tag: description.tag, body:, updated_by_user: current_user) description.reload render json: representation(description) diff --git a/backend/app/services/tag_description_operation.rb b/backend/app/services/tag_description_operation.rb index 342cefb..c6568de 100644 --- a/backend/app/services/tag_description_operation.rb +++ b/backend/app/services/tag_description_operation.rb @@ -7,7 +7,7 @@ class TagDescriptionOperation raise ActiveRecord::RecordNotUnique if TagDescription.exists?( tag_id: tag.id, language_code: LANGUAGE_CODE) - page = Wiki::Commit.create_legacy_content!( + page = Wiki::CompatibilityWriter.create!( tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:) revision = page.wiki_revisions.order(id: :desc).first record_version!(tag:, revision:, body:, user: created_by_user, @@ -16,18 +16,23 @@ class TagDescriptionOperation end end - def self.update!(description:, page:, body:, updated_by_user:, message: nil, + def self.update!(tag:, body:, updated_by_user:, message: nil, base_revision_id: nil) ApplicationRecord.transaction do + tag.lock! + description = TagDescription.lock.find_by( + tag_id: tag.id, language_code: LANGUAGE_CODE) description&.lock! - page.lock! - revision = Wiki::Commit.legacy_content!( - page:, body:, created_user: updated_by_user, message:, + tag_name = tag.tag_name(LANGUAGE_CODE) + page = WikiPage.lock.find_by!(tag_name:) + revision = Wiki::CompatibilityWriter.update!( + page:, body:, updated_by_user:, message:, base_revision_id:) - record_version!(tag: page.tag_name.tag, revision:, body: revision.body, + next unless revision + + record_version!(tag:, revision:, body: revision.body, user: updated_by_user, description:) - description || TagDescription.find_by!( - tag_id: page.tag_name.tag_id, language_code: LANGUAGE_CODE) + revision end end diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb index 80913f1..e1c37a5 100644 --- a/backend/app/services/wiki/commit.rb +++ b/backend/app/services/wiki/commit.rb @@ -43,11 +43,9 @@ module Wiki def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil tag = page.tag_name.tag - description = tag && page.tag_name.language_code == 'ja' && - TagDescription.find_by(tag_id: tag.id, language_code: 'ja') if tag && page.tag_name.language_code == 'ja' return TagDescriptionOperation.update!( - description:, page:, body:, updated_by_user: created_user, + tag:, body:, updated_by_user: created_user, message:, base_revision_id:) end diff --git a/backend/app/services/wiki/compatibility_writer.rb b/backend/app/services/wiki/compatibility_writer.rb new file mode 100644 index 0000000..2bb003c --- /dev/null +++ b/backend/app/services/wiki/compatibility_writer.rb @@ -0,0 +1,33 @@ +module Wiki + class CompatibilityWriter + def self.create! tag_name:, body:, created_by_user:, message: nil + Commit.create_legacy_content!( + tag_name:, body:, created_by_user:, message:) + end + + def self.update! page:, body:, updated_by_user:, message: nil, + base_revision_id: nil + normalised = Commit.normalise_body(body) + if normalised.blank? + return Commit.legacy_content!( + page:, body: normalised, created_user: updated_by_user, message:, + base_revision_id:) + end + + if base_revision_id.present? + current_id = page.wiki_revisions.maximum(:id) + if current_id && current_id != base_revision_id.to_i + raise Commit::Conflict, + "競合が発生してゐます" + + "(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." + end + end + + return if Commit.normalise_body(page.body) == normalised + + Commit.legacy_content!( + page:, body: normalised, created_user: updated_by_user, message:, + base_revision_id:) + end + end +end -- 2.53.0 From 9c4d3285f392dc404eef0f5d809c269c3f5d4256 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 4 Oct 2026 18:11:34 +0900 Subject: [PATCH 08/11] #422 --- .../spec/requests/tag_description_wiki_spec.rb | 2 +- .../requests/tag_description_wiki_write_spec.rb | 3 ++- backend/spec/requests/wiki_spec.rb | 16 ++++++++-------- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/backend/spec/requests/tag_description_wiki_spec.rb b/backend/spec/requests/tag_description_wiki_spec.rb index 3b9b192..90e2bbb 100644 --- a/backend/spec/requests/tag_description_wiki_spec.rb +++ b/backend/spec/requests/tag_description_wiki_spec.rb @@ -59,7 +59,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do end expect(response).to have_http_status(:ok) expect(json).to include( - 'body' => index == 2 ? 'new' : 'A', + 'body' => versions[index].body, 'revision_id' => versions[index].id, 'pred' => index.zero? ? nil : versions[index - 1].id, 'succ' => index == 2 ? nil : versions[index + 1].id) diff --git a/backend/spec/requests/tag_description_wiki_write_spec.rb b/backend/spec/requests/tag_description_wiki_write_spec.rb index 0b86adb..5eb7323 100644 --- a/backend/spec/requests/tag_description_wiki_write_spec.rb +++ b/backend/spec/requests/tag_description_wiki_write_spec.rb @@ -66,6 +66,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do describe 'PUT /wiki/:id' do let!(:page) do + tag Wiki::Commit.create_content!( tag_name:, body: 'before', created_by_user: user) end @@ -134,7 +135,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do patch "/tags/#{ tag.id }", params: { name: 'description_tag_renamed' }, - headers: + headers: headers expect(response).to have_http_status(:ok) expect(tag_name.reload.name).to eq('description_tag_renamed') diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index e1d31b1..bbd1c22 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -11,6 +11,12 @@ RSpec.describe 'Wiki API', type: :request do let!(:user) { create_member_user! } let!(:tn) { create(:tag_name, name: 'spec_wiki_title') } + let!(:tag) do + create(:tag, + primary_tag_name: tn, + category: :general, + deprecated_at: Time.zone.local(2026, 6, 1)) + end let!(:page) do Wiki::Commit.create_content!( tag_name: tn, @@ -18,12 +24,6 @@ RSpec.describe 'Wiki API', type: :request do created_by_user: user, message: 'init') end - let!(:tag) do - create(:tag, - primary_tag_name: tn, - category: :general, - deprecated_at: Time.zone.local(2026, 6, 1)) - end describe 'GET /wiki' do it 'returns wiki pages with title' do @@ -413,10 +413,10 @@ RSpec.describe 'Wiki API', type: :request do top = json.first expect(top).to include( 'revision_id' => rev2.id, - 'pred' => rev2.base_revision_id, + 'pred' => rev1.id, 'succ' => nil, 'kind' => 'content', - 'message' => 'r2' + 'message' => nil ) expect(top['wiki_page']).to include( 'id' => page.id, -- 2.53.0 From d63e0b95fa1082be24a22e042ceccccda6f38881 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 4 Oct 2026 23:06:14 +0900 Subject: [PATCH 09/11] #422 --- .../tag_descriptions_controller.rb | 2 +- backend/app/controllers/tags_controller.rb | 23 +----- .../app/controllers/wiki_pages_controller.rb | 24 ++++--- .../app/services/tag_description_operation.rb | 6 +- backend/app/services/wiki/commit.rb | 6 +- backend/config/routes.rb | 12 ++-- .../tag_description_wiki_write_spec.rb | 70 ++++++++++++++++++- .../tag_wiki_history_integrity_spec.rb | 26 +++---- backend/spec/requests/tags_spec.rb | 57 ++++++--------- .../requests/wiki_history_integrity_spec.rb | 18 ++--- backend/spec/requests/wiki_spec.rb | 13 ++-- .../requests/wiki_title_collision_spec.rb | 4 +- .../requests/wiki_unmigrated_contract_spec.rb | 19 +++++ 13 files changed, 165 insertions(+), 115 deletions(-) diff --git a/backend/app/controllers/tag_descriptions_controller.rb b/backend/app/controllers/tag_descriptions_controller.rb index 52859ac..726123f 100644 --- a/backend/app/controllers/tag_descriptions_controller.rb +++ b/backend/app/controllers/tag_descriptions_controller.rb @@ -46,7 +46,7 @@ class TagDescriptionsController < ApplicationController return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja') - return head :not_found unless description + return create unless description TagDescriptionOperation.update!( tag: description.tag, body:, updated_by_user: current_user) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 9be0365..7919a24 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -464,7 +464,6 @@ class TagsController < ApplicationController old_name = tag.name(locale.language_code) name_changed = name != old_name - wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed if tag.deprecated? == deprecated tag.update!(category:) @@ -485,9 +484,7 @@ class TagsController < ApplicationController tag, event_type: :update, created_by_user: current_user, - language_code: locale.language_code, - name_changed:, - wiki_page:) + language_code: locale.language_code) end render json: TagRepr.base(tag.reload, language_code: locale.language_code) @@ -518,7 +515,6 @@ class TagsController < ApplicationController old_name = tag.name(locale.language_code) name_changed = name.present? && name != old_name - wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed rename_tag_name!(locale, tag, name) if name_changed tag.update!(category:) if category.present? @@ -532,9 +528,7 @@ class TagsController < ApplicationController tag, event_type: :update, created_by_user: current_user, - language_code: locale.language_code, - name_changed:, - wiki_page:) + language_code: locale.language_code) end render json: TagRepr.base(tag.reload, language_code: locale.language_code) @@ -722,19 +716,8 @@ class TagsController < ApplicationController language_code:)) end - def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja', - name_changed: false, wiki_page: nil + def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja' TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:) - - return unless name_changed - - wiki_page ||= tag.tag_name('ja')&.wiki_page - return unless wiki_page&.wiki_versions&.exists? - - WikiVersionRecorder.record!( - page: wiki_page, - event_type: :update, - created_by_user:) end def validate_tag_rename locale, tag, name diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 3973e48..60b7407 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -118,15 +118,23 @@ class WikiPagesController < ApplicationController return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? - page = ApplicationRecord.transaction do - tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| - name.script_code = 'Jpan' - name.primary_flg = true - name.auto_generated = false - end + page = if title.include?(':') + ApplicationRecord.transaction do + tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| + name.script_code = 'Jpan' + name.primary_flg = true + name.auto_generated = false + end - Wiki::Commit.create_content!( - tag_name:, body:, created_by_user: current_user, message:) + Wiki::Commit.create_legacy_content!( + tag_name:, body:, created_by_user: current_user, message:) + end + else + tag_name = TagName.find_by(language_code: 'ja', name: title) + return render_unprocessable_entity field: :title unless tag_name&.tag + + TagDescriptionOperation.create!( + tag: tag_name.tag, body:, created_by_user: current_user, message:) end render json: WikiPageRepr.base(page), status: :created diff --git a/backend/app/services/tag_description_operation.rb b/backend/app/services/tag_description_operation.rb index c6568de..f3d9ebc 100644 --- a/backend/app/services/tag_description_operation.rb +++ b/backend/app/services/tag_description_operation.rb @@ -20,11 +20,9 @@ class TagDescriptionOperation base_revision_id: nil) ApplicationRecord.transaction do tag.lock! - description = TagDescription.lock.find_by( + description = TagDescription.lock.find_by!( tag_id: tag.id, language_code: LANGUAGE_CODE) - description&.lock! - tag_name = tag.tag_name(LANGUAGE_CODE) - page = WikiPage.lock.find_by!(tag_name:) + page = WikiPage.lock.find(description.id) revision = Wiki::CompatibilityWriter.update!( page:, body:, updated_by_user:, message:, base_revision_id:) diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb index e1c37a5..1414307 100644 --- a/backend/app/services/wiki/commit.rb +++ b/backend/app/services/wiki/commit.rb @@ -42,10 +42,10 @@ module Wiki end def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil - tag = page.tag_name.tag - if tag && page.tag_name.language_code == 'ja' + description = TagDescription.find_by(id: page.id) + if description return TagDescriptionOperation.update!( - tag:, body:, updated_by_user: created_user, + tag: description.tag, body:, updated_by_user: created_user, message:, base_revision_id:) end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index c6664bc..7e3ab03 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,12 +1,4 @@ Rails.application.routes.draw do - get 'tags/name/:name/description', to: 'tag_descriptions#show_by_title' - - scope 'tags/:id/description', controller: :tag_descriptions do - get '', action: :show - post '', action: :create - put '', action: :update - end - resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] scope 'tags/:parent_id/children', controller: :tag_children do @@ -22,6 +14,7 @@ Rails.application.routes.draw do scope :name do get ':name/deerjikists', action: :deerjikists_by_name + get ':name/description', to: 'tag_descriptions#show_by_title' get ':name/materials', action: :materials_by_name get ':name', action: :show_by_name end @@ -33,6 +26,9 @@ Rails.application.routes.draw do get :deerjikists put :deerjikists, action: :update_deerjikists + + get :description, action: 'tag_descriptions#show' + put :description, action: 'tag_descriptions#update' 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 5eb7323..798cffc 100644 --- a/backend/spec/requests/tag_description_wiki_write_spec.rb +++ b/backend/spec/requests/tag_description_wiki_write_spec.rb @@ -52,6 +52,58 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do expect(page.wiki_versions.last.body).to eq(version.body) end + ['missing_description', 'orphan_description'].each do |title| + it "rejects the bare title #{ title } without writing any resource" do + create(:tag_name, name: title) if title == 'orphan_description' + before_state = stored_state + + post '/wiki', params: { title:, body: 'initial' }, headers: headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include('type' => 'validation_error') + expect(stored_state).to eq(before_state) + end + end + + it 'creates a namespaced legacy Wiki resource without a Tag' do + expect do + post '/wiki', params: { title: 'Help:description_write', body: 'legacy' }, + headers: headers + end.to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change(TagDescription, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) + + expect(response).to have_http_status(:created) + page = WikiPage.find(json.fetch('id')) + expect(page.tag_name.tag).to be_nil + expect(page.body).to eq('legacy') + expect(page.current_revision.body).to eq('legacy') + end + + it 'creates only legacy Wiki for a namespaced title with an existing Tag' do + namespaced_name = create(:tag_name, name: 'help:foo') + namespaced_tag = create(:tag, primary_tag_name: namespaced_name) + + expect do + post '/wiki', params: { title: namespaced_name.name, body: 'legacy' }, + headers: headers + end.to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change(TagName, :count).by(0) + .and change(TagDescription, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) + + expect(response).to have_http_status(:created) + page = WikiPage.find(json.fetch('id')) + expect(page).to have_attributes(tag_name_id: namespaced_name.id, body: 'legacy') + expect(page.tag_name.tag).to eq(namespaced_tag) + expect(page.current_revision.body).to eq('legacy') + expect(page.wiki_versions.last.body).to eq('legacy') + end + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| it "rolls back both systems when #{ model.name } persistence fails" do tag_name @@ -101,6 +153,20 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body) end + it 'uses description identity even when its Wiki TagName no longer owns the Tag' do + tag_name.update_columns(tag_id: nil) + + 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) + expect(description.reload).to have_attributes(body: 'after', version_no: 2) + expect(TagDescriptionVersion.find(page.reload.current_revision.id)) + .to have_attributes(tag_id: tag.id, body: 'after', version_no: 2) + end + it 'does not add body history for a title-only update' do revision_count = WikiRevision.count wiki_version_count = WikiVersion.count @@ -111,10 +177,10 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do page_state = page.attributes description_state = description.attributes - update_description(body: 'before', title: 'description_renamed') + update_description(body: 'before', title: 'Help:description_renamed') expect(response).to have_http_status(:ok) - expect(tag_name.reload.name).to eq('description_renamed') + expect(tag_name.reload.name).to eq('Help:description_renamed') expect(WikiRevision.count).to eq(revision_count) expect(WikiVersion.count).to eq(wiki_version_count) expect(TagDescriptionVersion.count).to eq(description_version_count) diff --git a/backend/spec/requests/tag_wiki_history_integrity_spec.rb b/backend/spec/requests/tag_wiki_history_integrity_spec.rb index a464c05..356e7e0 100644 --- a/backend/spec/requests/tag_wiki_history_integrity_spec.rb +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -25,9 +25,10 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do end describe 'PATCH /tags/:id' do - it 'records wiki_version when tag name changes and tag has wiki' do + it 'renames the current Wiki resource without adding body history' do tag = create_tag!(name: 'patch_tag_wiki_before') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') + wiki_history = wiki_page.wiki_versions.map(&:attributes) expect { patch "/tags/#{ tag.id }", params: { @@ -35,23 +36,17 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(1) + .and change(WikiVersion, :count).by(0) expect(response).to have_http_status(:ok) tag.reload wiki_page.reload - version = wiki_page.wiki_versions.order(:version_no).last expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after') expect(wiki_page.title).to eq('patch_tag_wiki_after') - expect(version).to have_attributes( - event_type: 'update', - title: 'patch_tag_wiki_after', - body: 'wiki body before', - created_by_user_id: member_user.id - ) + expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) end it 'does not record wiki_version when only category changes' do @@ -79,9 +74,10 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do end describe 'PUT /tags/:id' do - it 'records wiki_version when tag name changes and tag has wiki' do + it 'renames the current Wiki resource without adding body history' do tag = create_tag!(name: 'put_tag_wiki_before') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') + wiki_history = wiki_page.wiki_versions.map(&:attributes) expect { put "/tags/#{ tag.id }", params: { @@ -93,23 +89,17 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(1) + .and change(WikiVersion, :count).by(0) expect(response).to have_http_status(:ok) tag.reload wiki_page.reload - version = wiki_page.wiki_versions.order(:version_no).last expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after') expect(wiki_page.title).to eq('put_tag_wiki_after') - expect(version).to have_attributes( - event_type: 'update', - title: 'put_tag_wiki_after', - body: 'wiki body before', - created_by_user_id: member_user.id - ) + expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) end it 'does not record wiki_version when only category changes' do diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index c8bfd54..0d0b75a 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -727,7 +727,7 @@ RSpec.describe 'Tags API', type: :request do expect(tag.reload.category).to eq('general') end - it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do + it 'PATCH の名称変更で Wiki の本文履歴を追加しない' do wiki_page = Wiki::Commit.create_content!( tag_name: primary_tag_name_for(tag, 'ja'), @@ -735,24 +735,20 @@ RSpec.describe 'Tags API', type: :request do created_by_user: member_user, message: 'init') + wiki_history = wiki_page.wiki_versions.map(&:attributes) + expect { patch "/tags/#{ tag.id }", params: { name: 'patch_wiki_renamed_tag', } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(1) + .and change(WikiVersion, :count).by(0) expect(response).to have_http_status(:ok) - version = wiki_page.reload.wiki_versions.order(:version_no).last - - expect(version).to have_attributes( - event_type: 'update', - title: 'patch_wiki_renamed_tag', - body: 'wiki body before', - created_by_user_id: member_user.id - ) + expect(wiki_page.reload.title).to eq('patch_wiki_renamed_tag') + expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) end it 'tag の category だけを変更しても wiki version は作成しない' do @@ -1380,7 +1376,7 @@ RSpec.describe 'Tags API', type: :request do expect(system_tag.category).to eq(old_category) end - it 'wiki を持つ tag を更新すると wiki version も作成する' do + it 'Wiki を持つ Tag の名称変更で本文履歴を追加しない' do wiki_page = Wiki::Commit.create_content!( tag_name: primary_tag_name_for(tag, 'ja'), @@ -1395,6 +1391,8 @@ RSpec.describe 'Tags API', type: :request do message: 'init' ) + wiki_history = wiki_page.wiki_versions.map(&:attributes) + expect { put "/tags/#{ tag.id }", params: { name: 'put_wiki_version_tag', @@ -1405,18 +1403,12 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(1) + .and change(WikiVersion, :count).by(0) expect(response).to have_http_status(:ok) - version = wiki_page.reload.wiki_versions.order(:version_no).last - - expect(version).to have_attributes( - event_type: 'update', - title: 'put_wiki_version_tag', - body: 'wiki body before', - created_by_user_id: member_user.id - ) + expect(wiki_page.reload.title).to eq('put_wiki_version_tag') + expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) end it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do @@ -1426,6 +1418,7 @@ RSpec.describe 'Tags API', type: :request do body: 'wiki body before', created_by_user: member_user, message: 'init') + wiki_history = wiki_page.wiki_versions.map(&:attributes) expect { put "/tags/#{ tag.id }", params: { @@ -1445,7 +1438,7 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(3) - .and change(WikiVersion, :count).by(2) + .and change(WikiVersion, :count).by(0) expect(response).to have_http_status(:ok) @@ -1457,10 +1450,8 @@ RSpec.describe 'Tags API', type: :request do expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b')) .to have_attributes(tag_id: tag.id, primary_flg: false) - versions = wiki_page.wiki_versions.order(:version_no).last(2) - - expect(versions.map(&:event_type)).to eq(['update', 'update']) - expect(versions.map(&:title)).to eq(['put_wiki_roundtrip_b', 'spec_tag']) + expect(wiki_page.title).to eq('spec_tag') + expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) end it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do @@ -1570,7 +1561,7 @@ RSpec.describe 'Tags API', type: :request do expect(TagImplication.where(tag:, parent_tag: child)).not_to exist end - it 'tag の name を変更すると対応する wiki version を作成する' do + it 'Tag の名称変更で Wiki の本文履歴を追加しない' do wiki_page = Wiki::Commit.create_content!( tag_name: primary_tag_name_for(tag, 'ja'), @@ -1578,6 +1569,8 @@ RSpec.describe 'Tags API', type: :request do created_by_user: member_user, message: 'init') + wiki_history = wiki_page.wiki_versions.map(&:attributes) + expect { put "/tags/#{ tag.id }", params: { name: 'put_wiki_renamed_tag', @@ -1588,18 +1581,12 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(1) + .and change(WikiVersion, :count).by(0) expect(response).to have_http_status(:ok) - version = wiki_page.reload.wiki_versions.order(:version_no).last - - expect(version).to have_attributes( - event_type: 'update', - title: 'put_wiki_renamed_tag', - body: 'wiki body before', - created_by_user_id: member_user.id - ) + expect(wiki_page.reload.title).to eq('put_wiki_renamed_tag') + expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) end end end diff --git a/backend/spec/requests/wiki_history_integrity_spec.rb b/backend/spec/requests/wiki_history_integrity_spec.rb index db365c6..50de843 100644 --- a/backend/spec/requests/wiki_history_integrity_spec.rb +++ b/backend/spec/requests/wiki_history_integrity_spec.rb @@ -21,7 +21,7 @@ RSpec.describe 'Wiki history integrity', type: :request do expect { post '/wiki', params: { - title: 'wiki_history_create_atomic', + title: 'Help:wiki_history_create_atomic', body: "a\nb\nc", message: 'initial commit', }, @@ -37,7 +37,7 @@ RSpec.describe 'Wiki history integrity', type: :request do revision = page.current_revision version = page.wiki_versions.order(:version_no).last - expect(page.title).to eq('wiki_history_create_atomic') + expect(page.title).to eq('Help:wiki_history_create_atomic') expect(page.body).to eq("a\nb\nc") expect(revision).to be_content @@ -47,7 +47,7 @@ RSpec.describe 'Wiki history integrity', type: :request do expect(version).to have_attributes( version_no: 1, event_type: 'create', - title: 'wiki_history_create_atomic', + title: 'Help:wiki_history_create_atomic', body: "a\nb\nc", reason: 'initial commit', created_by_user_id: user.id @@ -58,7 +58,7 @@ RSpec.describe 'Wiki history integrity', type: :request do expect { post '/wiki', params: { - title: 'wiki_history_blank_body', + title: 'Help:wiki_history_blank_body', body: "\r\n\r\n", message: 'blank', }, @@ -67,16 +67,17 @@ RSpec.describe 'Wiki history integrity', type: :request do .not_to change(WikiPage, :count) expect(response).to have_http_status(:unprocessable_entity) - expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_blank_body' })).not_to exist + expect(WikiPage.joins(:tag_name) + .where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist end it 'returns 422 and creates no partial page when title already exists' do - create_wiki_page(title: 'wiki_history_duplicate_title', body: 'first') + create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first') expect { post '/wiki', params: { - title: 'wiki_history_duplicate_title', + title: 'Help:wiki_history_duplicate_title', body: 'second', message: 'duplicate', }, @@ -85,7 +86,8 @@ RSpec.describe 'Wiki history integrity', type: :request do .not_to change(WikiPage, :count) expect(response).to have_http_status(:unprocessable_entity) - expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_duplicate_title' }).count).to eq(1) + expect(WikiPage.joins(:tag_name) + .where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1) end end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index bbd1c22..5a4eeae 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -111,7 +111,7 @@ RSpec.describe 'Wiki API', type: :request do context 'when success' do it 'creates wiki_page and first content revision' do expect do - post endpoint, params: { title: 'TestPage', body: "a\nb\nc", message: 'init' }, + post endpoint, params: { title: 'Help:TestPage', body: "a\nb\nc", message: 'init' }, headers: auth_headers(member) end .to change(WikiPage, :count).by(1) @@ -121,7 +121,7 @@ RSpec.describe 'Wiki API', type: :request do expect(response).to have_http_status(:created) page_id = json.fetch('id') - expect(json.fetch('title')).to eq('TestPage') + expect(json.fetch('title')).to eq('Help:TestPage') created_page = WikiPage.find(page_id) version = created_page.wiki_versions.order(:version_no).last @@ -129,7 +129,7 @@ RSpec.describe 'Wiki API', type: :request do expect(version).to have_attributes( version_no: 1, event_type: 'create', - title: 'TestPage', + title: 'Help:TestPage', body: "a\nb\nc", created_by_user_id: member.id ) @@ -151,7 +151,7 @@ RSpec.describe 'Wiki API', type: :request do WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current) post endpoint, - params: { title: 'Reuse', body: "a\na" }, + params: { title: 'Help:Reuse', body: "a\na" }, headers: auth_headers(member) page = WikiPage.find(JSON.parse(response.body).fetch('id')) @@ -166,7 +166,7 @@ RSpec.describe 'Wiki API', type: :request do duplicated = 'duplicated_line_for_wiki_line_upsert_spec' post endpoint, - params: { title: 'DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" }, + params: { title: 'Help:DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" }, headers: auth_headers(member) expect(response).to have_http_status(:created) @@ -182,7 +182,8 @@ RSpec.describe 'Wiki API', type: :request do it 'normalises CRLF and strips trailing newlines' do post endpoint, - params: { title: 'NormalisedBody', body: "a\r\nb\r\n\r\n", message: 'normalise' }, + params: { title: 'Help:NormalisedBody', + body: "a\r\nb\r\n\r\n", message: 'normalise' }, headers: auth_headers(member) expect(response).to have_http_status(:created) diff --git a/backend/spec/requests/wiki_title_collision_spec.rb b/backend/spec/requests/wiki_title_collision_spec.rb index f78f987..a8df14b 100644 --- a/backend/spec/requests/wiki_title_collision_spec.rb +++ b/backend/spec/requests/wiki_title_collision_spec.rb @@ -44,12 +44,12 @@ RSpec.describe 'Wiki title collision', type: :request do end it 'returns 422 when creating wiki with existing title' do - create_wiki_page(title: 'wiki_collision_create', body: 'already exists') + create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists') expect { post '/wiki', params: { - title: 'wiki_collision_create', + title: 'Help:wiki_collision_create', body: 'new body', message: 'duplicate create', }, diff --git a/backend/spec/requests/wiki_unmigrated_contract_spec.rb b/backend/spec/requests/wiki_unmigrated_contract_spec.rb index 1029b0f..9cd106b 100644 --- a/backend/spec/requests/wiki_unmigrated_contract_spec.rb +++ b/backend/spec/requests/wiki_unmigrated_contract_spec.rb @@ -14,6 +14,25 @@ RSpec.describe 'Unmigrated Wiki compatibility', type: :request do before { expect(TagDescription.exists?(id: page.id)).to be(false) } + it 'keeps a Tag-linked legacy page in the legacy domain when its title changes' do + create(:tag, primary_tag_name: tag_name) + + expect do + put "/wiki/#{ page.id }", + params: { title: 'legacy_tag_renamed', body: 'changed legacy body', + message: 'legacy rename', base_revision_id: last_revision.id }, + headers: { 'X-Transfer-Code' => user.inheritance_code } + end.to change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change(TagDescription, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) + + expect(response).to have_http_status(:ok) + expect(page.reload).to have_attributes(body: 'changed legacy body') + expect(page.title).to eq('legacy_tag_renamed') + expect(page.current_revision).to have_attributes(message: 'legacy rename') + end + it 'shows legacy current and historical content by ID' do get "/wiki/#{ page.id }" expect(response).to have_http_status(:ok) -- 2.53.0 From 99ae691ba1cab116ceed94dd618fb91c8f66f567 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 4 Oct 2026 23:52:26 +0900 Subject: [PATCH 10/11] #422 --- backend/config/routes.rb | 4 +- .../spec/requests/tag_descriptions_spec.rb | 289 +++++++++--------- 2 files changed, 140 insertions(+), 153 deletions(-) diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 7e3ab03..4402503 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -27,8 +27,8 @@ Rails.application.routes.draw do get :deerjikists put :deerjikists, action: :update_deerjikists - get :description, action: 'tag_descriptions#show' - put :description, action: 'tag_descriptions#update' + get :description, to: 'tag_descriptions#show' + put :description, to: 'tag_descriptions#update' end end diff --git a/backend/spec/requests/tag_descriptions_spec.rb b/backend/spec/requests/tag_descriptions_spec.rb index 88d1a83..a0746e5 100644 --- a/backend/spec/requests/tag_descriptions_spec.rb +++ b/backend/spec/requests/tag_descriptions_spec.rb @@ -42,10 +42,10 @@ RSpec.describe 'Tag description native API', type: :request do expect(request.path_parameters).to include(controller: 'tag_descriptions', action:) end - def native_write method, body:, request_headers: headers - public_send(method, endpoint, params: { body: }, headers: request_headers) - action = method == :post ? 'create' : 'update' - expect(request.path_parameters).to include(controller: 'tag_descriptions', action:) + def native_write body:, request_headers: headers + put endpoint, params: { body: }, headers: request_headers + expect(request.path_parameters).to include( + controller: 'tag_descriptions', action: 'update') end def expect_current description @@ -71,9 +71,8 @@ RSpec.describe 'Tag description native API', type: :request do controller: 'tag_descriptions', action: 'show_by_title', name: 'native_description') end - it 'routes POST by Tag ID to create' do - expect(post: '/tags/123/description').to route_to( - controller: 'tag_descriptions', action: 'create', id: '123') + it 'does not route POST by Tag ID' do + expect(post: '/tags/123/description').not_to be_routable end it 'routes PUT by Tag ID to update' do @@ -203,170 +202,158 @@ RSpec.describe 'Tag description native API', type: :request do end end - describe 'POST /tags/:id/description' do - before { tag } + describe 'PUT /tags/:id/description' do + context 'without an existing description' do + before { tag } - it 'creates both current resources and histories with matching compatibility IDs' do - expect { native_write(:post, body: 'created natively') } - .to change(TagDescription, :count).by(1) - .and change(TagDescriptionVersion, :count).by(1) - .and change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - expect(response).to have_http_status(:created) - description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') - page = WikiPage.find(description.id) - revision = page.current_revision - version = TagDescriptionVersion.find(revision.id) - expect(description).to have_attributes( - body: 'created natively', version_no: 1, - created_by_user_id: editor.id, updated_by_user_id: editor.id) - expect(version).to have_attributes( - tag_id: tag.id, language_code: 'ja', body: description.body, - version_no: 1, event_type: 'create', created_by_user_id: editor.id) - expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body) - expect(revision.body).to eq(version.body) - expect(page.wiki_versions.last.body).to eq(version.body) - expect_current(description) + it 'creates both current resources and histories with matching compatibility IDs' do + expect { native_write(body: 'created natively') } + .to change(TagDescription, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + .and change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + expect(response).to have_http_status(:created) + description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') + page = WikiPage.find(description.id) + revision = page.current_revision + version = TagDescriptionVersion.find(revision.id) + expect(description).to have_attributes( + body: 'created natively', version_no: 1, + created_by_user_id: editor.id, updated_by_user_id: editor.id) + expect(version).to have_attributes( + tag_id: tag.id, language_code: 'ja', body: description.body, + version_no: 1, event_type: 'create', created_by_user_id: editor.id) + expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body) + expect(revision.body).to eq(version.body) + expect(page.wiki_versions.last.body).to eq(version.body) + expect_current(description) - get "/wiki/#{ description.id }" - expect(response).to have_http_status(:ok) - expect(json).to include('body' => description.body, 'revision_id' => version.id) - end + get "/wiki/#{ description.id }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => description.body, 'revision_id' => version.id) + end - it 'returns the existing validation error format on duplicate creation' do - existing = TagDescription.create!( - tag:, language_code: 'ja', body: 'existing', version_no: 1, - created_by_user_id: author.id, updated_by_user_id: author.id) - before_state = stored_state - native_write(:post, body: 'duplicate') - expect(response).to have_http_status(:unprocessable_entity) - expect(json).to include('type' => 'validation_error', 'errors' => a_kind_of(Hash), - 'base_errors' => a_kind_of(Array), 'message' => a_kind_of(String)) - expect(stored_state).to eq(before_state) - expect(existing.reload.body).to eq('existing') - end - - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| - it "rolls back native create when #{ model.name } persistence fails" do - headers - before_state = stored_state - aggregate_failures do - fail_save(model) { native_write(:post, body: 'rollback create') } - expect(stored_state).to eq(before_state) + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + it "rolls back native create when #{ model.name } persistence fails" do + headers + before_state = stored_state + aggregate_failures do + fail_save(model) { native_write(body: 'rollback create') } + expect(stored_state).to eq(before_state) + end end end end - end - describe 'PUT /tags/:id/description' do - let!(:page) do - tag - Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author) - end - let!(:description) do - TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') - end - - it 'writes matching histories when the body changes' do - expect(page.reload.version_no).to eq(1) - expect(description.version_no).to eq(1) - expect { native_write(:put, body: 'after') } - .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) - expect(description.reload).to have_attributes( - body: 'after', version_no: 2, - created_by_user_id: author.id, updated_by_user_id: editor.id) - expect(page.reload).to have_attributes(body: 'after', version_no: 2) - - revision = page.current_revision - version = TagDescriptionVersion.find(revision.id) - expect(version).to have_attributes( - tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update', - body: 'after', created_by_user_id: editor.id) - expect(revision.body).to eq(version.body) - expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body) - get "/wiki/#{ description.id }" - expect(response).to have_http_status(:ok) - expect(json).to include('body' => 'after', 'revision_id' => version.id) - end - - ['before', "before\n\n"].each do |body| - it 'does not write histories when the normalised body is unchanged' do - before_state = stored_state - page_state = page.reload.attributes - description_state = description.reload.attributes - current_revision_id = page.current_revision.id - - native_write(:put, body:) - - expect(response).to have_http_status(:ok) - expect(stored_state).to eq(before_state) - expect(page.reload.attributes).to eq(page_state) - expect(description.reload.attributes).to eq(description_state) - expect(page.current_revision.id).to eq(current_revision_id) + context 'with an existing description' do + let!(:page) do + tag + Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author) + end + let!(:description) do + TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') end - end - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| - it "rolls back native update when #{ model.name } persistence fails" do - headers - before_state = stored_state - aggregate_failures do - fail_save(model) { native_write(:put, body: 'rollback update') } + it 'writes matching histories when the body changes' do + expect(page.reload.version_no).to eq(1) + expect(description.version_no).to eq(1) + expect { native_write(body: 'after') } + .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) + expect(description.reload).to have_attributes( + body: 'after', version_no: 2, + created_by_user_id: author.id, updated_by_user_id: editor.id) + expect(page.reload).to have_attributes(body: 'after', version_no: 2) + + revision = page.current_revision + version = TagDescriptionVersion.find(revision.id) + expect(version).to have_attributes( + tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update', + body: 'after', created_by_user_id: editor.id) + expect(revision.body).to eq(version.body) + expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body) + get "/wiki/#{ description.id }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'after', 'revision_id' => version.id) + end + + ['before', "before\n\n"].each do |body| + it 'does not write histories when the normalised body is unchanged' do + before_state = stored_state + page_state = page.reload.attributes + description_state = description.reload.attributes + current_revision_id = page.current_revision.id + + native_write(body:) + + expect(response).to have_http_status(:ok) expect(stored_state).to eq(before_state) + expect(page.reload.attributes).to eq(page_state) + expect(description.reload.attributes).to eq(description_state) + expect(page.current_revision.id).to eq(current_revision_id) + end + end + + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + it "rolls back native update when #{ model.name } persistence fails" do + headers + before_state = stored_state + aggregate_failures do + fail_save(model) { native_write(body: 'rollback update') } + expect(stored_state).to eq(before_state) + end end end end end describe 'write access and validation' do - [:post, :put].each do |method| - context "with #{ method.upcase }" do - before do - tag - if method == :put - Wiki::Commit.create_content!(tag_name:, body: 'existing', created_by_user: author) - end - end + before { tag } - it 'requires authentication without changing either system' do - before_state = stored_state - native_write(method, body: 'unauthorised', request_headers: { }) - expect(response).to have_http_status(:unauthorized) - expect(stored_state).to eq(before_state) - end + it 'requires authentication without changing either system' do + before_state = stored_state + native_write(body: 'unauthorised', request_headers: { }) + expect(response).to have_http_status(:unauthorized) + expect(stored_state).to eq(before_state) + end - it 'rejects a guest without changing either system' do - guest = create(:user, :guest) - before_state = stored_state - native_write(method, body: 'forbidden', - request_headers: { 'X-Transfer-Code' => guest.inheritance_code }) - expect(response).to have_http_status(:forbidden) - expect(stored_state).to eq(before_state) - end + it 'rejects a guest without changing either system' do + guest = create(:user, :guest) + before_state = stored_state + native_write(body: 'forbidden', + request_headers: { 'X-Transfer-Code' => guest.inheritance_code }) + expect(response).to have_http_status(:forbidden) + expect(stored_state).to eq(before_state) + end - it 'rejects a banned member without changing either system' do - banned = create(:user, :member, :banned) - before_state = stored_state - native_write(method, body: 'forbidden', - request_headers: { 'X-Transfer-Code' => banned.inheritance_code }) - expect(response).to have_http_status(:forbidden) - expect(stored_state).to eq(before_state) - end + it 'rejects a banned member without changing either system' do + banned = create(:user, :member, :banned) + before_state = stored_state + native_write(body: 'forbidden', + request_headers: { 'X-Transfer-Code' => banned.inheritance_code }) + expect(response).to have_http_status(:forbidden) + expect(stored_state).to eq(before_state) + end - it 'rejects blank body using the existing validation error format' do - headers - before_state = stored_state - native_write(method, body: '') - expect(response).to have_http_status(:unprocessable_entity) - expect(json).to include('type' => 'validation_error', - 'errors' => include('body' => a_kind_of(Array))) - expect(stored_state).to eq(before_state) - end - end + it 'rejects blank body using the existing validation error format' do + headers + before_state = stored_state + native_write(body: '') + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include('type' => 'validation_error', + 'errors' => include('body' => a_kind_of(Array))) + expect(stored_state).to eq(before_state) + end + + it 'returns 404 when PUT targets a missing Tag' do + missing_id = Tag.maximum(:id).to_i + 1 + put "/tags/#{ missing_id }/description", + params: { body: 'missing' }, + headers: headers + expect(response).to have_http_status(:not_found) end end end -- 2.53.0 From f78a08bea117ea633cb07b576aacecd860387f16 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 5 Oct 2026 00:59:38 +0900 Subject: [PATCH 11/11] #422 --- .../tag_descriptions_controller.rb | 38 ++++--------- .../app/controllers/wiki_pages_controller.rb | 4 ++ .../20261005000000_repair_wiki_updated_at.rb | 53 +++++++++++++++++++ backend/db/schema.rb | 2 +- 4 files changed, 69 insertions(+), 28 deletions(-) create mode 100644 backend/db/migrate/20261005000000_repair_wiki_updated_at.rb diff --git a/backend/app/controllers/tag_descriptions_controller.rb b/backend/app/controllers/tag_descriptions_controller.rb index 726123f..06d78c2 100644 --- a/backend/app/controllers/tag_descriptions_controller.rb +++ b/backend/app/controllers/tag_descriptions_controller.rb @@ -14,7 +14,7 @@ class TagDescriptionsController < ApplicationController render json: representation(description) end - def create + def update return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? @@ -24,39 +24,23 @@ class TagDescriptionsController < ApplicationController tag = Tag.find_by(id: params[:id]) return head :not_found unless tag - if TagDescription.exists?(tag:, language_code: 'ja') - return render_unprocessable_entity('説明はすでに存在します.') + description = TagDescription.find_by(tag:, language_code: 'ja') + + if description + TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user) + description.reload + render json: representation(description) + else + TagDescriptionOperation.create!(tag:, body:, created_by_user: current_user) + description = TagDescription.find_by!(tag:, language_code: 'ja') + render json: representation(description), status: :created end - - TagDescriptionOperation.create!(tag:, body:, created_by_user: current_user) - description = TagDescription.find_by!(tag:, language_code: 'ja') - - render json: representation(description), status: :created rescue ActiveRecord::RecordInvalid => e render_validation_error e.record rescue ActiveRecord::RecordNotUnique render_record_not_unique end - def update - return head :unauthorized unless current_user - return head :forbidden unless current_user.gte_member? - - body = params[:body].to_s - return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? - - description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja') - return create unless description - - TagDescriptionOperation.update!( - tag: description.tag, body:, updated_by_user: current_user) - description.reload - - render json: representation(description) - rescue ActiveRecord::RecordInvalid => e - render_validation_error e.record - end - private def representation description diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 60b7407..8fb84e9 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -2,6 +2,10 @@ class WikiPagesController < ApplicationController rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict def index + # 互換性のための化石リソース + # TODO: ヘルプおよびタグ・グループ追加後に, + # TODO: タグ説明とヘルプ、タグ・グループから取得する作りに変更する. + # title = params[:title].to_s.strip descriptions = TagDescription.where(language_code: 'ja') .includes(tag: :tag_names) diff --git a/backend/db/migrate/20261005000000_repair_wiki_updated_at.rb b/backend/db/migrate/20261005000000_repair_wiki_updated_at.rb new file mode 100644 index 0000000..5509b65 --- /dev/null +++ b/backend/db/migrate/20261005000000_repair_wiki_updated_at.rb @@ -0,0 +1,53 @@ +class RepairWikiUpdatedAt < ActiveRecord::Migration[8.0] + class WikiPage < ActiveRecord::Base + self.table_name = 'wiki_pages' + end + + class WikiVersion < ActiveRecord::Base + self.table_name = 'wiki_versions' + end + + class TagDescription < ActiveRecord::Base + self.table_name = 'tag_descriptions' + end + + class TagDescriptionVersion < ActiveRecord::Base + self.table_name = 'tag_description_versions' + end + + def up + say_with_time 'Repairing wiki_pages.updated_at from wiki_versions' do + WikiPage.find_each do |page| + version = + WikiVersion + .where(wiki_page_id: page.id) + .order(version_no: :desc) + .first + + next unless version + + page.update_columns(updated_at: version.created_at) + end + end + + say_with_time 'Repairing tag_descriptions.updated_at from tag_description_versions' do + TagDescription.find_each do |description| + version = + TagDescriptionVersion + .where( + tag_id: description.tag_id, + language_code: description.language_code) + .order(version_no: :desc) + .first + + next unless version + + description.update_columns(updated_at: version.created_at) + end + end + end + + def down + ; + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 914edcb..611cced 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_10_03_010000) do +ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) 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 -- 2.53.0