diff --git a/backend/app/controllers/tag_descriptions_controller.rb b/backend/app/controllers/tag_descriptions_controller.rb new file mode 100644 index 0000000..06d78c2 --- /dev/null +++ b/backend/app/controllers/tag_descriptions_controller.rb @@ -0,0 +1,57 @@ +class TagDescriptionsController < ApplicationController + def show + description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja') + return head :not_found unless description + + render json: representation(description) + end + + def show_by_title + tag_name = TagName.find_by(language_code: 'ja', name: params[:name].to_s.strip) + description = tag_name&.tag && TagDescription.find_by(tag: tag_name.tag, language_code: 'ja') + return head :not_found unless description + + render json: representation(description) + end + + def update + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + body = params[:body].to_s + return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? + + tag = Tag.find_by(id: params[:id]) + return head :not_found unless tag + + description = TagDescription.find_by(tag:, language_code: 'ja') + + if description + TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user) + description.reload + render json: representation(description) + else + TagDescriptionOperation.create!(tag:, body:, created_by_user: current_user) + description = TagDescription.find_by!(tag:, language_code: 'ja') + render json: representation(description), status: :created + end + rescue ActiveRecord::RecordInvalid => e + render_validation_error e.record + rescue ActiveRecord::RecordNotUnique + render_record_not_unique + end + + private + + def representation description + description.as_json(only: [ + :tag_id, + :language_code, + :body, + :version_no, + :created_by_user_id, + :updated_by_user_id, + :created_at, + :updated_at]) + end +end diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 9be0365..7919a24 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -464,7 +464,6 @@ class TagsController < ApplicationController old_name = tag.name(locale.language_code) name_changed = name != old_name - wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed if tag.deprecated? == deprecated tag.update!(category:) @@ -485,9 +484,7 @@ class TagsController < ApplicationController tag, event_type: :update, created_by_user: current_user, - language_code: locale.language_code, - name_changed:, - wiki_page:) + language_code: locale.language_code) end render json: TagRepr.base(tag.reload, language_code: locale.language_code) @@ -518,7 +515,6 @@ class TagsController < ApplicationController old_name = tag.name(locale.language_code) name_changed = name.present? && name != old_name - wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed rename_tag_name!(locale, tag, name) if name_changed tag.update!(category:) if category.present? @@ -532,9 +528,7 @@ class TagsController < ApplicationController tag, event_type: :update, created_by_user: current_user, - language_code: locale.language_code, - name_changed:, - wiki_page:) + language_code: locale.language_code) end render json: TagRepr.base(tag.reload, language_code: locale.language_code) @@ -722,19 +716,8 @@ class TagsController < ApplicationController language_code:)) end - def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja', - name_changed: false, wiki_page: nil + def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja' TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:) - - return unless name_changed - - wiki_page ||= tag.tag_name('ja')&.wiki_page - return unless wiki_page&.wiki_versions&.exists? - - WikiVersionRecorder.record!( - page: wiki_page, - event_type: :update, - created_by_user:) end def validate_tag_rename locale, tag, name diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index b0d0b8c..8fb84e9 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -2,47 +2,67 @@ class WikiPagesController < ApplicationController rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict def index + # 互換性のための化石リソース + # TODO: ヘルプおよびタグ・グループ追加後に, + # TODO: タグ説明とヘルプ、タグ・グループから取得する作りに変更する. + # title = params[:title].to_s.strip - if title.blank? - return render json: WikiPageRepr.base( - WikiPage.joins(:tag_name).includes(tag_name: :tag)) + descriptions = TagDescription.where(language_code: 'ja') + .includes(tag: :tag_names) + pages = unmigrated_wiki_pages.includes(tag_name: :tag) + + if title.present? + pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" + descriptions = descriptions.joins(tag: :tag_names) + .where(tag_names: { language_code: 'ja', primary_flg: true }) + .where('tag_names.name LIKE ?', pattern).limit(20) + pages = pages.where('tag_names.name LIKE ?', pattern).limit(20) end - q = WikiPage.joins(:tag_name).includes(tag_name: :tag) - .where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") - render json: WikiPageRepr.base(q.limit(20)) + json = descriptions.map { |description| WikiPageRepr.tag_description(description) } + json.concat(WikiPageRepr.base(pages)) + json = json.first(20) if title.present? + render json: end def show + tag_description = TagDescription.find_by(id: params[:id]) + + return render_wiki_page_by_tag_description tag_description if tag_description + page = WikiPage.joins(:tag_name) .includes(tag_name: :tag) .find_by(id: params[:id]) + render_wiki_page_or_404 page end def show_by_title title = params[:title].to_s.strip + + tag_description = tag_description_by_title(title) + + return render_wiki_page_by_tag_description tag_description if tag_description + page = WikiPage.joins(:tag_name) .includes(tag_name: :tag) .find_by(tag_name: { name: title }) + render_wiki_page_or_404 page end def exists - if WikiPage.exists?(id: params[:id]) - head :no_content - else - head :not_found - end + return head :no_content if TagDescription.exists?(id: params[:id]) + + head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found end def exists_by_title title = params[:title].to_s.strip - if WikiPage.joins(:tag_name).exists?(tag_names: { name: title }) - head :no_content - else - head :not_found - end + return head :no_content if tag_description_by_title(title) + + exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title }) + head exists ? :no_content : :not_found end def diff @@ -52,12 +72,20 @@ class WikiPagesController < ApplicationController from = params[:from].presence to = params[:to].presence - page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id) - - from_rev = from && page.wiki_revisions.find(from) - to_rev = to ? page.wiki_revisions.find(to) : page.current_revision - if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?)) - return render_unprocessable_entity('差分を表示できない版です.') + description = TagDescription.find_by(id:) + if description + versions = description_versions(description) + from_rev = from && versions.find(from) + to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no) + resource = WikiPageRepr.tag_description(description) + else + page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id) + from_rev = from && page.wiki_revisions.find(from) + to_rev = to ? page.wiki_revisions.find(to) : page.current_revision + if ((from_rev && !from_rev.content?) || !to_rev&.content?) + return render_unprocessable_entity('差分を表示できない版です.') + end + resource = WikiPageRepr.base(page) end diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines) @@ -75,9 +103,9 @@ class WikiPagesController < ApplicationController end }.flatten.compact - render json: { wiki_page_id: page.id, - title: page.title, - deprecated_at: page.deprecated_at, + render json: { wiki_page_id: resource.fetch('id'), + title: resource.fetch('title'), + deprecated_at: resource.fetch('deprecated_at'), older_revision_id: from_rev&.id, newer_revision_id: to_rev.id, diff: diff_json } @@ -94,18 +122,24 @@ class WikiPagesController < ApplicationController return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? - tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| - name.script_code = 'Jpan' - name.primary_flg = true - name.auto_generated = false - end + page = if title.include?(':') + ApplicationRecord.transaction do + tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| + name.script_code = 'Jpan' + name.primary_flg = true + name.auto_generated = false + end - page = - Wiki::Commit.create_content!( - tag_name:, - body:, - created_by_user: current_user, - message:) + Wiki::Commit.create_legacy_content!( + tag_name:, body:, created_by_user: current_user, message:) + end + else + tag_name = TagName.find_by(language_code: 'ja', name: title) + return render_unprocessable_entity field: :title unless tag_name&.tag + + TagDescriptionOperation.create!( + tag: tag_name.tag, body:, created_by_user: current_user, message:) + end render json: WikiPageRepr.base(page), status: :created rescue ActiveRecord::RecordInvalid => e @@ -132,7 +166,7 @@ class WikiPagesController < ApplicationController old_title = page.title - tag = Tag.find_by(tag_name_id: page.tag_name_id) + tag = page.tag_name.tag if tag && title != old_title TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) @@ -156,29 +190,35 @@ class WikiPagesController < ApplicationController head :ok end - def search - index - end - def changes id = params[:id].presence - q = WikiRevision.joins(wiki_page: :tag_name) - .includes(:created_user, wiki_page: { tag_name: :tag }) - .order(id: :desc) - q = q.where(wiki_page_id: id) if id + description = id && TagDescription.find_by(id:) + if description + return render json: description_changes(TagDescription.where(id: description.id)) + end - render json: q.limit(200).map { |rev| - { revision_id: rev.id, - pred: rev.base_revision_id, - succ: nil, - wiki_page: { id: rev.wiki_page_id, - title: rev.wiki_page.title, - deprecated_at: rev.wiki_page.deprecated_at }, - user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name }, - kind: rev.kind, - message: rev.message, - timestamp: rev.created_at } - }.compact + pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages + revisions = WikiRevision.joins(wiki_page: :tag_name) + .where(wiki_page_id: pages.select(:id)) + .includes(:created_user, wiki_page: { tag_name: :tag }) + .order(id: :desc).limit(200) + json = revisions.map { |rev| + { revision_id: rev.id, + pred: rev.base_revision_id, + succ: nil, + wiki_page: { id: rev.wiki_page_id, + title: rev.wiki_page.title, + deprecated_at: rev.wiki_page.deprecated_at }, + user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name }, + kind: rev.kind, + message: rev.message, + timestamp: rev.created_at } + } + unless id + json.concat(description_changes(TagDescription.where(language_code: 'ja'))) + json.sort_by! { |row| -row[:revision_id] } + end + render json: json.first(200) end private @@ -204,6 +244,67 @@ class WikiPagesController < ApplicationController render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:) end + def render_wiki_page_by_tag_description description + versions = description_versions(description) + historical = params[:version].present? + rev = historical ? versions.find_by(id: params[:version]) : + versions.find_by(version_no: description.version_no) + return head :not_found unless rev + + pred = versions.find_by(version_no: rev.version_no - 1)&.id + succ = versions.find_by(version_no: rev.version_no + 1)&.id + body = historical ? rev.body : description.body + updated_at = historical ? rev.created_at : description.updated_at + render json: WikiPageRepr.tag_description(description).merge( + body:, revision_id: rev.id, pred:, succ:, updated_at:) + end + + def description_versions description + TagDescriptionVersion.where(tag_id: description.tag_id, + language_code: description.language_code) + end + + def tag_description_by_title title + TagDescription.joins(tag: :tag_names) + .find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title }) + end + + def unmigrated_wiki_pages + descriptions = TagDescription.where(language_code: 'ja') + WikiPage.joins(:tag_name).where.not(id: descriptions.select(:id)) + .where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)', + descriptions.select(:tag_id)) + end + + def description_changes descriptions + versions = TagDescriptionVersion.where(language_code: 'ja', + tag_id: descriptions.select(:tag_id)) + .order(id: :desc).limit(200).to_a + return [] if versions.empty? + + by_tag = descriptions.where(tag_id: versions.map(&:tag_id)) + .includes(tag: :tag_names).index_by(&:tag_id) + users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id) + predecessors = TagDescriptionVersion.where( + language_code: 'ja', tag_id: by_tag.keys, + version_no: versions.map { |version| version.version_no - 1 }) + .pluck(:tag_id, :version_no, :id) + .to_h { |tag_id, version_no, id| [[tag_id, version_no], id] } + versions.map do |version| + description = by_tag.fetch(version.tag_id) + user = users[version.created_by_user_id] + { revision_id: version.id, + pred: predecessors[[version.tag_id, version.version_no - 1]], + succ: nil, + wiki_page: { id: description.id, title: description.tag.name('ja'), + deprecated_at: description.tag.deprecated_at }, + user: user && { id: user.id, name: user.name }, + kind: 'content', + message: nil, + timestamp: version.created_at } + end + end + def find_revision page if params[:version].present? page.wiki_revisions.find_by(id: params[:version]) diff --git a/backend/app/models/tag_description.rb b/backend/app/models/tag_description.rb new file mode 100644 index 0000000..2c5e226 --- /dev/null +++ b/backend/app/models/tag_description.rb @@ -0,0 +1,3 @@ +class TagDescription < ApplicationRecord + belongs_to :tag +end diff --git a/backend/app/models/tag_description_version.rb b/backend/app/models/tag_description_version.rb new file mode 100644 index 0000000..69f31ac --- /dev/null +++ b/backend/app/models/tag_description_version.rb @@ -0,0 +1,3 @@ +class TagDescriptionVersion < ApplicationRecord + ; +end diff --git a/backend/app/models/wiki_page.rb b/backend/app/models/wiki_page.rb index 68928f1..caee630 100644 --- a/backend/app/models/wiki_page.rb +++ b/backend/app/models/wiki_page.rb @@ -48,8 +48,11 @@ class WikiPage < ApplicationRecord page end - def pred_revision_id(revision_id) = - wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id) - def succ_revision_id(revision_id) = - wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id) + def pred_revision_id revision_id + wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id) + end + + def succ_revision_id revision_id + wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id) + end end diff --git a/backend/app/representations/wiki_page_repr.rb b/backend/app/representations/wiki_page_repr.rb index ee09de3..a217341 100644 --- a/backend/app/representations/wiki_page_repr.rb +++ b/backend/app/representations/wiki_page_repr.rb @@ -10,6 +10,21 @@ module WikiPageRepr wiki_page.as_json(BASE) end + def tag_description description + tag = description.tag + { 'id' => description.id, + 'tag_name_id' => tag.tag_name('ja')&.id, + 'body' => description.body, + 'created_user_id' => description.created_by_user_id, + 'updated_user_id' => description.updated_by_user_id, + 'created_at' => description.created_at, + 'updated_at' => description.updated_at, + 'discarded_at' => nil, + 'version_no' => description.version_no, + 'title' => tag.name('ja'), + 'deprecated_at' => tag.deprecated_at } + end + def many wiki_pages wiki_pages.map { |p| base(p) } end diff --git a/backend/app/services/tag_description_operation.rb b/backend/app/services/tag_description_operation.rb new file mode 100644 index 0000000..f3d9ebc --- /dev/null +++ b/backend/app/services/tag_description_operation.rb @@ -0,0 +1,54 @@ +class TagDescriptionOperation + LANGUAGE_CODE = 'ja' + + def self.create!(tag:, body:, created_by_user:, message: nil) + ApplicationRecord.transaction do + tag.lock! + raise ActiveRecord::RecordNotUnique if TagDescription.exists?( + tag_id: tag.id, language_code: LANGUAGE_CODE) + + page = Wiki::CompatibilityWriter.create!( + tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:) + revision = page.wiki_revisions.order(id: :desc).first + record_version!(tag:, revision:, body:, user: created_by_user, + description: nil) + page + end + end + + def self.update!(tag:, body:, updated_by_user:, message: nil, + base_revision_id: nil) + ApplicationRecord.transaction do + tag.lock! + description = TagDescription.lock.find_by!( + tag_id: tag.id, language_code: LANGUAGE_CODE) + page = WikiPage.lock.find(description.id) + revision = Wiki::CompatibilityWriter.update!( + page:, body:, updated_by_user:, message:, + base_revision_id:) + next unless revision + + record_version!(tag:, revision:, body: revision.body, + user: updated_by_user, description:) + revision + end + end + + def self.record_version! tag:, revision:, body:, user:, description: + version_no = description ? description.version_no + 1 : 1 + TagDescriptionVersion.create!( + id: revision.id, tag_id: tag.id, language_code: LANGUAGE_CODE, + version_no:, event_type: description ? 'update' : 'create', body:, + created_by_user_id: user.id, created_at: revision.created_at) + if description + description.update!(body:, version_no:, updated_by_user_id: user.id) + else + TagDescription.create!( + id: revision.wiki_page_id, tag_id: tag.id, + language_code: LANGUAGE_CODE, version_no:, body:, + created_by_user_id: user.id, updated_by_user_id: user.id) + end + end + + private_class_method :record_version! +end diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb index bd6fb7b..1414307 100644 --- a/backend/app/services/wiki/commit.rb +++ b/backend/app/services/wiki/commit.rb @@ -8,6 +8,15 @@ module Wiki end def self.create_content! tag_name:, body:, created_by_user:, message: nil + if tag_name.tag && tag_name.language_code == 'ja' + return TagDescriptionOperation.create!( + tag: tag_name.tag, body:, created_by_user:, message:) + end + + create_legacy_content!(tag_name:, body:, created_by_user:, message:) + end + + def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil normalised = normalise_body(body) page = WikiPage.new(tag_name:, @@ -33,6 +42,18 @@ module Wiki end def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil + description = TagDescription.find_by(id: page.id) + if description + return TagDescriptionOperation.update!( + tag: description.tag, body:, updated_by_user: created_user, + message:, base_revision_id:) + end + + legacy_content!(page:, body:, created_user:, message:, base_revision_id:) + end + + def self.legacy_content! page:, body:, created_user:, message: nil, + base_revision_id: nil new(page:, created_user:).content!(body:, message:, base_revision_id:) end @@ -57,9 +78,6 @@ module Wiki line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } tree_sha = Digest::SHA256.hexdigest(line_shas.join(',')) - line_id_by_sha = upsert_lines!(lines, line_shas) - line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) } - ActiveRecord::Base.transaction do @page.lock! @@ -72,6 +90,8 @@ module Wiki end end + line_id_by_sha = upsert_lines!(lines, line_shas) + line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) } @page.update!(body: normalised) WikiVersionRecorder.record!( @@ -94,7 +114,6 @@ module Wiki { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } end WikiRevisionLine.insert_all!(rows) if rows.any? - rev end end diff --git a/backend/app/services/wiki/compatibility_writer.rb b/backend/app/services/wiki/compatibility_writer.rb new file mode 100644 index 0000000..2bb003c --- /dev/null +++ b/backend/app/services/wiki/compatibility_writer.rb @@ -0,0 +1,33 @@ +module Wiki + class CompatibilityWriter + def self.create! tag_name:, body:, created_by_user:, message: nil + Commit.create_legacy_content!( + tag_name:, body:, created_by_user:, message:) + end + + def self.update! page:, body:, updated_by_user:, message: nil, + base_revision_id: nil + normalised = Commit.normalise_body(body) + if normalised.blank? + return Commit.legacy_content!( + page:, body: normalised, created_user: updated_by_user, message:, + base_revision_id:) + end + + if base_revision_id.present? + current_id = page.wiki_revisions.maximum(:id) + if current_id && current_id != base_revision_id.to_i + raise Commit::Conflict, + "競合が発生してゐます" + + "(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })." + end + end + + return if Commit.normalise_body(page.body) == normalised + + Commit.legacy_content!( + page:, body: normalised, created_user: updated_by_user, message:, + base_revision_id:) + end + end +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 0f721d1..4402503 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -14,6 +14,7 @@ Rails.application.routes.draw do scope :name do get ':name/deerjikists', action: :deerjikists_by_name + get ':name/description', to: 'tag_descriptions#show_by_title' get ':name/materials', action: :materials_by_name get ':name', action: :show_by_name end @@ -25,6 +26,9 @@ Rails.application.routes.draw do get :deerjikists put :deerjikists, action: :update_deerjikists + + get :description, to: 'tag_descriptions#show' + put :description, to: 'tag_descriptions#update' end end @@ -35,7 +39,7 @@ Rails.application.routes.draw do resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do collection do - get :search + get :search, action: :index get :changes scope :title do diff --git a/backend/db/migrate/20261003000000_create_tag_descriptions.rb b/backend/db/migrate/20261003000000_create_tag_descriptions.rb new file mode 100644 index 0000000..961a4ad --- /dev/null +++ b/backend/db/migrate/20261003000000_create_tag_descriptions.rb @@ -0,0 +1,63 @@ +class CreateTagDescriptions < ActiveRecord::Migration[8.0] + def change + create_table :tag_description_versions do |t| + t.references :tag, null: false, foreign_key: false + t.string :language_code, limit: 16, null: false + t.integer :version_no, null: false + t.string :event_type, null: false + t.text :body, null: false + t.string :source_language_code, limit: 16 + t.integer :source_version_no + t.references :created_by_user + t.datetime :created_at, null: false + + t.index [:tag_id, :language_code, :version_no], unique: true + t.foreign_key :tag_description_versions, + column: [:tag_id, :source_language_code, :source_version_no], + primary_key: [:tag_id, :language_code, :version_no] + + t.check_constraint 'version_no > 0', + name: 'chk_tag_description_versions_version_no_positive' + + t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')", + name: 'tag_description_versions_event_type_valid' + + t.check_constraint ('(source_language_code IS NULL AND source_version_no IS NULL) OR ' + + '(source_language_code IS NOT NULL AND source_version_no IS NOT NULL)'), + name: 'chk_tag_description_versions_source_pair' + + t.check_constraint 'source_language_code IS NULL OR source_language_code <> language_code', + name: 'chk_tag_description_versions_source_language_code_different' + end + + create_table :tag_descriptions do |t| + t.references :tag, null: false, foreign_key: true + t.string :language_code, limit: 16, null: false, index: true + t.integer :version_no, null: false + t.text :body, null: false + t.string :source_language_code, limit: 16 + t.integer :source_version_no + t.references :created_by_user, foreign_key: { to_table: :users } + t.references :updated_by_user, foreign_key: { to_table: :users } + t.timestamps + + t.foreign_key :languages, column: :language_code, primary_key: :code + t.foreign_key :tag_description_versions, + column: [:tag_id, :source_language_code, :source_version_no], + primary_key: [:tag_id, :language_code, :version_no] + + t.index [:tag_id, :language_code], unique: true + t.index [:source_language_code, :source_version_no] + + t.check_constraint 'version_no > 0', + name: 'chk_tag_descriptions_version_no_positive' + + t.check_constraint ('(source_language_code IS NULL AND source_version_no IS NULL) OR ' + + '(source_language_code IS NOT NULL AND source_version_no IS NOT NULL)'), + name: 'chk_tag_descriptions_source_pair' + + t.check_constraint 'source_language_code IS NULL OR source_language_code <> language_code', + name: 'chk_tag_descriptions_source_language_code_different' + end + end +end diff --git a/backend/db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages.rb b/backend/db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages.rb new file mode 100644 index 0000000..4d51db9 --- /dev/null +++ b/backend/db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages.rb @@ -0,0 +1,104 @@ +class MigrateTagDescriptionsFromWikiPages < ActiveRecord::Migration[8.0] + class WikiPage < ActiveRecord::Base + self.table_name = 'wiki_pages' + end + + class WikiRevision < ActiveRecord::Base + self.table_name = 'wiki_revisions' + end + + class WikiRevisionLine < ActiveRecord::Base + self.table_name = 'wiki_revision_lines' + end + + class TagName < ActiveRecord::Base + self.table_name = 'tag_names' + end + + class TagDescription < ActiveRecord::Base + self.table_name = 'tag_descriptions' + end + + class TagDescriptionVersion < ActiveRecord::Base + self.table_name = 'tag_description_versions' + end + + def up + WikiPage.find_each do |page| + tag_name = TagName.find_by(id: page.tag_name_id, language_code: 'ja') + next unless tag_name&.tag_id + + revisions = WikiRevision.where(wiki_page_id: page.id).order(:id).to_a + + revisions.each_with_index do |revision, index| + version_no = index + 1 + + tag_id = tag_name[:tag_id] + + unless tag_id + raise ( + 'tag_id is nil: ' + + "wiki_page_id=#{ page.id }, " + + "tag_name_id=#{ tag_name.id }, " + + "tag_name=#{ tag_name.attributes.inspect}") + end + + revision_id = revision.id + + say ( + "wiki_page_id=#{ page.id }, " + + "tag_name_id=#{ tag_name.id }, " + + "tag_id=#{ tag_id }, " + + "revision_id=#{ revision_id }") + + version = TagDescriptionVersion.new( + id: revision_id, + tag_id:, + language_code: 'ja', + version_no:, + event_type: version_no == 1 ? 'create' : 'update', + body: revision_body(revision_id), + source_language_code: nil, + source_version_no: nil, + created_by_user_id: revision.created_user_id, + created_at: revision.created_at) + + say version.attributes.inspect + + version.save! + end + + next if page.discarded_at.present? + next if revisions.empty? + + TagDescription.create!( + id: page.id, + tag_id: tag_name.tag_id, + language_code: 'ja', + version_no: revisions.length, + body: page.body, + source_language_code: nil, + source_version_no: nil, + created_by_user_id: page.created_user_id, + updated_by_user_id: page.updated_user_id, + created_at: page.created_at, + updated_at: page.updated_at) + end + end + + def down + raise ActiveRecord::IrreversibleMigration, '戻せません.' + end + + private + + def revision_body revision_id + WikiRevisionLine + .joins('INNER JOIN wiki_lines ' + + 'ON wiki_lines.id = wiki_revision_lines.wiki_line_id') + .where(wiki_revision_id: revision_id) + .order(:position) + .pluck('wiki_lines.body') + .join("\n") + end +end diff --git a/backend/db/migrate/20261005000000_repair_wiki_updated_at.rb b/backend/db/migrate/20261005000000_repair_wiki_updated_at.rb new file mode 100644 index 0000000..5509b65 --- /dev/null +++ b/backend/db/migrate/20261005000000_repair_wiki_updated_at.rb @@ -0,0 +1,53 @@ +class RepairWikiUpdatedAt < ActiveRecord::Migration[8.0] + class WikiPage < ActiveRecord::Base + self.table_name = 'wiki_pages' + end + + class WikiVersion < ActiveRecord::Base + self.table_name = 'wiki_versions' + end + + class TagDescription < ActiveRecord::Base + self.table_name = 'tag_descriptions' + end + + class TagDescriptionVersion < ActiveRecord::Base + self.table_name = 'tag_description_versions' + end + + def up + say_with_time 'Repairing wiki_pages.updated_at from wiki_versions' do + WikiPage.find_each do |page| + version = + WikiVersion + .where(wiki_page_id: page.id) + .order(version_no: :desc) + .first + + next unless version + + page.update_columns(updated_at: version.created_at) + end + end + + say_with_time 'Repairing tag_descriptions.updated_at from tag_description_versions' do + TagDescription.find_each do |description| + version = + TagDescriptionVersion + .where( + tag_id: description.tag_id, + language_code: description.language_code) + .order(version_no: :desc) + .first + + next unless version + + description.update_columns(updated_at: version.created_at) + end + end + end + + def down + ; + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index eee4568..611cced 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do +ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -440,6 +440,49 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do t.index ["user_id"], name: "index_settings_on_user_id", unique: true end + create_table "tag_description_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id", null: false + t.string "language_code", limit: 16, null: false + t.integer "version_no", null: false + t.string "event_type", null: false + t.text "body", null: false + t.string "source_language_code", limit: 16 + t.integer "source_version_no" + t.bigint "created_by_user_id" + t.datetime "created_at", null: false + t.index ["created_by_user_id"], name: "index_tag_description_versions_on_created_by_user_id" + t.index ["tag_id", "language_code", "version_no"], name: "idx_on_tag_id_language_code_version_no_ced77e57e6", unique: true + t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_4f13094dbc" + t.index ["tag_id"], name: "index_tag_description_versions_on_tag_id" + t.check_constraint "((`source_language_code` is null) and (`source_version_no` is null)) or ((`source_language_code` is not null) and (`source_version_no` is not null))", name: "chk_tag_description_versions_source_pair" + t.check_constraint "(`source_language_code` is null) or (`source_language_code` <> `language_code`)", name: "chk_tag_description_versions_source_language_code_different" + t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "tag_description_versions_event_type_valid" + t.check_constraint "`version_no` > 0", name: "chk_tag_description_versions_version_no_positive" + end + + create_table "tag_descriptions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_id", null: false + t.string "language_code", limit: 16, null: false + t.integer "version_no", null: false + t.text "body", null: false + t.string "source_language_code", limit: 16 + t.integer "source_version_no" + t.bigint "created_by_user_id" + t.bigint "updated_by_user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["created_by_user_id"], name: "index_tag_descriptions_on_created_by_user_id" + t.index ["language_code"], name: "index_tag_descriptions_on_language_code" + t.index ["source_language_code", "source_version_no"], name: "idx_on_source_language_code_source_version_no_00117fe02a" + t.index ["tag_id", "language_code"], name: "index_tag_descriptions_on_tag_id_and_language_code", unique: true + t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_d1be0a7696" + t.index ["tag_id"], name: "index_tag_descriptions_on_tag_id" + t.index ["updated_by_user_id"], name: "index_tag_descriptions_on_updated_by_user_id" + t.check_constraint "((`source_language_code` is null) and (`source_version_no` is null)) or ((`source_language_code` is not null) and (`source_version_no` is not null))", name: "chk_tag_descriptions_source_pair" + t.check_constraint "(`source_language_code` is null) or (`source_language_code` <> `language_code`)", name: "chk_tag_descriptions_source_language_code_different" + t.check_constraint "`version_no` > 0", name: "chk_tag_descriptions_version_no_positive" + end + create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_id", null: false t.bigint "parent_tag_id", null: false @@ -656,19 +699,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do t.index ["banned_at"], name: "index_users_on_banned_at" end - create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "wiki_page_id", null: false - t.integer "no", null: false - t.string "alt_text" - t.binary "sha256", limit: 32, null: false - t.bigint "created_by_user_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id" - t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true - t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true - end - create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "sha256", limit: 64, null: false t.text "body", null: false @@ -685,7 +715,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "discarded_at" - t.integer "next_asset_no", default: 1, null: false t.integer "version_no", null: false t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" @@ -788,6 +817,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do add_foreign_key "post_versions", "users", column: "created_by_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "settings", "users" + add_foreign_key "tag_description_versions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"] + add_foreign_key "tag_descriptions", "languages", column: "language_code", primary_key: "code" + add_foreign_key "tag_descriptions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"] + add_foreign_key "tag_descriptions", "tags" + add_foreign_key "tag_descriptions", "users", column: "created_by_user_id" + add_foreign_key "tag_descriptions", "users", column: "updated_by_user_id" add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags", column: "parent_tag_id" add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code" @@ -822,8 +857,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "users" add_foreign_key "user_theme_slots", "users" - add_foreign_key "wiki_assets", "users", column: "created_by_user_id" - add_foreign_key "wiki_assets", "wiki_pages" add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id" diff --git a/backend/spec/db/tag_description_migration_spec.rb b/backend/spec/db/tag_description_migration_spec.rb new file mode 100644 index 0000000..012bd29 --- /dev/null +++ b/backend/spec/db/tag_description_migration_spec.rb @@ -0,0 +1,34 @@ +require 'rails_helper' +require_relative '../../db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages' + +RSpec.describe 'Tag description migration identity' do + it 'preserves every revision ID and repeated body in an independent sequence' do + user = create_member_user! + tag_name = create(:tag_name, name: 'description_migration') + page = Wiki::Commit.create_content!( + tag_name:, body: 'A', created_by_user: user) + revisions = [page.current_revision] + ['A', 'B', 'B', 'A'].each do |body| + revisions << Wiki::Commit.content!( + page:, body:, created_user: user, base_revision_id: revisions.last.id) + end + tag = create(:tag, primary_tag_name: tag_name) + legacy_state = [page.reload.attributes, revisions.map(&:attributes)] + expect(page.wiki_versions.count).to eq(3) + + migration = MigrateTagDescriptionsFromWikiPages.new + migration.suppress_messages { migration.up } + + versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: 'ja') + .order(:version_no) + expect(versions.pluck(:id)).to eq(revisions.map(&:id)) + expect(versions.pluck(:version_no)).to eq([1, 2, 3, 4, 5]) + expect(versions.pluck(:body)).to eq(['A', 'A', 'B', 'B', 'A']) + expect(versions.pluck(:created_by_user_id)).to eq([user.id] * 5) + expect(versions.pluck(:created_at)).to eq(revisions.map(&:created_at)) + expect(TagDescription.find(page.id)).to have_attributes( + tag_id: tag.id, language_code: 'ja', version_no: 5, body: 'A') + expect([page.reload.attributes, revisions.map { |revision| revision.reload.attributes }]) + .to eq(legacy_state) + end +end diff --git a/backend/spec/requests/tag_description_wiki_spec.rb b/backend/spec/requests/tag_description_wiki_spec.rb new file mode 100644 index 0000000..90e2bbb --- /dev/null +++ b/backend/spec/requests/tag_description_wiki_spec.rb @@ -0,0 +1,276 @@ +require 'rails_helper' + +RSpec.describe 'Tag description Wiki compatibility', type: :request do + let!(:user) { create_member_user! } + let!(:tag_name) { create(:tag_name, name: 'description_contract') } + let!(:page) do + Wiki::Commit.create_content!( + tag_name:, body: 'old-revision', created_by_user: user) + end + let!(:revisions) do + first = page.current_revision + second = Wiki::Commit.content!(page:, body: 'old-second', created_user: user) + third = Wiki::Commit.content!(page:, body: 'old-third', created_user: user) + [first, second, third] + end + let!(:tag) { create(:tag, primary_tag_name: tag_name) } + let!(:versions) do + revisions.each_with_index.map do |revision, index| + TagDescriptionVersion.create!( + id: revision.id, tag_id: tag.id, language_code: 'ja', + version_no: index + 1, event_type: index.zero? ? 'create' : 'update', + body: index == 2 ? 'new-version' : 'A', + created_by_user_id: user.id, created_at: revision.created_at) + end + end + let!(:description) do + TagDescription.create!( + id: page.id, tag:, language_code: 'ja', version_no: 3, body: 'new', + created_by_user_id: user.id, updated_by_user_id: user.id) + end + + before { page.update_columns(body: 'old') } + + # Observe real SELECTs, including cached queries, only during the request. + def without_legacy_reads + queries = [] + subscriber = lambda do |*args| + sql = args.last.fetch(:sql) + if sql.match?(/\bSELECT\b/i) && sql.match?(/\bwiki_(pages|revisions|versions)\b/i) + queries << sql + end + end + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield } + expect(queries).to be_empty + end + + describe 'show' do + it 'uses current content and revision identity without legacy reads' do + without_legacy_reads { get "/wiki/#{ description.id }" } + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'new', 'revision_id' => versions.last.id, + 'pred' => versions.second.id, 'succ' => nil) + end + + [0, 1, 2].each do |index| + it "navigates displayed version #{ index + 1 }, preserving repeated bodies" do + without_legacy_reads do + get "/wiki/#{ description.id }", params: { version: versions[index].id } + end + expect(response).to have_http_status(:ok) + expect(json).to include( + 'body' => versions[index].body, + 'revision_id' => versions[index].id, + 'pred' => index.zero? ? nil : versions[index - 1].id, + 'succ' => index == 2 ? nil : versions[index + 1].id) + end + end + + it 'does not fall back to a revision absent from new history' do + versions.first.destroy! + without_legacy_reads do + get "/wiki/#{ description.id }", params: { version: revisions.first.id } + end + expect(response).to have_http_status(:not_found) + end + + it 'does not fall back when the new current revision is missing' do + versions.last.destroy! + without_legacy_reads { get "/wiki/#{ description.id }" } + expect(response).to have_http_status(:not_found) + end + end + + describe 'revision resource scope' do + let!(:other_tag) { create(:tag, primary_name: 'other_description_contract') } + let!(:other_version) do + TagDescriptionVersion.create!( + tag_id: other_tag.id, language_code: 'ja', version_no: 1, + event_type: 'create', body: 'other tag history', + created_by_user_id: user.id, created_at: Time.current) + end + let!(:other_description) do + TagDescription.create!( + tag: other_tag, language_code: 'ja', version_no: 1, body: 'other tag history', + created_by_user_id: user.id, updated_by_user_id: user.id) + end + + it 'returns 404 when show specifies another tag description version' do + get "/wiki/#{ description.id }", params: { version: other_version.id } + expect(response).to have_http_status(:not_found) + expect(response.body).not_to include(other_version.body) + end + + it 'returns 404 when diff from belongs to another tag description' do + get "/wiki/#{ description.id }/diff", + params: { from: other_version.id, to: versions.last.id } + expect(response).to have_http_status(:not_found) + expect(response.body).not_to include(other_version.body) + end + + it 'returns 404 when diff to belongs to another tag description' do + get "/wiki/#{ description.id }/diff", + params: { from: versions.first.id, to: other_version.id } + expect(response).to have_http_status(:not_found) + expect(response.body).not_to include(other_version.body) + end + end + + describe 'show_by_title' do + it 'resolves TagName and reads current content from new tables' do + without_legacy_reads { get "/wiki/title/#{ tag_name.name }" } + expect(response).to have_http_status(:ok) + expect(json).to include('id' => description.id, 'body' => 'new', + 'revision_id' => versions.last.id) + end + + it 'uses historical content and navigation from the new history' do + get "/wiki/title/#{ tag_name.name }", params: { version: versions.second.id } + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'A', 'revision_id' => versions.second.id, + 'pred' => versions.first.id, 'succ' => versions.last.id) + end + + it 'ignores changes to same-title legacy content and revision metadata' do + get "/wiki/title/#{ tag_name.name }" + original = json + page.update_columns(body: 'changed legacy body') + revisions.last.update_columns(message: 'legacy message', created_at: 1.day.ago) + revisions.last.wiki_lines.update_all(body: 'changed legacy revision') + get "/wiki/title/#{ tag_name.name }" + expect(json).to eq(original) + expect(json.fetch('body')).to eq('new') + end + end + + describe 'exists' do + it 'recognises an ID without reading legacy tables' do + without_legacy_reads { get "/wiki/#{ description.id }/exists" } + expect(response).to have_http_status(:no_content) + end + + it 'recognises a title without reading legacy tables' do + without_legacy_reads { get "/wiki/title/#{ tag_name.name }/exists" } + expect(response).to have_http_status(:no_content) + end + + context 'without a legacy page' do + before do + WikiVersion.where(wiki_page_id: page.id).delete_all + page.destroy! + end + + it 'recognises the description ID alone' do + get "/wiki/#{ description.id }/exists" + expect(response).to have_http_status(:no_content) + end + + it 'recognises the TagName alone' do + get "/wiki/title/#{ tag_name.name }/exists" + expect(response).to have_http_status(:no_content) + end + + it 'shows the description by title' do + get "/wiki/title/#{ tag_name.name }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'new', 'revision_id' => versions.last.id) + end + end + end + + describe 'diff' do + [false, true].each do |current| + it "uses new bodies with #{ current ? 'implicit current' : 'explicit to' }" do + params = { from: versions.first.id } + params[:to] = versions.last.id unless current + get "/wiki/#{ description.id }/diff", params: params + expect(response).to have_http_status(:ok) + expect(json).to include( + 'wiki_page_id' => description.id, + 'older_revision_id' => versions.first.id, + 'newer_revision_id' => versions.last.id, + 'diff' => [{ 'type' => 'removed', 'content' => 'A' }, + { 'type' => 'added', 'content' => 'new-version' }]) + end + end + + it 'does not query legacy tables for diff' do + without_legacy_reads do + get "/wiki/#{ description.id }/diff", + params: { from: versions.first.id, to: versions.last.id } + end + expect(response).to have_http_status(:ok) + end + + it 'keeps distinct revision IDs for identical consecutive bodies' do + get "/wiki/#{ description.id }/diff", + params: { from: versions.first.id, to: versions.second.id } + expect(response).to have_http_status(:ok) + expect(json).to include( + 'older_revision_id' => versions.first.id, + 'newer_revision_id' => versions.second.id, + 'diff' => [{ 'type' => 'context', 'content' => 'A' }]) + end + end + + describe 'changes' do + it 'lists every new version including consecutive identical bodies' do + without_legacy_reads { get '/wiki/changes', params: { id: description.id } } + expect(response).to have_http_status(:ok) + expect(json.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id)) + expect(json.first.keys).to match_array( + ['revision_id', 'pred', 'succ', 'wiki_page', 'user', 'kind', 'message', 'timestamp']) + expect(json.first).to include( + 'pred' => versions.second.id, 'succ' => nil, 'kind' => 'content', + 'timestamp' => versions.last.created_at.iso8601(3), + 'user' => { 'id' => user.id, 'name' => user.name }) + expect(json.first.fetch('wiki_page')).to include('id' => description.id, + 'title' => tag_name.name) + end + + it 'ignores legacy-only revisions and metadata changes' do + get '/wiki/changes', params: { id: description.id } + original = json + revisions.first.update_columns(message: 'legacy changed', created_at: 1.day.ago) + WikiRevision.create!( + wiki_page: page, created_user: user, kind: :content, + tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0) + get '/wiki/changes', params: { id: description.id } + expect(json).to eq(original) + expect(json.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id)) + end + end + + describe 'mixed changes' do + it 'includes unmigrated history and ignores migrated legacy-only revisions' do + legacy_page = Wiki::Commit.create_content!( + tag_name: create(:tag_name, name: 'Help_mixed'), + body: 'help', created_by_user: user) + legacy_revision = legacy_page.current_revision + extra = WikiRevision.create!( + wiki_page: page, created_user: user, kind: :content, + tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0) + + get '/wiki/changes' + expect(response).to have_http_status(:ok) + migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id } + expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id)) + expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id) + expect(json).to include(include( + 'revision_id' => legacy_revision.id, + 'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed'))) + end + end + + describe 'index' do + [nil, 'description_contract'].each do |title| + it "returns new content once in #{ title ? 'filtered' : 'unfiltered' } index" do + get '/wiki', params: { title: } + expect(response).to have_http_status(:ok) + rows = json.select { |row| row.fetch('id') == description.id } + expect(rows.size).to eq(1) + expect(rows.first).to include('body' => 'new', 'title' => tag_name.name) + end + end + end +end diff --git a/backend/spec/requests/tag_description_wiki_write_spec.rb b/backend/spec/requests/tag_description_wiki_write_spec.rb new file mode 100644 index 0000000..798cffc --- /dev/null +++ b/backend/spec/requests/tag_description_wiki_write_spec.rb @@ -0,0 +1,274 @@ +require 'rails_helper' + +RSpec.describe 'Tag description Wiki dual writes', type: :request do + let!(:user) { create_member_user! } + let!(:tag_name) { create(:tag_name, name: 'description_write') } + let(:tag) { create(:tag, primary_tag_name: tag_name) } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + + def stored_state + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion, + TagName, TagVersion].map do |model| + model.order(:id).map(&:attributes) + end + end + + # Fail after a real INSERT/UPDATE, exercising rollback rather than a mock write. + def fail_save model + failure = Class.new(StandardError) + callback = ->(_record) { raise failure, 'injected persistence failure' } + model.set_callback(:save, :after, callback) + expect { yield }.to raise_error(failure) + ensure + model.skip_callback(:save, :after, callback) + end + + describe 'POST /wiki' do + before { tag } + + def create_description + post '/wiki', params: { title: tag_name.name, body: 'initial', message: 'create' }, + headers: headers + end + + it 'creates both current records and histories with a shared revision ID' do + expect { create_description } + .to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change(TagDescription, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + expect(response).to have_http_status(:created) + + page = WikiPage.find(json.fetch('id')) + description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') + version = TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja') + expect(description).to have_attributes(id: page.id, body: 'initial', version_no: 1) + expect(version).to have_attributes( + id: page.current_revision.id, body: 'initial', version_no: 1, + event_type: 'create', created_by_user_id: user.id) + expect(page.body).to eq(description.body) + expect(page.current_revision.body).to eq(version.body) + expect(page.wiki_versions.last.body).to eq(version.body) + end + + ['missing_description', 'orphan_description'].each do |title| + it "rejects the bare title #{ title } without writing any resource" do + create(:tag_name, name: title) if title == 'orphan_description' + before_state = stored_state + + post '/wiki', params: { title:, body: 'initial' }, headers: headers + + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include('type' => 'validation_error') + expect(stored_state).to eq(before_state) + end + end + + it 'creates a namespaced legacy Wiki resource without a Tag' do + expect do + post '/wiki', params: { title: 'Help:description_write', body: 'legacy' }, + headers: headers + end.to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change(TagDescription, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) + + expect(response).to have_http_status(:created) + page = WikiPage.find(json.fetch('id')) + expect(page.tag_name.tag).to be_nil + expect(page.body).to eq('legacy') + expect(page.current_revision.body).to eq('legacy') + end + + it 'creates only legacy Wiki for a namespaced title with an existing Tag' do + namespaced_name = create(:tag_name, name: 'help:foo') + namespaced_tag = create(:tag, primary_tag_name: namespaced_name) + + expect do + post '/wiki', params: { title: namespaced_name.name, body: 'legacy' }, + headers: headers + end.to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change(TagName, :count).by(0) + .and change(TagDescription, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) + + expect(response).to have_http_status(:created) + page = WikiPage.find(json.fetch('id')) + expect(page).to have_attributes(tag_name_id: namespaced_name.id, body: 'legacy') + expect(page.tag_name.tag).to eq(namespaced_tag) + expect(page.current_revision.body).to eq('legacy') + expect(page.wiki_versions.last.body).to eq('legacy') + end + + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + it "rolls back both systems when #{ model.name } persistence fails" do + tag_name + before_state = stored_state + aggregate_failures do + fail_save(model) { create_description } + expect(stored_state).to eq(before_state) + end + end + end + end + + describe 'PUT /wiki/:id' do + let!(:page) do + tag + Wiki::Commit.create_content!( + tag_name:, body: 'before', created_by_user: user) + end + let!(:first_revision) { page.current_revision } + + let!(:description) do + TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') + end + + def update_description body: 'after', title: tag_name.name, base: first_revision.id + put "/wiki/#{ page.id }", + params: { title:, body:, message: 'edit', base_revision_id: base }, + headers: headers + 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(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(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(description.reload).to have_attributes(body: 'after', version_no: 2) + expect(TagDescriptionVersion.find(page.reload.current_revision.id)) + .to have_attributes(tag_id: tag.id, body: 'after', version_no: 2) + end + + it 'does not add body history for a title-only update' do + revision_count = WikiRevision.count + wiki_version_count = WikiVersion.count + 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: 'Help:description_renamed') + + expect(response).to have_http_status(:ok) + expect(tag_name.reload.name).to eq('Help:description_renamed') + expect(WikiRevision.count).to eq(revision_count) + expect(WikiVersion.count).to eq(wiki_version_count) + expect(TagDescriptionVersion.count).to eq(description_version_count) + 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_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 + end + end + + [['identical body', 'before'], + ['normalised-equivalent body', "before\n\n"]].each do |label, body| + it "does not write histories for #{ label }" do + before_state = stored_state + page_state = page.reload.attributes + description_state = description.reload.attributes + current_revision_id = page.current_revision.id + wiki_lines = WikiLine.order(:id).map(&:attributes) + revision_lines = WikiRevisionLine.order(:id).map(&:attributes) + page_version_no = page.version_no + description_version_no = description.version_no + + update_description(body:) + + expect(response).to have_http_status(:ok) + expect(WikiRevision.count).to eq(before_state[1].length) + expect(WikiVersion.count).to eq(before_state[2].length) + expect(TagDescriptionVersion.count).to eq(before_state[4].length) + expect(page.reload.version_no).to eq(page_version_no) + expect(description.reload.version_no).to eq(description_version_no) + expect(page.current_revision.id).to eq(current_revision_id) + expect(page.reload.attributes).to eq(page_state) + expect(description.reload.attributes).to eq(description_state) + expect(WikiLine.order(:id).map(&:attributes)).to eq(wiki_lines) + expect(WikiRevisionLine.order(:id).map(&:attributes)).to eq(revision_lines) + end + end + end +end diff --git a/backend/spec/requests/tag_descriptions_spec.rb b/backend/spec/requests/tag_descriptions_spec.rb new file mode 100644 index 0000000..a0746e5 --- /dev/null +++ b/backend/spec/requests/tag_descriptions_spec.rb @@ -0,0 +1,359 @@ +require 'rails_helper' + +RSpec.describe 'Tag description native API', type: :request do + let!(:author) { create_member_user! } + let(:editor) { create(:user, :member, name: 'description editor') } + let(:tag_name) { create(:tag_name, name: 'native_description') } + let(:tag) { create(:tag, primary_tag_name: tag_name) } + let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } } + let(:endpoint) { "/tags/#{ tag.id }/description" } + + def stored_state + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion, + WikiLine, WikiRevisionLine, TagName, TagVersion].map do |model| + model.order(:id).map(&:attributes) + end + end + + def fail_save model + failure = Class.new(StandardError) + callback = ->(_record) { raise failure, 'injected persistence failure' } + model.set_callback(:save, :after, callback) + expect { yield }.to raise_error(failure) + ensure + model.skip_callback(:save, :after, callback) + end + + def without_legacy_reads + queries = [] + subscriber = lambda do |*args| + sql = args.last.fetch(:sql) + if sql.match?(/\bSELECT\b/i) && sql.match?(/\bwiki_(pages|revisions|versions)\b/i) + queries << sql + end + end + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield } + expect(queries).to be_empty + end + + def native_get path, action: 'show' + get path + # A missing route must not make the resource-not-found examples pass. + expect(request.path_parameters).to include(controller: 'tag_descriptions', action:) + end + + def native_write body:, request_headers: headers + put endpoint, params: { body: }, headers: request_headers + expect(request.path_parameters).to include( + controller: 'tag_descriptions', action: 'update') + end + + def expect_current description + expect(json).to include( + 'tag_id' => description.tag_id, + 'language_code' => 'ja', 'body' => description.body, + 'version_no' => description.version_no, + 'created_by_user_id' => description.created_by_user_id, + 'updated_by_user_id' => description.updated_by_user_id, + 'created_at' => description.created_at.iso8601(3), + 'updated_at' => description.updated_at.iso8601(3)) + expect(json.keys).not_to include('title', 'name', 'wiki_page', 'tag_name_id') + end + + describe 'routing', type: :routing do + it 'routes GET by Tag ID to show' do + expect(get: '/tags/123/description').to route_to( + controller: 'tag_descriptions', action: 'show', id: '123') + end + + it 'routes GET by TagName to show_by_title' do + expect(get: '/tags/name/native_description/description').to route_to( + controller: 'tag_descriptions', action: 'show_by_title', name: 'native_description') + end + + it 'does not route POST by Tag ID' do + expect(post: '/tags/123/description').not_to be_routable + end + + it 'routes PUT by Tag ID to update' do + expect(put: '/tags/123/description').to route_to( + controller: 'tag_descriptions', action: 'update', id: '123') + end + end + + describe 'native reads' do + let!(:description) do + TagDescription.create!( + id: tag.id + 1_000_000, tag:, language_code: 'ja', body: 'native current', + version_no: 1, created_by_user_id: author.id, updated_by_user_id: editor.id) + end + let!(:version) do + TagDescriptionVersion.create!( + tag_id: tag.id, language_code: 'ja', body: 'native version', version_no: 1, + event_type: 'create', created_by_user_id: author.id, created_at: Time.current) + end + let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" } + + it 'reads the current resource by Tag ID without a WikiPage' do + expect(description.id).not_to eq(tag.id) + expect(WikiPage.exists?(id: description.id)).to be(false) + without_legacy_reads { native_get(endpoint) } + expect(response).to have_http_status(:ok) + expect_current(description) + end + + it 'does not interpret a compatibility handle as a Tag ID' do + other_tag = create(:tag, id: description.id, primary_name: 'handle_collision') + other = TagDescription.create!( + tag: other_tag, language_code: 'ja', body: 'other current', version_no: 1, + created_by_user_id: author.id, updated_by_user_id: author.id) + without_legacy_reads { native_get("/tags/#{ other_tag.id }/description") } + expect(response).to have_http_status(:ok) + expect_current(other) + end + + context 'with another language for the same Tag' do + before do + # Seed only the reference row; V1 does not enable an English locale. + Language.connection.execute(<<~SQL) + INSERT IGNORE INTO languages (code, name, created_at) + VALUES ('en', 'English', CURRENT_TIMESTAMP) + SQL + end + + it 'selects the Japanese resource' do + TagDescription.create!( + tag:, language_code: 'en', body: 'English current', version_no: 4, + created_by_user_id: author.id, updated_by_user_id: author.id) + without_legacy_reads { native_get(endpoint) } + expect(response).to have_http_status(:ok) + expect_current(description) + end + + it 'returns 404 if only the English description exists' do + description.update!(language_code: 'en') + without_legacy_reads { native_get(endpoint) } + expect(response).to have_http_status(:not_found) + end + end + + it 'resolves the primary Japanese TagName without a WikiPage' do + without_legacy_reads { native_get(name_endpoint, action: 'show_by_title') } + expect(response).to have_http_status(:ok) + expect_current(description) + end + + it 'resolves an alias through the existing TagName canonicalisation semantics' do + alias_name = create(:tag_name, :alias, tag:, name: 'native_alias') + expect(TagName.canonicalise(Locale.nipponese, [alias_name.name])).to eq([tag_name.name]) + without_legacy_reads do + native_get("/tags/name/#{ alias_name.name }/description", action: 'show_by_title') + end + expect(response).to have_http_status(:ok) + expect_current(description) + end + + [:id, :name].each do |lookup| + it "ignores divergent legacy body and history for #{ lookup } lookup" do + page = WikiPage.create!( + id: description.id, tag_name:, body: 'old current', + created_user: author, updated_user: author) + revision = WikiRevision.create!( + wiki_page: page, created_user: author, kind: :content, + lines_count: 1, tree_sha256: Digest::SHA256.hexdigest('old revision')) + line = WikiLine.create!(body: 'old revision', sha256: revision.tree_sha256) + WikiRevisionLine.create!(wiki_revision: revision, wiki_line: line, position: 0) + WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: author) + path = lookup == :id ? endpoint : name_endpoint + action = lookup == :id ? 'show' : 'show_by_title' + + without_legacy_reads { native_get(path, action:) } + expect(response).to have_http_status(:ok) + expect_current(description) + original = json + page.update_columns(body: 'changed old current') + line.update_columns(body: 'changed old revision') + revision.update_columns(message: 'changed old history', created_at: 1.day.ago) + without_legacy_reads { native_get(path, action:) } + expect(response).to have_http_status(:ok) + expect(json).to eq(original) + end + end + end + + describe 'missing native resources' do + it 'returns 404 for a Tag without a description' do + without_legacy_reads { native_get(endpoint) } + expect(response).to have_http_status(:not_found) + end + + it 'returns 404 for a primary name without a description' do + tag + without_legacy_reads do + native_get("/tags/name/#{ tag_name.name }/description", action: 'show_by_title') + end + expect(response).to have_http_status(:not_found) + end + + it 'returns 404 for a missing Tag' do + missing_id = Tag.maximum(:id).to_i + 1 + without_legacy_reads { native_get("/tags/#{ missing_id }/description") } + expect(response).to have_http_status(:not_found) + end + end + + describe 'PUT /tags/:id/description' do + context 'without an existing description' do + before { tag } + + it 'creates both current resources and histories with matching compatibility IDs' do + expect { native_write(body: 'created natively') } + .to change(TagDescription, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + .and change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + expect(response).to have_http_status(:created) + description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') + page = WikiPage.find(description.id) + revision = page.current_revision + version = TagDescriptionVersion.find(revision.id) + expect(description).to have_attributes( + body: 'created natively', version_no: 1, + created_by_user_id: editor.id, updated_by_user_id: editor.id) + expect(version).to have_attributes( + tag_id: tag.id, language_code: 'ja', body: description.body, + version_no: 1, event_type: 'create', created_by_user_id: editor.id) + expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body) + expect(revision.body).to eq(version.body) + expect(page.wiki_versions.last.body).to eq(version.body) + expect_current(description) + + get "/wiki/#{ description.id }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => description.body, 'revision_id' => version.id) + end + + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + it "rolls back native create when #{ model.name } persistence fails" do + headers + before_state = stored_state + aggregate_failures do + fail_save(model) { native_write(body: 'rollback create') } + expect(stored_state).to eq(before_state) + end + end + end + end + + context 'with an existing description' do + let!(:page) do + tag + Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author) + end + let!(:description) do + TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') + end + + it 'writes matching histories when the body changes' do + expect(page.reload.version_no).to eq(1) + expect(description.version_no).to eq(1) + expect { native_write(body: 'after') } + .to change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change(TagDescriptionVersion, :count).by(1) + expect(response).to have_http_status(:ok) + expect(description.reload).to have_attributes( + body: 'after', version_no: 2, + created_by_user_id: author.id, updated_by_user_id: editor.id) + expect(page.reload).to have_attributes(body: 'after', version_no: 2) + + revision = page.current_revision + version = TagDescriptionVersion.find(revision.id) + expect(version).to have_attributes( + tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update', + body: 'after', created_by_user_id: editor.id) + expect(revision.body).to eq(version.body) + expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body) + get "/wiki/#{ description.id }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'after', 'revision_id' => version.id) + end + + ['before', "before\n\n"].each do |body| + it 'does not write histories when the normalised body is unchanged' do + before_state = stored_state + page_state = page.reload.attributes + description_state = description.reload.attributes + current_revision_id = page.current_revision.id + + native_write(body:) + + expect(response).to have_http_status(:ok) + expect(stored_state).to eq(before_state) + expect(page.reload.attributes).to eq(page_state) + expect(description.reload.attributes).to eq(description_state) + expect(page.current_revision.id).to eq(current_revision_id) + end + end + + [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| + it "rolls back native update when #{ model.name } persistence fails" do + headers + before_state = stored_state + aggregate_failures do + fail_save(model) { native_write(body: 'rollback update') } + expect(stored_state).to eq(before_state) + end + end + end + end + end + + describe 'write access and validation' do + before { tag } + + it 'requires authentication without changing either system' do + before_state = stored_state + native_write(body: 'unauthorised', request_headers: { }) + expect(response).to have_http_status(:unauthorized) + expect(stored_state).to eq(before_state) + end + + it 'rejects a guest without changing either system' do + guest = create(:user, :guest) + before_state = stored_state + native_write(body: 'forbidden', + request_headers: { 'X-Transfer-Code' => guest.inheritance_code }) + expect(response).to have_http_status(:forbidden) + expect(stored_state).to eq(before_state) + end + + it 'rejects a banned member without changing either system' do + banned = create(:user, :member, :banned) + before_state = stored_state + native_write(body: 'forbidden', + request_headers: { 'X-Transfer-Code' => banned.inheritance_code }) + expect(response).to have_http_status(:forbidden) + expect(stored_state).to eq(before_state) + end + + it 'rejects blank body using the existing validation error format' do + headers + before_state = stored_state + native_write(body: '') + expect(response).to have_http_status(:unprocessable_entity) + expect(json).to include('type' => 'validation_error', + 'errors' => include('body' => a_kind_of(Array))) + expect(stored_state).to eq(before_state) + end + + it 'returns 404 when PUT targets a missing Tag' do + missing_id = Tag.maximum(:id).to_i + 1 + put "/tags/#{ missing_id }/description", + params: { body: 'missing' }, + headers: headers + expect(response).to have_http_status(:not_found) + end + end +end diff --git a/backend/spec/requests/tag_wiki_history_integrity_spec.rb b/backend/spec/requests/tag_wiki_history_integrity_spec.rb index a464c05..356e7e0 100644 --- a/backend/spec/requests/tag_wiki_history_integrity_spec.rb +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -25,9 +25,10 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do end describe 'PATCH /tags/:id' do - it 'records wiki_version when tag name changes and tag has wiki' do + it 'renames the current Wiki resource without adding body history' do tag = create_tag!(name: 'patch_tag_wiki_before') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') + wiki_history = wiki_page.wiki_versions.map(&:attributes) expect { patch "/tags/#{ tag.id }", params: { @@ -35,23 +36,17 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(1) + .and change(WikiVersion, :count).by(0) expect(response).to have_http_status(:ok) tag.reload wiki_page.reload - version = wiki_page.wiki_versions.order(:version_no).last expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after') expect(wiki_page.title).to eq('patch_tag_wiki_after') - expect(version).to have_attributes( - event_type: 'update', - title: 'patch_tag_wiki_after', - body: 'wiki body before', - created_by_user_id: member_user.id - ) + expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) end it 'does not record wiki_version when only category changes' do @@ -79,9 +74,10 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do end describe 'PUT /tags/:id' do - it 'records wiki_version when tag name changes and tag has wiki' do + it 'renames the current Wiki resource without adding body history' do tag = create_tag!(name: 'put_tag_wiki_before') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') + wiki_history = wiki_page.wiki_versions.map(&:attributes) expect { put "/tags/#{ tag.id }", params: { @@ -93,23 +89,17 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(1) + .and change(WikiVersion, :count).by(0) expect(response).to have_http_status(:ok) tag.reload wiki_page.reload - version = wiki_page.wiki_versions.order(:version_no).last expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after') expect(wiki_page.title).to eq('put_tag_wiki_after') - expect(version).to have_attributes( - event_type: 'update', - title: 'put_tag_wiki_after', - body: 'wiki body before', - created_by_user_id: member_user.id - ) + expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) end it 'does not record wiki_version when only category changes' do diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index c8bfd54..0d0b75a 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -727,7 +727,7 @@ RSpec.describe 'Tags API', type: :request do expect(tag.reload.category).to eq('general') end - it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do + it 'PATCH の名称変更で Wiki の本文履歴を追加しない' do wiki_page = Wiki::Commit.create_content!( tag_name: primary_tag_name_for(tag, 'ja'), @@ -735,24 +735,20 @@ RSpec.describe 'Tags API', type: :request do created_by_user: member_user, message: 'init') + wiki_history = wiki_page.wiki_versions.map(&:attributes) + expect { patch "/tags/#{ tag.id }", params: { name: 'patch_wiki_renamed_tag', } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(1) + .and change(WikiVersion, :count).by(0) expect(response).to have_http_status(:ok) - version = wiki_page.reload.wiki_versions.order(:version_no).last - - expect(version).to have_attributes( - event_type: 'update', - title: 'patch_wiki_renamed_tag', - body: 'wiki body before', - created_by_user_id: member_user.id - ) + expect(wiki_page.reload.title).to eq('patch_wiki_renamed_tag') + expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) end it 'tag の category だけを変更しても wiki version は作成しない' do @@ -1380,7 +1376,7 @@ RSpec.describe 'Tags API', type: :request do expect(system_tag.category).to eq(old_category) end - it 'wiki を持つ tag を更新すると wiki version も作成する' do + it 'Wiki を持つ Tag の名称変更で本文履歴を追加しない' do wiki_page = Wiki::Commit.create_content!( tag_name: primary_tag_name_for(tag, 'ja'), @@ -1395,6 +1391,8 @@ RSpec.describe 'Tags API', type: :request do message: 'init' ) + wiki_history = wiki_page.wiki_versions.map(&:attributes) + expect { put "/tags/#{ tag.id }", params: { name: 'put_wiki_version_tag', @@ -1405,18 +1403,12 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(1) + .and change(WikiVersion, :count).by(0) expect(response).to have_http_status(:ok) - version = wiki_page.reload.wiki_versions.order(:version_no).last - - expect(version).to have_attributes( - event_type: 'update', - title: 'put_wiki_version_tag', - body: 'wiki body before', - created_by_user_id: member_user.id - ) + expect(wiki_page.reload.title).to eq('put_wiki_version_tag') + expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) end it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do @@ -1426,6 +1418,7 @@ RSpec.describe 'Tags API', type: :request do body: 'wiki body before', created_by_user: member_user, message: 'init') + wiki_history = wiki_page.wiki_versions.map(&:attributes) expect { put "/tags/#{ tag.id }", params: { @@ -1445,7 +1438,7 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(3) - .and change(WikiVersion, :count).by(2) + .and change(WikiVersion, :count).by(0) expect(response).to have_http_status(:ok) @@ -1457,10 +1450,8 @@ RSpec.describe 'Tags API', type: :request do expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b')) .to have_attributes(tag_id: tag.id, primary_flg: false) - versions = wiki_page.wiki_versions.order(:version_no).last(2) - - expect(versions.map(&:event_type)).to eq(['update', 'update']) - expect(versions.map(&:title)).to eq(['put_wiki_roundtrip_b', 'spec_tag']) + expect(wiki_page.title).to eq('spec_tag') + expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) end it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do @@ -1570,7 +1561,7 @@ RSpec.describe 'Tags API', type: :request do expect(TagImplication.where(tag:, parent_tag: child)).not_to exist end - it 'tag の name を変更すると対応する wiki version を作成する' do + it 'Tag の名称変更で Wiki の本文履歴を追加しない' do wiki_page = Wiki::Commit.create_content!( tag_name: primary_tag_name_for(tag, 'ja'), @@ -1578,6 +1569,8 @@ RSpec.describe 'Tags API', type: :request do created_by_user: member_user, message: 'init') + wiki_history = wiki_page.wiki_versions.map(&:attributes) + expect { put "/tags/#{ tag.id }", params: { name: 'put_wiki_renamed_tag', @@ -1588,18 +1581,12 @@ RSpec.describe 'Tags API', type: :request do } } .to change(TagVersion, :count).by(2) - .and change(WikiVersion, :count).by(1) + .and change(WikiVersion, :count).by(0) expect(response).to have_http_status(:ok) - version = wiki_page.reload.wiki_versions.order(:version_no).last - - expect(version).to have_attributes( - event_type: 'update', - title: 'put_wiki_renamed_tag', - body: 'wiki body before', - created_by_user_id: member_user.id - ) + expect(wiki_page.reload.title).to eq('put_wiki_renamed_tag') + expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history) end end end diff --git a/backend/spec/requests/wiki_history_integrity_spec.rb b/backend/spec/requests/wiki_history_integrity_spec.rb index db365c6..50de843 100644 --- a/backend/spec/requests/wiki_history_integrity_spec.rb +++ b/backend/spec/requests/wiki_history_integrity_spec.rb @@ -21,7 +21,7 @@ RSpec.describe 'Wiki history integrity', type: :request do expect { post '/wiki', params: { - title: 'wiki_history_create_atomic', + title: 'Help:wiki_history_create_atomic', body: "a\nb\nc", message: 'initial commit', }, @@ -37,7 +37,7 @@ RSpec.describe 'Wiki history integrity', type: :request do revision = page.current_revision version = page.wiki_versions.order(:version_no).last - expect(page.title).to eq('wiki_history_create_atomic') + expect(page.title).to eq('Help:wiki_history_create_atomic') expect(page.body).to eq("a\nb\nc") expect(revision).to be_content @@ -47,7 +47,7 @@ RSpec.describe 'Wiki history integrity', type: :request do expect(version).to have_attributes( version_no: 1, event_type: 'create', - title: 'wiki_history_create_atomic', + title: 'Help:wiki_history_create_atomic', body: "a\nb\nc", reason: 'initial commit', created_by_user_id: user.id @@ -58,7 +58,7 @@ RSpec.describe 'Wiki history integrity', type: :request do expect { post '/wiki', params: { - title: 'wiki_history_blank_body', + title: 'Help:wiki_history_blank_body', body: "\r\n\r\n", message: 'blank', }, @@ -67,16 +67,17 @@ RSpec.describe 'Wiki history integrity', type: :request do .not_to change(WikiPage, :count) expect(response).to have_http_status(:unprocessable_entity) - expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_blank_body' })).not_to exist + expect(WikiPage.joins(:tag_name) + .where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist end it 'returns 422 and creates no partial page when title already exists' do - create_wiki_page(title: 'wiki_history_duplicate_title', body: 'first') + create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first') expect { post '/wiki', params: { - title: 'wiki_history_duplicate_title', + title: 'Help:wiki_history_duplicate_title', body: 'second', message: 'duplicate', }, @@ -85,7 +86,8 @@ RSpec.describe 'Wiki history integrity', type: :request do .not_to change(WikiPage, :count) expect(response).to have_http_status(:unprocessable_entity) - expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_duplicate_title' }).count).to eq(1) + expect(WikiPage.joins(:tag_name) + .where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1) end end diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index e1d31b1..5a4eeae 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -11,6 +11,12 @@ RSpec.describe 'Wiki API', type: :request do let!(:user) { create_member_user! } let!(:tn) { create(:tag_name, name: 'spec_wiki_title') } + let!(:tag) do + create(:tag, + primary_tag_name: tn, + category: :general, + deprecated_at: Time.zone.local(2026, 6, 1)) + end let!(:page) do Wiki::Commit.create_content!( tag_name: tn, @@ -18,12 +24,6 @@ RSpec.describe 'Wiki API', type: :request do created_by_user: user, message: 'init') end - let!(:tag) do - create(:tag, - primary_tag_name: tn, - category: :general, - deprecated_at: Time.zone.local(2026, 6, 1)) - end describe 'GET /wiki' do it 'returns wiki pages with title' do @@ -111,7 +111,7 @@ RSpec.describe 'Wiki API', type: :request do context 'when success' do it 'creates wiki_page and first content revision' do expect do - post endpoint, params: { title: 'TestPage', body: "a\nb\nc", message: 'init' }, + post endpoint, params: { title: 'Help:TestPage', body: "a\nb\nc", message: 'init' }, headers: auth_headers(member) end .to change(WikiPage, :count).by(1) @@ -121,7 +121,7 @@ RSpec.describe 'Wiki API', type: :request do expect(response).to have_http_status(:created) page_id = json.fetch('id') - expect(json.fetch('title')).to eq('TestPage') + expect(json.fetch('title')).to eq('Help:TestPage') created_page = WikiPage.find(page_id) version = created_page.wiki_versions.order(:version_no).last @@ -129,7 +129,7 @@ RSpec.describe 'Wiki API', type: :request do expect(version).to have_attributes( version_no: 1, event_type: 'create', - title: 'TestPage', + title: 'Help:TestPage', body: "a\nb\nc", created_by_user_id: member.id ) @@ -151,7 +151,7 @@ RSpec.describe 'Wiki API', type: :request do WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current) post endpoint, - params: { title: 'Reuse', body: "a\na" }, + params: { title: 'Help:Reuse', body: "a\na" }, headers: auth_headers(member) page = WikiPage.find(JSON.parse(response.body).fetch('id')) @@ -166,7 +166,7 @@ RSpec.describe 'Wiki API', type: :request do duplicated = 'duplicated_line_for_wiki_line_upsert_spec' post endpoint, - params: { title: 'DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" }, + params: { title: 'Help:DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" }, headers: auth_headers(member) expect(response).to have_http_status(:created) @@ -182,7 +182,8 @@ RSpec.describe 'Wiki API', type: :request do it 'normalises CRLF and strips trailing newlines' do post endpoint, - params: { title: 'NormalisedBody', body: "a\r\nb\r\n\r\n", message: 'normalise' }, + params: { title: 'Help:NormalisedBody', + body: "a\r\nb\r\n\r\n", message: 'normalise' }, headers: auth_headers(member) expect(response).to have_http_status(:created) @@ -413,10 +414,10 @@ RSpec.describe 'Wiki API', type: :request do top = json.first expect(top).to include( 'revision_id' => rev2.id, - 'pred' => rev2.base_revision_id, + 'pred' => rev1.id, 'succ' => nil, 'kind' => 'content', - 'message' => 'r2' + 'message' => nil ) expect(top['wiki_page']).to include( 'id' => page.id, diff --git a/backend/spec/requests/wiki_title_collision_spec.rb b/backend/spec/requests/wiki_title_collision_spec.rb index f78f987..a8df14b 100644 --- a/backend/spec/requests/wiki_title_collision_spec.rb +++ b/backend/spec/requests/wiki_title_collision_spec.rb @@ -44,12 +44,12 @@ RSpec.describe 'Wiki title collision', type: :request do end it 'returns 422 when creating wiki with existing title' do - create_wiki_page(title: 'wiki_collision_create', body: 'already exists') + create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists') expect { post '/wiki', params: { - title: 'wiki_collision_create', + title: 'Help:wiki_collision_create', body: 'new body', message: 'duplicate create', }, diff --git a/backend/spec/requests/wiki_unmigrated_contract_spec.rb b/backend/spec/requests/wiki_unmigrated_contract_spec.rb new file mode 100644 index 0000000..9cd106b --- /dev/null +++ b/backend/spec/requests/wiki_unmigrated_contract_spec.rb @@ -0,0 +1,115 @@ +require 'rails_helper' + +RSpec.describe 'Unmigrated Wiki compatibility', type: :request do + let!(:user) { create_member_user! } + let!(:tag_name) { create(:tag_name, name: 'Help_contract') } + let!(:page) do + Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: user) + end + let!(:first_revision) { page.current_revision } + let!(:last_revision) do + Wiki::Commit.content!(page:, body: 'after', created_user: user, + message: 'legacy edit', base_revision_id: first_revision.id) + end + + before { expect(TagDescription.exists?(id: page.id)).to be(false) } + + it 'keeps a Tag-linked legacy page in the legacy domain when its title changes' do + create(:tag, primary_tag_name: tag_name) + + expect do + put "/wiki/#{ page.id }", + params: { title: 'legacy_tag_renamed', body: 'changed legacy body', + message: 'legacy rename', base_revision_id: last_revision.id }, + headers: { 'X-Transfer-Code' => user.inheritance_code } + end.to change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + .and change(TagDescription, :count).by(0) + .and change(TagDescriptionVersion, :count).by(0) + + expect(response).to have_http_status(:ok) + expect(page.reload).to have_attributes(body: 'changed legacy body') + expect(page.title).to eq('legacy_tag_renamed') + expect(page.current_revision).to have_attributes(message: 'legacy rename') + end + + it 'shows legacy current and historical content by ID' do + get "/wiki/#{ page.id }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'after', 'revision_id' => last_revision.id, + 'pred' => first_revision.id, 'succ' => nil) + get "/wiki/#{ page.id }", params: { version: first_revision.id } + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'before', 'revision_id' => first_revision.id, + 'pred' => nil, 'succ' => last_revision.id) + end + + it 'shows legacy current and historical content by title' do + get "/wiki/title/#{ tag_name.name }" + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'after', 'revision_id' => last_revision.id) + get "/wiki/title/#{ tag_name.name }", params: { version: first_revision.id } + expect(response).to have_http_status(:ok) + expect(json).to include('body' => 'before', 'revision_id' => first_revision.id) + end + + it 'recognises legacy IDs' do + get "/wiki/#{ page.id }/exists" + expect(response).to have_http_status(:no_content) + end + + it 'recognises legacy titles' do + get "/wiki/title/#{ tag_name.name }/exists" + expect(response).to have_http_status(:no_content) + end + + it 'diffs legacy revisions with explicit and current target' do + [last_revision.id, nil].each do |to| + get "/wiki/#{ page.id }/diff", params: { from: first_revision.id, to: } + expect(response).to have_http_status(:ok) + expect(json).to include( + 'wiki_page_id' => page.id, 'title' => tag_name.name, + 'older_revision_id' => first_revision.id, 'newer_revision_id' => last_revision.id, + 'diff' => [{ 'type' => 'removed', 'content' => 'before' }, + { 'type' => 'added', 'content' => 'after' }]) + end + end + + describe 'revision resource scope' do + let!(:other_page) do + Wiki::Commit.create_content!( + tag_name: create(:tag_name, name: 'Help_other_contract'), + body: 'other page history', created_by_user: user) + end + let(:other_revision) { other_page.current_revision } + + it 'returns 404 when show specifies another page revision' do + get "/wiki/#{ page.id }", params: { version: other_revision.id } + expect(response).to have_http_status(:not_found) + end + + it 'returns 404 when diff from belongs to another page' do + get "/wiki/#{ page.id }/diff", + params: { from: other_revision.id, to: last_revision.id } + expect(response).to have_http_status(:not_found) + end + + it 'returns 404 when diff to belongs to another page' do + get "/wiki/#{ page.id }/diff", + params: { from: first_revision.id, to: other_revision.id } + expect(response).to have_http_status(:not_found) + end + end + + it 'retains legacy history metadata and ordering' do + get '/wiki/changes', params: { id: page.id } + expect(response).to have_http_status(:ok) + expect(json.map { |row| row.fetch('revision_id') }).to eq( + [last_revision.id, first_revision.id]) + expect(json.first).to include( + 'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content', + 'message' => 'legacy edit', 'timestamp' => last_revision.created_at.iso8601(3), + 'wiki_page' => { 'id' => page.id, 'title' => tag_name.name, 'deprecated_at' => nil }, + 'user' => { 'id' => user.id, 'name' => user.name }) + end +end