From 620a0b5cff60261c52b45cf65b008a6c59a4b647 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 15 Oct 2026 02:29:32 +0900 Subject: [PATCH 1/7] #431 --- .../app/controllers/wiki_pages_controller.rb | 4 +- .../migrate/20261015000000_drop_wiki_pages.rb | 9 ++ ...1015003000_remove_tag_name_id_from_tags.rb | 11 +++ ...000_delete_name_for_wiki_from_tag_names.rb | 15 +++ backend/db/schema.rb | 91 +------------------ 5 files changed, 39 insertions(+), 91 deletions(-) create mode 100644 backend/db/migrate/20261015000000_drop_wiki_pages.rb create mode 100644 backend/db/migrate/20261015003000_remove_tag_name_id_from_tags.rb create mode 100644 backend/db/migrate/20261015010000_delete_name_for_wiki_from_tag_names.rb diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index abf2748..797c088 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -1,9 +1,9 @@ class WikiPagesController < ApplicationController + # 互換性のための Facade API + rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict def index - # 互換性のための化石リソース - title = params[:title].to_s.strip descriptions = TagDescription.where(language_code: 'ja') diff --git a/backend/db/migrate/20261015000000_drop_wiki_pages.rb b/backend/db/migrate/20261015000000_drop_wiki_pages.rb new file mode 100644 index 0000000..5884566 --- /dev/null +++ b/backend/db/migrate/20261015000000_drop_wiki_pages.rb @@ -0,0 +1,9 @@ +class DropWikiPages < ActiveRecord::Migration[8.0] + def change + drop_table :wiki_versions + drop_table :wiki_revision_lines + drop_table :wiki_lines + drop_table :wiki_revisions + drop_table :wiki_pages + end +end diff --git a/backend/db/migrate/20261015003000_remove_tag_name_id_from_tags.rb b/backend/db/migrate/20261015003000_remove_tag_name_id_from_tags.rb new file mode 100644 index 0000000..701fd99 --- /dev/null +++ b/backend/db/migrate/20261015003000_remove_tag_name_id_from_tags.rb @@ -0,0 +1,11 @@ +class RemoveTagNameIdFromTags < ActiveRecord::Migration[8.0] + def up + remove_foreign_key :tags, column: :tag_name_id + remove_foreign_key :tag_names, column: :canonical_id + + remove_column :tags, :tag_name_id + remove_column :tag_names, :canonical_id + end + + def down = raise ActiveRecord::IrreversibleMigration, '戻せません.' +end diff --git a/backend/db/migrate/20261015010000_delete_name_for_wiki_from_tag_names.rb b/backend/db/migrate/20261015010000_delete_name_for_wiki_from_tag_names.rb new file mode 100644 index 0000000..152c30a --- /dev/null +++ b/backend/db/migrate/20261015010000_delete_name_for_wiki_from_tag_names.rb @@ -0,0 +1,15 @@ +class DeleteNameForWikiFromTagNames < ActiveRecord::Migration[8.0] + def up + execute <<~SQL + DELETE + FROM + tag_names + WHERE + tag_id IS NULL + SQL + + change_column_null :tag_names, :tag_id, false + end + + def down = raise ActiveRecord::IrreversibleMigration, '戻せません.' +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index dda45de..bf72236 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_08_010000) do +ActiveRecord::Schema[8.0].define(version: 2026_10_15_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 @@ -578,17 +578,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do end create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "tag_id" + t.bigint "tag_id", null: false t.string "language_code", limit: 16, null: false t.string "name", null: false t.string "script_code", limit: 4, null: false t.boolean "primary_flg", null: false t.boolean "auto_generated", default: false, null: false - t.bigint "canonical_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)" - t.index ["canonical_id"], name: "index_tag_names_on_canonical_id" t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true t.index ["script_code"], name: "fk_rails_dd783b3d1c" @@ -625,7 +623,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do end create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "tag_name_id", null: false t.string "category", default: "general", null: false t.datetime "deprecated_at" t.datetime "created_at", null: false @@ -633,7 +630,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do t.integer "post_count", default: 0, null: false t.integer "version_no", null: false t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" - t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" end @@ -773,76 +769,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do t.index ["banned_at"], name: "index_users_on_banned_at" 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 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["sha256"], name: "index_wiki_lines_on_sha256", unique: true - end - - create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "tag_name_id", null: false - t.text "body", null: false - t.bigint "created_user_id", null: false - t.bigint "updated_user_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.datetime "discarded_at" - 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" - t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true - t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" - t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive" - end - - create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "wiki_revision_id", null: false - t.integer "position", null: false - t.bigint "wiki_line_id", null: false - t.index ["wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_line_id" - t.index ["wiki_revision_id", "position"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_position", unique: true - t.index ["wiki_revision_id", "wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_wiki_line_id" - t.index ["wiki_revision_id"], name: "index_wiki_revision_lines_on_wiki_revision_id" - end - - create_table "wiki_revisions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "wiki_page_id", null: false - t.bigint "base_revision_id" - t.bigint "created_user_id", null: false - t.integer "kind", default: 0, null: false - t.bigint "redirect_page_id" - t.string "message" - t.integer "lines_count", default: 0, null: false - t.string "tree_sha256", limit: 64 - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["base_revision_id"], name: "index_wiki_revisions_on_base_revision_id" - t.index ["created_user_id"], name: "index_wiki_revisions_on_created_user_id" - t.index ["kind"], name: "index_wiki_revisions_on_kind" - t.index ["redirect_page_id"], name: "index_wiki_revisions_on_redirect_page_id" - t.index ["tree_sha256"], name: "index_wiki_revisions_on_tree_sha256" - t.index ["wiki_page_id", "id"], name: "index_wiki_revisions_on_wiki_page_id_and_id" - t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id" - end - - create_table "wiki_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "wiki_page_id", null: false - t.integer "version_no", null: false - t.string "event_type", null: false - t.string "title", null: false - t.text "body", null: false - t.text "reason" - t.datetime "created_at", null: false - t.bigint "created_by_user_id" - t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id" - t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true - t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id" - t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid" - t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive" - end - add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions" @@ -915,12 +841,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do add_foreign_key "tag_implications", "tags", column: "parent_tag_id" add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code" add_foreign_key "tag_names", "scripts", column: "script_code", primary_key: "code" - add_foreign_key "tag_names", "tag_names", column: "canonical_id" add_foreign_key "tag_names", "tags" add_foreign_key "tag_similarities", "tags" add_foreign_key "tag_similarities", "tags", column: "target_tag_id" add_foreign_key "tag_versions", "users", column: "created_by_user_id" - add_foreign_key "tags", "tag_names" add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "users" add_foreign_key "theatre_programmes", "posts" @@ -945,15 +869,4 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) 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_pages", "tag_names" - add_foreign_key "wiki_pages", "users", column: "created_user_id" - add_foreign_key "wiki_pages", "users", column: "updated_user_id" - add_foreign_key "wiki_revision_lines", "wiki_lines" - add_foreign_key "wiki_revision_lines", "wiki_revisions" - add_foreign_key "wiki_revisions", "users", column: "created_user_id" - add_foreign_key "wiki_revisions", "wiki_pages" - add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id" - add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id" - add_foreign_key "wiki_versions", "users", column: "created_by_user_id" - add_foreign_key "wiki_versions", "wiki_pages" end -- 2.53.0 From 2ef872bd3367f6e86a7a2fd41edba4e8a8582d54 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 15 Oct 2026 02:45:41 +0900 Subject: [PATCH 2/7] #431 --- backend/app/controllers/tags_controller.rb | 44 ++--- .../app/controllers/wiki_pages_controller.rb | 75 ++------ backend/app/models/locale.rb | 5 +- backend/app/models/tag.rb | 30 ++-- backend/app/models/tag_name.rb | 20 +-- .../app/models/tag_name_sanitisation_rule.rb | 8 +- backend/app/models/user.rb | 8 +- backend/app/models/wiki_line.rb | 15 -- backend/app/models/wiki_page.rb | 58 ------- backend/app/models/wiki_revision.rb | 55 ------ backend/app/models/wiki_revision_line.rb | 8 - backend/app/models/wiki_version.rb | 8 - backend/app/representations/wiki_page_repr.rb | 10 -- .../app/services/tag_description_operation.rb | 90 +++++----- backend/app/services/tag_group_operation.rb | 77 +------- backend/app/services/wiki/commit.rb | 164 ------------------ .../app/services/wiki/compatibility_writer.rb | 43 ----- backend/app/services/wiki_version_recorder.rb | 21 --- 18 files changed, 99 insertions(+), 640 deletions(-) delete mode 100644 backend/app/models/wiki_line.rb delete mode 100644 backend/app/models/wiki_page.rb delete mode 100644 backend/app/models/wiki_revision.rb delete mode 100644 backend/app/models/wiki_revision_line.rb delete mode 100644 backend/app/models/wiki_version.rb delete mode 100644 backend/app/services/wiki/commit.rb delete mode 100644 backend/app/services/wiki/compatibility_writer.rb delete mode 100644 backend/app/services/wiki_version_recorder.rb diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index d60d56d..13b71ba 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -770,31 +770,15 @@ class TagsController < ApplicationController TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user) end - current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id) - current_tag_name.association(:wiki_page).reset - - current_tag_name.update!(canonical_id: promoted_tag_name.id, - primary_flg: false, auto_generated: false) - promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true, auto_generated: false) - - TagName.where(tag:, - language_code: current_tag_name.language_code, - primary_flg: false) - .where.not(id: promoted_tag_name.id) - .find_each do |alias_tag_name| - alias_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false) - end - - tag.update!(tag_name_id: promoted_tag_name.id) - current_tag_name.association(:wiki_page).reset - current_tag_name.association(:tag).reset - current_tag_name.reload.update!(canonical_id: promoted_tag_name.id, primary_flg: false) + current_tag_name&.update!(primary_flg: false, auto_generated: false) + promoted_tag_name.update!(tag:, primary_flg: true, auto_generated: false) + tag.association(:tag_names).reset return unless old_owner_tag && old_owner_tag != tag record_tag_version!(old_owner_tag.reload, event_type: :update, created_by_user: current_user, - language_code: current_tag_name.language_code) + language_code: promoted_tag_name.language_code) end def update_aliases! locale, tag, alias_names @@ -802,20 +786,15 @@ class TagsController < ApplicationController affected_tags = [tag] - primary_tag_name = TagName.find_by!(tag:, - language_code: locale.language_code, - primary_flg: true) current_aliases = tag.tag_names.where(language_code: locale.language_code, primary_flg: false).to_a - current_aliases.each do |alias_tag_name| - next if alias_names.include?(alias_tag_name.name) - - affected_tags << alias_tag_name.tag - end + requested_aliases = TagName.where( + language_code: locale.language_code, name: alias_names) + .includes(:tag).index_by(&:name) alias_names.each do |alias_name| - alias_tag_name = TagName.find_by(language_code: locale.language_code, name: alias_name) + alias_tag_name = requested_aliases[alias_name] if alias_tag_name&.primary? && alias_tag_name.tag_id && alias_tag_name.tag_id != tag.id alias_tag_name.errors.add(:name, 'その名前は既に使はれてゐます.') raise ActiveRecord::RecordInvalid, alias_tag_name @@ -831,16 +810,15 @@ class TagsController < ApplicationController current_aliases.each do |alias_tag_name| next if alias_names.include?(alias_tag_name.name) - alias_tag_name.update!(canonical_id: nil, tag_id: nil) + alias_tag_name.destroy! end alias_names.each do |alias_name| - alias_tag_name = TagName.find_or_initialize_by( + alias_tag_name = requested_aliases[alias_name] || TagName.new( language_code: locale.language_code, name: alias_name) alias_tag_name.script_code = locale.script_code - alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false, - canonical_id: primary_tag_name.id) + alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false) end affected_tags.compact.uniq.each do |affected_tag| diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 797c088..688fe7b 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -1,7 +1,7 @@ class WikiPagesController < ApplicationController # 互換性のための Facade API - rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict + rescue_from TagDescriptionOperation::Conflict, with: :render_wiki_conflict def index title = params[:title].to_s.strip @@ -38,7 +38,7 @@ class WikiPagesController < ApplicationController tag_group = TagGroup.find_by(id: params[:id]) return render_tag_group tag_group if tag_group - render_wiki_page_or_404 nil + head :not_found end def show_by_title @@ -55,7 +55,7 @@ class WikiPagesController < ApplicationController return render_wiki_page_by_tag_description tag_description if tag_description - render_wiki_page_or_404 nil + head :not_found end def exists @@ -134,15 +134,15 @@ class WikiPagesController < ApplicationController if title.start_with?(TagGroupRepr::PREFIX) name = title.delete_prefix(TagGroupRepr::PREFIX) parsed = TagGroupMarkdownParser.parse(body:, group: nil) - group = TagGroupOperation.create!(name:, **parsed, actor: current_user, - require_compatibility: true) + group = TagGroupOperation.create!( + name:, **parsed.except(:compatibility_body), actor: current_user) version = current_group_version(group) return render_group_representation(TagGroupRepr.one(group), version, body: parsed.fetch(:compatibility_body), status: :created) end - page = + description = if title.include?(':') return render_unprocessable_entity field: :title else @@ -153,7 +153,6 @@ class WikiPagesController < ApplicationController tag: tag_name.tag, body:, created_by_user: current_user, message:) end - description = TagDescription.find_by!(tag_id: page.tag_name.tag_id, language_code: 'ja') render json: WikiPageRepr.tag_description(description), status: :created rescue ActiveRecord::RecordInvalid => e render_validation_error e.record @@ -179,22 +178,21 @@ class WikiPagesController < ApplicationController name = title.delete_prefix(TagGroupRepr::PREFIX) parsed = TagGroupMarkdownParser.parse(body:, group:) - TagGroupOperation.update!(group:, name:, **parsed, actor: current_user, - require_compatibility: true) + TagGroupOperation.update!( + group:, name:, **parsed.except(:compatibility_body), actor: current_user) return head :ok end return render_unprocessable_entity field: :title if title.include?(':') description = TagDescription.find_by(id: params[:id]) unless description - return render_unprocessable_entity field: :title if group || WikiPage.exists?(id: params[:id]) + return render_unprocessable_entity field: :title if group return head :not_found end tag = description.tag - tag_name = TagDescriptionOperation.compatibility_tag_name(tag) - if tag_name.name.include?(':') || (group && title != tag_name.name) - return render_unprocessable_entity field: :title - end + tag_name = tag.tag_name('ja') + return render_unprocessable_entity field: :title unless tag_name + return render_unprocessable_entity field: :title if tag_name.name.include?(':') base_revision_id = params[:base_revision_id].presence @@ -251,26 +249,6 @@ class WikiPagesController < ApplicationController private - def legacy_group_page? page - return false unless page - return true if page.title.start_with?(TagGroupRepr::PREFIX) - - prefix = ApplicationRecord.sanitize_sql_like(TagGroupRepr::PREFIX) - page.wiki_versions.where('title LIKE ?', "#{ prefix }%").exists? - end - - def legacy_update_identified? page, title, group - return false unless page - return true if page.title == title - return false if title == group.name - - namespace = page.title.split(':', 2) - return true if namespace.size == 2 && title.start_with?("#{ namespace.first }:") - - base = params[:base_revision_id].presence - base && page.wiki_revisions.exists?(id: base) - end - def render_tag_group group version = if params[:version].present? group_version(group, params[:version]) @@ -357,27 +335,6 @@ class WikiPagesController < ApplicationController end end - def render_wiki_page_or_404 page - return head :not_found unless page - - rev = find_revision(page) - return head :not_found unless rev - - if rev.redirect? - return ( - redirect_to wiki_page_by_title_path(title: rev.redirect_page.title), - status: :moved_permanently) - end - - body = rev.body - revision_id = rev.id - pred = page.pred_revision_id(revision_id) - succ = page.succ_revision_id(revision_id) - updated_at = rev.created_at - - 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? @@ -432,14 +389,6 @@ class WikiPagesController < ApplicationController end end - def find_revision page - if params[:version].present? - page.wiki_revisions.find_by(id: params[:version]) - else - page.current_revision - end - end - def render_wiki_conflict err render json: { error: 'conflict', message: err.message }, status: :conflict end diff --git a/backend/app/models/locale.rb b/backend/app/models/locale.rb index 1d39f44..b06fab9 100644 --- a/backend/app/models/locale.rb +++ b/backend/app/models/locale.rb @@ -13,14 +13,13 @@ class Locale < ApplicationRecord def generate_tag_names! ApplicationRecord.transaction do tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id) - Tag.where.not(id: tag_ids).find_each do + Tag.where.not(id: tag_ids).includes(:tag_names).find_each do TagName.create!(tag_id: _1.id, language_code:, name: TagName.generate_name(self, _1, _1.tag_name('ja')), script_code:, primary_flg: true, - auto_generated: true, - canonical_id: nil) + auto_generated: true) end end end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 173a52a..9baa7e6 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -91,7 +91,6 @@ class Tag < ApplicationRecord end end def name(language_code) = tag_name(language_code)&.name - def wiki_page = tag_name('ja')&.wiki_page def system? tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES) @@ -260,16 +259,13 @@ class Tag < ApplicationRecord name = name.to_s.strip ApplicationRecord.transaction do - tn = TagName.find_or_create_by!(language_code:, name:) do - _1.script_code = locale.script_code - _1.primary_flg = true - end + tn = TagName.find_by(language_code:, name:) + next tn.tag if tn - tag = tn.tag - next tag if tag - - tag = Tag.create!(tag_name_id: tn.id, category:) - tn.update!(tag:, primary_flg: true, canonical_id: nil, auto_generated: false) + tag = Tag.create!(category:) + tn = TagName.create!(tag:, language_code:, name:, + script_code: locale.script_code, primary_flg: true, + auto_generated: false) generate_tag_names!(locale, tag, tn) tag.association(:tag_names).reset @@ -303,20 +299,17 @@ class Tag < ApplicationRecord end end - source_tag_name = source_tag.tag_name('ja') - - if source_tag_name.wiki_page.present? - raise ActiveRecord::RecordInvalid.new(source_tag_name) + if source_tag.tag_descriptions.exists? + source_tag.errors.add(:base, :invalid) + raise ActiveRecord::RecordInvalid, source_tag end TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false, + auto_generated: false, updated_at: Time.current) source_tag.destroy! - source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, - updated_at: Time.current) - TagVersioning.record!(target_tag, event_type: :update, created_by_user:) end @@ -376,8 +369,7 @@ class Tag < ApplicationRecord name: TagName.generate_name(locale, tag, base_tag_name), script_code:, primary_flg: true, - auto_generated: true, - canonical_id: nil) + auto_generated: true) end end end diff --git a/backend/app/models/tag_name.rb b/backend/app/models/tag_name.rb index 6c119ab..05b6b92 100644 --- a/backend/app/models/tag_name.rb +++ b/backend/app/models/tag_name.rb @@ -1,14 +1,12 @@ class TagName < ApplicationRecord belongs_to :language, foreign_key: :language_code, primary_key: :code - belongs_to :tag, optional: true - has_one :wiki_page + belongs_to :tag validates :name, presence: true, length: { maximum: 255 }, uniqueness: { scope: :language_code } validate :alias_name_must_not_have_prefix - validate :alias_must_not_have_wiki_page validate :name_must_be_sanitised validate :name_mustnt_start_with_nico validate :generated_name_must_be_reserved @@ -21,14 +19,16 @@ class TagName < ApplicationRecord tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name) + primary_names = TagName.where( + language_code: locale.language_code, tag_id: tns.values.map(&:tag_id), + primary_flg: true).pluck(:tag_id, :name).to_h + names.map { |name| tag_name = tns[name] - if !tag_name || tag_name.primary? || tag_name.tag_id.nil? + if !tag_name || tag_name.primary? name else - TagName.find_by(language_code: locale.language_code, - tag_id: tag_name.tag_id, - primary_flg: true)&.name || name + primary_names[tag_name.tag_id] || name end }.uniq end @@ -53,12 +53,6 @@ class TagName < ApplicationRecord end end - def alias_must_not_have_wiki_page - if !(primary?) && wiki_page - errors.add :primary_flg, 'Wiki 参照がある名前はエーリアスになれません.' - end - end - def name_must_be_sanitised if name? && name != TagNameSanitisationRule.sanitise(name) errors.add :name, '名前に使用できない文字が含まれてゐます.' diff --git a/backend/app/models/tag_name_sanitisation_rule.rb b/backend/app/models/tag_name_sanitisation_rule.rb index 4731d4f..b853f00 100644 --- a/backend/app/models/tag_name_sanitisation_rule.rb +++ b/backend/app/models/tag_name_sanitisation_rule.rb @@ -33,13 +33,7 @@ class TagNameSanitisationRule < ApplicationRecord existing_tag = existing_tn.tag source_tag = tn.tag - if existing_tag - Tag.merge_tags!(existing_tag, source_tag) if tn.tag - elsif source_tag - tn.update_columns(tag_id: nil, updated_at: Time.current) - source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current) - existing_tn.update_columns(tag_id: source_tag.id, updated_at: Time.current) - end + Tag.merge_tags!(existing_tag, source_tag) if existing_tag != source_tag tn.destroy! next diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index 59b076c..3b0b87b 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -13,10 +13,10 @@ class User < ApplicationRecord has_many :ip_addresses, through: :user_ips has_many :user_post_views, dependent: :destroy has_many :viewed_posts, through: :user_post_views, source: :post - has_many :created_wiki_pages, - class_name: 'WikiPage', foreign_key: :created_user_id, dependent: :nullify - has_many :updated_wiki_pages, - class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify + has_many :created_tag_descriptions, class_name: 'TagDescription', + foreign_key: :created_by_user_id, dependent: :nullify + has_many :updated_tag_descriptions, class_name: 'TagDescription', + foreign_key: :updated_by_user_id, dependent: :nullify def viewed?(post) = user_post_views.exists?(post_id: post.id) diff --git a/backend/app/models/wiki_line.rb b/backend/app/models/wiki_line.rb deleted file mode 100644 index b9c3465..0000000 --- a/backend/app/models/wiki_line.rb +++ /dev/null @@ -1,15 +0,0 @@ -class WikiLine < ApplicationRecord - has_many :wiki_revision_lines, dependent: :restrict_with_exception - - validates :sha256, presence: true, uniqueness: true, length: { is: 64 } - validates :body, presence: true - - def self.upsert_by_body! body - sha = Digest::SHA256.hexdigest(body) - now = Time.current - - upsert(sha256: sha, body:, created_at: now, updated_at: now) - - find_by!(sha256: sha) - end -end diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb deleted file mode 100644 index caee630..0000000 --- a/backend/app/models/wiki_page.rb +++ /dev/null @@ -1,58 +0,0 @@ -require 'set' - - -class WikiPage < ApplicationRecord - include MyDiscard - - has_many :wiki_revisions, dependent: :destroy - belongs_to :created_user, class_name: 'User' - belongs_to :updated_user, class_name: 'User' - - has_many :redirected_from_revisions, - class_name: 'WikiRevision', - foreign_key: :redirect_page_id, - dependent: :nullify - - has_many :wiki_versions - - attribute :version_no, :integer, default: 1 - - belongs_to :tag_name - validates :tag_name, presence: true - validates :body, presence: true - - def title = tag_name.name - def deprecated_at = tag_name.tag&.deprecated_at - - def title= val - (self.tag_name ||= build_tag_name).name = val - end - - def current_revision = wiki_revisions.order(id: :desc).first - - def resolve_redirect limit: 10 - page = self - visited = Set.new - - limit.times do - return page if visited.include?(page.id) - - visited.add(page.id) - - rev = page.current_revision - return page if !(rev&.redirect?) || !(rev.redirect_page) - - page = rev.redirect_page - end - - page - end - - 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/app/models/wiki_revision.rb b/backend/app/models/wiki_revision.rb deleted file mode 100644 index 3e0781a..0000000 --- a/backend/app/models/wiki_revision.rb +++ /dev/null @@ -1,55 +0,0 @@ -class WikiRevision < ApplicationRecord - belongs_to :wiki_page - belongs_to :base_revision, class_name: 'WikiRevision', optional: true - belongs_to :created_user, class_name: 'User' - belongs_to :redirect_page, class_name: 'WikiPage', optional: true - - has_many :wiki_revision_lines, dependent: :delete_all - has_many :wiki_lines, through: :wiki_revision_lines - - enum :kind, content: 0, redirect: 1 - - validates :kind, presence: true - validates :lines_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :tree_sha256, length: { is: 64 }, allow_nil: true - - validate :kind_consistency - - def body - return unless content? - - wiki_revision_lines - .includes(:wiki_line) - .order(:position) - .map { |rev| rev.wiki_line.body } - .join("\n") - end - - private - - def kind_consistency - if content? - if tree_sha256.blank? - errors.add(:tree_sha256, '種類がページの場合は必須です.') - end - - if redirect_page_id.present? - errors.add(:redirect_page_id, '種類がページの場合は空である必要があります.') - end - end - - if redirect? - if redirect_page_id.blank? - errors.add(:redirect_page_id, '種類がリダイレクトの場合は必須です.') - end - - if tree_sha256.present? - errors.add(:tree_sha256, '種類がリダイレクトの場合は空である必要があります.') - end - - if lines_count.to_i > 0 - errors.add(:lines_count, '種類がリダイレクトの場合は 0 である必要があります.') - end - end - end -end diff --git a/backend/app/models/wiki_revision_line.rb b/backend/app/models/wiki_revision_line.rb deleted file mode 100644 index 8d58642..0000000 --- a/backend/app/models/wiki_revision_line.rb +++ /dev/null @@ -1,8 +0,0 @@ -class WikiRevisionLine < ApplicationRecord - belongs_to :wiki_revision - belongs_to :wiki_line - - validates :position, presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :position, uniqueness: { scope: :wiki_revision_id } -end diff --git a/backend/app/models/wiki_version.rb b/backend/app/models/wiki_version.rb deleted file mode 100644 index 4f55804..0000000 --- a/backend/app/models/wiki_version.rb +++ /dev/null @@ -1,8 +0,0 @@ -class WikiVersion < ApplicationRecord - include VersionRecord - - belongs_to :wiki_page - - validates :title, presence: true - validates :body, presence: true -end diff --git a/backend/app/representations/wiki_page_repr.rb b/backend/app/representations/wiki_page_repr.rb index e1b780e..2c747ba 100644 --- a/backend/app/representations/wiki_page_repr.rb +++ b/backend/app/representations/wiki_page_repr.rb @@ -2,14 +2,8 @@ module WikiPageRepr - BASE = { methods: [:title, :deprecated_at] }.freeze - module_function - def base wiki_page - wiki_page.as_json(BASE) - end - def tag_description description tag = description.tag { 'id' => description.id, @@ -29,8 +23,4 @@ module WikiPageRepr def tag_group tag_group TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group') end - - def many wiki_pages - wiki_pages.map { |p| base(p) } - end end diff --git a/backend/app/services/tag_description_operation.rb b/backend/app/services/tag_description_operation.rb index c0d0d11..69020dd 100644 --- a/backend/app/services/tag_description_operation.rb +++ b/backend/app/services/tag_description_operation.rb @@ -1,18 +1,20 @@ class TagDescriptionOperation LANGUAGE_CODE = 'ja' + class Conflict < StandardError + ; + end + 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::CompatibilityWriter.create!( - tag_name: compatibility_tag_name(tag), body:, created_by_user:, message:) - revision = page.wiki_revisions.order(id: :desc).first - record_version!(tag:, revision:, body: revision.body, user: created_by_user, - description: nil) - page + description = TagDescription.new(tag:, language_code: LANGUAGE_CODE) + body = normalise_body!(description, body) + record_version!(description:, body:, user: created_by_user) + description end end @@ -22,51 +24,47 @@ class TagDescriptionOperation tag.lock! description = TagDescription.lock.find_by!( tag_id: tag.id, language_code: LANGUAGE_CODE) - tag_name = compatibility_tag_name(tag) - page = WikiPage.lock.find_by(tag_name_id: tag_name.id) - unless page - versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: LANGUAGE_CODE) - if base_revision_id.present? && versions.maximum(:id) != base_revision_id.to_i - raise Wiki::Commit::Conflict, '競合が発生してゐます.' - end - page = Wiki::CompatibilityWriter.create!( - tag_name:, body:, created_by_user: updated_by_user, message:) - revision = page.current_revision - record_version!(tag:, revision:, body: revision.body, - user: updated_by_user, description:) - next revision + body = normalise_body!(description, body) + latest = TagDescriptionVersion.find_by( + tag_id: tag.id, language_code: LANGUAGE_CODE, + version_no: description.version_no) + if base_revision_id.present? && latest&.id != base_revision_id.to_i + raise Conflict, '競合が発生してゐます.' end - revision = Wiki::CompatibilityWriter.update!( - page:, body:, updated_by_user:, message:, - base_revision_id:) - next unless revision + next if latest && normalise_body(description.body) == body - record_version!(tag:, revision:, body: revision.body, - user: updated_by_user, description:) - revision + record_version!(description:, body:, user: updated_by_user) end end - def self.compatibility_tag_name tag - tag.tag_name(LANGUAGE_CODE) || TagName.find_by!(id: tag.tag_name_id, - language_code: LANGUAGE_CODE) + def self.record_version! description:, body:, user: + creating = description.new_record? + version_no = creating ? 1 : description.version_no + 1 + version = TagDescriptionVersion.create!( + tag_id: description.tag_id, language_code: description.language_code, + version_no:, event_type: creating ? 'create' : 'update', body:, + source_language_code: description.source_language_code, + source_version_no: description.source_version_no, + created_by_user_id: user.id, created_at: Time.current) + description.created_by_user_id = user.id if creating + description.update!(body:, version_no:, updated_by_user_id: user.id) + version 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!( - 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! + + def self.normalise_body body + body.to_s.gsub(/\r\n?/, "\n") + .encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕') + .sub(/\n+\z/, '') + end + private_class_method :normalise_body + + def self.normalise_body! description, body + normalised = normalise_body(body) + return normalised if normalised.present? + + description.errors.add(:body, :blank) + raise ActiveRecord::RecordInvalid, description + end + private_class_method :normalise_body! end diff --git a/backend/app/services/tag_group_operation.rb b/backend/app/services/tag_group_operation.rb index 985276e..b565486 100644 --- a/backend/app/services/tag_group_operation.rb +++ b/backend/app/services/tag_group_operation.rb @@ -1,10 +1,8 @@ class TagGroupOperation MAX_PLACEMENT_DEPTH = 5 - PREFIX = TagGroupRepr::PREFIX - def self.create!(name:, description:, members:, placements:, actor:, - require_compatibility: false, compatibility_body: nil) - group, sync_issue = ApplicationRecord.transaction do + def self.create!(name:, description:, members:, placements:, actor:) + ApplicationRecord.transaction do group = TagGroup.new(version_no: 1, created_by_user: actor, updated_by_user: actor) attributes = prepare!(group:, name:, description:, members:, placements:) @@ -13,21 +11,15 @@ class TagGroupOperation group.save! persist_structure!(group:, attributes:, actor:) persist_version!(group:, attributes:, actor:, event_type: 'create') - issue = sync_compatibility!(group:, actor:, old_name: nil, - require_compatibility:, compatibility_body:) - [group, issue] + group end - report_sync_issue(group, actor, sync_issue) - group end - def self.update!(group:, name:, description:, members:, placements:, actor:, - require_compatibility: false, compatibility_body: nil) - group, sync_issue = ApplicationRecord.transaction do + def self.update!(group:, name:, description:, members:, placements:, actor:) + ApplicationRecord.transaction do group.lock! - old_name = group.name attributes = prepare!(group:, name:, description:, members:, placements:) - next [group, nil] if unchanged?(group, attributes) + next group if unchanged?(group, attributes) group.update!(name: attributes.fetch(:name), description: attributes.fetch(:description), @@ -35,12 +27,8 @@ class TagGroupOperation updated_by_user: actor) persist_structure!(group:, attributes:, actor:) persist_version!(group:, attributes:, actor:, event_type: 'update') - issue = sync_compatibility!(group:, actor:, old_name:, - require_compatibility:, compatibility_body:) - [group, issue] + group end - report_sync_issue(group, actor, sync_issue) - group end def self.prepare!(group:, name:, description:, members:, placements:) @@ -313,57 +301,6 @@ class TagGroupOperation end private_class_method :persist_version! - def self.sync_compatibility!(group:, actor:, old_name:, - require_compatibility:, compatibility_body:) - representation = TagGroupRepr.one(group) - body = begin - TagGroupMarkdownRenderer.render(representation) - rescue TagGroupMarkdownRenderer::UnsupportedStructure => error - if compatibility_body.present? - compatibility_body - elsif require_compatibility - invalid!(group, :base, error.message) - else - return error.message - end - end - invalid!(group, :base, '本文は必須です.') if body.blank? - - title = "#{ PREFIX }#{ group.name }" - old_title = old_name && "#{ PREFIX }#{ old_name }" - page = if old_title - WikiPage.joins(:tag_name).find_by(tag_names: { name: old_title }) - else - nil - end - page ||= WikiPage.joins(:tag_name).find_by(tag_names: { name: title }) - - if page - tag_name = page.tag_name - tag_name.update!(name: title) if tag_name.name != title - Wiki::Commit.legacy_content!(page:, body:, created_user: actor, message: nil, - base_revision_id: nil) - else - tag_name = TagName.find_or_initialize_by(language_code: 'ja', name: title) - tag_name.script_code = 'Jpan' - tag_name.primary_flg = true - tag_name.auto_generated = false - tag_name.save! - Wiki::CompatibilityWriter.create!(tag_name:, body:, created_by_user: actor) - end - nil - end - private_class_method :sync_compatibility! - - def self.report_sync_issue(group, actor, reason) - return unless reason - - payload = { status: 'unsupported_structure', tag_group_id: group.id, - version_no: group.version_no, user_id: actor&.id, reason: } - Rails.logger.warn("tag_group_wiki_sync_skipped #{ payload.to_json }") - end - private_class_method :report_sync_issue - def self.placement_references(nodes) nodes.flat_map do |node| own = if node.key?('tag_id') diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb deleted file mode 100644 index d18bd63..0000000 --- a/backend/app/services/wiki/commit.rb +++ /dev/null @@ -1,164 +0,0 @@ -require 'digest' - - -module Wiki - class Commit - class Conflict < StandardError - ; - end - - def self.create_content! tag_name:, body:, created_by_user:, message: nil - if tag_name.tag && tag_name.language_code == 'ja' && !tag_name.name.include?(':') - return TagDescriptionOperation.create!( - tag: tag_name.tag, body:, created_by_user:, message:) - end - - tag_name.errors.add(:base, :invalid) - raise ActiveRecord::RecordInvalid, tag_name - end - - def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil - normalised = normalise_body(body) - - page = WikiPage.new(tag_name:, - body: normalised, - created_user: created_by_user, - updated_user: created_by_user) - - if normalised.blank? - page.errors.add(:body, :blank) - raise ActiveRecord::RecordInvalid, page - end - - ActiveRecord::Base.transaction do - page.save! - - new(page:, created_user: created_by_user).content!( - body: normalised, - message:, - base_revision_id: nil) - - page - end - end - - def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil - description = TagDescription.find_by(tag_id: page.tag_name.tag_id, language_code: 'ja') - description ||= TagDescription.joins(:tag).find_by( - tags: { tag_name_id: page.tag_name_id }, language_code: 'ja') - if description && !page.title.include?(':') - return TagDescriptionOperation.update!( - tag: description.tag, body:, updated_by_user: created_user, - message:, base_revision_id:) - end - - page.errors.add(:base, :invalid) - raise ActiveRecord::RecordInvalid, page - 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 - - def self.redirect! page:, redirect_page:, created_user:, message: nil, base_revision_id: nil - new(page:, created_user:).redirect!(redirect_page:, message:, base_revision_id:) - end - - def initialize page:, created_user: - @page = page - @created_user = created_user - end - - def content! body:, message:, base_revision_id: - normalised = self.class.normalise_body(body) - if normalised.blank? - @page.errors.add(:body, :blank) - raise ActiveRecord::RecordInvalid, @page - end - - lines = split_lines(normalised) - - line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } - tree_sha = Digest::SHA256.hexdigest(line_shas.join(',')) - - ActiveRecord::Base.transaction do - @page.lock! - - if base_revision_id.present? - current_id = @page.wiki_revisions.maximum(:id) - if current_id && current_id != base_revision_id.to_i - raise Conflict, - "競合が発生してゐます" + - "(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." - 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!( - page: @page, - event_type: @page.wiki_versions.exists? ? :update : :create, - reason: message, - created_by_user: @created_user) - - rev = WikiRevision.create!( - wiki_page: @page, - base_revision_id:, - created_user: @created_user, - kind: :content, - redirect_page_id: nil, - message:, - lines_count: lines.length, - tree_sha256: tree_sha) - - rows = line_ids.each_with_index.map do |line_id, pos| - { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } - end - WikiRevisionLine.insert_all!(rows) if rows.any? - rev - end - end - - def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.' - - def self.normalise_body body - s = body.to_s - s.gsub!(/\r\n?/, "\n") - s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕') - s.gsub(/\n+$/, '') - end - - private - - def split_lines(body) = body.split("\n") - - def upsert_lines! lines, line_shas - now = Time.current - - id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h - - missing_by_sha = { } - - line_shas.each_with_index do |sha, i| - next if id_by_sha.key?(sha) - next if missing_by_sha.key?(sha) - - missing_by_sha[sha] = { - sha256: sha, - body: lines[i], - created_at: now, - updated_at: now } - end - - if missing_by_sha.any? - WikiLine.upsert_all(missing_by_sha.values) - id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h - end - - id_by_sha - end - end -end diff --git a/backend/app/services/wiki/compatibility_writer.rb b/backend/app/services/wiki/compatibility_writer.rb deleted file mode 100644 index c7bcaa1..0000000 --- a/backend/app/services/wiki/compatibility_writer.rb +++ /dev/null @@ -1,43 +0,0 @@ -module Wiki - class CompatibilityWriter - def self.create! tag_name:, body:, created_by_user:, message: nil - ApplicationRecord.transaction do - tag_name.lock! - page = WikiPage.lock.find_by(tag_name_id: tag_name.id) - if page - update!(page:, body:, updated_by_user: created_by_user, message:) - page - else - Commit.create_legacy_content!(tag_name:, body:, created_by_user:, message:) - end - end - 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 - - if Commit.normalise_body(page.body) == normalised && page.wiki_revisions.exists? - return - end - - Commit.legacy_content!( - page:, body: normalised, created_user: updated_by_user, message:, - base_revision_id:) - end - end -end diff --git a/backend/app/services/wiki_version_recorder.rb b/backend/app/services/wiki_version_recorder.rb deleted file mode 100644 index 41e1db9..0000000 --- a/backend/app/services/wiki_version_recorder.rb +++ /dev/null @@ -1,21 +0,0 @@ -class WikiVersionRecorder < VersionRecorder - def self.record! page:, event_type:, reason: nil, created_by_user: - new(page:, event_type:, reason:, created_by_user:).record! - end - - def initialize page:, event_type:, reason: nil, created_by_user: - @reason = reason - super(record: page, event_type:, created_by_user:) - end - - private - - def version_class = WikiVersion - def version_association = :wiki_versions - def record_key = :wiki_page - - def snapshot_attributes = { - title: @record.title, - body: @record.body, - reason: @reason } -end -- 2.53.0 From c142f08e6ccce875edc7c8f996b501fb12637358 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 15 Oct 2026 20:02:34 +0900 Subject: [PATCH 3/7] #431 --- backend/app/controllers/tags_controller.rb | 8 ++- .../app/controllers/wiki_pages_controller.rb | 7 ++- backend/app/models/tag.rb | 7 ++- backend/app/models/tag_description_version.rb | 2 +- backend/app/models/tag_group.rb | 2 +- backend/app/models/tag_group_version.rb | 2 + .../app/models/tag_name_sanitisation_rule.rb | 45 +++++++------- .../services/shared_resource_id_allocator.rb | 58 +++++++++++++++---- .../app/services/tag_description_operation.rb | 7 +-- backend/app/services/tag_group_operation.rb | 20 ++++++- 10 files changed, 111 insertions(+), 47 deletions(-) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 13b71ba..2c4d67d 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -802,7 +802,8 @@ class TagsController < ApplicationController affected_tags << alias_tag_name&.tag end - affected_tags.compact.uniq.each do |affected_tag| + affected_tags = affected_tags.compact.uniq(&:id) + affected_tags.each do |affected_tag| TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user, language_code: locale.language_code) end @@ -821,7 +822,10 @@ class TagsController < ApplicationController alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false) end - affected_tags.compact.uniq.each do |affected_tag| + affected_tags.each do |affected_tag| + # update_all records this Tag after its parent relationships are updated. + next if affected_tag.id == tag.id + record_tag_version!(affected_tag, event_type: :update, created_by_user: current_user, language_code: locale.language_code) diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 688fe7b..5cae0b5 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -135,10 +135,10 @@ class WikiPagesController < ApplicationController name = title.delete_prefix(TagGroupRepr::PREFIX) parsed = TagGroupMarkdownParser.parse(body:, group: nil) group = TagGroupOperation.create!( - name:, **parsed.except(:compatibility_body), actor: current_user) + name:, **parsed.except(:compatibility_body), actor: current_user, + require_compatibility: true) version = current_group_version(group) return render_group_representation(TagGroupRepr.one(group), version, - body: parsed.fetch(:compatibility_body), status: :created) end @@ -179,7 +179,8 @@ class WikiPagesController < ApplicationController name = title.delete_prefix(TagGroupRepr::PREFIX) parsed = TagGroupMarkdownParser.parse(body:, group:) TagGroupOperation.update!( - group:, name:, **parsed.except(:compatibility_body), actor: current_user) + group:, name:, **parsed.except(:compatibility_body), actor: current_user, + require_compatibility: true) return head :ok end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 9baa7e6..b89b31f 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -305,9 +305,10 @@ class Tag < ApplicationRecord end TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) - source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false, - auto_generated: false, - updated_at: Time.current) + target_languages = target_tag.tag_names.where(primary_flg: true).pluck(:language_code) + source_tag.tag_names.where(language_code: target_languages) + .update_all(primary_flg: false, auto_generated: false, updated_at: Time.current) + source_tag.tag_names.update_all(tag_id: target_tag.id, updated_at: Time.current) source_tag.destroy! TagVersioning.record!(target_tag, event_type: :update, created_by_user:) diff --git a/backend/app/models/tag_description_version.rb b/backend/app/models/tag_description_version.rb index 69f31ac..39f167d 100644 --- a/backend/app/models/tag_description_version.rb +++ b/backend/app/models/tag_description_version.rb @@ -1,3 +1,3 @@ class TagDescriptionVersion < ApplicationRecord - ; + before_create { SharedResourceIdAllocator.assign!(self) } end diff --git a/backend/app/models/tag_group.rb b/backend/app/models/tag_group.rb index 19cf4ed..cda2107 100644 --- a/backend/app/models/tag_group.rb +++ b/backend/app/models/tag_group.rb @@ -11,7 +11,7 @@ class TagGroup < ApplicationRecord has_many :placements, -> { order(:position) }, class_name: 'TagGroupPlacement', dependent: :delete_all has_many :versions, -> { order(:version_no) }, - class_name: 'TagGroupVersion', dependent: :delete_all + class_name: 'TagGroupVersion' validates :name, presence: true, uniqueness: true, length: { maximum: 255 } validates :version_no, numericality: { only_integer: true, greater_than: 0 } diff --git a/backend/app/models/tag_group_version.rb b/backend/app/models/tag_group_version.rb index 95257e9..46a5cfb 100644 --- a/backend/app/models/tag_group_version.rb +++ b/backend/app/models/tag_group_version.rb @@ -1,6 +1,8 @@ class TagGroupVersion < ApplicationRecord include VersionRecord + before_create { SharedResourceIdAllocator.assign!(self) } + belongs_to :tag_group scope :ordered, -> { order(:version_no) } diff --git a/backend/app/models/tag_name_sanitisation_rule.rb b/backend/app/models/tag_name_sanitisation_rule.rb index b853f00..bd23abc 100644 --- a/backend/app/models/tag_name_sanitisation_rule.rb +++ b/backend/app/models/tag_name_sanitisation_rule.rb @@ -16,31 +16,36 @@ class TagNameSanitisationRule < ApplicationRecord def apply! Language.find_each do |language| - TagName.where(language:).find_each do |tn| - name = sanitise(tn.name) - next if name == tn.name - + TagName.where(language:).find_each do |candidate| TagName.transaction do + tn = TagName.find_by(id: candidate.id) + next unless tn + + name = sanitise(tn.name) + next if name == tn.name + existing_tn = TagName.find_by(language:, name:) - if existing_tn - unless existing_tn.primary_flg - existing_tn = TagName.find_by!(language:, - tag_id: existing_tn.tag_id, - primary_flg: true) + source_tag = tn.tag + target_tag = existing_tn ? existing_tn.tag : source_tag + [source_tag, target_tag].uniq(&:id).sort_by(&:id).each(&:lock!) + TagVersioning.ensure_snapshot!(source_tag, created_by_user: nil) + + if existing_tn && target_tag != source_tag + Tag.merge_tags!(target_tag, source_tag) + tn.reload.destroy! + elsif existing_tn + if tn.primary? + existing_tn.destroy! + tn.update_columns(name:, updated_at: Time.current) + else + tn.destroy! end - next if existing_tn.id == tn.id - - existing_tag = existing_tn.tag - source_tag = tn.tag - - Tag.merge_tags!(existing_tag, source_tag) if existing_tag != source_tag - tn.destroy! - - next + else + # TagName 側の自動サニタイズを回避 + tn.update_columns(name:, updated_at: Time.current) end - # TagName 側の自動サニタイズを回避 - tn.update_columns(name:, updated_at: Time.current) + TagVersioning.record!(target_tag, event_type: :update, created_by_user: nil) end end end diff --git a/backend/app/services/shared_resource_id_allocator.rb b/backend/app/services/shared_resource_id_allocator.rb index 03ad394..c1be915 100644 --- a/backend/app/services/shared_resource_id_allocator.rb +++ b/backend/app/services/shared_resource_id_allocator.rb @@ -4,7 +4,14 @@ require 'digest' class SharedResourceIdAllocator SPACES = { 'tags' => ['tags', 'external_tags'], - 'wiki' => ['tag_descriptions', 'tag_groups'] }.freeze + 'wiki' => ['tag_descriptions', 'tag_groups'], + 'wiki_history' => ['tag_description_versions', 'tag_group_versions'] }.freeze + HISTORY_IDS = { + 'tags' => [ + ['tag_versions', 'tag_id'], + ['nico_tag_versions', 'tag_id'], + ['tag_description_versions', 'tag_id']], + 'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze LOCK_TIMEOUT = 10 def self.assign! record @@ -13,7 +20,9 @@ class SharedResourceIdAllocator connection = record.class.connection transaction = record.class.current_transaction - raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction' unless transaction.open? + unless transaction.open? + raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction' + end database = connection.pool.db_config.database name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }" @@ -41,16 +50,45 @@ class SharedResourceIdAllocator raise end - return if record.id.present? - - # Locking reads see committed maxima even after earlier repeatable reads. - maxima = record.class.uncached do - tables.map do |table| + next_id = record.class.uncached do + historical_max = HISTORY_IDS.fetch(space, []).map do |table, column| quoted_table = connection.quote_table_name(table) + quoted_column = connection.quote_column_name(column) connection.select_value( - "SELECT id FROM #{ quoted_table } ORDER BY id DESC LIMIT 1 FOR UPDATE").to_i - end + "SELECT MAX(#{ quoted_column }) FROM #{ quoted_table }").to_i + end.max.to_i + [next_ids(connection, tables).max, historical_max + 1].max end - record.id = maxima.max + 1 + if record.id.present? + if record.id < next_id + raise ActiveRecord::RecordNotUnique, 'Shared ID is below the reserved position' + end + else + record.id = next_id + end + # The INSERT advances InnoDB's persistent counter, including explicit IDs. end + + def self.next_ids connection, tables + expiry = connection.select_value('SELECT @@SESSION.information_schema_stats_expiry').to_i + begin + connection.execute('SET SESSION information_schema_stats_expiry = 0') + quoted_tables = tables.map { connection.quote(_1) }.join(', ') + rows = connection.select_all(<<~SQL).to_a + SELECT TABLE_NAME, ENGINE, AUTO_INCREMENT + FROM information_schema.tables + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (#{ quoted_tables }) + SQL + persistent = rows.all? do |row| + row['ENGINE'] == 'InnoDB' && row['AUTO_INCREMENT'].to_i.positive? + end + unless rows.size == tables.size && persistent + raise ActiveRecord::ActiveRecordError, 'Persistent InnoDB counters are required' + end + rows.map { _1.fetch('AUTO_INCREMENT').to_i } + ensure + connection.execute("SET SESSION information_schema_stats_expiry = #{ expiry }") + end + end + private_class_method :next_ids end diff --git a/backend/app/services/tag_description_operation.rb b/backend/app/services/tag_description_operation.rb index 69020dd..80284e8 100644 --- a/backend/app/services/tag_description_operation.rb +++ b/backend/app/services/tag_description_operation.rb @@ -40,15 +40,14 @@ class TagDescriptionOperation def self.record_version! description:, body:, user: creating = description.new_record? version_no = creating ? 1 : description.version_no + 1 - version = TagDescriptionVersion.create!( + description.created_by_user_id = user.id if creating + description.update!(body:, version_no:, updated_by_user_id: user.id) + TagDescriptionVersion.create!( tag_id: description.tag_id, language_code: description.language_code, version_no:, event_type: creating ? 'create' : 'update', body:, source_language_code: description.source_language_code, source_version_no: description.source_version_no, created_by_user_id: user.id, created_at: Time.current) - description.created_by_user_id = user.id if creating - description.update!(body:, version_no:, updated_by_user_id: user.id) - version end private_class_method :record_version! diff --git a/backend/app/services/tag_group_operation.rb b/backend/app/services/tag_group_operation.rb index b565486..6cd506c 100644 --- a/backend/app/services/tag_group_operation.rb +++ b/backend/app/services/tag_group_operation.rb @@ -1,7 +1,8 @@ class TagGroupOperation MAX_PLACEMENT_DEPTH = 5 - def self.create!(name:, description:, members:, placements:, actor:) + def self.create!(name:, description:, members:, placements:, actor:, + require_compatibility: false) ApplicationRecord.transaction do group = TagGroup.new(version_no: 1, created_by_user: actor, updated_by_user: actor) @@ -10,27 +11,40 @@ class TagGroupOperation description: attributes.fetch(:description)) group.save! persist_structure!(group:, attributes:, actor:) + validate_compatibility!(group) if require_compatibility persist_version!(group:, attributes:, actor:, event_type: 'create') group end end - def self.update!(group:, name:, description:, members:, placements:, actor:) + def self.update!(group:, name:, description:, members:, placements:, actor:, + require_compatibility: false) ApplicationRecord.transaction do group.lock! attributes = prepare!(group:, name:, description:, members:, placements:) - next group if unchanged?(group, attributes) + if unchanged?(group, attributes) + validate_compatibility!(group) if require_compatibility + next group + end group.update!(name: attributes.fetch(:name), description: attributes.fetch(:description), version_no: group.version_no + 1, updated_by_user: actor) persist_structure!(group:, attributes:, actor:) + validate_compatibility!(group) if require_compatibility persist_version!(group:, attributes:, actor:, event_type: 'update') group end end + def self.validate_compatibility! group + TagGroupMarkdownRenderer.render(TagGroupRepr.one(group)) + rescue TagGroupMarkdownRenderer::UnsupportedStructure => error + invalid!(group, :base, error.message) + end + private_class_method :validate_compatibility! + def self.prepare!(group:, name:, description:, members:, placements:) record = group normalized_name = name.to_s.strip -- 2.53.0 From 1e464107a858912240199b9f93727fb466eed3f7 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 15 Oct 2026 20:20:00 +0900 Subject: [PATCH 4/7] #431 --- .../spec/db/tag_description_migration_spec.rb | 34 -- backend/spec/db/tag_group_migration_spec.rb | 119 ------- backend/spec/factories/tag_names.rb | 2 + backend/spec/factories/tags.rb | 13 +- backend/spec/factories/wiki_pages.rb | 9 - backend/spec/models/shared_tag_ids_spec.rb | 93 ++---- backend/spec/models/tag_group_spec.rb | 23 ++ .../models/tag_name_sanitisation_rule_spec.rb | 19 -- backend/spec/models/tag_name_spec.rb | 34 +- backend/spec/models/tag_spec.rb | 60 +--- backend/spec/requests/materials_spec.rb | 15 +- .../requests/tag_description_wiki_spec.rb | 90 +----- .../tag_description_wiki_write_spec.rb | 306 ++++-------------- .../spec/requests/tag_descriptions_spec.rb | 88 ++--- backend/spec/requests/tag_group_wiki_spec.rb | 156 +-------- .../requests/tag_group_wiki_write_spec.rb | 57 ++-- .../tag_group_write_integrity_spec.rb | 163 ++-------- backend/spec/requests/tag_groups_spec.rb | 58 ++-- .../spec/requests/tag_resource_ids_spec.rb | 126 +++----- backend/spec/requests/tag_versions_spec.rb | 11 +- .../tag_wiki_history_integrity_spec.rb | 30 +- backend/spec/requests/tags_spec.rb | 68 ++-- backend/spec/requests/wiki_conflict_spec.rb | 12 +- .../requests/wiki_creation_freeze_spec.rb | 18 +- .../requests/wiki_history_integrity_spec.rb | 170 ---------- .../spec/requests/wiki_index_order_spec.rb | 6 +- backend/spec/requests/wiki_spec.rb | 98 ++---- .../requests/wiki_title_collision_spec.rb | 21 +- .../requests/wiki_unmigrated_contract_spec.rb | 85 +---- ...shared_resource_id_allocator_mysql_spec.rb | 39 ++- .../shared_resource_id_allocator_spec.rb | 116 ++++++- .../services/wiki/commit_integrity_spec.rb | 185 ----------- backend/spec/services/wiki/commit_spec.rb | 151 --------- .../services/wiki_version_recorder_spec.rb | 100 ------ backend/spec/support/tag_group_records.rb | 45 +-- backend/spec/support/wiki_records.rb | 28 +- 36 files changed, 566 insertions(+), 2082 deletions(-) delete mode 100644 backend/spec/db/tag_description_migration_spec.rb delete mode 100644 backend/spec/db/tag_group_migration_spec.rb delete mode 100644 backend/spec/factories/wiki_pages.rb create mode 100644 backend/spec/models/tag_group_spec.rb delete mode 100644 backend/spec/requests/wiki_history_integrity_spec.rb delete mode 100644 backend/spec/services/wiki/commit_integrity_spec.rb delete mode 100644 backend/spec/services/wiki/commit_spec.rb delete mode 100644 backend/spec/services/wiki_version_recorder_spec.rb diff --git a/backend/spec/db/tag_description_migration_spec.rb b/backend/spec/db/tag_description_migration_spec.rb deleted file mode 100644 index c34c2a1..0000000 --- a/backend/spec/db/tag_description_migration_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -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_legacy_content!( - tag_name:, body: 'A', created_by_user: user) - revisions = [page.current_revision] - ['A', 'B', 'B', 'A'].each do |body| - revisions << Wiki::Commit.legacy_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/db/tag_group_migration_spec.rb b/backend/spec/db/tag_group_migration_spec.rb deleted file mode 100644 index c9e7566..0000000 --- a/backend/spec/db/tag_group_migration_spec.rb +++ /dev/null @@ -1,119 +0,0 @@ -require 'rails_helper' - -RSpec.describe 'Tag group migration identity and structure' do - include TagGroupRecords - - let!(:user) { create_member_user! } - - before { I18n.locale = :ja } - - it 'preserves page and revision IDs, timestamps and repeated historical structures' do - tag = group_tag('洗澡鹿') - body = "説明文\n\n- 洗澡鹿\n- 洗澡鹿" - page = legacy_group('伊地知ニジカ亜種', body) - revisions = [page.current_revision] - [body, '# 空の区分', body].each do |text| - revisions << Wiki::Commit.legacy_content!(page:, body: text, created_user: user) - end - before_state = group_state.except(*TagGroupRecords::GROUP_TABLES) - - migrate_groups - - versions = group_rows('tag_group_versions', tag_group_id: page.id) - .sort_by { _1.fetch('version_no') } - expect(versions.map { _1.fetch('id') }).to eq(revisions.map(&:id)) - expect(versions.map { _1.fetch('version_no') }).to eq([1, 2, 3, 4]) - expect(versions.map { _1.fetch('created_at') }).to eq(revisions.map(&:created_at)) - expect(versions.map { _1.fetch('created_by_user_id') }).to eq([user.id] * 4) - expect(versions.map { _1.fetch('event_type') }).to eq(['create', 'update', 'update', 'update']) - expect(group_version(page.id, 1).fetch('placements')).to eq( - group_version(page.id, 2).fetch('placements')) - expect(group_version(page.id, 3).fetch('placements')).to eq([ - { 'section_name' => '空の区分', 'description' => nil, 'children' => [] }]) - expect_group_snapshot( - page.id, number: 4, description: '説明文', - members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }], - placements: [{ 'tag_id' => tag.id, 'children' => [] }] * 2) - expect(group_rows('tag_groups', id: page.id).sole).to include( - 'created_at' => page.created_at, 'updated_at' => page.reload.updated_at) - expect(group_state.except(*TagGroupRecords::GROUP_TABLES)).to eq(before_state) - end - - it 'migrates nested list Sections and child groups as different placement kinds' do - series = group_tag('ぼざろクリーチャーシリーズ') - deer = group_tag('洗澡鹿') - nijika = group_tag('伊地知ニジカ') - child = legacy_group('伊地知ニジカ亜種', <<~MARKDOWN) - - ぼざろクリーチャーシリーズ - - 伊地知ニジカ亜種: - - 洗澡鹿 - MARKDOWN - parent = legacy_group('ぼざろクリーチャーシリーズのキャラクター', <<~MARKDOWN) - - ぼざろクリーチャーシリーズ - - 伊地知ニジカ - - タググループ:伊地知ニジカ亜種 - MARKDOWN - - migrate_groups - - expect_group_snapshot( - child.id, number: 1, - members: [series, deer].map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } }, - placements: [{ 'tag_id' => series.id, 'children' => [ - { 'section_name' => '伊地知ニジカ亜種', 'description' => nil, - 'children' => [{ 'tag_id' => deer.id, 'children' => [] }] }] }]) - expect_group_snapshot( - parent.id, number: 1, - members: [ - { 'tag_id' => series.id, 'version_no' => series.version_no }, - { 'tag_id' => nijika.id, 'version_no' => nijika.version_no }, - { 'tag_group_id' => child.id, 'version_no' => 1 }], - placements: [{ 'tag_id' => series.id, 'children' => [ - { 'tag_id' => nijika.id, 'children' => [ - { 'tag_group_id' => child.id, 'children' => [] }] }] }]) - end - - it 'preserves heading order, group paragraphs and Section descriptions' do - names = ['タグ希望', 'キャラクター名希望', '楽曲情報希望', 'bot操作', 'ニジラー情報不詳'] - tags = names.map { group_tag(_1) } - markdown = <<~MARKDOWN - 全体の説明 - - # 要求 - 要求の説明 - - - タグ希望 - - キャラクター名希望 - - 楽曲情報希望 - - # 情報 - - bot操作 - - ニジラー情報不詳 - MARKDOWN - page = legacy_group('メタタグ', markdown) - expect(page.current_revision.body).to eq(markdown.chomp) - - migrate_groups - - leaves = tags.map { { 'tag_id' => _1.id, 'children' => [] } } - leaves.first['children'] = leaves[1..2] - expect_group_snapshot( - page.id, number: 1, description: '全体の説明', - members: tags.map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } }, - placements: [ - { 'section_name' => '要求', 'description' => '要求の説明', - 'children' => [leaves.first] }, - { 'section_name' => '情報', 'description' => nil, - 'children' => leaves[3..4] }]) - end - - it 'leaves Help in the legacy domain' do - page = Wiki::Commit.create_legacy_content!( - tag_name: create(:tag_name, name: 'Help:移行対象外'), - body: 'Help 本文', created_by_user: user) - before_state = group_state - migrate_groups - expect(group_state).to eq(before_state) - expect(page.reload.body).to eq('Help 本文') - end -end diff --git a/backend/spec/factories/tag_names.rb b/backend/spec/factories/tag_names.rb index 0e98e3b..f5820cc 100644 --- a/backend/spec/factories/tag_names.rb +++ b/backend/spec/factories/tag_names.rb @@ -6,6 +6,8 @@ FactoryBot.define do auto_generated { false } sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" } + association :tag, primary_tag_name: nil + trait :alias do primary_flg { false } end diff --git a/backend/spec/factories/tags.rb b/backend/spec/factories/tags.rb index d4ac59b..a01a84c 100644 --- a/backend/spec/factories/tags.rb +++ b/backend/spec/factories/tags.rb @@ -4,7 +4,7 @@ FactoryBot.define do primary_name { nil } primary_tag_name do attributes = primary_name.nil? ? { } : { name: primary_name } - build(:tag_name, **attributes) + build(:tag_name, tag: nil, **attributes) end end @@ -12,17 +12,16 @@ FactoryBot.define do post_count { 0 } after(:build) do |tag, evaluator| + next unless evaluator.primary_tag_name + evaluator.primary_tag_name.primary_flg = true + evaluator.primary_tag_name.tag = tag tag.tag_names.target << evaluator.primary_tag_name end - before(:create) do |tag, evaluator| - evaluator.primary_tag_name.save! - # Compatibility write only; ownership is established through tag_id below. - tag.tag_name_id = evaluator.primary_tag_name.id - end - after(:create) do |tag, evaluator| + next unless evaluator.primary_tag_name + evaluator.primary_tag_name.update!(tag:, primary_flg: true) Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a diff --git a/backend/spec/factories/wiki_pages.rb b/backend/spec/factories/wiki_pages.rb deleted file mode 100644 index 7f5f41a..0000000 --- a/backend/spec/factories/wiki_pages.rb +++ /dev/null @@ -1,9 +0,0 @@ -FactoryBot.define do - factory :wiki_page do - title { "TestPage" } - association :created_user, factory: :user - association :updated_user, factory: :user - - body { ' ' } - end -end diff --git a/backend/spec/models/shared_tag_ids_spec.rb b/backend/spec/models/shared_tag_ids_spec.rb index 96ef653..378eebc 100644 --- a/backend/spec/models/shared_tag_ids_spec.rb +++ b/backend/spec/models/shared_tag_ids_spec.rb @@ -1,84 +1,37 @@ require 'rails_helper' RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do - include TagGroupRecords - let(:user) { create_member_user! } + it 'allocates distinct increasing IDs across the two tables' do + tag = create(:tag) + external = create(:external_tag) + next_tag = create(:tag) - def create_resource resource - if resource == :tag - Tag.find_or_create_by_tag_name!(Locale.nipponese, 'shared_id_new', category: :general) - else - ExternalTag.create!(platform: :nico, name: 'shared_id_new') - end + expect(external.id).to be > tag.id + expect(next_tag.id).to be > external.id end - [:tag, :external_tag].each do |resource| - it "starts #{ resource } at 1 when both tables are empty" do - expect(Tag.count).to eq(0) - expect(ExternalTag.count).to eq(0) - expect(create_resource(resource).id).to eq(1) - end + it 'does not reuse physically deleted IDs in either table' do + tag = create(:tag) + external = create(:external_tag) + TagName.where(tag_id: tag.id).delete_all + tag.destroy! + external.destroy! - it "allocates #{ resource } above the other table's maximum" do - create(:tag, id: resource == :tag ? 1 : 20) - create(:external_tag, id: resource == :external_tag ? 1 : 20) - - expect(create_resource(resource).id).to eq(21) - end - - it "uses the current maximum after deleting earlier #{ resource } records" do - tag = create(:tag, id: 100) - TagName.where(tag_id: tag.id).update_all(tag_id: nil) - tag.destroy! - create(:external_tag, id: 200).destroy! - expect(Tag.unscoped.count).to eq(0) - expect(ExternalTag.unscoped.count).to eq(0) - expect(create_resource(resource).id).to eq(1) - end - - it "preserves legacy overlaps and gaps when creating #{ resource }" do - [1, 2, 5].each { create(:tag, id: _1) } - [1, 3, 8].each { create(:external_tag, id: _1) } - tags = Tag.order(:id).map(&:attributes) - external_tags = ExternalTag.order(:id).map(&:attributes) - - record = create_resource(resource) - - aggregate_failures do - expect(record.id).to eq(9) - expect(Tag.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(tags) - expect(ExternalTag.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(external_tags) - expect(Tag.exists?(id: 3)).to be(false) - expect(ExternalTag.exists?(id: 2)).to be(false) - end - end - - it "ignores the description and group ID space when creating #{ resource }" do - tag = create(:tag, id: 1) - create(:external_tag, id: 2) - TagDescription.create!(id: 100, tag:, language_code: 'ja', body: '', version_no: 1) - seed_group(name: 'separate_space', id: 200) - - expect(create_resource(resource).id).to eq(3) - end + expect(create(:tag).id).to be > external.id + expect(create(:external_tag).id).to be > external.id end - it 'preserves overlapping IDs during updates without rewriting the other resource' do - tag = create(:tag, id: 5) - external = create(:external_tag, id: 5) + it 'retains existing overlapping IDs without renumbering on update or insert' do + tag = create(:tag) + external = create(:external_tag) + external.update_columns(id: tag.id) tag.update!(category: :meta) external.update!(name: 'updated_external') + before_tag = tag.reload.attributes + before_external = external.reload.attributes - expect(tag.reload.id).to eq(5) - expect(external.reload.id).to eq(5) - expect(tag.category).to eq('meta') - expect(external.name).to eq('updated_external') - expect(Tag.count).to eq(1) - expect(ExternalTag.count).to eq(1) - end - - it 'allocates distinct IDs for successive creations across both tables' do - records = [:tag, :external_tag].map { create_resource(_1) } - expect(records.map(&:id)).to eq([1, 2]) + expect(create(:tag).id).to be > tag.id + expect(tag.reload.attributes).to eq(before_tag) + expect(external.reload.attributes).to eq(before_external) end end diff --git a/backend/spec/models/tag_group_spec.rb b/backend/spec/models/tag_group_spec.rb new file mode 100644 index 0000000..ee52647 --- /dev/null +++ b/backend/spec/models/tag_group_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe TagGroup, type: :model do + let(:actor) { create_member_user! } + + def create_group name:, members: [], placements: [] + TagGroupOperation.create!(name:, description: nil, members:, placements:, actor:) + end + + it 'retains version IDs and snapshots after destroying an empty group' do + group = create_group(name: 'history owner') + versions = group.versions.order(:version_no).map(&:attributes) + + expect { group.destroy! }.to change(described_class, :count).by(-1) + .and change(TagGroupVersion, :count).by(0) + + expect(TagGroupVersion.where(tag_group_id: group.id).order(:version_no).map(&:attributes)) + .to eq(versions) + expect(TagGroupRepr.historical(TagGroupVersion.find(versions.first.fetch('id')))) + .to include('id' => group.id, 'name' => 'history owner', 'members' => []) + end + +end diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index 9cebc51..cec475d 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -98,25 +98,6 @@ RSpec.describe TagNameSanitisationRule, type: :model do end end - context 'when the source tag_name has a tag and the existing one has no tag' do - let!(:existing) { create(:tag_name, name: 'foobar') } - let!(:source_tag) { create(:tag, primary_name: 'tmp', category: :general) } - let!(:source_tag_name_id) { primary_tag_name_for(source_tag, 'ja').id } - - before do - primary_tag_name_for(source_tag, 'ja').update_columns( - name: 'foo_bar', updated_at: Time.current) - end - - it 'moves the tag to the existing tag_name' do - described_class.apply! - expect(existing.reload).to have_attributes( - tag_id: source_tag.id, language_code: 'ja', primary_flg: true) - expect(primary_tag_name_for(source_tag, 'ja')).to eq(existing) - expect(primary_tag_name_for(source_tag.reload, 'ja')).to eq(existing) - expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false) - end - end context 'when the sanitised name is an alias of an existing tag' do let!(:existing_tag) { create(:tag) } diff --git a/backend/spec/models/tag_name_spec.rb b/backend/spec/models/tag_name_spec.rb index 8239d65..3d8ff38 100644 --- a/backend/spec/models/tag_name_spec.rb +++ b/backend/spec/models/tag_name_spec.rb @@ -22,16 +22,23 @@ RSpec.describe TagName, type: :model do expect(duplicate.errors.of_kind?(:name, :taken)).to be(true) end - it 'creates Nipponese factory names without requiring a tag' do + it 'creates Nipponese factory names with an owning tag' do name = create(:tag_name) expect(name.reload).to have_attributes( language_code: 'ja', script_code: 'Jpan', primary_flg: true, - auto_generated: false, tag_id: nil) + auto_generated: false, tag_id: name.tag.id) + end + + it 'rejects an ownerless name at model and database boundaries' do + name = build(:tag_name, tag: nil) + expect(name).to be_invalid + expect { name.save!(validate: false) }.to raise_error(ActiveRecord::NotNullViolation) end it 'defaults ordinary persisted human names to confirmed provenance' do - name = described_class.create!(name: 'ordinary_human_name', language_code: 'ja', + name = described_class.create!(tag: create(:tag, primary_tag_name: nil), + name: 'ordinary_human_name', language_code: 'ja', script_code: 'Jpan', primary_flg: true) expect(name.reload.auto_generated).to be(false) @@ -46,7 +53,7 @@ RSpec.describe TagName, type: :model do expect(name.tag).to eq(tag) expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true)) .to eq(name) - expect(tag.reload.tag_name_id).to eq(name.id) + expect(tag.reload.tag_name('ja')&.id).to eq(name.id) end it 'creates a complete ordinary tag factory under the active Nipponese language' do @@ -96,19 +103,14 @@ RSpec.describe TagName, type: :model do end describe '.canonicalise' do - it 'leaves detached aliases unchanged' do - create(:tag_name, :alias, name: 'detached_alias') - expect(described_class.canonicalise(Locale.nipponese, ['detached_alias'])) - .to eq(['detached_alias']) - end - it 'does not resolve a detached alias through another detached primary' do - create(:tag_name, :alias, name: 'detached_alias') - create(:tag_name, name: 'unrelated_detached_primary') + it 'resolves an alias to its owning primary name' do + tag = create(:tag, primary_name: 'canonical') + create(:tag_name, :alias, tag:, name: 'alias') - expect(described_class.canonicalise(Locale.nipponese, ['detached_alias'])) - .to eq(['detached_alias']) + expect(described_class.canonicalise(Locale.nipponese, ['alias', 'unknown'])) + .to eq(['canonical', 'unknown']) end it 'resolves the primary name of the alias language on the same tag' do @@ -119,7 +121,7 @@ RSpec.describe TagName, type: :model do english_primary.update!(name: 'english_primary', auto_generated: false) english_alias = create(:tag_name, :alias, tag:, name: 'english_alias', language_code: 'en', script_code: 'Latn') - representative_id = tag.tag_name_id + representative_id = tag.tag_name('ja')&.id primary = described_class.find_by!(tag_id: english_alias.tag_id, language_code: english_alias.language_code, @@ -128,7 +130,7 @@ RSpec.describe TagName, type: :model do expect(primary).to eq(english_primary) expect(described_class.canonicalise(locale, [english_alias.name])) .to eq([english_primary.name]) - expect(tag.reload.tag_name_id).to eq(representative_id) + expect(tag.reload.tag_name('ja')&.id).to eq(representative_id) end it 'resolves only aliases in the requested language' do diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 0cc9d8c..2bff7fa 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' RSpec.describe Tag, type: :model do + include WikiRecords describe '#has_description?' do let(:tag) { create(:tag) } @@ -14,12 +15,6 @@ RSpec.describe Tag, type: :model do expect(tag.has_description?(language_code: 'ja')).to be(false) end - it 'does not use a Wiki page as evidence of a description' do - user = create_member_user! - Wiki::Commit.create_legacy_content!(tag_name: tag.tag_name('ja'), body: 'legacy', - created_by_user: user) - expect(tag.has_description?(language_code: 'ja')).to be(false) - end it 'reuses preloaded descriptions without reading Wiki or querying again' do TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1) @@ -334,38 +329,7 @@ RSpec.describe Tag, type: :model do .where(tag_names: { name: 'atomic_english_name' })).to be_empty end - it 'keeps a detached requested name detached if generation fails' do - pending '日本語以外の Locale 有効化は別課題で実装する' - locale - detached = create(:tag_name, :alias, name: 'atomic_detached', - language_code: 'en', script_code: 'Latn') - original_tag_count = described_class.count - allow(TagName).to receive(:generate_name).and_raise('generation failed') - expect { - described_class.find_or_create_by_tag_name!( - locale, detached.name, category: :general) - }.to raise_error('generation failed') - - expect(described_class.count).to eq(original_tag_count) - expect(detached.reload).to have_attributes(tag_id: nil, primary_flg: false) - end - - it 'promotes a detached alias into the requested primary name' do - pending '日本語以外の Locale 有効化は別課題で実装する' - locale - detached = create(:tag_name, :alias, name: 'detached_english', - language_code: 'en', script_code: 'Latn') - - tag = described_class.find_or_create_by_tag_name!( - locale, detached.name, category: :general) - - expect(detached.reload).to have_attributes( - tag_id: tag.id, primary_flg: true, canonical_id: nil, - auto_generated: false) - expect(TagName.where(tag:, language_code: 'en', primary_flg: true)) - .to contain_exactly(detached) - end it 'creates separate tag identities for the same name in different languages' do pending '日本語以外の Locale 有効化は別課題で実装する' @@ -390,7 +354,7 @@ RSpec.describe Tag, type: :model do pending '日本語以外の Locale 有効化は別課題で実装する' locale tag = create(:tag, primary_name: '日本語代表名') - representative_id = tag.tag_name_id + representative_id = tag.tag_name('ja')&.id english = primary_tag_name_for(tag, 'en') english.update!(name: 'english_primary', auto_generated: false) @@ -398,7 +362,7 @@ RSpec.describe Tag, type: :model do locale, english.name, category: :general) expect(found).to eq(tag) - expect(tag.reload.tag_name_id).to eq(representative_id) + expect(tag.reload.tag_name('ja')&.id).to eq(representative_id) expect(english.reload.tag_id).to eq(tag.id) end @@ -430,22 +394,10 @@ RSpec.describe Tag, type: :model do expect(primary_tag_name_for(tag, 'ja').auto_generated).to be(false) end - it 'promotes a detached Nipponese alias into a human-confirmed primary' do - detached = create(:tag_name, :alias, name: 'detached_nipponese') - - tag = described_class.find_or_create_by_tag_name!( - Locale.nipponese, detached.name, category: :general) - - expect(detached.reload).to have_attributes( - tag_id: tag.id, primary_flg: true, canonical_id: nil, - auto_generated: false) - expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) - .to contain_exactly(detached) - end it 'reuses the owning tag for an alias without changing its category' do tag = create(:tag, category: :character) - representative_id = tag.tag_name_id + representative_id = tag.tag_name('ja')&.id alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:) expect(alias_name.reload).to have_attributes( tag_id: tag.id, @@ -459,7 +411,7 @@ RSpec.describe Tag, type: :model do expect(found).to eq(tag) expect(found.category).to eq('character') - expect(tag.reload.tag_name_id).to eq(representative_id) + expect(tag.reload.tag_name('ja')&.id).to eq(representative_id) end it 'reuses the owning tag through another alias' do @@ -683,7 +635,7 @@ RSpec.describe Tag, type: :model do let!(:wiki_page) do admin = create_admin_user! - Wiki::Commit.create_content!( + seed_description( tag_name: source_tag_name, body: 'source wiki body', created_by_user: admin, diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index b891faa..0a2ea89 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -222,28 +222,27 @@ RSpec.describe 'Materials API', type: :request do expect(queries).to be_empty end - it 'reads the description even when the legacy revision has different content' do - page = seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'legacy body', + it 'reads the description even when the stored version has different content' do + page = seed_description(tag_name: tag.tag_name('ja'), body: 'legacy body', created_by_user: member_user) TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') .update!(body: 'description body') get_material_without_legacy_reads expect(json.fetch('wiki_page_body')).to eq('description body') - expect(page.current_revision.body).to eq('legacy body') + expect(current_description_version(page).body).to eq('legacy body') end it 'uses tag_id rather than description ID without a Wiki page or TagName' do - description = TagDescription.create!(id: tag.id + 1000, tag:, language_code: 'ja', + description = TagDescription.create!(tag:, language_code: 'ja', body: 'independent body', version_no: 1) - tag.tag_names.update_all(tag_id: nil) + tag.tag_names.delete_all get_material_without_legacy_reads - expect(description.id).not_to eq(tag.id) expect(json.fetch('wiki_page_body')).to eq('independent body') expect(json.fetch('tag').fetch('has_wiki')).to be(true) end - it 'returns nil when only legacy Wiki content exists' do - seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'legacy only', + it 'returns nil when only description history remains' do + seed_description(tag_name: tag.tag_name('ja'), body: 'legacy only', created_by_user: member_user) TagDescription.where(tag_id: tag.id).delete_all get_material_without_legacy_reads diff --git a/backend/spec/requests/tag_description_wiki_spec.rb b/backend/spec/requests/tag_description_wiki_spec.rb index 02b1184..5a52675 100644 --- a/backend/spec/requests/tag_description_wiki_spec.rb +++ b/backend/spec/requests/tag_description_wiki_spec.rb @@ -4,34 +4,21 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do include WikiRecords let!(:user) { create_member_user! } let!(:tag_name) { create(:tag_name, name: 'description_contract') } - let!(:page) do - seed_wiki_page( - tag_name:, body: 'old-revision', created_by_user: user) - end - let!(:revisions) do - first = page.current_revision - second = Wiki::Commit.legacy_content!(page:, body: 'old-second', created_user: user) - third = Wiki::Commit.legacy_content!(page:, body: 'old-third', created_user: user) - [first, second, third] - end - let!(:tag) { create(:tag, primary_tag_name: tag_name) } + let!(:tag) { tag_name.tag } let!(:versions) do - revisions.each_with_index.map do |revision, index| + ['A', 'A', 'new-version'].each_with_index.map do |body, 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) + tag_id: tag.id, language_code: 'ja', version_no: index + 1, + event_type: index.zero? ? 'create' : 'update', body:, + created_by_user_id: user.id, created_at: Time.current) end end let!(:description) do TagDescription.create!( - id: page.id, tag:, language_code: 'ja', version_no: 3, body: 'new', + 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 = [] @@ -70,7 +57,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do 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 } + get "/wiki/#{ description.id }", params: { version: versions.first.id } end expect(response).to have_http_status(:not_found) end @@ -132,16 +119,6 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do '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 @@ -155,28 +132,6 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do 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 @@ -229,39 +184,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do '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 'excludes unmigrated history and migrated legacy-only revisions' do - legacy_page = seed_wiki_page( - 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).not_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| diff --git a/backend/spec/requests/tag_description_wiki_write_spec.rb b/backend/spec/requests/tag_description_wiki_write_spec.rb index 3b3af9d..0658d7c 100644 --- a/backend/spec/requests/tag_description_wiki_write_spec.rb +++ b/backend/spec/requests/tag_description_wiki_write_spec.rb @@ -1,280 +1,92 @@ require 'rails_helper' -RSpec.describe 'Tag description Wiki dual writes', type: :request do +RSpec.describe 'Tag description Wiki facade writes', type: :request do include WikiRecords let!(:user) { create_member_user! } - let!(:tag_name) { create(:tag_name, name: 'description_write') } - let(:tag) { create(:tag, primary_tag_name: tag_name) } + let!(:tag) { create(:tag, primary_name: 'description_write') } let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } def stored_state - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion, - TagName, TagVersion].map do |model| + [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].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 a description with matching Wiki content and histories' 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) - description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') - expect(description).to have_attributes(body: 'initial', version_no: 1) - page = WikiPage.find_by!(tag_name:) - expect(page.body).to eq(description.body) - expect(page.current_revision.body).to eq(description.body) - expect(page.wiki_versions.sole.body).to eq(description.body) - expect(json.fetch('id')).to eq(description.id) - expect(TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')) - .to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id) - end - - it 'reuses an existing compatibility page and synchronises its histories' do - page = Wiki::Commit.create_legacy_content!( - tag_name:, body: 'legacy', created_by_user: user) - expect { create_description } - .to change(WikiPage, :count).by(0) - .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) - expect(page.reload.body).to eq(description.body) - expect(TagDescriptionVersion.find(page.current_revision.id).body).to eq(page.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 - - [false, true].each do |linked| - it "rejects namespaced creation with Tag present: #{ linked }" do - name = create(:tag_name, name: 'Help:frozen') - create(:tag, primary_tag_name: name) if linked - before_state = stored_state - post '/wiki', params: { title: name.name, body: 'legacy' }, headers: headers - expect(response.status).to be_between(400, 499) - expect(stored_state).to eq(before_state) - end - end - - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| - it "rolls back new description creation when #{ model.name } persistence fails" do - before_state = stored_state - aggregate_failures do - fail_save(model) { create_description } - expect(stored_state).to eq(before_state) - end - end - end - end - - it 'creates missing compatibility data without writing an ID-colliding Help page' do - description = TagDescription.create!( - id: 500, tag:, language_code: 'ja', body: 'before', version_no: 1) - help = WikiPage.create!(id: description.id, - tag_name: create(:tag_name, name: 'Help:unrelated'), - body: 'unrelated', created_user: user, updated_user: user) - Wiki::Commit.legacy_content!(page: help, body: help.body, created_user: user) - before_help = help.attributes + it 'creates a native description and exposes its version ID as revision ID' do expect do - put "/wiki/#{ description.id }", params: { title: tag_name.name, body: 'after' }, - headers: headers - end.to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - expect(response).to have_http_status(:ok) - expect(description.reload).to have_attributes(id: 500, body: 'after') - expect(help.reload.attributes).to eq(before_help) - compatibility = WikiPage.find_by!(tag_name:) - expect(compatibility.body).to eq(description.body) - expect(compatibility.current_revision.body).to eq(description.body) + post '/wiki', params: { title: tag.name('ja'), body: 'initial' }, headers: headers + end.to change(TagDescription, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + expect(response).to have_http_status(:created) + description = TagDescription.find(json.fetch('id')) + expect(description).to have_attributes(tag_id: tag.id, body: 'initial', version_no: 1) + get "/wiki/#{ description.id }" + expect(json.fetch('revision_id')).to eq(current_description_version(description).id) end - describe 'PUT /wiki/:id' do - let!(:page) do - tag - seed_wiki_page( - tag_name:, body: 'before', created_by_user: user) - end - let!(:first_revision) { page.current_revision } - + context 'with an existing description' do let!(:description) do - TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') + TagDescriptionOperation.create!(tag:, body: 'before', created_by_user: user) end + let!(:first_version) { current_description_version(description) } - 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 }, + def update_description body: 'after', title: tag.name('ja'), base: first_version.id + put "/wiki/#{ description.id }", params: { title:, body:, 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(1) - expect { update_description } - .to change(WikiPage, :count).by(0) - .and 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: 2) - expect(version).to have_attributes( - 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 '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(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - .and change(TagDescriptionVersion, :count).by(1) - + it 'records a body change without changing resource identity or old history' do + snapshot = first_version.attributes + expect { update_description }.to 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) + expect(first_version.reload.attributes).to eq(snapshot) + expect(current_description_version(description)).to have_attributes( + body: 'after', event_type: 'update', created_by_user_id: user.id) end - 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(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 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: headers - - expect(response).to have_http_status(:ok) - expect(tag_name.reload.name).to eq('description_tag_renamed') - 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( - 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', "before\r\n\r\n"].each do |body| + it "does not write for normalised-equivalent body #{ body.inspect }" do before_state = stored_state - aggregate_failures do - fail_save(model) do - update_description(body: 'rollback body', title: 'rollback_rename') - end - expect(stored_state).to eq(before_state) - end + update_description(body:) + expect(response).to have_http_status(:ok) + expect(stored_state).to eq(before_state) 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:) + it 'records a title-only edit in Tag history without adding body history' do + snapshot = description.attributes + expect { update_description(body: 'before', title: 'renamed') } + .to change(TagVersion, :count).by(2) + .and change(TagDescriptionVersion, :count).by(0) + expect(response).to have_http_status(:ok) + expect(tag.reload.name('ja')).to eq('renamed') + expect(description.reload.attributes).to eq(snapshot) + end + ['conflicting body', 'after'].each do |body| + it "rejects a stale revision even for #{ body } without a partial rename" do + update_description 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) + before_state = stored_state + update_description(body:, title: 'conflicting rename') + expect(response).to have_http_status(:conflict) + expect(stored_state).to eq(before_state) + end + end + + [TagDescription, TagDescriptionVersion].each do |model| + it "rolls back the rename and body after #{ model.name } persistence fails" do + before_state = stored_state + failure = Class.new(StandardError) + callback = ->(_record) { raise failure } + model.set_callback(:save, :after, callback) + begin + expect { update_description(title: 'rollback rename') }.to raise_error(failure) + expect(stored_state).to eq(before_state) + ensure + model.skip_callback(:save, :after, callback) + end end end end diff --git a/backend/spec/requests/tag_descriptions_spec.rb b/backend/spec/requests/tag_descriptions_spec.rb index 7901144..7fecf45 100644 --- a/backend/spec/requests/tag_descriptions_spec.rb +++ b/backend/spec/requests/tag_descriptions_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' RSpec.describe 'Tag description native API', type: :request do + include WikiRecords + include TagGroupRecords let!(:author) { create_member_user! } let(:editor) { create(:user, :member, name: 'description editor') } let(:tag_name) { create(:tag_name, name: 'native_description') } @@ -9,8 +11,7 @@ RSpec.describe 'Tag description native API', type: :request do let(:endpoint) { "/tags/#{ tag.id }/description" } def stored_state - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion, - WikiLine, WikiRevisionLine, TagName, TagVersion].map do |model| + [TagDescription, TagDescriptionVersion, TagName, TagVersion].map do |model| model.order(:id).map(&:attributes) end end @@ -84,7 +85,7 @@ RSpec.describe 'Tag description native API', type: :request do describe 'native reads' do let!(:description) do TagDescription.create!( - id: tag.id + 1_000_000, tag:, language_code: 'ja', body: 'native current', + 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 @@ -95,15 +96,14 @@ RSpec.describe 'Tag description native API', type: :request do 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_tag = create(:tag, primary_name: 'handle_collision') + description.update_columns(id: other_tag.id) 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) @@ -153,32 +153,6 @@ RSpec.describe 'Tag description native API', type: :request do 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 @@ -206,44 +180,24 @@ RSpec.describe 'Tag description native API', type: :request do context 'without an existing description' do before { tag } - it 'creates a description with synchronised Wiki content and histories' do - unrelated = Wiki::Commit.create_legacy_content!( - tag_name: create(:tag_name, name: 'Help:unrelated_native_description'), - body: 'unrelated body', created_by_user: author) - unrelated_state = [unrelated.attributes, - unrelated.wiki_revisions.map(&:attributes), - unrelated.wiki_versions.map(&:attributes)] + + it 'creates native history that is immediately available through the facade' 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_by!(tag_name: tag.tag_name('ja')) - 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) - unrelated.reload - expect([unrelated.attributes, unrelated.wiki_revisions.map(&:attributes), - unrelated.wiki_versions.map(&:attributes)]).to eq(unrelated_state) expect_current(description) - + version = current_description_version(description) + expect(version).to have_attributes( + body: 'created natively', version_no: 1, event_type: 'create', + created_by_user_id: editor.id) get "/wiki/#{ description.id }" expect(response).to have_http_status(:ok) expect(json).to include('body' => description.body, 'revision_id' => version.id) end - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + [TagDescription, TagDescriptionVersion].each do |model| it "rolls back native create when #{ model.name } persistence fails" do headers before_state = stored_state @@ -258,7 +212,7 @@ RSpec.describe 'Tag description native API', type: :request do context 'with an existing description' do let!(:page) do tag - Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author) + seed_description(tag_name:, body: 'before', created_by_user: author) end let!(:description) do TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') @@ -268,22 +222,20 @@ RSpec.describe 'Tag description native API', type: :request 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) + .to 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 + revision = current_description_version(page) 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) + expect(description_versions(page).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) @@ -294,7 +246,7 @@ RSpec.describe 'Tag description native API', type: :request do before_state = stored_state page_state = page.reload.attributes description_state = description.reload.attributes - current_revision_id = page.current_revision.id + current_revision_id = current_description_version(page).id native_write(body:) @@ -302,11 +254,11 @@ RSpec.describe 'Tag description native API', type: :request do 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) + expect(current_description_version(page).id).to eq(current_revision_id) end end - [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + [TagDescription, TagDescriptionVersion].each do |model| it "rolls back native update when #{ model.name } persistence fails" do headers before_state = stored_state diff --git a/backend/spec/requests/tag_group_wiki_spec.rb b/backend/spec/requests/tag_group_wiki_spec.rb index b42805c..d24add8 100644 --- a/backend/spec/requests/tag_group_wiki_spec.rb +++ b/backend/spec/requests/tag_group_wiki_spec.rb @@ -7,21 +7,17 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do let!(:user) { create_member_user! } let!(:old_tag) { group_tag('旧タグ') } let!(:new_tag) { group_tag('新タグ') } - let!(:page) { legacy_group('読取り契約', '- 旧タグ') } - let!(:first_revision) { page.current_revision } + let!(:page) { create_markdown_group('読取り契約', '- 旧タグ') } + let!(:first_revision) { page.versions.sole } let!(:last_revision) do - Wiki::Commit.legacy_content!(page:, body: '- 新タグ', created_user: user) + parsed = TagGroupMarkdownParser.parse(body: '- 新タグ') + TagGroupOperation.update!( + group: page, name: page.name, actor: user, + **parsed.slice(:description, :members, :placements)) + page.versions.order(:version_no).last end - before do - I18n.locale = :ja - migrate_groups - page.update_columns(body: 'legacy stale current') - page.wiki_revisions.each do |revision| - revision.wiki_lines.update_all(body: 'legacy stale revision') - end - page.wiki_versions.update_all(body: 'legacy stale version') - end + before { I18n.locale = :ja } [:id, :title].each do |lookup| context "with #{ lookup } lookup" do @@ -71,36 +67,13 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do - help = seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:混在'), body: 'help', created_by_user: user) TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user) get '/wiki' expect(response).to have_http_status(:ok) expect(json.map { _1.fetch('title') }).to include( 'タググループ:読取り契約', '新タグ') - expect(json.map { _1.fetch('title') }).not_to include(help.title) end - it 'excludes Help when its ID collides with a new group' do - help = seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body', - created_by_user: user) - seed_group(name: '別 ID重複', id: help.id, description: 'group body') - - get '/wiki' - expect(response).to have_http_status(:ok) - expect(json.map { _1.fetch('title') }).to include('タググループ:別 ID重複') - expect(json.map { _1.fetch('title') }).not_to include(help.title) - - get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }" - expect(response).to have_http_status(:not_found) - - get '/wiki/changes' - expect(response).to have_http_status(:ok) - expect(json.map { _1.dig('wiki_page', 'title') }).to include( - 'タググループ:別 ID重複') - expect(json.map { _1.dig('wiki_page', 'title') }).not_to include(help.title) - end it 'finds groups by a partial prefix of their displayed title' do get '/wiki', params: { title: 'タググル' } @@ -112,8 +85,9 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end it 'does not restore a stale namespaced Wiki page after a group rename' do - old_page = legacy_group('旧名称', '- 旧本文') - seed_group(name: '新名称', id: old_page.id, description: '正本') + group = create_markdown_group('旧名称', '正本') + TagGroupOperation.update!( + group:, name: '新名称', description: '正本', members: [], placements: [], actor: user) get group_title_path('旧名称') expect(response).to have_http_status(:not_found) @@ -125,12 +99,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end it 'orders mixed resource history by timestamp rather than independent IDs' do - help = seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help', - created_by_user: user) TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user) TagGroupVersion.update_all(created_at: 3.days.ago) - WikiRevision.where(wiki_page_id: help.id).update_all(created_at: 2.days.ago) TagDescriptionVersion.update_all(created_at: 1.day.ago) get '/wiki/changes' @@ -139,7 +109,6 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) } expect(timestamps).to eq(timestamps.sort.reverse) titles = json.map { _1.dig('wiki_page', 'title') } - expect(titles).not_to include(help.title) expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約') end @@ -169,19 +138,6 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do 'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約')) end - it 'includes group history but excludes all legacy-only revisions' do - extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user) - help = seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user) - get '/wiki/changes' - expect(response).to have_http_status(:ok) - rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' } - expect(rows.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id]) - expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id) - expect(json).not_to include(include( - 'revision_id' => help.current_revision.id, - 'wiki_page' => include('title' => help.title))) - end [false, true].each do |explicit| it "diffs structured snapshots with #{ explicit ? 'explicit' : 'current' } target" do @@ -215,95 +171,5 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do expect(json.fetch('body').strip).to eq('独立した説明') end - context 'when Help, description and group share the same numeric ID' do - let(:colliding_help) do - seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:三者ID衝突'), - body: 'help', created_by_user: user) - end - let(:colliding_group_id) do - seed_group(name: '三者ID衝突', id: colliding_help.id, description: 'group') - end - let(:colliding_description) do - TagDescription.create!( - id: colliding_help.id, tag: new_tag, language_code: 'ja', - body: 'description', version_no: 1) - end - let(:colliding_description_version) do - TagDescriptionVersion.create!( - tag_id: new_tag.id, language_code: 'ja', version_no: 1, - event_type: 'create', body: 'description', created_by_user_id: user.id, - created_at: Time.current) - end - before do - colliding_group_id - colliding_description - colliding_description_version - end - - it 'lists both new resources sharing a numeric ID without legacy Help' do - get '/wiki' - - expect(response).to have_http_status(:ok) - rows = json.select { _1.fetch('id') == colliding_help.id } - expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly( - [colliding_description.id, '新タグ'], - [colliding_group_id, 'タググループ:三者ID衝突']) - end - - it 'lists each resource history without deduplicating the shared resource ID' do - get '/wiki/changes' - - expect(response).to have_http_status(:ok) - rows = json.select { _1.fetch('wiki_page').fetch('id') == colliding_help.id } - histories = rows.map do |row| - [row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')] - end - expect(histories).to contain_exactly( - [colliding_description_version.id, '新タグ'], - [group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突']) - end - - it 'still excludes legacy copies of an actually migrated group and its history' do - extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user) - - get '/wiki' - - expect(response).to have_http_status(:ok) - rows = json.select { _1.fetch('id') == page.id } - expect(rows.map { _1.fetch('title') }).to eq(['タググループ:読取り契約']) - - get '/wiki/changes' - - expect(response).to have_http_status(:ok) - rows = json.select { _1.fetch('wiki_page').fetch('id') == page.id } - expect(rows.map { _1.fetch('revision_id') }) - .to contain_exactly(first_revision.id, last_revision.id) - expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id) - expect(rows.map { _1.fetch('wiki_page').fetch('title') }) - .to all(eq('タググループ:読取り契約')) - end - end - - it 'resolves new resources by title while excluding colliding legacy Help' do - help = seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user) - seed_group(name: 'ID衝突', id: help.id, description: 'group') - TagDescription.create!( - id: help.id, tag: new_tag, language_code: 'ja', body: 'description', version_no: 1) - TagDescriptionVersion.create!( - tag_id: new_tag.id, language_code: 'ja', version_no: 1, - event_type: 'create', body: 'description', created_at: Time.current) - { 'タググループ:ID衝突' => 'group', '新タグ' => 'description' } - .each do |title, body| - get "/wiki/title/#{ CGI.escapeURIComponent(title) }" - expect(response).to have_http_status(:ok) - expect(json.fetch('body').strip).to eq(body) - end - without_group_legacy_reads do - get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }" - end - expect(response).to have_http_status(:not_found) - end end diff --git a/backend/spec/requests/tag_group_wiki_write_spec.rb b/backend/spec/requests/tag_group_wiki_write_spec.rb index 7498e9f..846537f 100644 --- a/backend/spec/requests/tag_group_wiki_write_spec.rb +++ b/backend/spec/requests/tag_group_wiki_write_spec.rb @@ -33,11 +33,10 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do end end - it 'creates the group, snapshot and matching compatibility histories' do + it 'creates the group, snapshot and native version history' do expect { create_group } - .to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) + .to change(TagGroup, :count).by(1) + .and change(TagGroupVersion, :count).by(1) expect(response).to have_http_status(:created) group = group_rows('tag_groups', name: '新規').sole expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規') @@ -45,10 +44,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do version = group_version(group.fetch('id'), 1) expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id) expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id) - page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' }) - expect(page.body).to eq('- 洗澡鹿') - expect(page.current_revision.body).to eq(page.body) - expect(page.wiki_versions.sole.body).to eq(page.body) + get group_title_path('新規') + expect(response).to have_http_status(:ok) + expect(json.fetch('body')).to eq('- 洗澡鹿') end it 'keeps repeated placements but only one membership and one snapshot member' do @@ -108,10 +106,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do end context 'updating a migrated group' do - let!(:page) { legacy_group('更新', '- 洗澡鹿') } - let!(:old_revision) { page.current_revision } + let!(:page) { create_markdown_group('更新', '- 洗澡鹿') } + let!(:old_revision) { page.versions.order(:version_no).last } - before { migrate_groups } def update_group body: '- 伊地知ニジカ', title: 'タググループ:更新', request_headers: headers put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers @@ -119,9 +116,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do [1, 3].each do |repetitions| it "keeps identical Markdown unchanged after #{ repetitions } Wiki PUTs" do - body = page.body + body = group_markdown(page) travel_to(1.hour.from_now) do - expect_noop_group_update(group_id: page.id, page:) do + expect_noop_group_update(group_id: page.id) do repetitions.times do update_group(body:) expect(response).to have_http_status(:ok) @@ -134,13 +131,13 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do it 'keeps identical Markdown unchanged with a stale base_revision_id' do update_group expect(response).to have_http_status(:ok) - body = page.reload.body - expect(page.current_revision.id).not_to eq(old_revision.id) + body = group_markdown(page) + expect(page.versions.order(:version_no).last.id).not_to eq(old_revision.id) travel_to(1.hour.from_now) do - expect_noop_group_update(group_id: page.id, page:) do + expect_noop_group_update(group_id: page.id) do put "/wiki/#{ page.id }", - params: { title: page.title, body:, base_revision_id: old_revision.id }, + params: { title: "タググループ:#{ page.name }", body:, base_revision_id: old_revision.id }, headers: headers end end @@ -148,9 +145,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do it 'keeps the original editor on identical Markdown from another member' do editor = create(:user, :member) - body = page.body + body = group_markdown(page) travel_to(1.hour.from_now) do - expect_noop_group_update(group_id: page.id, page:) do + expect_noop_group_update(group_id: page.id) do update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code }) end end @@ -168,11 +165,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do page.id, number: 2, members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }], placements: changed_placements) - body = page.reload.body + body = group_markdown(page) expect(body).to eq('- 伊地知ニジカ') travel_to(1.hour.from_now) do - expect_noop_group_update(group_id: page.id, page:) { update_group(body:) } + expect_noop_group_update(group_id: page.id) { update_group(body:) } end end @@ -187,16 +184,13 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do expect(group_version(page.id, 1)).to eq(old) expect(group_version(page.id, 2)).to include( 'event_type' => 'update', 'created_by_user_id' => user.id) - expect(page.reload.body.strip).to eq('- 伊地知ニジカ') - expect(page.wiki_revisions.count).to eq(2) - expect(page.wiki_versions.count).to eq(2) - expect(page.current_revision.body).to eq(page.body) - expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body) + expect(group_markdown(page).strip).to eq('- 伊地知ニジカ') + expect(page.versions.count).to eq(2) end it 'accepts the current base_revision_id used by the existing frontend' do put "/wiki/#{ page.id }", - params: { title: page.title, body: '- 伊地知ニジカ', + params: { title: "タググループ:#{ page.name }", body: '- 伊地知ニジカ', base_revision_id: old_revision.id }, headers: headers expect(response).to have_http_status(:ok) expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(2) @@ -207,12 +201,12 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do expect(response).to have_http_status(:ok) put "/wiki/#{ page.id }", - params: { title: page.title, body: '- 洗澡鹿', + params: { title: "タググループ:#{ page.name }", body: '- 洗澡鹿', base_revision_id: old_revision.id }, headers: headers expect(response).to have_http_status(:ok) expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(3) - expect(page.reload.body.strip).to eq('- 洗澡鹿') + expect(group_markdown(page).strip).to eq('- 洗澡鹿') end it 'records the editor and update time' do @@ -257,9 +251,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do let!(:page) do next unless operation == :update - legacy = legacy_group('更新', '- 洗澡鹿') - seed_group(name: '更新', id: legacy.id, members:, placements:) - legacy + create_markdown_group('更新', '- 洗澡鹿') end let!(:child_id) { seed_group(name: '子グループ', description: '子') } @@ -296,7 +288,6 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do end tables = TagGroupRecords::GROUP_TABLES - tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions'] tables.each do |table| it "rolls back all writes after a real #{ table } write fails" do expect_atomic_group_write(table) { write_group(operation, headers) } diff --git a/backend/spec/requests/tag_group_write_integrity_spec.rb b/backend/spec/requests/tag_group_write_integrity_spec.rb index 98c2e5d..24e5d99 100644 --- a/backend/spec/requests/tag_group_write_integrity_spec.rb +++ b/backend/spec/requests/tag_group_write_integrity_spec.rb @@ -24,11 +24,6 @@ RSpec.describe 'Tag group write integrity', type: :request do put "/wiki/#{ id }", params: { title:, body:, base_revision_id: base }, headers: headers end - def create_help body - seed_wiki_page( - tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user) - end - def expect_no_writes before_state = group_state yield @@ -37,66 +32,28 @@ RSpec.describe 'Tag group write integrity', type: :request do end context 'resource identity' do - it 'rejects removing the prefix from a migrated group atomically' do - page = legacy_group('更新', '- 洗澡鹿') - seed_group(name: '更新', id: page.id, members:, placements:) - - expect_no_writes do - put_wiki(page.id, title: '更新', base: page.current_revision.id) - end - end it 'rejects an unprefixed update to an independent group' do id = seed_group(name: '独立', members:, placements:) expect_no_writes { put_wiki(id, title: '独立') } end - it 'does not revive legacy writes when the compatibility title is stale' do - page = legacy_group('更新', '- 洗澡鹿') - seed_group(name: '更新', id: page.id, members:, placements:) - page.tag_name.update!(name: '古い別名') - expect_no_writes { put_wiki(page.id, title: '古い別名') } - end - - it 'renames the group and its compatibility page through the operation' do - page = legacy_group('更新', '- 洗澡鹿') - seed_group(name: '更新', id: page.id, members:, placements:) + it 'renames the group and its facade title through the operation' do + page = create_markdown_group('更新', '- 洗澡鹿') put_wiki(page.id, title: 'タググループ:改名') expect(response).to have_http_status(:ok) expect(TagGroup.find(page.id)).to have_attributes(name: '改名', version_no: 2) - expect(page.reload.title).to eq('タググループ:改名') - expect(page.body).to eq('- 伊地知ニジカ') + expect("タググループ:#{ page.reload.name }").to eq('タググループ:改名') + expect(group_markdown(page)).to eq('- 伊地知ニジカ') end - it 'rejects a standalone Help update despite a colliding group ID' do - page = create_help('Help body') - id = seed_group(name: '別グループ', id: page.id, members:, placements:) - group_before = TagGroup.find(id).attributes - version_before = group_version(id, 1) - expect_no_writes { put_wiki(id, title: 'Help:改名', body: 'Help after') } - - expect(page.reload.body).to eq('Help body') - expect(page.title).to eq('Help:既存') - expect(TagGroup.find(id).attributes).to eq(group_before) - expect(group_version(id, 1)).to eq(version_before) - end - - it 'rejects an ambiguous unprefixed rename when Help and group IDs collide' do - page = create_help('Help body') - seed_group(name: '更新', id: page.id, members:, placements:) - - expect_no_writes { put_wiki(page.id, title: '更新') } - expect_no_writes do - put_wiki(page.id, title: '更新', base: page.current_revision.id) - end - end it 'keeps a description update separate from a colliding group' do - page = Wiki::Commit.create_content!( + page = seed_description( tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user) description = TagDescription.find_by!(tag_id: tag.id) id = seed_group(name: '更新', id: description.id, members:, placements:) @@ -110,106 +67,22 @@ RSpec.describe 'Tag group write integrity', type: :request do expect(TagGroup.find(id).attributes).to eq(group_before) end - it 'does not route a colliding Help write into an unrelated description' do - page = create_help('Help before') - id = seed_group(name: '更新', id: page.id, members:, placements:) - description = TagDescription.create!( - id:, tag:, language_code: 'ja', version_no: 1, body: 'description') + end - expect_no_writes { put_wiki(id, title: 'Help:既存', body: 'Help after') } - expect(page.reload.body).to eq('Help before') - expect(description.reload.body).to eq('description') - expect(TagGroup.find(id).version_no).to eq(1) - put_wiki(id, title: '洗澡鹿', body: 'description after') - expect(response).to have_http_status(:ok) - expect(description.reload.body).to eq('description after') - put_wiki(id, title: 'タググループ:更新') - expect(response).to have_http_status(:ok) - expect(TagGroup.find(id).version_no).to eq(2) - expect(page.reload.body).to eq('Help before') - expect(description.reload.body).to eq('description after') + it 'rolls back a facade create when its saved structure cannot be rendered' do + allow(TagGroupMarkdownRenderer).to receive(:render) + .and_raise(TagGroupMarkdownRenderer::UnsupportedStructure, 'unsupported') + expect_no_writes do + post '/wiki', params: { title: 'タググループ:新規', body: '- 洗澡鹿' }, headers: headers end end - context 'compatibility synchronisation' do - it 'synchronises a native rename without modifying an ID-colliding Help page' do - help = create_help('Help before') - id = seed_group(name: '更新', id: help.id, members:, placements:) - help_before = help.attributes - - expect do - put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json - end.to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - - expect(response).to have_http_status(:ok) - expect(TagGroup.find(id).name).to eq('改名') - expect(help.reload.attributes).to eq(help_before) - end - - ['wiki_lines', 'wiki_revision_lines'].each do |table| - it "rolls back native and compatibility state on #{ table } failure" do - expect_atomic_group_write(table) do - post '/tag_groups', params: native_payload, headers:, as: :json - end - end - end - - it 'rolls back a renderer failure instead of treating it as unsupported structure' do - allow(TagGroupMarkdownRenderer).to receive(:render).and_raise('renderer failure') - before_state = group_state - - expect do - post '/tag_groups', params: native_payload, headers:, as: :json - end.to raise_error(RuntimeError, 'renderer failure') - - expect(group_state).to eq(before_state) - end - - it 'preserves unplaced membership and reports the skipped sync with its version' do - page = legacy_group('更新', '- 洗澡鹿') - id = seed_group(name: '更新', id: page.id, members:, placements:) - wiki_before = group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5)) - warnings = [] - allow(Rails.logger).to receive(:warn) { warnings << _1 } - - put "/tag_groups/#{ id }", - params: native_payload(member_rows: members + [{ 'tag_id' => other_tag.id }]), - headers:, as: :json - - expect(response).to have_http_status(:ok) - expect(json.fetch('members').map { _1.fetch('tag').fetch('id') }) - .to contain_exactly(tag.id, other_tag.id) - expect(group_version(id, 2).fetch('members')).to include( - { 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }) - expect(group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5))).to eq(wiki_before) - payload = JSON.parse(warnings.grep(/tag_group_wiki_sync_skipped/).sole.split(' ', 2).last) - expect(payload).to include( - 'status' => 'unsupported_structure', 'tag_group_id' => id, - 'version_no' => 2, 'user_id' => user.id, - 'reason' => '掲載位置のない membership は Markdown にできません.') - end - - it 'preserves a list Section description and records why no Wiki was created' do - nodes = [{ 'tag_id' => tag.id, 'children' => [ - { 'section_name' => '区分', 'description' => '構造化の説明', 'children' => [] }] }] - warnings = [] - allow(Rails.logger).to receive(:warn) { warnings << _1 } - before_count = WikiPage.count - - post '/tag_groups', params: native_payload(nodes:), headers:, as: :json - - expect(response).to have_http_status(:created) - id = json.fetch('id') - expect(group_version(id, 1).fetch('placements')).to eq(nodes) - expect(json.fetch('placements').sole.fetch('children').sole) - .to include('description' => '構造化の説明') - expect(WikiPage.count).to eq(before_count) - expect(warnings.grep(/tag_group_wiki_sync_skipped/).sole) - .to include('リスト内 Section の説明文は Markdown にできません.') - end + it 'rolls back a facade update when its saved structure cannot be rendered' do + id = seed_group(name: '更新', members:, placements:) + allow(TagGroupMarkdownRenderer).to receive(:render) + .and_raise(TagGroupMarkdownRenderer::UnsupportedStructure, 'unsupported') + expect_no_writes { put_wiki(id, title: 'タググループ:改名') } end context 'identifier Markdown' do @@ -293,10 +166,10 @@ RSpec.describe 'Tag group write integrity', type: :request do expect(group_version(id, 2)).to include('description' => '説明を追加', 'members' => members) end - it 'rolls back membership removal and placement edits on a late sync failure' do + it 'rolls back membership removal and placement edits on a version persistence failure' do new_members = [{ 'tag_id' => tag.id }] new_nodes = [{ 'tag_id' => tag.id, 'children' => [] }] - expect_atomic_group_write('wiki_versions') do + expect_atomic_group_write('tag_group_versions') do put "/tag_groups/#{ id }", params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json end diff --git a/backend/spec/requests/tag_groups_spec.rb b/backend/spec/requests/tag_groups_spec.rb index d910dfb..ebca4c7 100644 --- a/backend/spec/requests/tag_groups_spec.rb +++ b/backend/spec/requests/tag_groups_spec.rb @@ -234,8 +234,7 @@ RSpec.describe 'Tag group native API', type: :request do context 'compatibility writes for representable structures' do let(:group_id) do - page = legacy_group('専用API', '説明') - seed_group(name: '専用API', id: page.id, description: '説明') + seed_group(name: '専用API', description: '説明') end let(:members) { [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }] } let(:snapshot_members) do @@ -252,10 +251,9 @@ RSpec.describe 'Tag group native API', type: :request do group_id, number: 2, description: '全体の説明', placements:, members: snapshot_members + [ { 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }]) - page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:専用API' }) travel_to(1.hour.from_now) do - expect_noop_group_update(group_id:, page:) do + expect_noop_group_update(group_id:) do native_write(:update, unplaced_payload) end end @@ -267,20 +265,18 @@ RSpec.describe 'Tag group native API', type: :request do members: [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }], placements: } end let(:additional_tag) { group_tag('追加対象タグ') } - let(:compatibility_page) do - WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' }) - end before do native_write(:update, synchronised_payload) expect(response).to have_http_status(:ok) - expect(compatibility_page.body).to include('- 掲載タグ', '- タググループ:子') + expect(group_markdown(TagGroup.find(group_id))) + .to include('- 掲載タグ', '- タググループ:子') end [1, 3].each do |repetitions| it "keeps identical native content unchanged after #{ repetitions } PUTs" do travel_to(1.hour.from_now) do - expect_noop_group_update(group_id:, page: compatibility_page) do + expect_noop_group_update(group_id:) do repetitions.times do native_write(:update, synchronised_payload) expect(response).to have_http_status(:ok) @@ -292,7 +288,7 @@ RSpec.describe 'Tag group native API', type: :request do it 'treats membership order alone as a no-op' do travel_to(1.hour.from_now) do - expect_noop_group_update(group_id:, page: compatibility_page) do + expect_noop_group_update(group_id:) do native_write(:update, synchronised_payload.merge( members: synchronised_payload.fetch(:members).reverse)) end @@ -302,7 +298,7 @@ RSpec.describe 'Tag group native API', type: :request do it 'keeps the original editor when another member submits identical content' do editor = create(:user, :member) travel_to(1.hour.from_now) do - expect_noop_group_update(group_id:, page: compatibility_page) do + expect_noop_group_update(group_id:) do put "/tag_groups/#{ group_id }", params: synchronised_payload, headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json end @@ -313,7 +309,7 @@ RSpec.describe 'Tag group native API', type: :request do put "/wiki/#{ group_id }", params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers expect(response).to have_http_status(:ok) - expect(compatibility_page.reload.body).to eq('- 未掲載タグ') + expect(group_markdown(TagGroup.find(group_id))).to eq('- 未掲載タグ') same_content = { name: '構造化', description: nil, members: [{ 'tag_id' => unplaced.id }], placements: [{ 'tag_id' => unplaced.id, 'children' => [] }] } @@ -323,7 +319,7 @@ RSpec.describe 'Tag group native API', type: :request do placements: same_content.fetch(:placements)) travel_to(1.hour.from_now) do - expect_noop_group_update(group_id:, page: compatibility_page) do + expect_noop_group_update(group_id:) do native_write(:update, same_content) end end @@ -332,7 +328,7 @@ RSpec.describe 'Tag group native API', type: :request do ['name', 'description', 'membership addition', 'membership removal', 'Section name', 'Section description', 'placement order', 'placement hierarchy'] .each do |change| - it "records a real #{ change } change in both histories" do + it "records a real #{ change } change in native history" do changed = synchronised_payload.deep_dup section = changed.fetch(:placements).first children = section.fetch('children') @@ -356,11 +352,6 @@ RSpec.describe 'Tag group native API', type: :request do old_versions = group_rows('tag_group_versions', tag_group_id: group_id) group = TagGroup.find(group_id) number = group.version_no - page = compatibility_page - old_revision_id = page.current_revision.id - old_wiki_version_id = page.wiki_versions.order(version_no: :desc).first.id - revision_count = page.wiki_revisions.count - version_count = page.wiki_versions.count travel_to(1.hour.from_now) { native_write(:update, changed) } expect(response).to have_http_status(:ok) @@ -379,23 +370,17 @@ RSpec.describe 'Tag group native API', type: :request do versions = group_rows('tag_group_versions', tag_group_id: group_id) expect(versions.size).to eq(old_versions.size + 1) expect(versions.select { _1.fetch('version_no') <= number }).to eq(old_versions) - expect(page.wiki_revisions.count).to eq(revision_count + 1) - expect(page.wiki_versions.count).to eq(version_count + 1) - expect(page.reload.current_revision.id).not_to eq(old_revision_id) - expect(page.wiki_versions.order(version_no: :desc).first.id) - .not_to eq(old_wiki_version_id) get group_title_path(changed.fetch(:name)) expect(response).to have_http_status(:ok) - expect(json.fetch('body')).to eq(page.body) + expect(json.fetch('body')).to eq(group_markdown(group)) end end end - it 'creates native state and its compatibility histories' do + it 'creates native state and its native history' do expect { native_write(:create) } - .to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) + .to change(TagGroup, :count).by(1) + .and change(TagGroupVersion, :count).by(1) expect(response).to have_http_status(:created) expect_group_snapshot( json.fetch('id'), number: 1, members: snapshot_members, @@ -410,18 +395,13 @@ RSpec.describe 'Tag group native API', type: :request do number = operation == :create ? 1 : 2 expect_group_snapshot( id, number:, members: snapshot_members, placements:, description: '全体の説明') - page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' }) - expect(page.body).to include('全体の説明', '# 区分', '- 掲載タグ', '- タググループ:子') - expect(page.current_revision.body).to eq(page.body) - expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body) - expect(page.wiki_revisions.count).to eq(number) - expect(page.wiki_versions.count).to eq(number) + expect(TagGroup.find(id).versions.count).to eq(number) get group_title_path('構造化') expect(response).to have_http_status(:ok) - expect(json.fetch('body')).to eq(page.body) + expect(json.fetch('body')).to include('全体の説明', '# 区分', '- 掲載タグ') end - (TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions']) + TagGroupRecords::GROUP_TABLES .each do |table| it "rolls back native #{ operation } after #{ table } writes" do expect_atomic_group_write(table) { native_write(operation) } @@ -429,12 +409,12 @@ RSpec.describe 'Tag group native API', type: :request do end end - it 'assigns the next group ID from the existing maximum' do + it 'assigns the next group ID above the existing maximum' do child_id expected_id = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1 native_write(:create) expect(response).to have_http_status(:created) - expect(json.fetch('id')).to eq(expected_id) + expect(json.fetch('id')).to be >= expected_id end TagGroupRecords::GROUP_TABLES.each do |table| diff --git a/backend/spec/requests/tag_resource_ids_spec.rb b/backend/spec/requests/tag_resource_ids_spec.rb index 7c324cc..e06e96b 100644 --- a/backend/spec/requests/tag_resource_ids_spec.rb +++ b/backend/spec/requests/tag_resource_ids_spec.rb @@ -1,101 +1,59 @@ require 'rails_helper' RSpec.describe 'Tag resource ID allocation', type: :request do - include TagGroupRecords include WikiRecords - let!(:user) { create_member_user! } let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } - def seed_description id - tag = create(:tag, primary_name: "description_id_#{ id }") - TagDescription.create!(id:, tag:, language_code: 'ja', body: 'existing', version_no: 1) - end - - def create_resource resource - if resource == :description - tag = create(:tag, primary_name: 'new_description') - post '/wiki', params: { title: tag.name('ja'), body: 'new description' }, headers: headers - else - post '/tag_groups', params: { - name: 'new_group', description: 'new group', members: [], placements: [] }, - headers:, as: :json - end + def create_description name + tag = create(:tag, primary_name: name) + post '/wiki', params: { title: tag.name('ja'), body: 'description' }, headers: headers expect(response).to have_http_status(:created) + TagDescription.find(json.fetch('id')) end - [:description, :group].each do |resource| - it "keeps #{ resource } independent of the Tag and ExternalTag ID space" do - create(:tag, id: 100) - create(:external_tag, id: 200) - seed_description(1) - seed_group(name: 'shared_space', id: 2) + def create_group name + post '/tag_groups', params: { name:, members: [], placements: [] }, headers:, as: :json + expect(response).to have_http_status(:created) + TagGroup.find(json.fetch('id')) + end - create_resource(resource) - - expect(json.fetch('id')).to eq(3) - end - - it "uses the other resource's larger maximum when allocating #{ resource }" do - seed_description(resource == :description ? 1 : 20) - seed_group(name: 'other_space', id: resource == :group ? 1 : 20) - create_resource(resource) - expect(json.fetch('id')).to eq(21) - end - - it "starts #{ resource } at 1 even after previous allocated rows were removed" do - expect(TagDescription.count).to eq(0) - expect(TagGroup.count).to eq(0) - if resource == :description - seed_description(100).destroy! - else - id = seed_group(name: 'removed', id: 100) - TagGroup.find(id).destroy! - end - create_resource(resource) - expect(json.fetch('id')).to eq(1) - end - - it "allocates #{ resource } above both maxima, preserving legacy overlaps and gaps" do - [1, 2, 5].each { seed_description(_1) } - [1, 3, 8].each { seed_group(name: "group_#{ _1 }", id: _1) } - descriptions = TagDescription.order(:id).map(&:attributes) - groups = TagGroup.order(:id).map(&:attributes) - - create_resource(resource) - - expect(json.fetch('id')).to eq(9) - expect(TagDescription.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(descriptions) - expect(TagGroup.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(groups) - expect(TagDescription.exists?(id: 3)).to be(false) - expect(TagGroup.exists?(id: 2)).to be(false) + it 'shares resource and revision IDs while keeping independent version numbers' do + description = create_description('first') + group = create_group('second') + other = create_description('third') + expect([description.id, group.id, other.id]).to eq( + [description.id, group.id, other.id].sort.uniq) + versions = [current_description_version(description), group.versions.sole, + current_description_version(other)] + expect(versions.map(&:id)).to eq(versions.map(&:id).sort.uniq) + expect(versions.map(&:version_no)).to eq([1, 1, 1]) + [description, group, other].zip(versions).each do |resource, version| + get "/wiki/#{ resource.id }" + expect(response).to have_http_status(:ok) + expect(json.fetch('revision_id')).to eq(version.id) end end - it 'preserves group ID and unrelated rows on update' do - id = seed_group(name: 'existing', id: 5, description: 'before') - other_id = seed_group(name: 'other', id: 8, description: 'other') - other = TagGroup.find(other_id).attributes - put "/tag_groups/#{ id }", params: { - name: 'renamed', description: 'after', members: [], placements: [] }, headers:, as: :json + it 'does not reuse a deleted description ID for a group or vice versa' do + description = create_description('deleted') + description.destroy! + group = create_group('deleted group') + expect(group.id).to be > description.id + group.destroy! + expect(create_description('replacement').id).to be > group.id + end + + it 'preserves resource and old revision identity on an update' do + description = create_description('stable') + original = current_description_version(description) + snapshot = original.attributes + put "/wiki/#{ description.id }", params: { + title: 'stable', body: 'after', base_revision_id: original.id }, headers: headers expect(response).to have_http_status(:ok) - expect(json.fetch('id')).to eq(id) - expect(TagGroup.find(id).description).to eq('after') - expect(TagGroup.find(other_id).attributes).to eq(other) - expect(TagGroup.count).to eq(2) - end - - it 'preserves description identity on a compatibility update' do - tag = create(:tag, primary_name: 'existing_description') - page = seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user) - description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') - id = description.id - put "/wiki/#{ id }", params: { - title: tag.name('ja'), body: 'after', base_revision_id: page.current_revision.id }, - headers: headers - expect(response).to have_http_status(:ok) - expect(description.reload.id).to eq(id) - expect(description.body).to eq('after') - expect(TagDescription.count).to eq(1) + expect(json.fetch('id')).to eq(description.id) + expect(description.reload).to have_attributes(body: 'after', version_no: 2) + expect(original.reload.attributes).to eq(snapshot) + expect(current_description_version(description).id).to be > original.id end end diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index f105773..7165a0f 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' RSpec.describe 'TagVersions API', type: :request do + include WikiRecords let(:member) { create(:user, :member, name: 'version member') } let!(:tag) { create(:tag, primary_name: 'tag_versions_target', category: :general) } @@ -240,18 +241,18 @@ RSpec.describe 'TagVersions API', type: :request do wiki_tag = create(:tag, primary_tag_name: wiki_tag_name, category: :general) wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: wiki_tag_name, body: 'before', created_by_user: member, message: 'init') - Wiki::Commit.content!( - page: wiki_page, + TagDescriptionOperation.update!( + tag: wiki_page.tag, body: 'after', - created_user: member, + updated_by_user: member, message: 'edit', - base_revision_id: wiki_page.current_revision.id) + base_revision_id: current_description_version(wiki_page).id) get '/tags/versions', params: { id: wiki_tag.id } diff --git a/backend/spec/requests/tag_wiki_history_integrity_spec.rb b/backend/spec/requests/tag_wiki_history_integrity_spec.rb index 665eee2..bd8e7a9 100644 --- a/backend/spec/requests/tag_wiki_history_integrity_spec.rb +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -14,7 +14,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do end def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user - seed_wiki_page( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body:, created_by_user: user, @@ -29,7 +29,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request 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) + wiki_history = description_versions(wiki_page).map(&:attributes) expect { patch "/tags/#{ tag.id }", params: { @@ -37,7 +37,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response).to have_http_status(:ok) @@ -45,16 +45,16 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do wiki_page.reload 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(wiki_page.tag.name('ja')).to eq('patch_tag_wiki_after') - expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) + expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history) end it 'does not record wiki_version when only category changes' do tag = create_tag!(name: 'patch_tag_category_only') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') - before_wiki_versions = wiki_page.wiki_versions.count + before_wiki_versions = description_versions(wiki_page).count expect { patch "/tags/#{ tag.id }", params: { @@ -70,7 +70,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_category_only') expect(tag.category).to eq('meme') - expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions) + expect(description_versions(wiki_page).count).to eq(before_wiki_versions) end end @@ -78,7 +78,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request 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) + wiki_history = description_versions(wiki_page).map(&:attributes) expect { put "/tags/#{ tag.id }", params: { @@ -90,7 +90,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response).to have_http_status(:ok) @@ -98,16 +98,16 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do wiki_page.reload 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(wiki_page.tag.name('ja')).to eq('put_tag_wiki_after') - expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) + expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history) end it 'does not record wiki_version when only category changes' do tag = create_tag!(name: 'put_tag_category_only') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') - before_wiki_versions = wiki_page.wiki_versions.count + before_wiki_versions = description_versions(wiki_page).count expect { put "/tags/#{ tag.id }", params: { @@ -127,14 +127,14 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_category_only') expect(tag.category).to eq('meme') - expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions) + expect(description_versions(wiki_page).count).to eq(before_wiki_versions) end it 'does not record wiki_version when only aliases change' do tag = create_tag!(name: 'put_tag_alias_only') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') - before_wiki_versions = wiki_page.wiki_versions.count + before_wiki_versions = description_versions(wiki_page).count expect { put "/tags/#{ tag.id }", params: { @@ -148,7 +148,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do .to change(TagVersion, :count).by(2) expect(response).to have_http_status(:ok) - expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_versions) + expect(description_versions(wiki_page).count).to eq(before_wiki_versions) end end end diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index d30f5e4..897aed4 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -2,6 +2,7 @@ require 'cgi' require 'rails_helper' RSpec.describe 'Tags API', type: :request do + include WikiRecords let!(:tn) { create(:tag_name, name: 'spec_tag') } let!(:tag) { create(:tag, primary_tag_name: tn, category: :general) } let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', tag:) } @@ -745,13 +746,13 @@ RSpec.describe 'Tags API', type: :request do it 'PATCH の名称変更で Wiki の本文履歴を追加しない' do wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') - wiki_history = wiki_page.wiki_versions.map(&:attributes) + wiki_history = description_versions(wiki_page).map(&:attributes) expect { patch "/tags/#{ tag.id }", params: { @@ -759,23 +760,23 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response).to have_http_status(:ok) - expect(wiki_page.reload.title).to eq('patch_wiki_renamed_tag') - expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) + expect(wiki_page.reload.tag.name('ja')).to eq('patch_wiki_renamed_tag') + expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history) end it 'tag の category だけを変更しても wiki version は作成しない' do wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') - before_wiki_version_count = wiki_page.reload.wiki_versions.count + before_wiki_version_count = description_versions(wiki_page).count expect { patch "/tags/#{ tag.id }", params: { @@ -784,7 +785,7 @@ RSpec.describe 'Tags API', type: :request do }.to change(TagVersion, :count).by(2) expect(response).to have_http_status(:ok) - expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count) + expect(description_versions(wiki_page).count).to eq(before_wiki_version_count) end it 'full update で旧名 alias が残った tag を PATCH で旧名へ戻せる' do @@ -815,7 +816,7 @@ RSpec.describe 'Tags API', type: :request do it '別 tag の正規名には変更できない' do wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'patch collision wiki', created_by_user: member_user, @@ -831,7 +832,7 @@ RSpec.describe 'Tags API', type: :request do expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag') expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name)) .to contain_exactly('unko') - expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja')) + expect(wiki_page.reload.tag.tag_name('ja')).to eq(primary_tag_name_for(tag, 'ja')) end it 'system tag の name は変更できない' do @@ -1209,7 +1210,7 @@ RSpec.describe 'Tags API', type: :request do expect(old_name_alias).to have_attributes( tag_id: tag.id, language_code: 'ja', primary_flg: false) - expect(alias_tn.reload.tag_id).to be_nil + expect(TagName.exists?(alias_tn.id)).to be(false) expect(TagName.where(tag_id: tag.parents.select(:id), language_code: 'ja', primary_flg: true).pluck(:name)) @@ -1394,20 +1395,20 @@ RSpec.describe 'Tags API', type: :request do it 'Wiki を持つ Tag の名称変更で本文履歴を追加しない' do wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') - Wiki::Commit.content!( - page: wiki_page, + TagDescriptionOperation.update!( + tag: wiki_page.tag, body: 'wiki body before', - created_user: member_user, + updated_by_user: member_user, message: 'init' ) - wiki_history = wiki_page.wiki_versions.map(&:attributes) + wiki_history = description_versions(wiki_page).map(&:attributes) expect { put "/tags/#{ tag.id }", params: { @@ -1419,22 +1420,22 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response).to have_http_status(:ok) - expect(wiki_page.reload.title).to eq('put_wiki_version_tag') - expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) + expect(wiki_page.reload.tag.name('ja')).to eq('put_wiki_version_tag') + expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history) end it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') - wiki_history = wiki_page.wiki_versions.map(&:attributes) + wiki_history = description_versions(wiki_page).map(&:attributes) expect { put "/tags/#{ tag.id }", params: { @@ -1454,20 +1455,19 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(3) - .and change(WikiVersion, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response).to have_http_status(:ok) tag.reload - expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja')) - expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b').wiki_page) - .to be_nil + expect(wiki_page.reload.tag.tag_name('ja')).to eq(primary_tag_name_for(tag, 'ja')) + expect(wiki_page.reload.tag_id).to eq(tag.id) expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b')) .to have_attributes(tag_id: tag.id, primary_flg: false) - expect(wiki_page.title).to eq('spec_tag') - expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) + expect(wiki_page.tag.name('ja')).to eq('spec_tag') + expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history) end it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do @@ -1520,7 +1520,7 @@ RSpec.describe 'Tags API', type: :request do name: 'put_alias_collision_name', tag: old_owner) wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'put collision wiki', created_by_user: member_user, @@ -1554,7 +1554,7 @@ RSpec.describe 'Tags API', type: :request do expect(old_owner_versions.last.event_type).to eq('update') expect(version_alias_names(old_owner_versions.last, 'ja')) .not_to include('put_alias_collision_name') - expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja')) + expect(wiki_page.reload.tag.tag_name('ja')).to eq(primary_tag_name_for(tag, 'ja')) end it 'parent_tags に指定すると循環する tag は 422 にする' do @@ -1579,13 +1579,13 @@ RSpec.describe 'Tags API', type: :request do it 'Tag の名称変更で Wiki の本文履歴を追加しない' do wiki_page = - Wiki::Commit.create_content!( + seed_description( tag_name: primary_tag_name_for(tag, 'ja'), body: 'wiki body before', created_by_user: member_user, message: 'init') - wiki_history = wiki_page.wiki_versions.map(&:attributes) + wiki_history = description_versions(wiki_page).map(&:attributes) expect { put "/tags/#{ tag.id }", params: { @@ -1597,12 +1597,12 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response).to have_http_status(:ok) - expect(wiki_page.reload.title).to eq('put_wiki_renamed_tag') - expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) + expect(wiki_page.reload.tag.name('ja')).to eq('put_wiki_renamed_tag') + expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history) end end end diff --git a/backend/spec/requests/wiki_conflict_spec.rb b/backend/spec/requests/wiki_conflict_spec.rb index 8aebaa6..5da7988 100644 --- a/backend/spec/requests/wiki_conflict_spec.rb +++ b/backend/spec/requests/wiki_conflict_spec.rb @@ -10,18 +10,18 @@ RSpec.describe 'Wiki conflict handling', type: :request do it 'returns 409 when base_revision_id is stale' do page = - seed_wiki_page( + seed_description( tag_name: create(:tag, primary_name: 'wiki_conflict_request').tag_name('ja'), body: 'first', created_by_user: user, message: 'init') - stale_id = page.current_revision.id + stale_id = current_description_version(page).id - Wiki::Commit.content!( - page:, + TagDescriptionOperation.update!( + tag: page.tag, body: 'second', - created_user: user, + updated_by_user: user, message: 'other edit', base_revision_id: stale_id) @@ -36,6 +36,6 @@ RSpec.describe 'Wiki conflict handling', type: :request do page.reload expect(page.body).to eq('second') - expect(page.current_revision.message).to eq('other edit') + expect(current_description_version(page)).to have_attributes(body: 'second', version_no: 2) end end diff --git a/backend/spec/requests/wiki_creation_freeze_spec.rb b/backend/spec/requests/wiki_creation_freeze_spec.rb index 96e4af4..a51b8ac 100644 --- a/backend/spec/requests/wiki_creation_freeze_spec.rb +++ b/backend/spec/requests/wiki_creation_freeze_spec.rb @@ -7,27 +7,11 @@ RSpec.describe 'Wiki creation freeze', type: :request do let!(:user) { create_member_user! } let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } - it 'creates missing compatibility history when updating an independent group' do - id = seed_group(name: 'independent', description: 'before') - expect do - put "/tag_groups/#{ id }", params: { - name: 'independent', description: 'after', members: [], placements: [] }, - headers:, as: :json - end.to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - expect(response).to have_http_status(:ok) - expect(TagGroup.find(id).description).to eq('after') - page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:independent' }) - expect(page.body).to include('after') - expect(page.current_revision.body).to eq(page.body) - expect(page.wiki_versions.sole.body).to eq(page.body) - end context 'existing description write protection' do let!(:tag) { create(:tag, primary_name: 'protected_description') } let!(:page) do - seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user) + seed_description(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user) end [nil, :guest, :banned].each do |role| diff --git a/backend/spec/requests/wiki_history_integrity_spec.rb b/backend/spec/requests/wiki_history_integrity_spec.rb deleted file mode 100644 index 60fda7f..0000000 --- a/backend/spec/requests/wiki_history_integrity_spec.rb +++ /dev/null @@ -1,170 +0,0 @@ -require 'cgi' -require 'rails_helper' - -RSpec.describe 'Wiki history integrity', type: :request do - include WikiRecords - let!(:user) { create_member_user! } - - def auth_headers user - { 'X-Transfer-Code' => user.inheritance_code } - end - - def create_wiki_page title:, body: 'body', message: 'init', user: self.user - seed_wiki_page( - tag_name: create(:tag, primary_name: title).tag_name('ja'), - body:, - created_by_user: user, - message:) - end - - describe 'POST /wiki' do - it 'rejects frozen creation without partial page or history' do - expect do - post '/wiki', params: { title: 'Help:wiki_history_create_atomic', body: 'initial' }, - headers: auth_headers(user) - end.to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) - expect(response.status).to be_between(400, 499) - end - - it 'returns 422 and creates nothing when normalised body is blank' do - expect { - post '/wiki', - params: { - title: 'Help:wiki_history_blank_body', - body: "\r\n\r\n", - message: 'blank', - }, - headers: auth_headers(user) - } - .not_to change(WikiPage, :count) - - expect(response).to have_http_status(:unprocessable_entity) - expect(WikiPage.joins(:tag_name) - .where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist - end - - it 'rejects frozen creation without modifying an existing page or history' do - create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first') - - expect do - post '/wiki', - params: { title: 'Help:wiki_history_duplicate_title', - body: 'second', message: 'duplicate' }, - headers: auth_headers(user) - end.to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) - - expect(response.status).to be_between(400, 499) - expect(WikiPage.joins(:tag_name) - .where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1) - end - end - - describe 'PUT /wiki/:id' do - it 'updates body and records wiki_revision and wiki_version' do - page = create_wiki_page(title: 'wiki_history_update_body', body: 'before') - current_id = page.current_revision.id - - expect { - put "/wiki/#{ page.id }", - params: { - title: 'wiki_history_update_body', - body: 'after', - message: 'edit body', - base_revision_id: current_id, - }, - headers: auth_headers(user) - } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - - expect(response).to have_http_status(:ok) - - page.reload - version = page.wiki_versions.order(:version_no).last - - expect(page.title).to eq('wiki_history_update_body') - expect(page.body).to eq('after') - - expect(version).to have_attributes( - event_type: 'update', - title: 'wiki_history_update_body', - body: 'after', - reason: 'edit body', - created_by_user_id: user.id - ) - end - - it 'renames title and records wiki_version with new title' do - page = create_wiki_page(title: 'wiki_history_rename_before', body: 'before') - current_id = page.current_revision.id - - expect { - put "/wiki/#{ page.id }", - params: { - title: 'wiki_history_rename_after', - body: 'after', - message: 'rename', - base_revision_id: current_id, - }, - headers: auth_headers(user) - } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - - expect(response).to have_http_status(:ok) - - page.reload - version = page.wiki_versions.order(:version_no).last - - expect(page.title).to eq('wiki_history_rename_after') - expect(page.body).to eq('after') - - expect(version).to have_attributes( - event_type: 'update', - title: 'wiki_history_rename_after', - body: 'after', - reason: 'rename', - created_by_user_id: user.id - ) - end - - it 'does not change title, body, revision, or version on stale base_revision_id' do - page = create_wiki_page(title: 'wiki_history_conflict_page', body: 'first') - stale_id = page.current_revision.id - - Wiki::Commit.content!( - page:, - body: 'second', - created_user: user, - message: 'other edit', - base_revision_id: stale_id) - - page.reload - current_title = page.title - current_body = page.body - revision_count = page.wiki_revisions.count - version_count = page.wiki_versions.count - - put "/wiki/#{ page.id }", - params: { - title: 'wiki_history_conflict_renamed', - body: 'third', - message: 'stale edit', - base_revision_id: stale_id, - }, - headers: auth_headers(user) - - expect(response).to have_http_status(:conflict) - - page.reload - expect(page.title).to eq(current_title) - expect(page.body).to eq(current_body) - expect(page.wiki_revisions.count).to eq(revision_count) - expect(page.wiki_versions.count).to eq(version_count) - end - end -end diff --git a/backend/spec/requests/wiki_index_order_spec.rb b/backend/spec/requests/wiki_index_order_spec.rb index c5ce7e9..9d9dd24 100644 --- a/backend/spec/requests/wiki_index_order_spec.rb +++ b/backend/spec/requests/wiki_index_order_spec.rb @@ -28,15 +28,15 @@ RSpec.describe 'Wiki resource ordering', type: :request do it 'breaks timestamp ties by resource kind and ID while retaining legacy overlaps' do time = Time.utc(2026, 1, 1) tag = create(:tag, primary_name: 'tied') - TagDescription.create!(id: 51, tag:, language_code: 'ja', body: 'current', + description = TagDescription.create!(tag:, language_code: 'ja', body: 'current', version_no: 1, updated_at: time) - group = TagGroup.find(seed_group(name: 'tied', id: 51)) + group = TagGroup.find(seed_group(name: 'tied', id: description.id)) group.update_columns(updated_at: time) 2.times do get '/wiki', params: { title: 'tied' } expect(json.map { [_1.fetch('resource_type'), _1.fetch('id')] }) - .to eq([['tag_description', 51], ['tag_group', 51]]) + .to eq([['tag_description', description.id], ['tag_group', description.id]]) end end end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 377f851..e3d7735 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -19,7 +19,7 @@ RSpec.describe 'Wiki API', type: :request do deprecated_at: Time.zone.local(2026, 6, 1)) end let!(:page) do - seed_wiki_page( + seed_description( tag_name: tn, body: 'init', created_by_user: user, @@ -113,9 +113,9 @@ RSpec.describe 'Wiki API', type: :request do it "rejects frozen creation of #{ title } without history writes" do expect do post endpoint, params: { title:, body: 'initial' }, headers: auth_headers(member) - end.to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) + end.to change(TagDescription, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) + expect(response.status).to be_between(400, 499) end end @@ -132,7 +132,7 @@ RSpec.describe 'Wiki API', type: :request do let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') } let!(:page) do - seed_wiki_page( + seed_description( tag_name: test_tag_name, body: "a\nb", created_by_user: member, @@ -166,7 +166,7 @@ RSpec.describe 'Wiki API', type: :request do context 'when success' do it 'creates new revision and returns 200' do - current_id = page.wiki_revisions.maximum(:id) + current_id = description_versions(page).maximum(:id) expect do put "/wiki/#{page.id}", @@ -174,26 +174,22 @@ RSpec.describe 'Wiki API', type: :request do base_revision_id: current_id }, headers: auth_headers(member) end - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) + .to change(TagDescriptionVersion, :count).by(1) - version = page.wiki_versions.order(:version_no).last + + version = description_versions(page).order(:version_no).last expect(version).to have_attributes( event_type: 'update', - title: 'TestPage', body: "x\ny", - created_by_user_id: member.id - ) + created_by_user_id: member.id) expect(response).to have_http_status(:ok) page.reload - rev = page.current_revision - expect(rev).to be_content - expect(rev.message).to eq('edit') + rev = current_description_version(page) expect(page.body).to eq("x\ny") - expect(rev.base_revision_id).to eq(current_id) + expect(rev.version_no).to eq(2) end it 'wiki body だけを変更しても tag version は作成しない' do @@ -206,13 +202,13 @@ RSpec.describe 'Wiki API', type: :request do created_by_user: member) linked_page = - seed_wiki_page( + seed_description( tag_name: linked_tag_name, body: 'before', created_by_user: member, message: 'init') - current_id = linked_page.current_revision.id + current_id = current_description_version(linked_page).id before_count = linked_tag.reload.tag_versions.count expect { @@ -225,8 +221,8 @@ RSpec.describe 'Wiki API', type: :request do }, headers: auth_headers(member) } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) + .to change(TagDescriptionVersion, :count).by(1) + expect(response).to have_http_status(:ok) expect(linked_tag.reload.tag_versions.count).to eq(before_count) @@ -236,10 +232,10 @@ RSpec.describe 'Wiki API', type: :request do context 'when conflict' do it 'returns 409 when base_revision_id mismatches' do # 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める - Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit') + TagDescriptionOperation.update!(tag: page.tag, body: "zzz", updated_by_user: member, message: 'other edit') page.reload - stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id + stale_id = description_versions(page).order(:id).first.id # わざと古い id put "/wiki/#{page.id}", params: { title: 'TestPage', body: 'x', base_revision_id: stale_id }, headers: auth_headers(member) @@ -281,13 +277,13 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/search' do before do search_tag = create(:tag, primary_name: 'spec_wiki_title_2') - seed_wiki_page( + seed_description( tag_name: search_tag.tag_name('ja'), body: 'search body 2', created_by_user: user, message: 'init') - seed_wiki_page( + seed_description( tag_name: create(:tag_name, name: 'unrelated_title'), body: 'unrelated body', created_by_user: user, @@ -318,13 +314,13 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/changes' do let!(:rev1) do - Wiki::Commit.content!(page: page, body: "a\nb", created_user: user, message: 'r1') - page.current_revision + TagDescriptionOperation.update!(tag: page.tag, body: "a\nb", updated_by_user: user, message: 'r1') + current_description_version(page) end let!(:rev2) do - Wiki::Commit.content!(page: page, body: "a\nc", created_user: user, message: 'r2') - page.current_revision + TagDescriptionOperation.update!(tag: page.tag, body: "a\nc", updated_by_user: user, message: 'r2') + current_description_version(page) end it 'returns latest revisions (optionally filtered by page id)' do @@ -359,11 +355,8 @@ RSpec.describe 'Wiki API', type: :request do # 別ページを作って revision 無し tn2 = create(:tag_name, name: 'spec_no_rev') # 異常データ: revision 無し WikiPage を直接作る - p2 = WikiPage.create!( - tag_name: tn2, - body: 'init', - created_user: user, - updated_user: user) + p2 = TagDescription.create!( + tag: tn2.tag, language_code: 'ja', body: 'init', version_no: 1) get "/wiki/changes?id=#{p2.id}" expect(response).to have_http_status(:ok) @@ -399,13 +392,13 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/:id/diff' do let!(:rev_a) do - Wiki::Commit.content!(page: page, body: "a\nb\nc", created_user: user, message: 'A') - page.current_revision + TagDescriptionOperation.update!(tag: page.tag, body: "a\nb\nc", updated_by_user: user, message: 'A') + current_description_version(page) end let!(:rev_b) do - Wiki::Commit.content!(page: page, body: "a\nx\nc", created_user: user, message: 'B') - page.current_revision + TagDescriptionOperation.update!(tag: page.tag, body: "a\nx\nc", updated_by_user: user, message: 'B') + current_description_version(page) end it 'returns diff json between revisions' do @@ -431,44 +424,23 @@ RSpec.describe 'Wiki API', type: :request do expect(response).to have_http_status(:ok) expect(json['older_revision_id']).to eq(rev_a.id) - expect(json['newer_revision_id']).to eq(page.current_revision.id) + expect(json['newer_revision_id']).to eq(current_description_version(page).id) end end - describe 'Wiki::Commit.redirect!' do - it 'raises because redirect revisions are deprecated' do - target_tag_name = create(:tag_name, name: 'redirect_deprecated_target') - target = - seed_wiki_page( - tag_name: target_tag_name, - body: 'target', - created_by_user: user, - message: 'init') - - expect { - Wiki::Commit.redirect!( - page: page, - redirect_page: target, - created_user: user, - message: 'redirect', - base_revision_id: page.current_revision.id - ) - }.to raise_error(RuntimeError, '廃止しました.') - end - end it 'wiki title を変更すると対応する tag の version を作成する' do linked_tag_name = create(:tag_name, name: 'wiki_linked_tag_for_version') linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general) linked_page = - seed_wiki_page( + seed_description( tag_name: linked_tag_name, body: 'before', created_by_user: user, message: 'init') - current_id = linked_page.current_revision.id + current_id = current_description_version(linked_page).id expect { put "/wiki/#{ linked_page.id }", @@ -480,8 +452,8 @@ RSpec.describe 'Wiki API', type: :request do }, headers: auth_headers(user) } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) + .to change(TagDescriptionVersion, :count).by(1) + .and change { linked_tag.reload.tag_versions.count }.by(2) expect(response).to have_http_status(:ok) diff --git a/backend/spec/requests/wiki_title_collision_spec.rb b/backend/spec/requests/wiki_title_collision_spec.rb index d61122f..f6f3011 100644 --- a/backend/spec/requests/wiki_title_collision_spec.rb +++ b/backend/spec/requests/wiki_title_collision_spec.rb @@ -9,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do end def create_wiki_page title:, body: - seed_wiki_page( + seed_description( tag_name: create(:tag_name, name: title), body:, created_by_user: user, @@ -20,9 +20,9 @@ RSpec.describe 'Wiki title collision', type: :request do source = create_wiki_page(title: 'wiki_collision_source', body: 'source body') create_wiki_page(title: 'wiki_collision_target', body: 'target body') - source_revision_count = source.wiki_revisions.count - source_version_count = source.wiki_versions.count - old_title = source.title + source_revision_count = description_versions(source).count + source_version_count = description_versions(source).count + old_title = source.tag.reload.name('ja') old_body = source.body put "/wiki/#{ source.id }", @@ -30,7 +30,7 @@ RSpec.describe 'Wiki title collision', type: :request do title: 'wiki_collision_target', body: 'new body', message: 'rename collision', - base_revision_id: source.current_revision.id, + base_revision_id: current_description_version(source).id, }, headers: auth_headers(user) @@ -38,10 +38,10 @@ RSpec.describe 'Wiki title collision', type: :request do source.reload - expect(source.title).to eq(old_title) + expect(source.tag.reload.name('ja')).to eq(old_title) expect(source.body).to eq(old_body) - expect(source.wiki_revisions.count).to eq(source_revision_count) - expect(source.wiki_versions.count).to eq(source_version_count) + expect(description_versions(source).count).to eq(source_revision_count) + expect(description_versions(source).count).to eq(source_version_count) end it 'rejects frozen creation with an existing title without adding history' do @@ -52,9 +52,8 @@ RSpec.describe 'Wiki title collision', type: :request do params: { title: 'Help:wiki_collision_create', body: 'new body', message: 'duplicate create' }, headers: auth_headers(user) - end.to change(WikiPage, :count).by(0) - .and change(WikiRevision, :count).by(0) - .and change(WikiVersion, :count).by(0) + end.to change(TagDescription, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) expect(response.status).to be_between(400, 499) end diff --git a/backend/spec/requests/wiki_unmigrated_contract_spec.rb b/backend/spec/requests/wiki_unmigrated_contract_spec.rb index e60c8e0..869be12 100644 --- a/backend/spec/requests/wiki_unmigrated_contract_spec.rb +++ b/backend/spec/requests/wiki_unmigrated_contract_spec.rb @@ -1,78 +1,25 @@ require 'rails_helper' -RSpec.describe 'Unmigrated Wiki compatibility', type: :request do - include WikiRecords - let!(:user) { create_member_user! } - let!(:tag_name) { create(:tag_name, name: 'Help_contract') } - let!(:page) do - seed_wiki_page(tag_name:, body: 'before', created_by_user: user) - end - let!(:first_revision) { page.current_revision } - let!(:last_revision) do - Wiki::Commit.legacy_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 'rejects standalone updates even after a Tag is linked to the legacy page' do - create(:tag, primary_tag_name: tag_name) - original = page.reload.attributes - - 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(0) - .and change(WikiVersion, :count).by(0) - .and change(TagDescription, :count).by(0) - .and change(TagDescriptionVersion, :count).by(0) - - expect(response).to have_http_status(:unprocessable_entity) - expect(page.reload.attributes).to eq(original) - expect(page.current_revision).to have_attributes(message: 'legacy edit') - end - +RSpec.describe 'Removed standalone Wiki resources', type: :request do include TagGroupRecords + let(:user) { create_member_user! } - [:id, :title].each do |lookup| - [nil, :historical].each do |edition| - it "rejects legacy #{ lookup } #{ edition || 'current' } without legacy SELECTs" do - path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }" - params = edition ? { version: first_revision.id } : { } - without_group_legacy_reads { get path, params: } - expect(response).to have_http_status(:not_found) - end - end - - it "does not recognise legacy #{ lookup } existence" do - path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }" - without_group_legacy_reads { get "#{ path }/exists" } - expect(response).to have_http_status(:not_found) - end - end - - ['/wiki', '/wiki/search', '/wiki/changes'].each do |path| - it "excludes unmigrated content from #{ path } without legacy SELECTs" do + ['Help:old', 'ヘルプ:旧ページ'].each do |title| + it "does not resolve #{ title } as a Wiki resource" do + path = "/wiki/title/#{ CGI.escapeURIComponent(title) }" without_group_legacy_reads { get path } - expect(response).to have_http_status(:ok) - expect(json).to eq([]) - end - end - - it 'returns empty scoped history without legacy SELECTs' do - without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } } - expect(response).to have_http_status(:ok) - expect(json).to eq([]) - end - - [false, true].each do |explicit| - it "rejects legacy diff with #{ explicit ? 'explicit' : 'current' } target" do - params = { from: first_revision.id } - params[:to] = last_revision.id if explicit - without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: } expect(response).to have_http_status(:not_found) + get "#{ path }/exists" + expect(response).to have_http_status(:not_found) + end + + it "rejects standalone creation of #{ title } without writes" do + user + before_state = group_state + post '/wiki', params: { title:, body: 'body' }, + headers: { 'X-Transfer-Code' => user.inheritance_code } + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) end end end diff --git a/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb b/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb index 40f9559..a6d908f 100644 --- a/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb +++ b/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb @@ -9,9 +9,9 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do expect(ApplicationRecord.connection.adapter_name).to match(/mysql/i) expect(ApplicationRecord.connection_pool.size).to be >= 3 @prefix = "shared_ids_#{ SecureRandom.hex(8) }" - @names = [0, 1].map { create(:tag_name, name: "#{ @prefix }_#{ _1 }") } - @tag = Tag.create!(tag_name_id: @names.first.id, category: :general) - @names.first.update!(tag_id: @tag.id) + @tag = create(:tag, primary_name: "#{ @prefix }_owner") + @group = TagGroup.create!(name: "#{ @prefix }_owner", version_no: 1) + @tag_ids = Queue.new @threads = [] @release = Queue.new end @@ -23,17 +23,21 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do raise 'Shared ID worker did not finish' unless thread.join(15) end ensure + TagDescriptionVersion.where(tag_id: @tag.id).delete_all if @tag + if @group + TagGroupVersion.where(tag_group_id: @group.id).delete_all + @group.delete + end if @prefix ExternalTag.where(name: "#{ @prefix }_external").delete_all TagGroup.where(name: "#{ @prefix }_group").delete_all end TagDescription.where(tag_id: @tag.id).delete_all if @tag - if @names - ids = @names.map(&:id) - TagName.where(id: ids).update_all(tag_id: nil) - Tag.where(tag_name_id: ids).delete_all - TagName.where(id: ids).delete_all - end + ids = [] + ids << @tag_ids.pop until @tag_ids.empty? if @tag_ids + ids << @tag.id if @tag + TagName.where(tag_id: ids).delete_all + Tag.where(id: ids).delete_all end end @@ -51,11 +55,19 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do def create_resource kind case kind when :tag - Tag.create!(tag_name_id: @names.last.id, category: :general) + Tag.create!(category: :general).tap { @tag_ids << _1.id } when :external ExternalTag.create!(platform: :nico, name: "#{ @prefix }_external") when :description TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1) + when :description_version + TagDescriptionVersion.create!( + tag_id: @tag.id, language_code: 'ja', body: 'body', version_no: 1, + event_type: 'create', created_at: Time.current) + when :group_version + TagGroupVersion.create!( + tag_group: @group, name: @group.name, version_no: 1, event_type: 'create', + members: [], placements: [], created_at: Time.current) when :group TagGroup.create!(name: "#{ @prefix }_group", version_no: 1) end @@ -88,7 +100,8 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do thread.value end - { tags: [:external, :tag], wiki: [:description, :group] }.each do |space, kinds| + { tags: [:external, :tag], wiki: [:description, :group], + wiki_history: [:description_version, :group_version] }.each do |space, kinds| [false, true].each do |rollback| it "serialises #{ space } inserts through #{ rollback ? 'rollback' : 'commit' }" do tables = SharedResourceIdAllocator::SPACES.fetch(space.to_s) @@ -96,7 +109,7 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do ApplicationRecord.connection.select_value("SELECT MAX(id) FROM #{ table }").to_i end first, (first_id, connection_id, lock_name) = hold_creation(kinds.first, rollback:) - expect(first_id).to eq(maxima.max + 1) + expect(first_id).to be > maxima.max connection = ApplicationRecord.connection quoted_lock = connection.quote(lock_name) expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i) @@ -110,7 +123,7 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do expect(await_queue(started)).not_to eq(connection_id) @release << true expect(result(first)).to eq(first_id) - expect(result(second)).to eq(rollback ? first_id : first_id + 1) + expect(result(second)).to be > first_id expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i).to eq(1) end end diff --git a/backend/spec/services/shared_resource_id_allocator_spec.rb b/backend/spec/services/shared_resource_id_allocator_spec.rb index 823fb4a..a972036 100644 --- a/backend/spec/services/shared_resource_id_allocator_spec.rb +++ b/backend/spec/services/shared_resource_id_allocator_spec.rb @@ -1,6 +1,100 @@ require 'rails_helper' RSpec.describe SharedResourceIdAllocator do + let(:tag) { create(:tag) } + + def resource table, id: nil, number: 1 + case table + when 'tags' + Tag.new(id:, category: :general) + when 'external_tags' + ExternalTag.new(id:, platform: :nico, name: "external #{ number }") + when 'tag_descriptions' + TagDescription.new(id:, tag:, language_code: 'ja', body: 'body', version_no: number) + when 'tag_groups' + TagGroup.new(id:, name: "group #{ number }", version_no: number) + when 'tag_description_versions' + TagDescriptionVersion.new( + id:, tag_id: tag.id, language_code: 'ja', body: 'body', version_no: number, + event_type: 'create', created_at: Time.current) + when 'tag_group_versions' + TagGroupVersion.new( + id:, tag_group_id: group.id, name: group.name, version_no: number, + event_type: 'create', members: [], placements: [], created_at: Time.current) + end + end + + let(:group) { TagGroup.create!(name: 'history owner', version_no: 1) } + + described_class::SPACES.each do |space, tables| + tables.permutation.each do |first_table, second_table| + it "keeps deleted #{ first_table } IDs reserved for #{ second_table }" do + first = resource(first_table) + first.save! + used_id = first.id + first.class.where(id: first.id).delete_all + second = resource(second_table) + second.save! + expect(second.id).to be > used_id + end + + it "advances #{ space } after an explicit INSERT in #{ first_table }" do + first = resource(first_table) + first.save! + high = first.id + 100 + explicit = resource(first_table, id: high, number: 2) + first.class.where(id: first.id).delete_all + explicit.save! + explicit.class.where(id: explicit.id).delete_all + second = resource(second_table) + second.save! + expect(second.id).to be > high + end + end + + it "rejects explicit IDs below the reserved #{ space } position" do + first = resource(tables.first) + first.save! + first.class.where(id: first.id).delete_all + expect { resource(tables.last, id: first.id).save! } + .to raise_error(ActiveRecord::RecordNotUnique) + end + + it "preserves historical #{ space } overlaps without repairing them" do + first = resource(tables.first) + first.save! + second = resource(tables.last) + second.save! + second.class.where(id: second.id).update_all(id: first.id) + second = second.class.find(first.id) + snapshots = [first.reload.attributes, second.reload.attributes] + next_record = resource(tables.last, number: 2) + next_record.save! + expect(next_record.id).to be > first.id + expect(first.reload.attributes).to eq(snapshots.first) + expect(second.reload.attributes).to eq(snapshots.last) + end + end + + it 'reserves a Tag ID referenced only by remaining description history' do + high = [Tag.maximum(:id).to_i, ExternalTag.maximum(:id).to_i].max + 1000 + TagDescriptionVersion.create!( + tag_id: high, language_code: 'ja', version_no: 1, body: 'history', + event_type: 'create', created_at: Time.current) + expect(create(:tag).id).to be > high + expect(create(:external_tag).id).to be > high + end + + it 'reserves a group ID referenced only by remaining group history' do + high = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1000 + version = resource('tag_group_versions') + version.save! + TagGroupVersion.where(id: version.id).update_all(tag_group_id: high) + expect(TagGroup.create!(name: 'replacement', version_no: 1).id).to be > high + end +end + +RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do let(:record) { build(:external_tag) } let(:connection) { double('connection') } let(:transaction) { double('transaction', open?: true) } @@ -11,6 +105,14 @@ RSpec.describe SharedResourceIdAllocator do config = double('config', database: 'test_shared_ids') allow(connection).to receive_message_chain(:pool, :db_config).and_return(config) allow(connection).to receive(:quote) { "'#{ _1 }'" } + allow(connection).to receive(:quote_column_name) { "`#{ _1 }`" } + allow(connection).to receive(:execute) { sql << _1 } + allow(connection).to receive(:select_all) do |statement| + tables = described_class::SPACES.values.flatten.select do |table| + statement.include?("'#{ table }'") + end + tables.map { { 'TABLE_NAME' => _1, 'ENGINE' => 'InnoDB', 'AUTO_INCREMENT' => 9 } } + end allow(connection).to receive(:quote_table_name) { "`#{ _1 }`" } allow(connection).to receive(:select_value) do |statement| sql << statement @@ -35,7 +137,6 @@ RSpec.describe SharedResourceIdAllocator do described_class.assign!(record) expect(record.id).to eq(9) expect(sql.grep(/RELEASE_LOCK/)).to be_empty - expect(sql.grep(/FOR UPDATE/).size).to eq(2) callbacks.fetch(event).call expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) end @@ -49,22 +150,22 @@ RSpec.describe SharedResourceIdAllocator do end it 'releases a successfully acquired lock when allocation fails and rolls back' do - allow(connection).to receive(:select_value).with(/FOR UPDATE/).and_raise('query failed') + allow(connection).to receive(:select_all).and_raise('query failed') expect { described_class.assign!(record) }.to raise_error(RuntimeError, 'query failed') callbacks.fetch(:after_rollback).call expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) end - it 'preserves explicitly supplied historical IDs' do - record.id = 5 + it 'accepts an explicit ID above the reserved position' do + record.id = 50 described_class.assign!(record) - expect(record.id).to eq(5) + expect(record.id).to eq(50) expect(sql.grep(/FOR UPDATE/)).to be_empty callbacks.fetch(:after_commit).call end it 'uses the same named lock within each space and different locks between spaces' do - locks = [Tag, ExternalTag, TagDescription, TagGroup].map do |model| + locks = [Tag, ExternalTag, TagDescription, TagGroup, TagDescriptionVersion, TagGroupVersion].map do |model| allow(model).to receive(:connection).and_return(connection) allow(model).to receive(:current_transaction).and_return(transaction) allow(model).to receive(:uncached).and_yield @@ -76,7 +177,8 @@ RSpec.describe SharedResourceIdAllocator do end expect(locks[0]).to eq(locks[1]) expect(locks[2]).to eq(locks[3]) - expect(locks[0]).not_to eq(locks[2]) + expect(locks[4]).to eq(locks[5]) + expect([locks[0], locks[2], locks[4]].uniq.size).to eq(3) end it 'reports lock release failures' do diff --git a/backend/spec/services/wiki/commit_integrity_spec.rb b/backend/spec/services/wiki/commit_integrity_spec.rb deleted file mode 100644 index e58a60c..0000000 --- a/backend/spec/services/wiki/commit_integrity_spec.rb +++ /dev/null @@ -1,185 +0,0 @@ -require 'digest' -require 'rails_helper' - -RSpec.describe Wiki::Commit do - include WikiRecords - let(:user) { create_member_user! } - - def create_page title:, body: 'initial body' - seed_wiki_page( - tag_name: create(:tag, primary_name: title).tag_name('ja'), - body:, - created_by_user: user, - message: 'init') - end - - describe '.create_content!' do - it 'rejects standalone Help creation without adding page or history' do - name = create(:tag_name, name: 'Help:frozen_service') - counts = [WikiPage.count, WikiRevision.count, WikiVersion.count] - expect do - described_class.create_content!(tag_name: name, body: 'initial', created_by_user: user) - end.to raise_error(ActiveRecord::RecordInvalid) - expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts) - end - - it 'keeps the internal compatibility writer available' do - name = create(:tag_name, name: 'タググループ:compatibility_service') - page = nil - expect do - page = described_class.create_legacy_content!( - tag_name: name, body: 'initial', created_by_user: user) - end.to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - expect(page.current_revision.body).to eq('initial') - expect(page.wiki_versions.sole.body).to eq('initial') - end - - it 'rejects body that becomes blank after normalisation' do - tag_name = create(:tag, primary_name: 'commit_integrity_blank').tag_name('ja') - - expect { - described_class.create_content!( - tag_name:, - body: "\r\n\r\n", - created_by_user: user, - message: 'blank') - } - .to raise_error(ActiveRecord::RecordInvalid) - - expect(WikiPage.where(tag_name:)).not_to exist - end - end - - describe '.content!' do - it 'rejects standalone Help updates without modifying histories' do - page = seed_wiki_page(tag_name: create(:tag_name, name: 'Help:standalone'), - body: 'before', created_by_user: user) - counts = [WikiRevision.count, WikiVersion.count] - expect do - described_class.content!(page:, body: 'after', created_user: user) - end.to raise_error(ActiveRecord::RecordInvalid) - expect(page.reload.body).to eq('before') - expect([WikiRevision.count, WikiVersion.count]).to eq(counts) - end - - it 'updates page body, revision, and version' do - page = create_page(title: 'commit_integrity_update', body: 'before') - current_id = page.current_revision.id - - expect { - described_class.content!( - page:, - body: 'after', - created_user: user, - message: 'edit', - base_revision_id: current_id) - } - .to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - - page.reload - version = page.wiki_versions.order(:version_no).last - - expect(page.body).to eq('after') - expect(version.body).to eq('after') - expect(version.reason).to eq('edit') - end - - it 'does not record tag_version on body-only wiki update' do - tag_name = create(:tag_name, name: 'commit_integrity_linked_tag') - tag = create(:tag, primary_tag_name: tag_name, category: :general) - - page = - seed_wiki_page( - tag_name:, - body: 'before', - created_by_user: user, - message: 'init') - - TagVersionRecorder.record!( - tag:, - event_type: :create, - created_by_user: user) - - before_count = tag.reload.tag_versions.count - - described_class.content!( - page:, - body: 'after', - created_user: user, - message: 'edit', - base_revision_id: page.current_revision.id) - - expect(tag.reload.tag_versions.count).to eq(before_count) - end - - it 'raises conflict and leaves page, revision, and version unchanged' do - page = create_page(title: 'commit_integrity_conflict', body: 'first') - stale_id = page.current_revision.id - - described_class.content!( - page:, - body: 'second', - created_user: user, - message: 'second', - base_revision_id: stale_id) - - page.reload - before_body = page.body - before_revision_count = page.wiki_revisions.count - before_version_count = page.wiki_versions.count - - expect { - described_class.content!( - page:, - body: 'third', - created_user: user, - message: 'stale', - base_revision_id: stale_id) - } - .to raise_error(Wiki::Commit::Conflict) - - page.reload - expect(page.body).to eq(before_body) - expect(page.wiki_revisions.count).to eq(before_revision_count) - expect(page.wiki_versions.count).to eq(before_version_count) - end - - it 'deduplicates duplicated missing wiki lines' do - page = create_page(title: 'commit_integrity_dedup', body: 'before') - duplicated = 'commit_integrity_duplicate_line' - - described_class.content!( - page:, - body: "#{ duplicated }\n#{ duplicated }", - created_user: user, - message: 'dedup', - base_revision_id: page.current_revision.id) - - revision = page.reload.current_revision - - expect(WikiLine.where(body: duplicated).count).to eq(1) - expect(revision.wiki_revision_lines.count).to eq(2) - expect(revision.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1) - end - end - - describe '.redirect!' do - it 'raises because redirect revisions are deprecated' do - page = create_page(title: 'commit_integrity_redirect_source', body: 'source') - target = create_page(title: 'commit_integrity_redirect_target', body: 'target') - - expect { - described_class.redirect!( - page:, - redirect_page: target, - created_user: user, - message: 'redirect', - base_revision_id: page.current_revision.id) - } - .to raise_error(RuntimeError, '廃止しました.') - end - end -end diff --git a/backend/spec/services/wiki/commit_spec.rb b/backend/spec/services/wiki/commit_spec.rb deleted file mode 100644 index 6acd297..0000000 --- a/backend/spec/services/wiki/commit_spec.rb +++ /dev/null @@ -1,151 +0,0 @@ -require 'rails_helper' - -RSpec.describe Wiki::Commit do - include WikiRecords - let(:user) { create_member_user! } - - def create_page(title: 'commit_spec_page', body: 'initial body') - tag_name = create(:tag, primary_name: title).tag_name('ja') - - seed_wiki_page( - tag_name:, - body:, - created_by_user: user, - message: 'init') - end - - describe '.content!' do - it 'stores normalised body in wiki_pages and wiki_versions' do - page = create_page(title: 'commit_normalised_page') - - described_class.content!( - page:, - body: "a\r\nb\r\n\r\n", - created_user: user, - message: 'init' - ) - - page.reload - version = page.wiki_versions.order(:version_no).last - - expect(page.body).to eq("a\nb") - expect(version.body).to eq("a\nb") - expect(page.current_revision.lines_count).to eq(2) - end - - it 'deduplicates duplicated missing wiki lines before upsert' do - page = create_page(title: 'commit_duplicate_line_page') - duplicated = 'commit_duplicate_line' - - described_class.content!( - page:, - body: "#{ duplicated }\n#{ duplicated }", - created_user: user, - message: 'init' - ) - - page.reload - - expect(WikiLine.where(body: duplicated).count).to eq(1) - expect(page.current_revision.lines_count).to eq(2) - expect(page.current_revision.wiki_revision_lines.count).to eq(2) - end - - it 'raises conflict when base_revision_id is stale' do - page = create_page(title: 'commit_conflict_page') - - first = described_class.content!( - page:, - body: 'first', - created_user: user, - message: 'first' - ) - - described_class.content!( - page:, - body: 'second', - created_user: user, - message: 'second', - base_revision_id: first.id - ) - - expect { - described_class.content!( - page:, - body: 'third', - created_user: user, - message: 'third', - base_revision_id: first.id - ) - }.to raise_error(Wiki::Commit::Conflict) - end - - it 'does not record tag version when corresponding tag has no versions' do - tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions') - tag = create(:tag, primary_tag_name: tag_name, category: :general) - - page = - seed_wiki_page( - tag_name:, - body: 'before', - created_by_user: user, - message: 'init') - - expect(tag.reload.tag_versions.count).to eq(0) - - current_revision_id = page.current_revision.id - - expect { - described_class.content!( - page:, - body: 'after', - created_user: user, - message: 'edit', - base_revision_id: current_revision_id) - }.to change(WikiVersion, :count).by(1) - - expect(tag.reload.tag_versions.count).to eq(0) - end - - it 'does not record tag version when corresponding tag has no versions' do - tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions') - tag = create(:tag, primary_tag_name: tag_name, category: :general) - - page = - seed_wiki_page( - tag_name:, - body: 'before', - created_by_user: user, - message: 'init') - - current_revision_id = page.current_revision.id - - expect { - described_class.content!( - page:, - body: 'after', - created_user: user, - message: 'edit', - base_revision_id: current_revision_id) - }.to change(WikiVersion, :count).by(1) - - expect(tag.reload.tag_versions.count).to eq(0) - end - end - - describe '.redirect!' do - it 'raises because redirect revisions are deprecated' do - page = create_page(title: 'commit_redirect_source') - target = create_page(title: 'commit_redirect_target') - - expect { - described_class.redirect!( - page:, - redirect_page: target, - created_user: user, - message: 'redirect' - ) - }.to raise_error(RuntimeError, '廃止しました.') - end - end -end diff --git a/backend/spec/services/wiki_version_recorder_spec.rb b/backend/spec/services/wiki_version_recorder_spec.rb deleted file mode 100644 index 6c1457c..0000000 --- a/backend/spec/services/wiki_version_recorder_spec.rb +++ /dev/null @@ -1,100 +0,0 @@ -require 'rails_helper' - -RSpec.describe WikiVersionRecorder do - include WikiRecords - let(:user) { create_member_user! } - - def create_page title:, body: 'body' - seed_wiki_page( - tag_name: create(:tag_name, name: title), - body:, - created_by_user: user, - message: 'init') - end - - describe '.record!' do - it 'records title, body, reason, user, and version number' do - page = create_page(title: 'wiki_version_recorder_basic', body: 'body') - - expect { - described_class.record!( - page:, - event_type: :update, - reason: 'manual reason', - created_by_user: user) - } - .to change { page.reload.wiki_versions.count }.by(1) - - version = page.wiki_versions.order(:version_no).last - - expect(version).to have_attributes( - version_no: 2, - event_type: 'update', - title: 'wiki_version_recorder_basic', - body: 'body', - reason: 'manual reason', - created_by_user_id: user.id - ) - end - - it 'does not create duplicated update version for identical snapshot' do - page = create_page(title: 'wiki_version_recorder_duplicate', body: 'body') - - described_class.record!( - page:, - event_type: :update, - reason: nil, - created_by_user: user) - - before_count = page.reload.wiki_versions.count - - described_class.record!( - page:, - event_type: :update, - reason: nil, - created_by_user: user) - - expect(page.reload.wiki_versions.count).to eq(before_count) - end - - it 'creates update version when title changes' do - page = create_page(title: 'wiki_version_recorder_title_before', body: 'body') - page.tag_name.update!(name: 'wiki_version_recorder_title_after') - - expect { - described_class.record!( - page:, - event_type: :update, - reason: 'rename', - created_by_user: user) - } - .to change { page.reload.wiki_versions.count }.by(1) - - version = page.wiki_versions.order(:version_no).last - - expect(version.title).to eq('wiki_version_recorder_title_after') - expect(version.body).to eq('body') - expect(version.reason).to eq('rename') - end - - it 'creates update version when body changes' do - page = create_page(title: 'wiki_version_recorder_body', body: 'before') - page.update!(body: 'after') - - expect { - described_class.record!( - page:, - event_type: :update, - reason: 'body', - created_by_user: user) - } - .to change { page.reload.wiki_versions.count }.by(1) - - version = page.wiki_versions.order(:version_no).last - - expect(version.title).to eq('wiki_version_recorder_body') - expect(version.body).to eq('after') - expect(version.reason).to eq('body') - end - end -end diff --git a/backend/spec/support/tag_group_records.rb b/backend/spec/support/tag_group_records.rb index ed3a929..9d370c7 100644 --- a/backend/spec/support/tag_group_records.rb +++ b/backend/spec/support/tag_group_records.rb @@ -7,8 +7,7 @@ module TagGroupRecords 'tag_groups', 'tag_group_tags', 'tag_group_groups', 'tag_group_placements', 'tag_group_versions'].freeze WRITE_TABLES = GROUP_TABLES + [ - 'wiki_pages', 'wiki_revisions', 'wiki_versions', 'wiki_lines', - 'wiki_revision_lines', 'tag_names', 'tag_description_versions', + 'tag_names', 'tag_description_versions', 'tag_descriptions'] def group_connection = ApplicationRecord.connection @@ -34,13 +33,11 @@ module TagGroupRecords WRITE_TABLES.to_h { |table| [table, group_rows(table)] } end - def expect_noop_group_update group_id:, page: + def expect_noop_group_update group_id: before_state = group_state group = TagGroup.find(group_id) version_id = TagGroupVersion.find_by!( tag_group_id: group_id, version_no: group.version_no).id - revision_id = page.reload.current_revision.id - wiki_version_id = page.wiki_versions.order(version_no: :desc).first.id writes = [] tables = Regexp.union(WRITE_TABLES) subscriber = lambda do |*args| @@ -59,8 +56,6 @@ module TagGroupRecords end expect(TagGroupVersion.find_by!( tag_group_id: group_id, version_no: group.reload.version_no).id).to eq(version_id) - expect(page.reload.current_revision.id).to eq(revision_id) - expect(page.wiki_versions.order(version_no: :desc).first.id).to eq(wiki_version_id) expect(writes).to be_empty end end @@ -99,25 +94,13 @@ module TagGroupRecords tag.reload end - def legacy_group name, body - # Historical fixtures preserve blank lines independently of today's writer. - body = body.sub(/\n+\z/, '') - page = WikiPage.create!( - tag_name: create(:tag_name, name: "タググループ:#{ name }"), - body:, created_user: user, updated_user: user) - WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: user) - lines = body.split("\n") - shas = lines.map { Digest::SHA256.hexdigest(_1) } - revision = WikiRevision.create!( - wiki_page: page, kind: :content, created_user: user, lines_count: lines.size, - tree_sha256: Digest::SHA256.hexdigest(shas.join(','))) - lines.each_with_index do |line, position| - sha = shas.fetch(position) - line_id = WikiLine.find_by(sha256: sha)&.id || insert_group_row('wiki_lines', { - body: line, sha256: sha, created_at: Time.current, updated_at: Time.current }) - WikiRevisionLine.create!(wiki_revision: revision, wiki_line_id: line_id, position:) - end - page + def create_markdown_group name, body + parsed = TagGroupMarkdownParser.parse(body:) + TagGroupOperation.create!(name:, actor: user, **parsed.slice(:description, :members, :placements)) + end + + def group_markdown group + TagGroupMarkdownRenderer.render(TagGroupRepr.one(group.reload)) end def seed_group name:, placements: [], members: [], description: nil, id: nil @@ -176,16 +159,6 @@ module TagGroupRecords "/wiki/title/#{ CGI.escapeURIComponent("タググループ:#{ name }") }" end - def migrate_groups - require_relative '../../db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages' - migration = MigrateTagGroupsFromWikiPages.new - @group_migration_messages = [] - allow(migration).to receive(:say) do |message, *_args| - @group_migration_messages << message if message.start_with?('Wiki #', 'Tag Group') - end - migration.suppress_messages { migration.up } - end - def expect_group_snapshot group_id, number:, placements:, members:, description: nil current = group_rows('tag_groups', id: group_id).sole version = group_version(group_id, number) diff --git a/backend/spec/support/wiki_records.rb b/backend/spec/support/wiki_records.rb index f321aba..4dbcfb1 100644 --- a/backend/spec/support/wiki_records.rb +++ b/backend/spec/support/wiki_records.rb @@ -1,20 +1,14 @@ -# Historical records must remain seedable after application creation is frozen. module WikiRecords - def seed_wiki_page(tag_name:, body:, created_by_user:, message: nil) - page = WikiPage.create!(tag_name:, body:, - created_user: created_by_user, updated_user: created_by_user) - revision = Wiki::Commit.legacy_content!( - page:, body:, created_user: created_by_user, message:) - if tag_name.tag && tag_name.language_code == 'ja' - TagDescription.create!( - id: page.id, tag: tag_name.tag, language_code: 'ja', body: page.reload.body, - version_no: 1, created_by_user_id: created_by_user.id, - updated_by_user_id: created_by_user.id) - TagDescriptionVersion.create!( - id: revision.id, tag_id: tag_name.tag_id, language_code: 'ja', - version_no: 1, event_type: 'create', body: page.body, - created_by_user_id: created_by_user.id, created_at: revision.created_at) - end - page + def seed_description(tag_name:, body:, created_by_user:, message: nil) + TagDescriptionOperation.create!(tag: tag_name.tag, body:, created_by_user:, message:) + end + + def description_versions(description) + TagDescriptionVersion.where( + tag_id: description.tag_id, language_code: description.language_code) + end + + def current_description_version(description) + description_versions(description).find_by!(version_no: description.reload.version_no) end end -- 2.53.0 From 47c40afcc1756717dc03622d3c98f2c49f67d3e7 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 15 Oct 2026 22:07:07 +0900 Subject: [PATCH 5/7] #431 --- backend/app/models/tag_group.rb | 9 ++++++ backend/app/services/post_create_plan.rb | 27 ++++++++--------- .../services/shared_resource_id_allocator.rb | 21 ++++++-------- .../services/tag_group_markdown_renderer.rb | 23 +++++++++------ backend/spec/db/tag_name_constraints_spec.rb | 21 ++++++++++---- backend/spec/models/tag_group_spec.rb | 29 +++++++++++++++++++ backend/spec/requests/materials_spec.rb | 3 +- .../spec/requests/tag_resource_ids_spec.rb | 8 +++-- backend/spec/requests/wiki_spec.rb | 1 - .../shared_resource_id_allocator_spec.rb | 11 ++++--- 10 files changed, 102 insertions(+), 51 deletions(-) diff --git a/backend/app/models/tag_group.rb b/backend/app/models/tag_group.rb index cda2107..1e8699f 100644 --- a/backend/app/models/tag_group.rb +++ b/backend/app/models/tag_group.rb @@ -1,5 +1,6 @@ class TagGroup < ApplicationRecord before_create { SharedResourceIdAllocator.assign!(self) } + before_destroy :delete_current_structure! belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :updated_by_user, class_name: 'User', optional: true @@ -15,4 +16,12 @@ class TagGroup < ApplicationRecord validates :name, presence: true, uniqueness: true, length: { maximum: 255 } validates :version_no, numericality: { only_integer: true, greater_than: 0 } + + private + + def delete_current_structure! + placements.reorder(position: :desc).each(&:delete) + tag_group_tags.delete_all + tag_group_groups.delete_all + end end diff --git a/backend/app/services/post_create_plan.rb b/backend/app/services/post_create_plan.rb index 58fbaf2..a86b3ba 100644 --- a/backend/app/services/post_create_plan.rb +++ b/backend/app/services/post_create_plan.rb @@ -95,14 +95,10 @@ class PostCreatePlan def build_default_tag_specs locale, direct_tag_specs default_tag_specs = [] if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME } - default_tag_specs << { - name: TAGME_TAG_NAME, - category: :meta } + default_tag_specs << { name: TAGME_TAG_NAME, category: :meta } end if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) } - default_tag_specs << { - name: NO_DEERJIKIST_TAG_NAME, - category: :meta } + default_tag_specs << { name: NO_DEERJIKIST_TAG_NAME, category: :meta } end default_tag_specs @@ -117,9 +113,9 @@ class PostCreatePlan end def validate_new_tag_spec! locale, spec - tag_name = TagName.new(name: spec[:name], language_code: locale.language_code, - script_code: locale.script_code, primary_flg: true) tag = Tag.new(category: spec[:category]) + tag_name = TagName.new(tag:, name: spec[:name], language_code: locale.language_code, + script_code: locale.script_code, primary_flg: true) return if tag_name.valid? && tag.valid? post = Post.new @@ -140,10 +136,10 @@ class PostCreatePlan end expanded_parent_specs = Tag.expand_parent_tags(existing_snapshot_tags) - .reject(&:deprecated?) - .map { |tag| - { name: tag.name(locale.language_code), - category: tag.category.to_sym } + .reject(&:deprecated?) + .map { |tag| + { name: tag.name(locale.language_code), + category: tag.category.to_sym } } merge_tag_specs(snapshot_tag_specs + expanded_parent_specs) end @@ -164,9 +160,10 @@ class PostCreatePlan def existing_tags_by_name locale @existing_tags_by_name ||= begin names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq - TagName.where(language_code: locale.language_code, primary_flg: true, name: names) - .includes(:tag) - .each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag } + TagName + .where(language_code: locale.language_code, primary_flg: true, name: names) + .includes(:tag) + .each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag } end end diff --git a/backend/app/services/shared_resource_id_allocator.rb b/backend/app/services/shared_resource_id_allocator.rb index c1be915..1ca7afd 100644 --- a/backend/app/services/shared_resource_id_allocator.rb +++ b/backend/app/services/shared_resource_id_allocator.rb @@ -50,23 +50,20 @@ class SharedResourceIdAllocator raise end + # 明示-tic な Id. 指定は許可する. + return if record.id.present? + next_id = record.class.uncached do - historical_max = HISTORY_IDS.fetch(space, []).map do |table, column| + historical_max = HISTORY_IDS.fetch(space, []).map { |table, column| quoted_table = connection.quote_table_name(table) quoted_column = connection.quote_column_name(column) - connection.select_value( - "SELECT MAX(#{ quoted_column }) FROM #{ quoted_table }").to_i - end.max.to_i + connection.select_value("SELECT MAX(#{ quoted_column }) FROM #{ quoted_table }").to_i + }.max.to_i + [next_ids(connection, tables).max, historical_max + 1].max end - if record.id.present? - if record.id < next_id - raise ActiveRecord::RecordNotUnique, 'Shared ID is below the reserved position' - end - else - record.id = next_id - end - # The INSERT advances InnoDB's persistent counter, including explicit IDs. + + record.id = next_id end def self.next_ids connection, tables diff --git a/backend/app/services/tag_group_markdown_renderer.rb b/backend/app/services/tag_group_markdown_renderer.rb index 75b08c0..cde7bd5 100644 --- a/backend/app/services/tag_group_markdown_renderer.rb +++ b/backend/app/services/tag_group_markdown_renderer.rb @@ -19,15 +19,20 @@ class TagGroupMarkdownRenderer blocks << clean_text(@group['description']) end - sections = placements.select { _1.key?('section_name') } - if sections.any? && sections.size != placements.size - raise UnsupportedStructure, - '最上位の見出し Section と Placement の混在は Markdown にできません.' - end + first_section = placements.index { _1.key?('section_name') } - if placements.all? { _1.key?('section_name') } - blocks.concat(placements.map { render_heading(_1, 2) }) - else + if first_section + if placements.drop(first_section).any? { !_1.key?('section_name') } + raise UnsupportedStructure, + '見出し Section の後に最上位 Placement は配置できません.' + end + + if first_section.positive? + blocks << render_list(placements.take(first_section), 0) + end + + blocks.concat(placements.drop(first_section).map { render_heading(_1, 2) }) + elsif placements.any? blocks << render_list(placements, 0) end @@ -74,7 +79,7 @@ class TagGroupMarkdownRenderer first_section = section_indexes.first if children[first_section..].any? { !_1.key?('section_name') } raise UnsupportedStructure, - '見出し Section と兄弟 Placement の交互配置は Markdown にできません.' + '見出し Section と兄弟 Placement の交互配置は Markdown にできません.' end list_nodes = children[0...first_section] blocks << render_list(list_nodes, 0) if list_nodes.any? diff --git a/backend/spec/db/tag_name_constraints_spec.rb b/backend/spec/db/tag_name_constraints_spec.rb index 02ba449..1a0c5e7 100644 --- a/backend/spec/db/tag_name_constraints_spec.rb +++ b/backend/spec/db/tag_name_constraints_spec.rb @@ -1,11 +1,17 @@ require 'rails_helper' RSpec.describe 'TagName database invariants' do - def insert_name!(**attributes) - TagName.insert_all!([{ name: 'constraint_name', language_code: 'ja', - script_code: 'Jpan', primary_flg: true, - created_at: Time.current, updated_at: Time.current, - **attributes }]) + def insert_name! **attributes + defaults = { + tag_id: create(:tag, primary_tag_name: nil).id, + name: 'constraint_name', + language_code: 'ja', + script_code: 'Jpan', + primary_flg: true, + created_at: Time.current, + updated_at: Time.current } + + TagName.insert_all!([defaults.merge(attributes)]) end it 'defaults persisted names to human-confirmed provenance' do @@ -33,4 +39,9 @@ RSpec.describe 'TagName database invariants' do .to raise_error(ActiveRecord::RecordNotUnique) expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1) end + + it 'rejects a name without a tag' do + expect { insert_name!(tag_id: nil) } + .to raise_error(ActiveRecord::NotNullViolation) + end end diff --git a/backend/spec/models/tag_group_spec.rb b/backend/spec/models/tag_group_spec.rb index ee52647..67c1125 100644 --- a/backend/spec/models/tag_group_spec.rb +++ b/backend/spec/models/tag_group_spec.rb @@ -20,4 +20,33 @@ RSpec.describe TagGroup, type: :model do .to include('id' => group.id, 'name' => 'history owner', 'members' => []) end + it 'destroys a group with nested placements and child membership, retaining history' do + child = create_group(name: 'child group') + + parent = create_group( + name: 'parent group', + members: [{ 'tag_group_id' => child.id }], + placements: [{ + 'section_name' => 'section', + 'description' => nil, + 'children' => [{ + 'tag_group_id' => child.id, + 'children' => [] + }] + }]) + + versions = parent.versions.order(:version_no).map(&:attributes) + + expect { parent.destroy! } + .to change(TagGroup, :count).by(-1) + .and change(TagGroupVersion, :count).by(0) + + expect(TagGroupVersion.where(tag_group_id: parent.id) + .order(:version_no).map(&:attributes)).to eq(versions) + + expect(child.reload).to be_persisted + expect(TagGroupPlacement.where(tag_group_id: parent.id)).to be_empty + expect(TagGroupTag.where(tag_group_id: parent.id)).to be_empty + expect(TagGroupGroup.where(tag_group_id: parent.id)).to be_empty + end end diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index 0a2ea89..093a8f1 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -232,10 +232,9 @@ RSpec.describe 'Materials API', type: :request do expect(current_description_version(page).body).to eq('legacy body') end - it 'uses tag_id rather than description ID without a Wiki page or TagName' do + it 'uses tag_id rather than description Id. without a Wiki page' do description = TagDescription.create!(tag:, language_code: 'ja', body: 'independent body', version_no: 1) - tag.tag_names.delete_all get_material_without_legacy_reads expect(json.fetch('wiki_page_body')).to eq('independent body') expect(json.fetch('tag').fetch('has_wiki')).to be(true) diff --git a/backend/spec/requests/tag_resource_ids_spec.rb b/backend/spec/requests/tag_resource_ids_spec.rb index e06e96b..0e38838 100644 --- a/backend/spec/requests/tag_resource_ids_spec.rb +++ b/backend/spec/requests/tag_resource_ids_spec.rb @@ -46,12 +46,14 @@ RSpec.describe 'Tag resource ID allocation', type: :request do it 'preserves resource and old revision identity on an update' do description = create_description('stable') + original_resource_id = description.id original = current_description_version(description) snapshot = original.attributes - put "/wiki/#{ description.id }", params: { - title: 'stable', body: 'after', base_revision_id: original.id }, headers: headers + put "/wiki/#{ description.id }", + params: { title: 'stable', body: 'after', base_revision_id: original.id }, + headers: headers expect(response).to have_http_status(:ok) - expect(json.fetch('id')).to eq(description.id) + expect(description.reload.id).to eq(original_resource_id) expect(description.reload).to have_attributes(body: 'after', version_no: 2) expect(original.reload.attributes).to eq(snapshot) expect(current_description_version(description).id).to be > original.id diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index e3d7735..80f9e28 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -453,7 +453,6 @@ RSpec.describe 'Wiki API', type: :request do headers: auth_headers(user) } .to change(TagDescriptionVersion, :count).by(1) - .and change { linked_tag.reload.tag_versions.count }.by(2) expect(response).to have_http_status(:ok) diff --git a/backend/spec/services/shared_resource_id_allocator_spec.rb b/backend/spec/services/shared_resource_id_allocator_spec.rb index a972036..8ec2114 100644 --- a/backend/spec/services/shared_resource_id_allocator_spec.rb +++ b/backend/spec/services/shared_resource_id_allocator_spec.rb @@ -52,12 +52,15 @@ RSpec.describe SharedResourceIdAllocator do end end - it "rejects explicit IDs below the reserved #{ space } position" do + it "allows explicit overlapping Id.s in #{ space }" do first = resource(tables.first) first.save! - first.class.where(id: first.id).delete_all - expect { resource(tables.last, id: first.id).save! } - .to raise_error(ActiveRecord::RecordNotUnique) + + second = resource(tables.last, id: first.id) + second.save! + + expect(second.id).to eq(first.id) + expect(first.reload.id).to eq(first.id) end it "preserves historical #{ space } overlaps without repairing them" do -- 2.53.0 From 6ba87beef6406799792ccfc6094789767a5f9d50 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Thu, 15 Oct 2026 23:49:38 +0900 Subject: [PATCH 6/7] #431 --- .../tag_descriptions_controller.rb | 19 ++- .../app/controllers/tag_groups_controller.rb | 16 ++- .../app/controllers/wiki_pages_controller.rb | 127 ++++++++++-------- backend/app/representations/tag_group_repr.rb | 53 +++++--- backend/app/representations/wiki_page_repr.rb | 7 +- .../app/services/tag_description_operation.rb | 16 +-- .../app/services/tag_group_markdown_parser.rb | 43 +++--- backend/app/services/tag_group_operation.rb | 29 ++-- 8 files changed, 176 insertions(+), 134 deletions(-) diff --git a/backend/app/controllers/tag_descriptions_controller.rb b/backend/app/controllers/tag_descriptions_controller.rb index 06d78c2..dfbaa4f 100644 --- a/backend/app/controllers/tag_descriptions_controller.rb +++ b/backend/app/controllers/tag_descriptions_controller.rb @@ -1,14 +1,18 @@ class TagDescriptionsController < ApplicationController def show - description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja') + locale = resolve_locale! + description = TagDescription.find_by(tag_id: params[:id], language_code: locale.language_code) 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') + locale = resolve_locale! + tag_name = TagName.find_by( + language_code: locale.language_code, name: params[:name].to_s.strip) + description = tag_name&.tag && TagDescription.find_by( + tag: tag_name.tag, language_code: locale.language_code) return head :not_found unless description render json: representation(description) @@ -21,18 +25,19 @@ class TagDescriptionsController < ApplicationController body = params[:body].to_s return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? + locale = resolve_locale! tag = Tag.find_by(id: params[:id]) return head :not_found unless tag - description = TagDescription.find_by(tag:, language_code: 'ja') + description = TagDescription.find_by(tag:, language_code: locale.language_code) if description - TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user) + TagDescriptionOperation.update!(locale, 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') + TagDescriptionOperation.create!(locale, tag:, body:, created_by_user: current_user) + description = TagDescription.find_by!(tag:, language_code: locale.language_code) render json: representation(description), status: :created end rescue ActiveRecord::RecordInvalid => e diff --git a/backend/app/controllers/tag_groups_controller.rb b/backend/app/controllers/tag_groups_controller.rb index 8117eea..1872f40 100644 --- a/backend/app/controllers/tag_groups_controller.rb +++ b/backend/app/controllers/tag_groups_controller.rb @@ -1,33 +1,37 @@ class TagGroupsController < ApplicationController def index + locale = resolve_locale! groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id) - render json: TagGroupRepr.many(groups.to_a) + render json: TagGroupRepr.many(locale, groups.to_a) end def show + locale = resolve_locale! group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id]) return head :not_found unless group - render json: TagGroupRepr.one(group) + render json: TagGroupRepr.one(locale, group) end def create return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? - group = TagGroupOperation.create!(**write_attributes, actor: current_user) - render json: TagGroupRepr.one(group), status: :created + locale = resolve_locale! + group = TagGroupOperation.create!(locale, **write_attributes, actor: current_user) + render json: TagGroupRepr.one(locale, group), status: :created end def update return head :unauthorized unless current_user return head :forbidden unless current_user.gte_member? + locale = resolve_locale! group = TagGroup.find_by(id: params[:id]) return head :not_found unless group - TagGroupOperation.update!(group:, **write_attributes, actor: current_user) - render json: TagGroupRepr.one(group.reload) + TagGroupOperation.update!(locale, group:, **write_attributes, actor: current_user) + render json: TagGroupRepr.one(locale, group.reload) end private diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 5cae0b5..a9f8953 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -4,9 +4,12 @@ class WikiPagesController < ApplicationController rescue_from TagDescriptionOperation::Conflict, with: :render_wiki_conflict def index + locale = resolve_locale! + title = params[:title].to_s.strip descriptions = - TagDescription.where(language_code: 'ja') + TagDescription + .where(language_code: locale.language_code) .includes(tag: :tag_names) descriptions = descriptions.order(updated_at: :desc, id: :asc) @@ -15,13 +18,14 @@ class WikiPagesController < ApplicationController pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" descriptions = descriptions.joins(tag: :tag_names) - .where(tag_names: { language_code: 'ja', primary_flg: true }) + .where(tag_names: { language_code: locale.language_code, primary_flg: true }) .where('tag_names.name LIKE ?', pattern).limit(20) tag_groups = tag_groups.where( - 'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20) + 'CONCAT(?, name) LIKE ? OR CONCAT(?, name) LIKE ?', + TagGroupRepr::PREFIX, pattern, 'tag_group:', pattern).limit(20) end - json = descriptions.map { |description| WikiPageRepr.tag_description(description) } + json = descriptions.map { |description| WikiPageRepr.tag_description(locale, description) } json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) }) json.sort_by! do |row| [-row['updated_at'].to_f, row.fetch('resource_type'), row.fetch('id')] @@ -31,47 +35,53 @@ class WikiPagesController < ApplicationController end def show - tag_description = TagDescription.find_by(id: params[:id]) + locale = resolve_locale! + tag_description = TagDescription.find_by(id: params[:id], language_code: locale.language_code) - return render_wiki_page_by_tag_description tag_description if tag_description + return render_wiki_page_by_tag_description locale, tag_description if tag_description tag_group = TagGroup.find_by(id: params[:id]) - return render_tag_group tag_group if tag_group + return render_tag_group locale, tag_group if tag_group head :not_found end def show_by_title + locale = resolve_locale! title = params[:title].to_s.strip - if title.start_with?(TagGroupRepr::PREFIX) - name = title.delete_prefix(TagGroupRepr::PREFIX) + if (name = TagGroupRepr.name_from_title(title)) tag_group = TagGroup.find_by(name:) - return render_tag_group tag_group if tag_group + return render_tag_group locale, tag_group if tag_group return head :not_found end - tag_description = tag_description_by_title(title) + tag_description = tag_description_by_title(locale, title) - return render_wiki_page_by_tag_description tag_description if tag_description + return render_wiki_page_by_tag_description locale, tag_description if tag_description head :not_found end def exists - return head :no_content if TagDescription.exists?(id: params[:id]) + locale = resolve_locale! + if TagDescription.exists?(id: params[:id], language_code: locale.language_code) + return head :no_content + end return head :no_content if TagGroup.exists?(id: params[:id]) head :not_found end def exists_by_title + locale = resolve_locale! title = params[:title].to_s.strip - if title.start_with?(TagGroupRepr::PREFIX) - exists = TagGroup.exists?(name: title.delete_prefix(TagGroupRepr::PREFIX)) + if (name = TagGroupRepr.name_from_title(title)) + exists = TagGroup.exists?(name:) return head exists ? :no_content : :not_found end - return head :no_content if tag_description_by_title(title) + + return head :no_content if tag_description_by_title(locale, title) head :not_found end @@ -80,15 +90,17 @@ class WikiPagesController < ApplicationController id = params[:id] return render_bad_request('id は必須です.') if id.blank? + locale = resolve_locale! + from = params[:from].presence to = params[:to].presence - description = TagDescription.find_by(id:) + description = TagDescription.find_by(id:, language_code: locale.language_code) 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) + resource = WikiPageRepr.tag_description(locale, description) else tag_group = TagGroup.find_by(id:) if tag_group @@ -96,8 +108,8 @@ class WikiPagesController < ApplicationController to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group) return head :not_found if (from && !from_rev) || !to_rev - from_body = from_rev && render_group_version(from_rev) - to_body = render_group_version(to_rev) + from_body = from_rev && render_group_version(locale, from_rev) + to_body = render_group_version(locale, to_rev) resource = { 'id' => tag_group.id, 'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }", 'deprecated_at' => nil } @@ -131,14 +143,15 @@ class WikiPagesController < ApplicationController return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? - if title.start_with?(TagGroupRepr::PREFIX) - name = title.delete_prefix(TagGroupRepr::PREFIX) - parsed = TagGroupMarkdownParser.parse(body:, group: nil) + locale = resolve_locale! + + if (name = TagGroupRepr.name_from_title(title)) + parsed = TagGroupMarkdownParser.parse(locale, body:, group: nil) group = TagGroupOperation.create!( - name:, **parsed.except(:compatibility_body), actor: current_user, + locale, name:, **parsed.except(:compatibility_body), actor: current_user, require_compatibility: true) version = current_group_version(group) - return render_group_representation(TagGroupRepr.one(group), version, + return render_group_representation(TagGroupRepr.one(locale, group), version, status: :created) end @@ -146,14 +159,15 @@ class WikiPagesController < ApplicationController if title.include?(':') return render_unprocessable_entity field: :title else - tag_name = TagName.find_by(language_code: 'ja', name: title) + tag_name = TagName.find_by(language_code: locale.language_code, name: title) return render_unprocessable_entity field: :title unless tag_name&.tag TagDescriptionOperation.create!( + locale, tag: tag_name.tag, body:, created_by_user: current_user, message:) end - render json: WikiPageRepr.tag_description(description), status: :created + render json: WikiPageRepr.tag_description(locale, description), status: :created rescue ActiveRecord::RecordInvalid => e render_validation_error e.record rescue ActiveRecord::RecordNotUnique @@ -172,26 +186,28 @@ class WikiPagesController < ApplicationController return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? + locale = resolve_locale! + group = TagGroup.find_by(id: params[:id]) - if title.start_with?(TagGroupRepr::PREFIX) + if (name = TagGroupRepr.name_from_title(title)) return head :not_found unless group - name = title.delete_prefix(TagGroupRepr::PREFIX) - parsed = TagGroupMarkdownParser.parse(body:, group:) + parsed = TagGroupMarkdownParser.parse(locale, body:, group:) TagGroupOperation.update!( - group:, name:, **parsed.except(:compatibility_body), actor: current_user, + locale, group:, name:, **parsed.except(:compatibility_body), actor: current_user, require_compatibility: true) return head :ok end return render_unprocessable_entity field: :title if title.include?(':') - description = TagDescription.find_by(id: params[:id]) + description = TagDescription.find_by(id: params[:id], language_code: locale.language_code) unless description return render_unprocessable_entity field: :title if group return head :not_found end + tag = description.tag - tag_name = tag.tag_name('ja') + tag_name = tag.tag_name(locale.language_code) return render_unprocessable_entity field: :title unless tag_name return render_unprocessable_entity field: :title if tag_name.name.include?(':') @@ -207,7 +223,7 @@ class WikiPagesController < ApplicationController tag_name.update!(name: title) if title != old_title message = params[:message].presence - TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user, + TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user, message:, base_revision_id:) if title != old_title @@ -226,10 +242,12 @@ class WikiPagesController < ApplicationController end def changes + locale = resolve_locale! + id = params[:id].presence - description = id && TagDescription.find_by(id:) + description = id && TagDescription.find_by(id:, language_code: locale.language_code) if description - return render json: description_changes(TagDescription.where(id: description.id)) + return render json: description_changes(locale, TagDescription.where(id: description.id)) end tag_group = id && TagGroup.find_by(id:) @@ -240,7 +258,8 @@ class WikiPagesController < ApplicationController json = [] unless id json.concat(tag_group_changes(TagGroupVersion.all)) - json.concat(description_changes(TagDescription.where(language_code: 'ja'))) + json.concat(description_changes(locale, + TagDescription.where(language_code: locale.language_code))) json = json.each_with_index.sort_by do |row, index| [-row.fetch(:timestamp).to_f, index] end.map(&:first) @@ -250,7 +269,7 @@ class WikiPagesController < ApplicationController private - def render_tag_group group + def render_tag_group locale, group version = if params[:version].present? group_version(group, params[:version]) else @@ -258,16 +277,17 @@ class WikiPagesController < ApplicationController end return head :not_found unless version - representation = if params[:version].present? - TagGroupRepr.historical(version) - else - TagGroupRepr.one(group) - end + representation = + if params[:version].present? + TagGroupRepr.historical(locale, version) + else + TagGroupRepr.one(locale, group) + end render_group_representation(representation, version) end - def render_group_version version - representation = TagGroupRepr.historical(version) + def render_group_version locale, version + representation = TagGroupRepr.historical(locale, version) TagGroupMarkdownRenderer.render(representation) end @@ -336,7 +356,7 @@ class WikiPagesController < ApplicationController end end - def render_wiki_page_by_tag_description description + def render_wiki_page_by_tag_description locale, description versions = description_versions(description) historical = params[:version].present? rev = historical ? versions.find_by(id: params[:version]) : @@ -347,7 +367,7 @@ class WikiPagesController < ApplicationController 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( + render json: WikiPageRepr.tag_description(locale, description).merge( body:, revision_id: rev.id, pred:, succ:, updated_at:) end @@ -356,13 +376,14 @@ class WikiPagesController < ApplicationController language_code: description.language_code) end - def tag_description_by_title title + def tag_description_by_title locale, title TagDescription.joins(tag: :tag_names) - .find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title }) + .find_by(language_code: locale.language_code, + tag_names: { language_code: locale.language_code, name: title }) end - def description_changes descriptions - versions = TagDescriptionVersion.where(language_code: 'ja', + def description_changes locale, descriptions + versions = TagDescriptionVersion.where(language_code: locale.language_code, tag_id: descriptions.select(:tag_id)) .order(id: :desc).limit(200).to_a return [] if versions.empty? @@ -371,7 +392,7 @@ class WikiPagesController < ApplicationController .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, + language_code: locale.language_code, 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] } @@ -381,7 +402,7 @@ class WikiPagesController < ApplicationController { 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'), + wiki_page: { id: description.id, title: description.tag.name(locale.language_code), deprecated_at: description.tag.deprecated_at }, user: user && { id: user.id, name: user.name }, kind: 'content', diff --git a/backend/app/representations/tag_group_repr.rb b/backend/app/representations/tag_group_repr.rb index 98a5cb4..1b2afa4 100644 --- a/backend/app/representations/tag_group_repr.rb +++ b/backend/app/representations/tag_group_repr.rb @@ -3,7 +3,12 @@ module TagGroupRepr module_function - def many groups + def name_from_title title + prefix = [PREFIX, 'tag_group:'].find { title.downcase.start_with?(_1) } + title[prefix.length..] if prefix + end + + def many locale, groups ids = groups.map(&:id) return [] if ids.empty? @@ -14,14 +19,20 @@ module TagGroupRepr placements = TagGroupPlacement.where(tag_group_id: ids).order(:position) .group_by(&:tag_group_id) - target_ids = tag_members.values.flatten.map(&:tag_id) + - group_members.values.flatten.map(&:child_tag_group_id) + + target_ids = + tag_members.values.flatten.map(&:tag_id) + placements.values.flatten.filter_map(&:tag_id) - child_ids = group_members.values.flatten.map(&:child_tag_group_id) + + child_ids = + group_members.values.flatten.map(&:child_tag_group_id) + placements.values.flatten.filter_map(&:child_tag_group_id) tags = Tag.select(:id, :category).where(id: target_ids.uniq).index_by(&:id) - names = TagName.where(tag_id: target_ids.uniq, language_code: 'ja', primary_flg: true) - .pluck(:tag_id, :name).to_h + names = + TagName + .where(tag_id: target_ids.uniq, + language_code: locale.language_code, + primary_flg: true) + .pluck(:tag_id, :name) + .to_h child_groups = TagGroup.where(id: child_ids.uniq).index_by(&:id) groups.map do |group| @@ -32,8 +43,8 @@ module TagGroupRepr end end - def one group - many([group]).first + def one locale, group + many(locale, [group]).first end def tag_group wiki_page @@ -48,7 +59,7 @@ module TagGroupRepr TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no) end - def historical version + def historical locale, version members = Array(version.members) placements = Array(version.placements) tag_references = members.filter_map { |member| member['tag_id'] } + @@ -68,10 +79,10 @@ module TagGroupRepr [member.fetch('tag_group_id'), member.fetch('version_no')] end.to_h historical_members = members.map do |member| - historical_member(member, tag_versions, group_versions) + historical_member(locale, member, tag_versions, group_versions) end historical_placements = placements.map do |node| - historical_placement(node, tag_versions, group_versions, + historical_placement(locale, node, tag_versions, group_versions, tag_version_numbers, group_version_numbers) end { 'id' => version.tag_group_id, @@ -89,7 +100,7 @@ module TagGroupRepr def group_json group, tag_members, group_members, rows, tags, names, child_groups tree = placement_tree(rows, tags, names, child_groups) members = tag_members.sort_by(&:tag_id).map do |member| - { 'tag' => tag_json(tags.fetch(member.tag_id), names.fetch(member.tag_id)) } + { 'tag' => tag_json(tags.fetch(member.tag_id), names[member.tag_id]) } end members.concat(group_members.sort_by(&:child_tag_group_id).map do |member| { 'tag_group' => group_json_reference(child_groups.fetch(member.child_tag_group_id)) } @@ -115,7 +126,7 @@ module TagGroupRepr build = lambda do |parent| by_parent.fetch(parent, []).sort_by(&:position).map do |row| node = if row.tag_id - { 'tag' => tag_json(tags.fetch(row.tag_id), names.fetch(row.tag_id)) } + { 'tag' => tag_json(tags.fetch(row.tag_id), names[row.tag_id]) } elsif row.child_tag_group_id { 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) } else @@ -153,11 +164,11 @@ module TagGroupRepr end end - def historical_member member, tag_versions, group_versions + def historical_member locale, member, tag_versions, group_versions if member['tag_id'] id = member.fetch('tag_id') version = tag_versions.fetch([id, member.fetch('version_no')]) - { 'tag' => { 'id' => id, 'name' => japanese_version_name(version), + { 'tag' => { 'id' => id, 'name' => version_name(locale, version), 'category' => version.category } } else id = member.fetch('tag_group_id') @@ -166,13 +177,13 @@ module TagGroupRepr end end - def historical_placement(node, tag_versions, group_versions, - tag_version_numbers, group_version_numbers) + def historical_placement locale, node, tag_versions, group_versions, + tag_version_numbers, group_version_numbers result = if node['tag_id'] id = node.fetch('tag_id') version_no = tag_version_numbers.fetch(id) version = tag_versions.fetch([id, version_no]) - { 'tag' => { 'id' => id, 'name' => japanese_version_name(version), + { 'tag' => { 'id' => id, 'name' => version_name(locale, version), 'category' => version.category } } elsif node['tag_group_id'] id = node.fetch('tag_group_id') @@ -183,12 +194,12 @@ module TagGroupRepr { 'section_name' => node.fetch('section_name'), 'description' => node['description'] } end result.merge('children' => Array(node['children']).map do |child| - historical_placement(child, tag_versions, group_versions, + historical_placement(locale, child, tag_versions, group_versions, tag_version_numbers, group_version_numbers) end) end - def japanese_version_name version - version.names.fetch('ja').fetch('name') + def version_name locale, version + version.names[locale.language_code]&.fetch('name') end end diff --git a/backend/app/representations/wiki_page_repr.rb b/backend/app/representations/wiki_page_repr.rb index 2c747ba..74324d8 100644 --- a/backend/app/representations/wiki_page_repr.rb +++ b/backend/app/representations/wiki_page_repr.rb @@ -4,11 +4,12 @@ module WikiPageRepr module_function - def tag_description description + def tag_description locale, description tag = description.tag + { 'id' => description.id, 'resource_type' => 'tag_description', - 'tag_name_id' => tag.tag_name('ja')&.id, + 'tag_name_id' => tag.tag_name(locale.language_code)&.id, 'body' => description.body, 'created_user_id' => description.created_by_user_id, 'updated_user_id' => description.updated_by_user_id, @@ -16,7 +17,7 @@ module WikiPageRepr 'updated_at' => description.updated_at, 'discarded_at' => nil, 'version_no' => description.version_no, - 'title' => tag.name('ja'), + 'title' => tag.name(locale.language_code), 'deprecated_at' => tag.deprecated_at } end diff --git a/backend/app/services/tag_description_operation.rb b/backend/app/services/tag_description_operation.rb index 80284e8..991390c 100644 --- a/backend/app/services/tag_description_operation.rb +++ b/backend/app/services/tag_description_operation.rb @@ -1,32 +1,30 @@ class TagDescriptionOperation - LANGUAGE_CODE = 'ja' - class Conflict < StandardError ; end - def self.create!(tag:, body:, created_by_user:, message: nil) + def self.create! locale, 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) + tag_id: tag.id, language_code: locale.language_code) - description = TagDescription.new(tag:, language_code: LANGUAGE_CODE) + description = TagDescription.new(tag:, language_code: locale.language_code) body = normalise_body!(description, body) record_version!(description:, body:, user: created_by_user) + description end end - def self.update!(tag:, body:, updated_by_user:, message: nil, - base_revision_id: nil) + def self.update! locale, 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) + tag_id: tag.id, language_code: locale.language_code) body = normalise_body!(description, body) latest = TagDescriptionVersion.find_by( - tag_id: tag.id, language_code: LANGUAGE_CODE, + tag_id: tag.id, language_code: locale.language_code, version_no: description.version_no) if base_revision_id.present? && latest&.id != base_revision_id.to_i raise Conflict, '競合が発生してゐます.' diff --git a/backend/app/services/tag_group_markdown_parser.rb b/backend/app/services/tag_group_markdown_parser.rb index bfbeba7..595e7b1 100644 --- a/backend/app/services/tag_group_markdown_parser.rb +++ b/backend/app/services/tag_group_markdown_parser.rb @@ -7,8 +7,8 @@ class TagGroupMarkdownParser ; end - def self.parse(body:, group: nil) - new(body:, group:).parse + def self.parse(locale, body:, group: nil) + new(body:, group:).parse(locale) end def initialize(body:, group:) @@ -16,7 +16,7 @@ class TagGroupMarkdownParser @group = group end - def parse + def parse locale parse_body = @body.gsub(/([^\n])\n([\x23]{1,6}[ \t]+\S)/, "\\1\n\n\\2") root = Kramdown::Document.new(parse_body, input: 'GFM', auto_ids: false).root placements = [] @@ -51,7 +51,7 @@ class TagGroupMarkdownParser when :ul invalid!('未対応のリスト項目があります.') if node.attr.any? target = headings.any? ? headings.last.fetch(:node).fetch('children') : placements - target.concat(node.children.map { parse_list_item(_1) }) + target.concat(node.children.map { parse_list_item(locale, _1) }) else invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.") end @@ -66,7 +66,7 @@ class TagGroupMarkdownParser private - def parse_list_item(item) + def parse_list_item locale, item invalid!('リスト項目ではありません.') unless item.type == :li invalid!('未対応のリスト項目があります.') if item.attr.any? @@ -81,29 +81,30 @@ class TagGroupMarkdownParser label = own.map { inline_text(_1) }.join(' ').strip invalid!('空のリスト項目は使用できません.') if label.blank? - node = if label.start_with?(PREFIX) - group_name = label.delete_prefix(PREFIX).strip - child_group = TagGroup.find_by(name: group_name) - invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group - invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id - { 'tag_group_id' => child_group.id } - elsif label.end_with?(':', ':') - section_name = label.sub(/[::]\z/, '').strip - invalid!('Section 名は必須です.') if section_name.blank? - { 'section_name' => section_name, 'description' => nil } - else - { 'tag_id' => resolve_tag(label) } - end + node = + if (group_name = TagGroupRepr.name_from_title(label)) + group_name = group_name.strip + child_group = TagGroup.find_by(name: group_name) + invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group + invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id + { 'tag_group_id' => child_group.id } + elsif label.end_with?(':', ':') + section_name = label.sub(/[::]\z/, '').strip + invalid!('Section 名は必須です.') if section_name.blank? + { 'section_name' => section_name, 'description' => nil } + else + { 'tag_id' => resolve_tag(locale, label) } + end node['children'] = nested.flat_map do |list| invalid!('未対応の入れ子リストです.') unless list.type == :ul - list.children.map { parse_list_item(_1) } + list.children.map { parse_list_item(locale, _1) } end node end - def resolve_tag(name) - names = TagName.where(language_code: 'ja', name:).to_a + def resolve_tag locale, name + names = TagName.where(language_code: locale.language_code, name:).to_a tag_ids = names.filter_map(&:tag_id).uniq invalid!("Tag #{ name.inspect } を解決できません.") if tag_ids.empty? invalid!("Tag 名 #{ name.inspect } が曖昧です.") if tag_ids.many? diff --git a/backend/app/services/tag_group_operation.rb b/backend/app/services/tag_group_operation.rb index 6cd506c..a3f653b 100644 --- a/backend/app/services/tag_group_operation.rb +++ b/backend/app/services/tag_group_operation.rb @@ -1,29 +1,29 @@ class TagGroupOperation MAX_PLACEMENT_DEPTH = 5 - def self.create!(name:, description:, members:, placements:, actor:, + def self.create!(locale, name:, description:, members:, placements:, actor:, require_compatibility: false) ApplicationRecord.transaction do group = TagGroup.new(version_no: 1, created_by_user: actor, updated_by_user: actor) - attributes = prepare!(group:, name:, description:, members:, placements:) + attributes = prepare!(locale:, group:, name:, description:, members:, placements:) group.assign_attributes(name: attributes.fetch(:name), description: attributes.fetch(:description)) group.save! persist_structure!(group:, attributes:, actor:) - validate_compatibility!(group) if require_compatibility + validate_compatibility!(locale, group) if require_compatibility persist_version!(group:, attributes:, actor:, event_type: 'create') group end end - def self.update!(group:, name:, description:, members:, placements:, actor:, + def self.update!(locale, group:, name:, description:, members:, placements:, actor:, require_compatibility: false) ApplicationRecord.transaction do group.lock! - attributes = prepare!(group:, name:, description:, members:, placements:) + attributes = prepare!(locale:, group:, name:, description:, members:, placements:) if unchanged?(group, attributes) - validate_compatibility!(group) if require_compatibility + validate_compatibility!(locale, group) if require_compatibility next group end @@ -32,20 +32,20 @@ class TagGroupOperation version_no: group.version_no + 1, updated_by_user: actor) persist_structure!(group:, attributes:, actor:) - validate_compatibility!(group) if require_compatibility + validate_compatibility!(locale, group) if require_compatibility persist_version!(group:, attributes:, actor:, event_type: 'update') group end end - def self.validate_compatibility! group - TagGroupMarkdownRenderer.render(TagGroupRepr.one(group)) + def self.validate_compatibility! locale, group + TagGroupMarkdownRenderer.render(TagGroupRepr.one(locale, group)) rescue TagGroupMarkdownRenderer::UnsupportedStructure => error invalid!(group, :base, error.message) end private_class_method :validate_compatibility! - def self.prepare!(group:, name:, description:, members:, placements:) + def self.prepare!(locale:, group:, name:, description:, members:, placements:) record = group normalized_name = name.to_s.strip invalid!(record, :name, 'グループ名は必須です.') if normalized_name.blank? @@ -63,7 +63,7 @@ class TagGroupOperation member_rows = normalize_members(record, members) placement_tree = normalize_placements(record, placements) - validate_references!(record, member_rows, placement_tree) + validate_references!(locale, record, member_rows, placement_tree) validate_containment!(record, member_rows) { name: normalized_name, description:, members: member_rows, placements: placement_tree } @@ -187,7 +187,7 @@ class TagGroupOperation end private_class_method :normalize_placement_nodes - def self.validate_references!(record, members, placements) + def self.validate_references!(locale, record, members, placements) membership = members.to_set placed = placement_references(placements).uniq missing = placed - membership.to_a @@ -206,13 +206,14 @@ class TagGroupOperation tag_versions = TagVersion.where(tag_id: tag_ids).pluck(:tag_id, :version_no).to_set group_versions = TagGroupVersion.where(tag_group_id: group_ids) .pluck(:tag_group_id, :version_no).to_set - tag_names = TagName.where(tag_id: tag_ids, language_code: 'ja', primary_flg: true) + tag_names = TagName.where( + tag_id: tag_ids, language_code: locale.language_code, primary_flg: true) .pluck(:tag_id).uniq members.each do |member| if member.key?('tag_id') id = member.fetch('tag_id') - invalid!(record, :members, "Tag ##{ id } に日本語の正規名がありません.") unless + invalid!(record, :members, :invalid) unless tag_names.include?(id) version_no = tags.fetch(id).version_no unless tag_versions.include?([id, version_no]) -- 2.53.0 From f362b915a5730d78ea02f56b7297bb61f72a61e2 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Fri, 16 Oct 2026 00:15:21 +0900 Subject: [PATCH 7/7] #431 --- backend/spec/db/wiki_removal_spec.rb | 23 +++ backend/spec/models/tag_group_spec.rb | 16 +- .../models/tag_name_sanitisation_rule_spec.rb | 60 +++++++ backend/spec/models/user_spec.rb | 25 +++ .../tag_description_wiki_write_spec.rb | 3 +- .../spec/requests/tag_group_locale_spec.rb | 122 ++++++++++++++ .../spec/requests/tag_group_markdown_spec.rb | 9 + backend/spec/requests/tag_group_wiki_spec.rb | 10 +- backend/spec/requests/tag_versions_spec.rb | 1 + backend/spec/requests/tags_spec.rb | 7 +- backend/spec/requests/wiki_conflict_spec.rb | 1 + backend/spec/requests/wiki_locale_spec.rb | 158 ++++++++++++++++++ backend/spec/requests/wiki_spec.rb | 18 +- backend/spec/support/ainu_locale_contract.rb | 22 +++ backend/spec/support/tag_group_records.rb | 11 +- backend/spec/support/wiki_records.rb | 4 +- 16 files changed, 458 insertions(+), 32 deletions(-) create mode 100644 backend/spec/db/wiki_removal_spec.rb create mode 100644 backend/spec/models/user_spec.rb create mode 100644 backend/spec/requests/tag_group_locale_spec.rb create mode 100644 backend/spec/requests/wiki_locale_spec.rb create mode 100644 backend/spec/support/ainu_locale_contract.rb diff --git a/backend/spec/db/wiki_removal_spec.rb b/backend/spec/db/wiki_removal_spec.rb new file mode 100644 index 0000000..33fe633 --- /dev/null +++ b/backend/spec/db/wiki_removal_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe 'Schema after Wiki removal' do + it 'has no legacy Wiki tables or name indirection columns' do + connection = ApplicationRecord.connection + ['wiki_pages', 'wiki_revisions', 'wiki_lines', 'wiki_revision_lines', 'wiki_versions'] + .each do |table| + expect(connection.table_exists?(table)).to be(false) + end + expect(connection.column_exists?(:tags, :tag_name_id)).to be(false) + expect(connection.column_exists?(:tag_names, :canonical_id)).to be(false) + end + + it 'requires TagName ownership while keeping the native resources and history' do + connection = ApplicationRecord.connection + expect(TagName.columns_hash.fetch('tag_id').null).to be(false) + expect(connection.foreign_key_exists?(:tag_names, :tags)).to be(true) + ['tag_descriptions', 'tag_description_versions', 'tag_groups', 'tag_group_versions'] + .each do |table| + expect(connection.table_exists?(table)).to be(true) + end + end +end diff --git a/backend/spec/models/tag_group_spec.rb b/backend/spec/models/tag_group_spec.rb index 67c1125..9e5be9d 100644 --- a/backend/spec/models/tag_group_spec.rb +++ b/backend/spec/models/tag_group_spec.rb @@ -4,7 +4,8 @@ RSpec.describe TagGroup, type: :model do let(:actor) { create_member_user! } def create_group name:, members: [], placements: [] - TagGroupOperation.create!(name:, description: nil, members:, placements:, actor:) + TagGroupOperation.create!( + Locale.nipponese, name:, description: nil, members:, placements:, actor:) end it 'retains version IDs and snapshots after destroying an empty group' do @@ -16,7 +17,8 @@ RSpec.describe TagGroup, type: :model do expect(TagGroupVersion.where(tag_group_id: group.id).order(:version_no).map(&:attributes)) .to eq(versions) - expect(TagGroupRepr.historical(TagGroupVersion.find(versions.first.fetch('id')))) + expect(TagGroupRepr.historical( + Locale.nipponese, TagGroupVersion.find(versions.first.fetch('id')))) .to include('id' => group.id, 'name' => 'history owner', 'members' => []) end @@ -26,14 +28,8 @@ RSpec.describe TagGroup, type: :model do parent = create_group( name: 'parent group', members: [{ 'tag_group_id' => child.id }], - placements: [{ - 'section_name' => 'section', - 'description' => nil, - 'children' => [{ - 'tag_group_id' => child.id, - 'children' => [] - }] - }]) + placements: [{ 'section_name' => 'section', 'description' => nil, + 'children' => [{ 'tag_group_id' => child.id, 'children' => [] }] }]) versions = parent.versions.order(:version_no).map(&:attributes) diff --git a/backend/spec/models/tag_name_sanitisation_rule_spec.rb b/backend/spec/models/tag_name_sanitisation_rule_spec.rb index cec475d..58bd168 100644 --- a/backend/spec/models/tag_name_sanitisation_rule_spec.rb +++ b/backend/spec/models/tag_name_sanitisation_rule_spec.rb @@ -34,6 +34,66 @@ RSpec.describe TagNameSanitisationRule, type: :model do described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo') end + [:primary, :alias].each do |source_kind| + it "keeps the primary identity when a #{ source_kind } collides within its Tag" do + tag = create(:tag, primary_name: source_kind == :primary ? 'temporary' : 'foobar') + primary = tag.tag_name('ja') + alias_name = create(:tag_name, :alias, tag:, + name: source_kind == :primary ? 'foobar' : 'temporary_alias') + source = source_kind == :primary ? primary : alias_name + source.update_columns(name: 'foo_bar') + + expect { described_class.apply! }.to change(TagName, :count).by(-1) + .and change(Tag, :count).by(0) + + expect(primary.reload).to have_attributes( + name: 'foobar', primary_flg: true, tag_id: tag.id) + expect(TagName.exists?(alias_name.id)).to be(false) + expect(TagName.where(tag:, language_code: 'ja', primary_flg: true)) + .to contain_exactly(primary) + expect(tag.tag_versions.order(:version_no).last.names.fetch('ja')) + .to include('name' => 'foobar', 'aliases' => []) + end + end + + it 'deduplicates colliding aliases without changing the primary name' do + tag = create(:tag, primary_name: 'primary') + primary = tag.tag_name('ja') + kept = create(:tag_name, :alias, tag:, name: 'foobar') + removed = create(:tag_name, :alias, tag:, name: 'temporary_alias') + removed.update_columns(name: 'foo_bar') + + described_class.apply! + + expect(primary.reload).to have_attributes(name: 'primary', primary_flg: true) + expect(kept.reload.tag_id).to eq(tag.id) + expect(TagName.exists?(removed.id)).to be(false) + expect(tag.tag_versions.order(:version_no).last.names.fetch('ja').fetch('aliases')) + .to eq([{ 'name' => 'foobar', 'script_code' => 'Jpan' }]) + end + + it 'rolls back a collision merge when the source owns a description' do + user = create_member_user! + target = create(:tag, primary_name: 'foobar') + source = create(:tag, primary_name: 'temporary') + TagDescriptionOperation.create!( + Locale.nipponese, tag: source, body: 'retained', created_by_user: user) + source.tag_name('ja').update_columns(name: 'foo_bar') + post = create(:post) + PostTag.create!(post:, tag: source) + models = [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion, PostTag] + snapshots = models.map { |model| model.order(*Array(model.primary_key)).map(&:attributes) } + + expect { described_class.apply! }.to raise_error(ActiveRecord::RecordInvalid) + + after_state = models.map do |model| + model.order(*Array(model.primary_key)).map(&:attributes) + end + expect(after_state).to eq(snapshots) + expect(post.reload.tags).to contain_exactly(source) + expect(target.reload.name('ja')).to eq('foobar') + end + context 'when only another language has the sanitised name' do include_context 'English locale' diff --git a/backend/spec/models/user_spec.rb b/backend/spec/models/user_spec.rb new file mode 100644 index 0000000..692d682 --- /dev/null +++ b/backend/spec/models/user_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +RSpec.describe User, type: :model do + it 'nullifies description authorship without deleting native content or history' do + author = create_member_user! + editor = create_member_user! + tag = create(:tag) + description = TagDescriptionOperation.create!( + Locale.nipponese, tag:, body: 'initial', created_by_user: author) + TagDescriptionOperation.update!( + Locale.nipponese, tag:, body: 'edited', updated_by_user: editor) + versions = TagDescriptionVersion.where(tag_id: tag.id).order(:version_no) + snapshots = versions.map(&:attributes) + + expect(author.created_tag_descriptions).to contain_exactly(description) + expect(editor.updated_tag_descriptions).to contain_exactly(description) + expect { author.destroy! }.to change(TagDescription, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) + expect(description.reload).to have_attributes( + body: 'edited', created_by_user_id: nil, updated_by_user_id: editor.id) + expect { editor.destroy! }.not_to change(TagDescription, :count) + expect(description.reload.updated_by_user_id).to be_nil + expect(versions.reload.map(&:attributes)).to eq(snapshots) + end +end diff --git a/backend/spec/requests/tag_description_wiki_write_spec.rb b/backend/spec/requests/tag_description_wiki_write_spec.rb index 0658d7c..53b9675 100644 --- a/backend/spec/requests/tag_description_wiki_write_spec.rb +++ b/backend/spec/requests/tag_description_wiki_write_spec.rb @@ -26,7 +26,8 @@ RSpec.describe 'Tag description Wiki facade writes', type: :request do context 'with an existing description' do let!(:description) do - TagDescriptionOperation.create!(tag:, body: 'before', created_by_user: user) + TagDescriptionOperation.create!( + Locale.nipponese, tag:, body: 'before', created_by_user: user) end let!(:first_version) { current_description_version(description) } diff --git a/backend/spec/requests/tag_group_locale_spec.rb b/backend/spec/requests/tag_group_locale_spec.rb new file mode 100644 index 0000000..bc3088e --- /dev/null +++ b/backend/spec/requests/tag_group_locale_spec.rb @@ -0,0 +1,122 @@ +require 'rails_helper' + +RSpec.describe 'Tag group locale contracts', type: :request do + include TagGroupRecords + include_context 'Ainu locale contract' + + let!(:user) { create_member_user! } + let!(:tag) { create(:tag, primary_name: 'japanese_member') } + let!(:ainu_name) { add_ainu_name(tag, 'ainu_member') } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:members) { [{ 'tag_id' => tag.id }] } + let(:placements) do + [{ 'section_name' => 'section', 'description' => nil, 'children' => [ + { 'tag_id' => tag.id, 'children' => [ + { 'tag_id' => tag.id, 'children' => [] }] }] }] + end + let!(:group) do + TagVersioning.ensure_snapshot!(tag, created_by_user: user) + TagGroupOperation.create!( + ainu_locale, name: 'locale group', description: nil, members:, placements:, actor: user) + end + + it 'uses selected-language names in native collection and nested placements' do + ["/tag_groups/#{ group.id }", '/tag_groups'].each do |path| + get path, params: { locale: 'ain' } + expect(response).to have_http_status(:ok) + representation = json.is_a?(Array) ? json.sole : json + expect(representation.fetch('members').sole.fetch('tag').fetch('name')) + .to eq('ainu_member') + node = representation.fetch('placements').sole.fetch('children').sole + expect(node.fetch('tag').fetch('name')).to eq('ainu_member') + expect(node.fetch('children').sole.fetch('tag').fetch('name')).to eq('ainu_member') + expect(response.body).not_to include('japanese_member') + end + end + + it 'uses snapshot names recursively for history and current names for the latest view' do + version = group.versions.sole + ainu_name.update!(name: 'renamed_ainu') + get "/wiki/#{ group.id }", params: { locale: 'ain' } + expect(response).to have_http_status(:ok) + expect(json.fetch('body')).to include('renamed_ainu') + get "/wiki/#{ group.id }", params: { locale: 'ain', version: version.id } + expect(response).to have_http_status(:ok) + expect(json.fetch('body').scan('ainu_member').length).to eq(2) + expect(json.fetch('body')).not_to include('renamed_ainu', 'japanese_member') + get "/wiki/#{ group.id }/diff", params: { locale: 'ain', from: version.id } + expect(response).to have_http_status(:ok) + expect(json.fetch('diff').map { _1.fetch('type') }.uniq).to eq(['context']) + end + + ['タググループ:', 'tag_group:', 'TAG_GROUP:'].each do |prefix| + it "round trips #{ prefix } through create, lookup, search and update" do + post '/wiki', params: { + locale: 'ain', title: "#{ prefix }parent", body: '- tag_group:locale group' }, + headers: headers + expect(response).to have_http_status(:created) + id = json.fetch('id') + expect(json.fetch('body')).to include('タググループ:locale group') + path = "/wiki/title/#{ CGI.escapeURIComponent("#{ prefix }parent") }" + get path, params: { locale: 'ain' } + expect(response).to have_http_status(:ok) + expect(json.fetch('id')).to eq(id) + get "#{ path }/exists", params: { locale: 'ain' } + expect(response).to have_http_status(:no_content) + get '/wiki/search', params: { locale: 'ain', title: "#{ prefix }parent" } + expect(json.map { _1.fetch('id') }).to eq([id]) + put "/wiki/#{ id }", params: { + locale: 'ain', title: "#{ prefix }renamed", body: '- ainu_member' }, headers: headers + expect(response).to have_http_status(:ok) + get "/tag_groups/#{ id }", params: { locale: 'ain' } + expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member') + end + end + + it 'resolves Markdown names in the requested language and rolls back invalid input' do + parsed = TagGroupMarkdownParser.parse(ainu_locale, body: '- ainu_member') + expect(parsed.fetch(:members)).to eq(members) + expect do + TagGroupMarkdownParser.parse(ainu_locale, body: '- japanese_member') + end.to raise_error(TagGroupMarkdownParser::InvalidDocument) + before_state = group_state + put "/wiki/#{ group.id }", params: { + locale: 'ain', title: 'タググループ:partial rename', body: '- japanese_member' }, + headers: headers + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + + it 'passes locale into native validation and facade compatibility checks' do + post '/tag_groups', params: { + locale: 'ain', name: 'native', description: nil, members:, placements: }, + headers:, as: :json + expect(response).to have_http_status(:created) + id = json.fetch('id') + expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member') + get "/wiki/#{ id }", params: { locale: 'ain' } + expect(response).to have_http_status(:ok) + body = json.fetch('body') + put "/wiki/#{ id }", params: { locale: 'ain', title: 'tag_group:native', body: }, + headers: headers + expect(response).to have_http_status(:ok) + expect(TagGroup.find(id).version_no).to eq(1) + end + + it 'does not substitute current or Japanese names for a missing historical language' do + other = create(:tag, primary_name: 'historical_japanese') + TagVersioning.ensure_snapshot!(other, created_by_user: user) + old_group = TagGroupOperation.create!( + Locale.nipponese, name: 'old group', description: nil, + members: [{ 'tag_id' => other.id }], + placements: [{ 'tag_id' => other.id, 'children' => [] }], actor: user) + add_ainu_name(other, 'current_ainu') + version = old_group.versions.sole + representation = TagGroupRepr.historical(ainu_locale, version) + expect(representation.fetch('members').sole.fetch('tag').fetch('name')).to be_nil + expect(representation.fetch('placements').sole.fetch('tag').fetch('name')).to be_nil + get "/wiki/#{ old_group.id }", params: { locale: 'ain', version: version.id } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).not_to include('historical_japanese', 'current_ainu') + end +end diff --git a/backend/spec/requests/tag_group_markdown_spec.rb b/backend/spec/requests/tag_group_markdown_spec.rb index 68cc37b..2e12c9e 100644 --- a/backend/spec/requests/tag_group_markdown_spec.rb +++ b/backend/spec/requests/tag_group_markdown_spec.rb @@ -115,6 +115,15 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: : MARKDOWN end + it 'round trips a top-level list followed by heading Sections' do + check_round_trip( + members: [tag_member(series), tag_member(deer)], + placements: [tag_node(series), + { 'section_name' => '区分', 'description' => nil, + 'children' => [tag_node(deer)] }], + markdown: "- ぼざろクリーチャーシリーズ\n\n## 区分\n\n- 洗澡鹿") + end + it 'rejects mixed top-level heading Sections and Tags without losing structure' do id = seed_group( name: '混在構造', members: [tag_member(series)], diff --git a/backend/spec/requests/tag_group_wiki_spec.rb b/backend/spec/requests/tag_group_wiki_spec.rb index d24add8..73f1bad 100644 --- a/backend/spec/requests/tag_group_wiki_spec.rb +++ b/backend/spec/requests/tag_group_wiki_spec.rb @@ -10,8 +10,9 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do let!(:page) { create_markdown_group('読取り契約', '- 旧タグ') } let!(:first_revision) { page.versions.sole } let!(:last_revision) do - parsed = TagGroupMarkdownParser.parse(body: '- 新タグ') + parsed = TagGroupMarkdownParser.parse(Locale.nipponese, body: '- 新タグ') TagGroupOperation.update!( + Locale.nipponese, group: page, name: page.name, actor: user, **parsed.slice(:description, :members, :placements)) page.versions.order(:version_no).last @@ -67,7 +68,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do - TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user) + TagDescriptionOperation.create!( + Locale.nipponese, tag: new_tag, body: 'tag description', created_by_user: user) get '/wiki' expect(response).to have_http_status(:ok) expect(json.map { _1.fetch('title') }).to include( @@ -87,6 +89,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do it 'does not restore a stale namespaced Wiki page after a group rename' do group = create_markdown_group('旧名称', '正本') TagGroupOperation.update!( + Locale.nipponese, group:, name: '新名称', description: '正本', members: [], placements: [], actor: user) get group_title_path('旧名称') @@ -99,7 +102,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end it 'orders mixed resource history by timestamp rather than independent IDs' do - TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user) + TagDescriptionOperation.create!( + Locale.nipponese, tag: new_tag, body: 'description', created_by_user: user) TagGroupVersion.update_all(created_at: 3.days.ago) TagDescriptionVersion.update_all(created_at: 1.day.ago) diff --git a/backend/spec/requests/tag_versions_spec.rb b/backend/spec/requests/tag_versions_spec.rb index 7165a0f..f05bb0b 100644 --- a/backend/spec/requests/tag_versions_spec.rb +++ b/backend/spec/requests/tag_versions_spec.rb @@ -248,6 +248,7 @@ RSpec.describe 'TagVersions API', type: :request do message: 'init') TagDescriptionOperation.update!( + Locale.nipponese, tag: wiki_page.tag, body: 'after', updated_by_user: member, diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 897aed4..7a66889 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -1402,11 +1402,8 @@ RSpec.describe 'Tags API', type: :request do message: 'init') TagDescriptionOperation.update!( - tag: wiki_page.tag, - body: 'wiki body before', - updated_by_user: member_user, - message: 'init' - ) + Locale.nipponese, tag: wiki_page.tag, body: 'wiki body before', + updated_by_user: member_user, message: 'init') wiki_history = description_versions(wiki_page).map(&:attributes) diff --git a/backend/spec/requests/wiki_conflict_spec.rb b/backend/spec/requests/wiki_conflict_spec.rb index 5da7988..55c2f35 100644 --- a/backend/spec/requests/wiki_conflict_spec.rb +++ b/backend/spec/requests/wiki_conflict_spec.rb @@ -19,6 +19,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do stale_id = current_description_version(page).id TagDescriptionOperation.update!( + Locale.nipponese, tag: page.tag, body: 'second', updated_by_user: user, diff --git a/backend/spec/requests/wiki_locale_spec.rb b/backend/spec/requests/wiki_locale_spec.rb new file mode 100644 index 0000000..f8a653b --- /dev/null +++ b/backend/spec/requests/wiki_locale_spec.rb @@ -0,0 +1,158 @@ +require 'rails_helper' + +RSpec.describe 'Wiki and native API locale contracts', type: :request do + include WikiRecords + include TagGroupRecords + include_context 'Ainu locale contract' + + let!(:user) { create_member_user! } + let!(:tag) { create(:tag, primary_name: 'japanese_title') } + let!(:ainu_name) { add_ainu_name(tag, 'ainu_title') } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let!(:japanese) do + TagDescriptionOperation.create!( + Locale.nipponese, tag:, body: 'Japanese body', created_by_user: user) + end + let!(:ainu) do + TagDescriptionOperation.create!( + ainu_locale, tag:, body: 'Ainu body', created_by_user: user) + end + + ['/wiki', '/wiki/search'].each do |path| + it "filters #{ path } by the selected language" do + get path, params: { locale: 'ain' } + expect(response).to have_http_status(:ok) + expect(json.map { _1.fetch('id') }).to eq([ainu.id]) + expect(json.sole).to include('title' => 'ainu_title', 'body' => 'Ainu body') + + get path, params: { locale: 'ain', title: 'japanese' } + expect(response).to have_http_status(:ok) + expect(json).to eq([]) + end + end + + it 'uses the existing default locale when no locale is specified' do + get '/wiki' + expect(response).to have_http_status(:ok) + expect(json.map { _1.fetch('id') }).to eq([japanese.id]) + end + + it 'resolves titles and aliases only in the selected language in both APIs' do + ['/wiki/title/japanese_title', '/tags/name/japanese_title/description'].each do |path| + get path, params: { locale: 'ain' } + expect(response).to have_http_status(:not_found) + end + ['/wiki/title/ainu_title', '/tags/name/ainu_title/description'].each do |path| + get path, params: { locale: 'ain' } + expect(response).to have_http_status(:ok) + expect(json.fetch('body')).to eq('Ainu body') + end + get '/wiki/title/ainu_title/exists', params: { locale: 'ain' } + expect(response).to have_http_status(:no_content) + get '/wiki/title/japanese_title/exists', params: { locale: 'ain' } + expect(response).to have_http_status(:not_found) + end + + it 'does not disclose another language through ID, existence, diff or history' do + revision = current_description_version(japanese) + ["/wiki/#{ japanese.id }", "/wiki/#{ japanese.id }/exists", + "/wiki/#{ japanese.id }/diff"].each do |path| + get path, params: { locale: 'ain', from: revision.id } + expect(response).to have_http_status(:not_found) + expect(response.body).not_to include('Japanese body') + end + get '/wiki/changes', params: { locale: 'ain', id: japanese.id } + expect(response).to have_http_status(:ok) + expect(json).to eq([]) + get '/wiki/changes', params: { locale: 'ain' } + expect(json.map { _1.fetch('revision_id') }) + .to eq([current_description_version(ainu).id]) + end + + it 'does not update a description addressed through another language ID' do + snapshot = [japanese.attributes, ainu.attributes, ainu_name.attributes] + expect do + put "/wiki/#{ japanese.id }", params: { + locale: 'ain', title: 'renamed', body: 'wrong target' }, headers: headers + end.not_to change(TagDescriptionVersion, :count) + expect(response).to have_http_status(:not_found) + expect([japanese.reload.attributes, ainu.reload.attributes, ainu_name.reload.attributes]) + .to eq(snapshot) + end + + [:wiki, :native].each do |api| + it "creates the selected language through #{ api } without changing Japanese history" do + other = create(:tag, primary_name: 'other_japanese') + add_ainu_name(other, 'other_ainu') + original = TagDescriptionOperation.create!( + Locale.nipponese, tag: other, body: 'original', created_by_user: user) + snapshot = original.attributes + expect do + if api == :wiki + post '/wiki', params: { locale: 'ain', title: 'other_ainu', body: 'created' }, + headers: headers + else + put "/tags/#{ other.id }/description", params: { locale: 'ain', body: 'created' }, + headers: headers + end + end.to change(TagDescription, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + expect(response).to have_http_status(:created) + created = TagDescription.find_by!(tag: other, language_code: 'ain') + expect(created).to have_attributes(body: 'created', version_no: 1) + expect(current_description_version(created)).to have_attributes(language_code: 'ain') + expect(original.reload.attributes).to eq(snapshot) + end + end + + it 'shares selected-language edits and revision identity across both APIs' do + snapshot = japanese.attributes + first = current_description_version(ainu) + put "/tags/#{ tag.id }/description", params: { locale: 'ain', body: 'native edit' }, + headers: headers + expect(response).to have_http_status(:ok) + second = current_description_version(ainu) + expect(second).to have_attributes(language_code: 'ain', version_no: 2) + expect(second.id).not_to eq(first.id) + get "/wiki/#{ ainu.id }", params: { locale: 'ain' } + expect(json).to include('body' => 'native edit', 'revision_id' => second.id) + + put "/wiki/#{ ainu.id }", params: { + locale: 'ain', title: 'ainu_title', body: 'facade edit', + base_revision_id: second.id }, headers: headers + expect(response).to have_http_status(:ok) + get "/tags/#{ tag.id }/description", params: { locale: 'ain' } + expect(json).to include('body' => 'facade edit', 'language_code' => 'ain', 'version_no' => 3) + expect(japanese.reload.attributes).to eq(snapshot) + end + + it 'rejects a stale selected-language revision without a partial title or body edit' do + stale = current_description_version(ainu) + TagDescriptionOperation.update!( + ainu_locale, tag:, body: 'current', updated_by_user: user) + snapshot = [ainu.reload.attributes, ainu_name.attributes, japanese.attributes] + expect do + put "/wiki/#{ ainu.id }", params: { + locale: 'ain', title: 'conflicting_name', body: 'current', + base_revision_id: stale.id }, headers: headers + end.to change(TagDescriptionVersion, :count).by(0).and change(TagVersion, :count).by(0) + expect(response).to have_http_status(:conflict) + expect([ainu.reload.attributes, ainu_name.reload.attributes, japanese.reload.attributes]) + .to eq(snapshot) + end + + it 'scopes historical show and diff to the selected description' do + first = current_description_version(ainu) + TagDescriptionOperation.update!( + ainu_locale, tag:, body: 'later', updated_by_user: user) + get "/wiki/#{ ainu.id }", params: { locale: 'ain', version: first.id } + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'Ainu body', 'revision_id' => first.id) + get "/wiki/#{ ainu.id }/diff", params: { locale: 'ain', from: first.id } + expect(response).to have_http_status(:ok) + expect(json.fetch('newer_revision_id')).to eq(current_description_version(ainu).id) + get "/wiki/#{ ainu.id }/diff", params: { + locale: 'ain', from: current_description_version(japanese).id } + expect(response).to have_http_status(:not_found) + end +end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 80f9e28..12663c0 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -232,7 +232,9 @@ RSpec.describe 'Wiki API', type: :request do context 'when conflict' do it 'returns 409 when base_revision_id mismatches' do # 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める - TagDescriptionOperation.update!(tag: page.tag, body: "zzz", updated_by_user: member, message: 'other edit') + TagDescriptionOperation.update!( + Locale.nipponese, tag: page.tag, body: 'zzz', + updated_by_user: member, message: 'other edit') page.reload stale_id = description_versions(page).order(:id).first.id # わざと古い id @@ -314,12 +316,14 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/changes' do let!(:rev1) do - TagDescriptionOperation.update!(tag: page.tag, body: "a\nb", updated_by_user: user, message: 'r1') + TagDescriptionOperation.update!( + Locale.nipponese, tag: page.tag, body: "a\nb", updated_by_user: user, message: 'r1') current_description_version(page) end let!(:rev2) do - TagDescriptionOperation.update!(tag: page.tag, body: "a\nc", updated_by_user: user, message: 'r2') + TagDescriptionOperation.update!( + Locale.nipponese, tag: page.tag, body: "a\nc", updated_by_user: user, message: 'r2') current_description_version(page) end @@ -354,7 +358,7 @@ RSpec.describe 'Wiki API', type: :request do it 'returns empty array when page has no revisions and filtered by id' do # 別ページを作って revision 無し tn2 = create(:tag_name, name: 'spec_no_rev') - # 異常データ: revision 無し WikiPage を直接作る + # 異常データ: Version のない TagDescription を直接作る p2 = TagDescription.create!( tag: tn2.tag, language_code: 'ja', body: 'init', version_no: 1) @@ -392,12 +396,14 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/:id/diff' do let!(:rev_a) do - TagDescriptionOperation.update!(tag: page.tag, body: "a\nb\nc", updated_by_user: user, message: 'A') + TagDescriptionOperation.update!( + Locale.nipponese, tag: page.tag, body: "a\nb\nc", updated_by_user: user, message: 'A') current_description_version(page) end let!(:rev_b) do - TagDescriptionOperation.update!(tag: page.tag, body: "a\nx\nc", updated_by_user: user, message: 'B') + TagDescriptionOperation.update!( + Locale.nipponese, tag: page.tag, body: "a\nx\nc", updated_by_user: user, message: 'B') current_description_version(page) end diff --git a/backend/spec/support/ainu_locale_contract.rb b/backend/spec/support/ainu_locale_contract.rb new file mode 100644 index 0000000..83187cf --- /dev/null +++ b/backend/spec/support/ainu_locale_contract.rb @@ -0,0 +1,22 @@ +RSpec.shared_context 'Ainu locale contract' do + let(:ainu_locale) { instance_double(Locale, language_code: 'ain', script_code: 'Jpan') } + + before do + # Only the foreign-key reference is seeded; no Locale or enum is enabled. + Language.connection.execute(<<~SQL) + INSERT IGNORE INTO languages (code, name, created_at) + VALUES ('ain', 'Ainu', CURRENT_TIMESTAMP) + SQL + allow(Locale).to receive(:find_by).and_call_original + allow(Locale).to receive(:find_by).with(code: 'ain').and_return(ainu_locale) + end + + def add_ainu_name tag, name + TagName.insert_all!([{ + tag_id: tag.id, language_code: 'ain', script_code: 'Jpan', name:, + primary_flg: true, auto_generated: false, + created_at: Time.current, updated_at: Time.current }]) + tag.tag_names.reset + TagName.find_by!(tag_id: tag.id, language_code: 'ain', name:) + end +end diff --git a/backend/spec/support/tag_group_records.rb b/backend/spec/support/tag_group_records.rb index 9d370c7..82c1693 100644 --- a/backend/spec/support/tag_group_records.rb +++ b/backend/spec/support/tag_group_records.rb @@ -94,13 +94,14 @@ module TagGroupRecords tag.reload end - def create_markdown_group name, body - parsed = TagGroupMarkdownParser.parse(body:) - TagGroupOperation.create!(name:, actor: user, **parsed.slice(:description, :members, :placements)) + def create_markdown_group name, body, locale: Locale.nipponese + parsed = TagGroupMarkdownParser.parse(locale, body:) + TagGroupOperation.create!( + locale, name:, actor: user, **parsed.slice(:description, :members, :placements)) end - def group_markdown group - TagGroupMarkdownRenderer.render(TagGroupRepr.one(group.reload)) + def group_markdown group, locale: Locale.nipponese + TagGroupMarkdownRenderer.render(TagGroupRepr.one(locale, group.reload)) end def seed_group name:, placements: [], members: [], description: nil, id: nil diff --git a/backend/spec/support/wiki_records.rb b/backend/spec/support/wiki_records.rb index 4dbcfb1..ff06bb1 100644 --- a/backend/spec/support/wiki_records.rb +++ b/backend/spec/support/wiki_records.rb @@ -1,6 +1,6 @@ module WikiRecords - def seed_description(tag_name:, body:, created_by_user:, message: nil) - TagDescriptionOperation.create!(tag: tag_name.tag, body:, created_by_user:, message:) + def seed_description(tag_name:, body:, created_by_user:, message: nil, locale: Locale.nipponese) + TagDescriptionOperation.create!(locale, tag: tag_name.tag, body:, created_by_user:, message:) end def description_versions(description) -- 2.53.0