diff --git a/backend/app/controllers/tag_groups_controller.rb b/backend/app/controllers/tag_groups_controller.rb new file mode 100644 index 0000000..8117eea --- /dev/null +++ b/backend/app/controllers/tag_groups_controller.rb @@ -0,0 +1,39 @@ +class TagGroupsController < ApplicationController + def index + groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id) + render json: TagGroupRepr.many(groups.to_a) + end + + def show + 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) + 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 + end + + def update + return head :unauthorized unless current_user + return head :forbidden unless current_user.gte_member? + + 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) + end + + private + + def write_attributes + { name: params[:name], description: params[:description], + members: params[:members], placements: params[:placements] } + end +end diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 8fb84e9..929c550 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -3,23 +3,27 @@ class WikiPagesController < ApplicationController def index # 互換性のための化石リソース - # TODO: ヘルプおよびタグ・グループ追加後に, - # TODO: タグ説明とヘルプ、タグ・グループから取得する作りに変更する. - # + title = params[:title].to_s.strip - descriptions = TagDescription.where(language_code: 'ja') + descriptions = + TagDescription.where(language_code: 'ja') .includes(tag: :tag_names) pages = unmigrated_wiki_pages.includes(tag_name: :tag) + tag_groups = TagGroup.all if title.present? pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" - descriptions = descriptions.joins(tag: :tag_names) + descriptions = + descriptions.joins(tag: :tag_names) .where(tag_names: { language_code: 'ja', primary_flg: true }) .where('tag_names.name LIKE ?', pattern).limit(20) + tag_groups = tag_groups.where( + 'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20) pages = pages.where('tag_names.name LIKE ?', pattern).limit(20) end json = descriptions.map { |description| WikiPageRepr.tag_description(description) } + json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) }) json.concat(WikiPageRepr.base(pages)) json = json.first(20) if title.present? render json: @@ -30,6 +34,9 @@ class WikiPagesController < ApplicationController return render_wiki_page_by_tag_description tag_description if tag_description + tag_group = TagGroup.find_by(id: params[:id]) + return render_tag_group tag_group if tag_group + page = WikiPage.joins(:tag_name) .includes(tag_name: :tag) .find_by(id: params[:id]) @@ -40,25 +47,39 @@ class WikiPagesController < ApplicationController def show_by_title title = params[:title].to_s.strip + if title.start_with?(TagGroupRepr::PREFIX) + name = title.delete_prefix(TagGroupRepr::PREFIX) + tag_group = TagGroup.find_by(name:) + return render_tag_group tag_group if tag_group + return head :not_found + end + 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 }) + page = + WikiPage + .joins(:tag_name) + .includes(tag_name: :tag) + .find_by(tag_name: { name: title }) render_wiki_page_or_404 page end def exists return head :no_content if TagDescription.exists?(id: params[:id]) + return head :no_content if TagGroup.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 title.start_with?(TagGroupRepr::PREFIX) + exists = TagGroup.exists?(name: title.delete_prefix(TagGroupRepr::PREFIX)) + return head exists ? :no_content : :not_found + end return head :no_content if tag_description_by_title(title) exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title }) @@ -79,29 +100,33 @@ class WikiPagesController < ApplicationController 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('差分を表示できない版です.') + tag_group = TagGroup.find_by(id:) + if tag_group + from_rev = from && group_version(tag_group, from) + 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) + resource = { 'id' => tag_group.id, + 'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }", + 'deprecated_at' => nil } + 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 + from_body = from_rev&.body + to_body = to_rev.body + resource = WikiPageRepr.base(page) end - resource = WikiPageRepr.base(page) end - diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines) - diff_json = diffs.map { |change| - case change.action - when ?= - { type: 'context', content: change.old_element } - when ?! - [{ type: 'removed', content: change.old_element }, - { type: 'added', content: change.new_element }] - when ?+ - { type: 'added', content: change.new_element } - when ?- - { type: 'removed', content: change.old_element } - end - }.flatten.compact + from_body ||= from_rev&.body + to_body ||= to_rev.body + diff_json = wiki_diff(from_body, to_body) render json: { wiki_page_id: resource.fetch('id'), title: resource.fetch('title'), @@ -109,6 +134,8 @@ class WikiPagesController < ApplicationController older_revision_id: from_rev&.id, newer_revision_id: to_rev.id, diff: diff_json } + rescue TagGroupMarkdownRenderer::UnsupportedStructure => error + render_unprocessable_entity(error.message) end def create @@ -122,6 +149,17 @@ 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) + group = TagGroupOperation.create!(name:, **parsed, 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 + page = if title.include?(':') ApplicationRecord.transaction do tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| @@ -146,6 +184,8 @@ class WikiPagesController < ApplicationController render_validation_error e.record rescue ActiveRecord::RecordNotUnique render_record_not_unique + rescue TagGroupMarkdownParser::InvalidDocument => error + render_unprocessable_entity(error.message) end def update @@ -158,7 +198,26 @@ class WikiPagesController < ApplicationController return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? - page = WikiPage.find(params[:id]) + group = TagGroup.find_by(id: params[:id]) + if title.start_with?(TagGroupRepr::PREFIX) + return head :not_found unless group + + name = title.delete_prefix(TagGroupRepr::PREFIX) + parsed = TagGroupMarkdownParser.parse(body:, group:) + TagGroupOperation.update!(group:, name:, **parsed, actor: current_user, + require_compatibility: true) + return head :ok + end + + page = WikiPage.find_by(id: params[:id]) + if legacy_group_page?(page) + return render_unprocessable_entity field: :title + end + if group && !legacy_update_identified?(page, title, group) + return render_unprocessable_entity field: :title + end + return head :not_found unless page + base_revision_id = params[:base_revision_id].presence ApplicationRecord.transaction do @@ -175,11 +234,17 @@ class WikiPagesController < ApplicationController page.tag_name.update!(name: title) if title != old_title message = params[:message].presence - Wiki::Commit.content!(page:, - body:, - created_user: current_user, - message:, - base_revision_id:) + description = TagDescription.find_by(id: page.id) + description_page = description && ( + tag&.id == description.tag_id || + (tag.nil? && !old_title.include?(':'))) + if description && !description_page + Wiki::Commit.legacy_content!(page:, body:, created_user: current_user, + message:, base_revision_id:) + else + Wiki::Commit.content!(page:, body:, created_user: current_user, + message:, base_revision_id:) + end if tag && title != old_title tag.reload @@ -188,6 +253,12 @@ class WikiPagesController < ApplicationController end head :ok + rescue ActiveRecord::RecordInvalid => e + render_validation_error e.record + rescue ActiveRecord::RecordNotUnique + render_record_not_unique + rescue TagGroupMarkdownParser::InvalidDocument => error + render_unprocessable_entity(error.message) end def changes @@ -197,6 +268,11 @@ class WikiPagesController < ApplicationController return render json: description_changes(TagDescription.where(id: description.id)) end + tag_group = id && TagGroup.find_by(id:) + if tag_group + return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id)) + end + pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages revisions = WikiRevision.joins(wiki_page: :tag_name) .where(wiki_page_id: pages.select(:id)) @@ -215,14 +291,122 @@ class WikiPagesController < ApplicationController timestamp: rev.created_at } } unless id + json.concat(tag_group_changes(TagGroupVersion.all)) json.concat(description_changes(TagDescription.where(language_code: 'ja'))) - json.sort_by! { |row| -row[:revision_id] } + json = json.each_with_index.sort_by do |row, index| + [-row.fetch(:timestamp).to_f, index] + end.map(&:first) end render json: json.first(200) end 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]) + else + current_group_version(group) + end + return head :not_found unless version + + representation = if params[:version].present? + TagGroupRepr.historical(version) + else + TagGroupRepr.one(group) + end + render_group_representation(representation, version) + end + + def render_group_version version + representation = TagGroupRepr.historical(version) + TagGroupMarkdownRenderer.render(representation) + end + + def render_group_representation representation, version, body: nil, status: :ok + body ||= TagGroupMarkdownRenderer.render(representation) + versions = TagGroupVersion.where(tag_group_id: version.tag_group_id) + pred = versions.find_by(version_no: version.version_no - 1)&.id + succ = versions.find_by(version_no: version.version_no + 1)&.id + updated_at = version.created_at + render json: { + 'id' => version.tag_group_id, + 'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }", + 'deprecated_at' => nil, + 'body' => body, + 'revision_id' => version.id, + 'pred' => pred, + 'succ' => succ, + 'updated_at' => updated_at }, status: + rescue TagGroupMarkdownRenderer::UnsupportedStructure => error + render_unprocessable_entity(error.message) + end + + def current_group_version group + TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no) + end + + def group_version group, version_id + TagGroupVersion.find_by(tag_group_id: group.id, id: version_id) + end + + def wiki_diff from_body, to_body + diffs = Diff::LCS.sdiff(from_body.to_s.lines, to_body.to_s.lines) + diffs.map { |change| + case change.action + when ?= + { type: 'context', content: change.old_element } + when ?! + [{ type: 'removed', content: change.old_element }, + { type: 'added', content: change.new_element }] + when ?+ + { type: 'added', content: change.new_element } + when ?- + { type: 'removed', content: change.old_element } + end + }.flatten.compact + end + + def tag_group_changes versions + rows = versions.includes(:created_by_user).order(id: :desc).limit(200).to_a + groups = TagGroup.where(id: rows.map(&:tag_group_id).uniq).index_by(&:id) + predecessors = TagGroupVersion.where(tag_group_id: groups.keys, + version_no: rows.map { _1.version_no - 1 }) + .pluck(:tag_group_id, :version_no, :id) + .to_h { |group_id, number, version_id| [[group_id, number], version_id] } + rows.map do |version| + group = groups.fetch(version.tag_group_id) + user = version.created_by_user + { revision_id: version.id, + pred: predecessors[[group.id, version.version_no - 1]], + succ: nil, + wiki_page: { id: group.id, title: "#{ TagGroupRepr::PREFIX }#{ version.name }", + deprecated_at: nil }, + user: user && { id: user.id, name: user.name }, + kind: 'content', message: nil, timestamp: version.created_at } + end + end + def render_wiki_page_or_404 page return head :not_found unless page @@ -271,7 +455,10 @@ class WikiPagesController < ApplicationController def unmigrated_wiki_pages descriptions = TagDescription.where(language_code: 'ja') - WikiPage.joins(:tag_name).where.not(id: descriptions.select(:id)) + + WikiPage + .joins(:tag_name) + .where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%") .where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)', descriptions.select(:tag_id)) end diff --git a/backend/app/models/tag_group.rb b/backend/app/models/tag_group.rb new file mode 100644 index 0000000..3533641 --- /dev/null +++ b/backend/app/models/tag_group.rb @@ -0,0 +1,16 @@ +class TagGroup < ApplicationRecord + belongs_to :created_by_user, class_name: 'User', optional: true + belongs_to :updated_by_user, class_name: 'User', optional: true + + has_many :tag_group_tags, dependent: :delete_all + has_many :tags, through: :tag_group_tags + has_many :tag_group_groups, dependent: :delete_all + has_many :child_tag_groups, through: :tag_group_groups + has_many :placements, -> { order(:position) }, + class_name: 'TagGroupPlacement', dependent: :delete_all + has_many :versions, -> { order(:version_no) }, + class_name: 'TagGroupVersion', dependent: :delete_all + + validates :name, presence: true, uniqueness: true, length: { maximum: 255 } + validates :version_no, numericality: { only_integer: true, greater_than: 0 } +end diff --git a/backend/app/models/tag_group_group.rb b/backend/app/models/tag_group_group.rb new file mode 100644 index 0000000..6175694 --- /dev/null +++ b/backend/app/models/tag_group_group.rb @@ -0,0 +1,7 @@ +class TagGroupGroup < ApplicationRecord + self.primary_key = [:tag_group_id, :child_tag_group_id] + + belongs_to :tag_group + belongs_to :child_tag_group, class_name: 'TagGroup' + belongs_to :created_by_user, class_name: 'User', optional: true +end diff --git a/backend/app/models/tag_group_placement.rb b/backend/app/models/tag_group_placement.rb new file mode 100644 index 0000000..8dde6fa --- /dev/null +++ b/backend/app/models/tag_group_placement.rb @@ -0,0 +1,9 @@ +class TagGroupPlacement < ApplicationRecord + self.primary_key = [:tag_group_id, :position] + + belongs_to :tag_group + belongs_to :tag, optional: true + belongs_to :child_tag_group, class_name: 'TagGroup', optional: true + belongs_to :created_by_user, class_name: 'User', optional: true + belongs_to :updated_by_user, class_name: 'User', optional: true +end diff --git a/backend/app/models/tag_group_tag.rb b/backend/app/models/tag_group_tag.rb new file mode 100644 index 0000000..314a7db --- /dev/null +++ b/backend/app/models/tag_group_tag.rb @@ -0,0 +1,7 @@ +class TagGroupTag < ApplicationRecord + self.primary_key = [:tag_group_id, :tag_id] + + belongs_to :tag_group + belongs_to :tag + belongs_to :created_by_user, class_name: 'User', optional: true +end diff --git a/backend/app/models/tag_group_version.rb b/backend/app/models/tag_group_version.rb new file mode 100644 index 0000000..95257e9 --- /dev/null +++ b/backend/app/models/tag_group_version.rb @@ -0,0 +1,7 @@ +class TagGroupVersion < ApplicationRecord + include VersionRecord + + belongs_to :tag_group + + scope :ordered, -> { order(:version_no) } +end diff --git a/backend/app/representations/tag_group_repr.rb b/backend/app/representations/tag_group_repr.rb new file mode 100644 index 0000000..98a5cb4 --- /dev/null +++ b/backend/app/representations/tag_group_repr.rb @@ -0,0 +1,194 @@ +module TagGroupRepr + PREFIX = 'タググループ:' + + module_function + + def many groups + ids = groups.map(&:id) + return [] if ids.empty? + + tag_members = TagGroupTag.where(tag_group_id: ids) + .group_by(&:tag_group_id) + group_members = TagGroupGroup.where(tag_group_id: ids) + .group_by(&:tag_group_id) + 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) + + placements.values.flatten.filter_map(&:tag_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 + child_groups = TagGroup.where(id: child_ids.uniq).index_by(&:id) + + groups.map do |group| + group_json(group, + tag_members.fetch(group.id, []), + group_members.fetch(group.id, []), + placements.fetch(group.id, []), tags, names, child_groups) + end + end + + def one group + many([group]).first + end + + def tag_group wiki_page + { 'id' => wiki_page.id, + 'title' => "#{ PREFIX }#{ wiki_page.name }", + 'deprecated_at' => nil, + 'created_at' => wiki_page.created_at, + 'updated_at' => wiki_page.updated_at } + end + + def current_version group + TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no) + end + + def historical version + members = Array(version.members) + placements = Array(version.placements) + tag_references = members.filter_map { |member| member['tag_id'] } + + collect_ids(placements, 'tag_id') + group_references = members.filter_map { |member| member['tag_group_id'] } + + collect_ids(placements, 'tag_group_id') + tag_versions = historical_tag_versions(tag_references) + group_versions = historical_group_versions(group_references) + tag_version_numbers = members.filter_map do |member| + next unless member['tag_id'] + + [member.fetch('tag_id'), member.fetch('version_no')] + end.to_h + group_version_numbers = members.filter_map do |member| + next unless member['tag_group_id'] + + [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) + end + historical_placements = placements.map do |node| + historical_placement(node, tag_versions, group_versions, + tag_version_numbers, group_version_numbers) + end + { 'id' => version.tag_group_id, + 'name' => version.name, + 'description' => version.description, + 'version_no' => version.version_no, + 'members' => historical_members, + 'placements' => historical_placements, + 'created_at' => version.created_at, + 'updated_at' => version.created_at, + 'created_by_user' => user_json(version.created_by_user), + 'updated_by_user' => user_json(version.created_by_user) } + end + + 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)) } + 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)) } + end) + { 'id' => group.id, + 'name' => group.name, + 'description' => group.description, + 'version_no' => group.version_no, + 'members' => members, + 'placements' => tree, + 'created_at' => group.created_at, + 'updated_at' => group.updated_at, + 'created_by_user' => user_json(group.created_by_user), + 'updated_by_user' => user_json(group.updated_by_user) } + end + + def user_json user + UserRepr.base(user) if user + end + + def placement_tree rows, tags, names, child_groups + by_parent = rows.group_by(&:parent_position) + 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)) } + elsif row.child_tag_group_id + { 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) } + else + { 'section_name' => row.section_name, 'description' => row.description } + end + node.merge('children' => build.call(row.position)) + end + end + build.call(nil) + end + + def tag_json tag, name + { 'id' => tag.id, 'name' => name, 'category' => tag.category } + end + + def group_json_reference group + { 'id' => group.id, 'name' => group.name } + end + + def collect_ids nodes, key + nodes.flat_map do |node| + [node[key], *collect_ids(Array(node['children']), key)].compact + end + end + + def historical_tag_versions ids + TagVersion.where(tag_id: ids.uniq).to_a.index_by do |version| + [version.tag_id, version.version_no] + end + end + + def historical_group_versions ids + TagGroupVersion.where(tag_group_id: ids.uniq).to_a.index_by do |version| + [version.tag_group_id, version.version_no] + end + end + + def historical_member 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), + 'category' => version.category } } + else + id = member.fetch('tag_group_id') + version = group_versions.fetch([id, member.fetch('version_no')]) + { 'tag_group' => { 'id' => id, 'name' => version.name } } + end + end + + def historical_placement(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), + 'category' => version.category } } + elsif node['tag_group_id'] + id = node.fetch('tag_group_id') + version_no = group_version_numbers.fetch(id) + version = group_versions.fetch([id, version_no]) + { 'tag_group' => { 'id' => id, 'name' => version.name } } + else + { '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, + tag_version_numbers, group_version_numbers) + end) + end + + def japanese_version_name version + version.names.fetch('ja').fetch('name') + end +end diff --git a/backend/app/representations/wiki_page_repr.rb b/backend/app/representations/wiki_page_repr.rb index a217341..12fb3b6 100644 --- a/backend/app/representations/wiki_page_repr.rb +++ b/backend/app/representations/wiki_page_repr.rb @@ -25,6 +25,10 @@ module WikiPageRepr 'deprecated_at' => tag.deprecated_at } end + def tag_group tag_group + TagGroupRepr.tag_group(tag_group) + end + def many wiki_pages wiki_pages.map { |p| base(p) } end diff --git a/backend/app/services/tag_group_markdown_parser.rb b/backend/app/services/tag_group_markdown_parser.rb new file mode 100644 index 0000000..bfbeba7 --- /dev/null +++ b/backend/app/services/tag_group_markdown_parser.rb @@ -0,0 +1,177 @@ +require 'kramdown' + +class TagGroupMarkdownParser + PREFIX = TagGroupRepr::PREFIX + + class InvalidDocument < StandardError + ; + end + + def self.parse(body:, group: nil) + new(body:, group:).parse + end + + def initialize(body:, group:) + @body = body.to_s.gsub(/\r\n?/, "\n") + @group = group + end + + def parse + 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 = [] + description_parts = [] + headings = [] + + root.children.each do |node| + case node.type + when :blank + next + when :header + level = node.options.fetch(:level) + name = inline_text(node).strip + invalid!('Section 名は必須です.') if name.blank? + + headings.pop while headings.any? && headings.last.fetch(:level) >= level + parent = headings.last&.fetch(:node) + section = { 'section_name' => name, 'description' => nil, + 'children' => [] } + (parent ? parent.fetch('children') : placements) << section + headings << { level:, node: section } + when :p + text = inline_markdown(node).strip + next if text.empty? + + if headings.any? + section = headings.last.fetch(:node) + section['description'] = [section['description'], text].compact.join("\n\n") + else + description_parts << text + end + 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) }) + else + invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.") + end + end + + description = description_parts.presence&.join("\n\n") + { description:, placements:, members: build_members(placements), + compatibility_body: @body.sub(/\n+\z/, '') } + rescue Kramdown::Error => error + invalid!("Markdown を解析できません:#{ error.message }") + end + + private + + def parse_list_item(item) + invalid!('リスト項目ではありません.') unless item.type == :li + invalid!('未対応のリスト項目があります.') if item.attr.any? + + nested, own = item.children.partition { |child| child.type == :ul } + invalid!('順序付きリストは使用できません.') if own.any? { _1.type == :ol } + own.reject! { _1.type == :blank } + invalid!('未対応のリスト項目があります.') unless own.all? do |child| + [:p, :text].include?(child.type) + end + invalid!('リスト項目は一つの段落で指定してください.') if own.size != 1 + + 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['children'] = nested.flat_map do |list| + invalid!('未対応の入れ子リストです.') unless list.type == :ul + list.children.map { parse_list_item(_1) } + end + node + end + + def resolve_tag(name) + names = TagName.where(language_code: 'ja', 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? + + tag_ids.first + end + + def build_members(nodes) + references = [] + visit = lambda do |items| + items.each do |node| + references << node.slice('tag_id', 'tag_group_id') if + node.key?('tag_id') || node.key?('tag_group_id') + visit.call(node.fetch('children')) + end + end + visit.call(nodes) + references.uniq + end + + def inline_text(node) + if node.attr.any? + invalid!("未対応のインライン構文 #{ node.type.inspect } です.") + end + + case node.type + when :text + node.value.to_s + when :p, :header + node.children.map { inline_text(_1) }.join + else + invalid!("未対応のインライン構文 #{ node.type.inspect } です.") + end + end + + def inline_markdown(node) + case node.type + when :text + node.value.to_s + when :codespan + text = node.value.to_s + longest = text.scan(/`+/).map(&:length).max || 0 + delimiter = '`' * (longest + 1) + padded = text.start_with?('`', ' ') || text.end_with?('`', ' ') + content = padded ? " #{ text } " : text + "#{ delimiter }#{ content }#{ delimiter }" + when :softbreak + "\n" + when :br + " \n" + when :p + node.children.map { inline_markdown(_1) }.join + when :a + href = node.attr.fetch('href') + label = node.children.map { inline_markdown(_1) }.join + title = node.attr['title']&.gsub('"', '\\"') + suffix = title ? " \"#{ title }\"" : '' + "[#{ label }](#{ href }#{ suffix })" + when :em + "*#{ node.children.map { inline_markdown(_1) }.join }*" + when :strong + "**#{ node.children.map { inline_markdown(_1) }.join }**" + else + invalid!("未対応の説明文構文 #{ node.type.inspect } です.") + end + end + + def invalid! message + raise InvalidDocument, message + end +end diff --git a/backend/app/services/tag_group_markdown_renderer.rb b/backend/app/services/tag_group_markdown_renderer.rb new file mode 100644 index 0000000..75b08c0 --- /dev/null +++ b/backend/app/services/tag_group_markdown_renderer.rb @@ -0,0 +1,118 @@ +class TagGroupMarkdownRenderer + class UnsupportedStructure < StandardError + ; + end + + def self.render group + new(group).render + end + + def initialize group + @group = group + end + + def render + placements = @group.fetch('placements') + validate_membership!(placements) + blocks = [] + if @group['description'].present? + 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 + + if placements.all? { _1.key?('section_name') } + blocks.concat(placements.map { render_heading(_1, 2) }) + else + blocks << render_list(placements, 0) + end + + blocks.compact.reject(&:empty?).join("\n").sub(/\n+\z/, '') + end + + private + + def validate_membership! placements + placed = placement_members(placements).uniq.sort + members = @group.fetch('members').map { |member| + if member['tag'] + ['tag', member.fetch('tag').fetch('id')] + else + ['tag_group', member.fetch('tag_group').fetch('id')] + end + }.uniq.sort + missing = members - placed + if missing.any? + raise UnsupportedStructure, '掲載位置のない membership は Markdown にできません.' + end + end + + def placement_members nodes + nodes.flat_map do |node| + own = if node['tag'] + [['tag', node.fetch('tag').fetch('id')]] + elsif node['tag_group'] + [['tag_group', node.fetch('tag_group').fetch('id')]] + else + [] + end + own + placement_members(node.fetch('children')) + end + end + + def render_heading section, level + header = "#{ '#' * level } #{ clean_text(section.fetch('section_name')) }" + blocks = [header] + blocks << clean_text(section['description']) if section['description'].present? + children = section.fetch('children') + section_indexes = children.each_index.select { |index| children[index].key?('section_name') } + if section_indexes.any? + first_section = section_indexes.first + if children[first_section..].any? { !_1.key?('section_name') } + raise UnsupportedStructure, + '見出し Section と兄弟 Placement の交互配置は Markdown にできません.' + end + list_nodes = children[0...first_section] + blocks << render_list(list_nodes, 0) if list_nodes.any? + blocks.concat(children[first_section..].map { render_heading(_1, level + 1) }) + elsif children.any? + blocks << render_list(children, 0) + end + blocks.join("\n") + end + + def render_list nodes, depth + nodes.map do |node| + indent = ' ' * depth + if node.key?('section_name') + raise UnsupportedStructure, + 'リスト内 Section の説明文は Markdown にできません.' if node['description'].present? + + children = node.fetch('children') + head = "#{ indent }- #{ clean_text(node.fetch('section_name')) }:" + tail = render_list(children, depth + 1) if children.any? + [head, tail].compact.join("\n") + else + label = if node['tag'] + node.fetch('tag').fetch('name') + else + "#{ TagGroupRepr::PREFIX }#{ node.fetch('tag_group').fetch('name') }" + end + raise UnsupportedStructure, '名称のない Placement は Markdown にできません.' if label.blank? + + head = "#{ indent }- #{ clean_text(label) }" + children = node.fetch('children') + tail = render_list(children, depth + 1) if children.any? + [head, tail].compact.join("\n") + end + end.join("\n") + end + + def clean_text value + value.to_s.gsub(/\r\n?/, "\n").lines.map(&:rstrip).join("\n").strip + end +end diff --git a/backend/app/services/tag_group_operation.rb b/backend/app/services/tag_group_operation.rb new file mode 100644 index 0000000..905773b --- /dev/null +++ b/backend/app/services/tag_group_operation.rb @@ -0,0 +1,410 @@ +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 + group = TagGroup.new(version_no: 1, created_by_user: actor, + updated_by_user: actor) + attributes = prepare!(group:, name:, description:, members:, placements:) + group.assign_attributes(name: attributes.fetch(:name), + description: attributes.fetch(:description)) + 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] + 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 + group.lock! + old_name = group.name + attributes = prepare!(group:, name:, description:, members:, placements:) + next [group, nil] if unchanged?(group, attributes) + + 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:) + persist_version!(group:, attributes:, actor:, event_type: 'update') + issue = sync_compatibility!(group:, actor:, old_name:, + require_compatibility:, compatibility_body:) + [group, issue] + end + report_sync_issue(group, actor, sync_issue) + group + end + + def self.prepare!(group:, name:, description:, members:, placements:) + record = group + normalized_name = name.to_s.strip + invalid!(record, :name, 'グループ名は必須です.') if normalized_name.blank? + if normalized_name.length > 255 + invalid!(record, :name, 'グループ名は 255 文字以内で入力してください.') + end + invalid!(record, :description, '説明文は文字列で入力してください.') unless + description.nil? || description.is_a?(String) + + record.name = normalized_name + record.description = description + unless record.valid? + raise ActiveRecord::RecordInvalid, record + end + + member_rows = normalize_members(record, members) + placement_tree = normalize_placements(record, placements) + validate_references!(record, member_rows, placement_tree) + validate_containment!(record, member_rows) + { name: normalized_name, description:, members: member_rows, + placements: placement_tree } + end + private_class_method :prepare! + + def self.unchanged?(group, attributes) + unless (group.name_in_database == attributes.fetch(:name) && + group.description_in_database == attributes.fetch(:description)) + return false + end + + members = TagGroupTag.where(tag_group_id: group.id).pluck(:tag_id) + .map { { 'tag_id' => _1 } } + members.concat(TagGroupGroup.where(tag_group_id: group.id) + .pluck(:child_tag_group_id).map { { 'tag_group_id' => _1 } }) + requested_members = attributes.fetch(:members).map do |member| + member.except('version_no') + end + return false unless members.to_set == requested_members.to_set + + rows = TagGroupPlacement.where(tag_group_id: group.id).order(:position) + .group_by(&:parent_position) + build = lambda do |parent_position| + rows.fetch(parent_position, []).map do |row| + node = if row.tag_id + { 'tag_id' => row.tag_id } + elsif row.child_tag_group_id + { 'tag_group_id' => row.child_tag_group_id } + else + { 'section_name' => row.section_name, 'description' => row.description } + end + node.merge('children' => build.call(row.position)) + end + end + build.call(nil) == comparable_placements(attributes.fetch(:placements)) + end + private_class_method :unchanged? + + def self.comparable_placements(nodes) + nodes.map do |node| + result = node.dup + result['description'] = node['description'] if node.key?('section_name') + result['children'] = comparable_placements(node.fetch('children')) + result + end + end + private_class_method :comparable_placements + + def self.normalize_members(record, members) + invalid!(record, :members, 'members は配列で指定してください.') unless members.is_a?(Array) + + result = members.map do |member| + member = stringify_keys(member) + unless member.is_a?(Hash) && member.keys.one? && + (member.key?('tag_id') || member.key?('tag_group_id')) + invalid!(record, :members, '各 member は tag_id または tag_group_id を一つ指定してください.') + end + key = member.keys.first + id = integer_id(record, member.fetch(key), :members) + { key => id } + end + invalid!(record, :members, 'membership に重複があります.') unless result.uniq == result + result + end + private_class_method :normalize_members + + def self.normalize_placements(record, placements) + invalid!(record, :placements, 'placements は配列で指定してください.') unless + placements.is_a?(Array) + + normalize_placement_nodes(record, placements, 1) + end + private_class_method :normalize_placements + + def self.normalize_placement_nodes(record, nodes, depth) + if depth > MAX_PLACEMENT_DEPTH && nodes.any? + invalid!(record, :placements, '配置の深さは 5 階層までです.') + end + + nodes.map do |raw_node| + node = stringify_keys(raw_node) + unless node.is_a?(Hash) && node.key?('children') + invalid!(record, :placements, '各 Placement に children 配列が必要です.') + end + children = node.delete('children') + unless children.is_a?(Array) + invalid!(record, :placements, 'children は配列で指定してください.') + end + + kinds = ['tag_id', 'tag_group_id', 'section_name'].select { node.key?(_1) } + invalid!(record, :placements, 'Placement の種類を一つ指定してください.') unless + kinds.one? + kind = kinds.first + allowed = [kind, 'description'] + invalid!(record, :placements, '不明な Placement 項目があります.') unless + (node.keys - allowed).empty? + + case kind + when 'tag_id', 'tag_group_id' + node[kind] = integer_id(record, node.fetch(kind), :placements) + if node['description'].present? + invalid!(record, :placements, 'Tag / Tag Group Placement に説明文は指定できません.') + end + node.delete('description') + when 'section_name' + section_name = node.fetch(kind) + unless section_name.is_a?(String) && section_name.strip.present? && + section_name.length <= 255 + invalid!(record, :placements, 'Section 名は 1〜255 文字で入力してください.') + end + node[kind] = section_name.strip + unless node['description'].nil? || node['description'].is_a?(String) + invalid!(record, :placements, 'Section の説明文は文字列で入力してください.') + end + end + + node['children'] = normalize_placement_nodes(record, children, depth + 1) + node + end + end + private_class_method :normalize_placement_nodes + + def self.validate_references!(record, members, placements) + membership = members.to_set + placed = placement_references(placements).uniq + missing = placed - membership.to_a + invalid!(record, :placements, 'Placement の対象は membership に含めてください.') if + missing.any? + + tag_ids = members.filter_map { _1['tag_id'] } + group_ids = members.filter_map { _1['tag_group_id'] } + tags = Tag.where(id: tag_ids).index_by(&:id) + groups = TagGroup.where(id: group_ids).index_by(&:id) + invalid!(record, :members, '存在しない Tag が含まれています.') if + tags.size != tag_ids.uniq.size + invalid!(record, :members, '存在しない Tag Group が含まれています.') if + groups.size != group_ids.uniq.size + + 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) + .pluck(:tag_id).uniq + + members.each do |member| + if member.key?('tag_id') + id = member.fetch('tag_id') + invalid!(record, :members, "Tag ##{ id } に日本語の正規名がありません.") unless + tag_names.include?(id) + version_no = tags.fetch(id).version_no + unless tag_versions.include?([id, version_no]) + invalid!(record, :members, "Tag ##{ id } の Version がありません.") + end + member['version_no'] = version_no + else + id = member.fetch('tag_group_id') + version_no = groups.fetch(id).version_no + unless group_versions.include?([id, version_no]) + invalid!(record, :members, "Tag Group ##{ id } の Version がありません.") + end + member['version_no'] = version_no + end + end + end + private_class_method :validate_references! + + def self.validate_containment!(record, members) + target_id = record.id + return unless target_id + + children = members.filter_map { _1['tag_group_id'] } + edges = TagGroupGroup.pluck(:tag_group_id, :child_tag_group_id) + .group_by(&:first).transform_values { _1.map(&:last) } + edges[target_id] = children + visiting = Set.new + visited = Set.new + visit = lambda do |group_id| + invalid!(record, :members, 'Tag Group の循環包含はできません.') if + group_id == target_id && visiting.include?(group_id) + return if visited.include?(group_id) + + visiting.add(group_id) + edges.fetch(group_id, []).each do |child_id| + invalid!(record, :members, 'Tag Group の循環包含はできません.') if + child_id == target_id + visit.call(child_id) + end + visiting.delete(group_id) + visited.add(group_id) + end + children.each { visit.call(_1) } + end + private_class_method :validate_containment! + + def self.persist_structure!(group:, attributes:, actor:) + tag_members = TagGroupTag.where(tag_group_id: group.id).index_by(&:tag_id) + group_members = TagGroupGroup.where(tag_group_id: group.id).index_by(&:child_tag_group_id) + attributes.fetch(:members).each do |member| + if member.key?('tag_id') + next if tag_members.delete(member.fetch('tag_id')) + + TagGroupTag.create!(tag_group_id: group.id, tag_id: member.fetch('tag_id'), + created_by_user: actor) + else + next if group_members.delete(member.fetch('tag_group_id')) + + TagGroupGroup.create!(tag_group_id: group.id, + child_tag_group_id: member.fetch('tag_group_id'), + created_by_user: actor) + end + end + existing = TagGroupPlacement.where(tag_group_id: group.id).index_by(&:position) + persist_placements!(group:, nodes: attributes.fetch(:placements), actor:, existing:) + existing.values.sort_by(&:position).reverse_each(&:delete) + tag_members.each_value(&:delete) + group_members.each_value(&:delete) + end + private_class_method :persist_structure! + + def self.persist_placements!(group:, nodes:, actor:, existing:, parent_position: nil, + next_position: [0]) + nodes.each do |node| + next_position[0] += 1 + position = next_position[0] + row = existing.delete(position) || TagGroupPlacement.new( + tag_group_id: group.id, position:, created_by_user: actor) + row.assign_attributes( + parent_position:, + section_name: node['section_name'], description: node['description'], + tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id']) + if row.new_record? || row.changed? + row.updated_by_user = actor + row.save! + end + persist_placements!(group:, nodes: node.fetch('children'), actor:, existing:, + parent_position: position, next_position:) + end + end + private_class_method :persist_placements! + + def self.persist_version!(group:, attributes:, actor:, event_type:) + TagGroupVersion.create!(tag_group: group, version_no: group.version_no, + event_type:, name: group.name, + description: group.description, + members: attributes.fetch(:members), + placements: attributes.fetch(:placements), + created_by_user: actor) + 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::Commit.create_legacy_content!(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') + [{ 'tag_id' => node.fetch('tag_id') }] + elsif node.key?('tag_group_id') + [{ 'tag_group_id' => node.fetch('tag_group_id') }] + else + [] + end + own + placement_references(node.fetch('children')) + end + end + private_class_method :placement_references + + def self.stringify_keys(value) + case value + when ActionController::Parameters + value.to_unsafe_h.deep_stringify_keys + when Hash + value.deep_stringify_keys + else + value + end + end + private_class_method :stringify_keys + + def self.integer_id(record, value, attribute) + integer = if value.is_a?(Integer) + value + elsif value.is_a?(String) && value.match?(/\A[0-9]+\z/) + value.to_i + end + invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer + invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer.positive? + integer + end + private_class_method :integer_id + + def self.invalid!(record, attribute, message) + record.errors.add(attribute, message) + raise ActiveRecord::RecordInvalid, record + end + private_class_method :invalid! +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 4402503..0102d01 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + resources :tag_groups, only: [:index, :show, :create, :update] + resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] scope 'tags/:parent_id/children', controller: :tag_children do diff --git a/backend/db/migrate/20261008000000_create_tag_groups.rb b/backend/db/migrate/20261008000000_create_tag_groups.rb new file mode 100644 index 0000000..f113502 --- /dev/null +++ b/backend/db/migrate/20261008000000_create_tag_groups.rb @@ -0,0 +1,149 @@ +class CreateTagGroups < ActiveRecord::Migration[8.0] + def change + # 翻訳系は上流の Translation リソースが未完のため保留 + + create_table :tag_groups do |t| + t.integer :version_no, null: false + t.string :name, limit: 255, null: false + t.text :description + t.references :created_by_user, foreign_key: { to_table: :users } + t.references :updated_by_user, foreign_key: { to_table: :users } + t.timestamps + + t.check_constraint 'version_no > 0', name: 'chk_tag_groups_version_no_positive' + t.index :name, unique: true + end + + create_table :tag_group_tags, primary_key: [:tag_group_id, :tag_id] do |t| + t.references :tag_group, null: false, foreign_key: true, index: false + t.references :tag, null: false, foreign_key: true + t.references :created_by_user, foreign_key: { to_table: :users } + t.datetime :created_at, null: false + end + + create_table :tag_group_groups, primary_key: [:tag_group_id, :child_tag_group_id] do |t| + t.references :tag_group, null: false, foreign_key: true, index: false + t.references :child_tag_group, + null: false, foreign_key: { to_table: :tag_groups } + t.references :created_by_user, foreign_key: { to_table: :users } + t.datetime :created_at, null: false + + t.check_constraint 'tag_group_id <> child_tag_group_id', + name: 'chk_tag_group_groups_tag_group_id_not_child_tag_group_id' + end + + create_table :tag_group_placements, primary_key: [:tag_group_id, :position] do |t| + t.references :tag_group, null: false, foreign_key: true, index: false + t.integer :position, null: false + t.integer :parent_position + t.string :section_name, limit: 255 + t.text :description + t.references :tag + t.references :child_tag_group + 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 :tag_group_placements, + column: [:tag_group_id, :parent_position], + primary_key: [:tag_group_id, :position] + + t.foreign_key :tag_group_tags, + column: [:tag_group_id, :tag_id], + primary_key: [:tag_group_id, :tag_id] + + t.foreign_key :tag_group_groups, + column: [:tag_group_id, :child_tag_group_id], + primary_key: [:tag_group_id, :child_tag_group_id] + + t.check_constraint 'position > parent_position', + name: 'chk_tag_group_placements_position_gt_parent_position' + + t.check_constraint ('((section_name IS NOT NULL AND tag_id IS NULL AND child_tag_group_id IS NULL) OR ' + + '(section_name IS NULL AND tag_id IS NOT NULL AND child_tag_group_id IS NULL) OR ' + + '(section_name IS NULL AND tag_id IS NULL AND child_tag_group_id IS NOT NULL)) AND ' + + '(section_name IS NOT NULL OR description IS NULL)'), + name: 'chk_tag_group_placements_references_exclusive' + end + + create_table :tag_group_versions do |t| + t.references :tag_group, null: false + t.integer :version_no, null: false + t.string :event_type, null: false + t.string :name, limit: 255, null: false + t.text :description + t.json :members, null: false + t.json :placements, null: false + t.references :created_by_user + t.datetime :created_at, null: false + + t.check_constraint 'version_no > 0', + name: 'chk_tag_group_versions_version_no_positive' + t.index [:tag_group_id, :version_no], unique: true + + add_members_constraint!(t) + add_placements_constraint!(t) + end + end + + private + + def add_members_constraint! t + schema = { type: 'array', + uniqueItems: true, + items: member_schema } + + quoted_schema = connection.quote(JSON.generate(schema)) + + t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, members)", + name: 'chk_tag_group_versions_members_schema' + end + + def member_schema + { oneOf: [tag_member_schema, group_member_schema] } + end + + def tag_member_schema + { type: 'object', + required: ['tag_id', 'version_no'], + properties: { tag_id: { type: 'integer', minimum: 1 }, + version_no: { type: 'integer', minimum: 1 } }, + additionalProperties: false } + end + + def group_member_schema + { type: 'object', + required: ['tag_group_id', 'version_no'], + properties: { tag_group_id: { type: 'integer', minimum: 1 }, + version_no: { type: 'integer', minimum: 1 } }, + additionalProperties: false } + end + + def add_placements_constraint! t + schema = { type: 'array', items: placement_schema(5) } + + quoted_schema = connection.quote(JSON.generate(schema)) + + t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, placements)", + name: 'chk_tag_group_versions_placements_schema' + end + + def placement_schema rest + { type: 'object', + required: ['children'], + properties: { section_name: { type: 'string' }, + description: { type: ['string', 'null'] }, + tag_id: { type: 'integer', minimum: 1 }, + tag_group_id: { type: 'integer', minimum: 1 }, + children: if rest > 1 + { type: 'array', items: placement_schema(rest - 1) } + else + { type: 'array', maxItems: 0 } + end }, + oneOf: [{ required: ['section_name'] }, + { required: ['tag_id'] }, + { required: ['tag_group_id'] }], + dependencies: { description: ['section_name'] }, + additionalProperties: false } + end +end diff --git a/backend/db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages.rb b/backend/db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages.rb new file mode 100644 index 0000000..9e68d7e --- /dev/null +++ b/backend/db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages.rb @@ -0,0 +1,518 @@ +class MigrateTagGroupsFromWikiPages < ActiveRecord::Migration[8.0] + PREFIX = 'タググループ:' + + def up + require 'json' + require 'time' + require 'kramdown' + require 'kramdown-parser-gfm' + + assert_empty_destination! + + pages = connection.select_all(<<~SQL).to_a + SELECT + wp.id + , wp.created_user_id + , wp.updated_user_id + , wp.created_at + , wp.updated_at + , wp.discarded_at + , tn.name + FROM + wiki_pages wp + INNER JOIN + tag_names tn + ON + tn.id = wp.tag_name_id + WHERE + tn.language_code = 'ja' + AND tn.name LIKE #{ quote("#{ PREFIX }%") } + ORDER BY + wp.id + SQL + + return if pages.empty? + + pages.each do |page| + if page['discarded_at'] + say "Wiki ##{ page.fetch('id') }: 破棄済みですが,Revision の移行を継続します." + end + end + + tag_ids = load_tag_ids + page_ids = pages.map { |page| page.fetch('id').to_i } + + revisions = connection.select_all(<<~SQL).to_a + SELECT + wr.id + , wr.wiki_page_id + , wr.kind + , wr.redirect_page_id + , wr.created_user_id + , wr.created_at + FROM + wiki_revisions wr + WHERE + wr.wiki_page_id IN (#{ page_ids.join(', ') }) + ORDER BY + wr.wiki_page_id + , wr.id + SQL + + revisions_by_page = revisions.group_by { |row| row.fetch('wiki_page_id').to_i } + + pages = pages.select do |page| + page_id = page.fetch('id').to_i + if revisions_by_page.fetch(page_id, []).empty? + # tag_groups.version_no > 0 のため,Revision 0 件の Wiki は作成しない. + say "Wiki ##{ page_id }: Revision が 0 件のため移行対象から除外します." + false + else + true + end + end + + return if pages.empty? + + group_ids = pages.to_h { |page| [page.fetch('name'), page.fetch('id').to_i] } + revision_history = pages.to_h do |page| + page_id = page.fetch('id').to_i + history = revisions_by_page.fetch(page_id).each_with_index.map do |revision, index| + { 'id' => revision.fetch('id').to_i, + 'version_no' => index + 1, + 'created_at' => revision.fetch('created_at') } + end + [page_id, history] + end + + tag_history = connection.select_all(<<~SQL).to_a.group_by { |row| row.fetch('tag_id').to_i } + SELECT + tag_id + , version_no + , created_at + FROM + tag_versions + ORDER BY + tag_id + , created_at + , version_no + SQL + + prepared = { } + pages.each do |page| + page_id = page.fetch('id').to_i + history = revisions_by_page.fetch(page_id) + + prepared[page_id] = history.each_with_index.map do |revision, index| + context = "Wiki ##{ page_id } / Revision ##{ revision.fetch('id') }" + body = revision_body(revision.fetch('id').to_i) + + if revision.fetch('kind').to_i == 1 + destination = revision['redirect_page_id'] + description = "旧 Wiki のリダイレクト先: Wiki ##{ destination }" + placements = [] + members = [] + say "#{ context }: リダイレクトを説明文として移行します." + else + begin + description, placements = parse_wiki(body, tag_ids, group_ids, context) + validate_tree!(placements, context) + members = build_members(placements, revision.fetch('created_at'), + tag_history, revision_history, context) + rescue StandardError => error + # 解析できない版も捨てず,当時の Markdown を説明文として保存する. + say "#{ context }: 構造化を保留し,本文を保存します (#{ error.message })." + description = body + placements = [] + members = [] + end + end + + { revision:, version_no: index + 1, description:, members:, placements:, body: } + end + end + + # 自己包含は禁止されてゐるため,当該版は原文の保存を優先する. + prepared.each do |group_id, versions| + current = versions.last + next unless current.fetch(:members).any? { |member| member['tag_group_id'] == group_id } + + say "Tag Group ##{ group_id }: 直接の自己参照を検出したため,現行構造化を保留します." + current[:description] = current.fetch(:body) + current[:members] = [] + current[:placements] = [] + end + + begin + validate_current_groups!(prepared) + rescue StandardError => error + say "Tag Group の包含関係に問題がありますが,移行を継続します (#{ error.message })." + end + + # 移行先の書込みはすべて検証の後.DDL は含めない. + connection.transaction do + pages.each do |page| + page_id = page.fetch('id').to_i + current = prepared.fetch(page_id).last + + insert_row('tag_groups', { + id: page_id, + # page.version_no ではなく,この Wiki に紐付く Revision の総数. + version_no: revisions_by_page.fetch(page_id).size, + name: page.fetch('name').delete_prefix(PREFIX), + description: current.fetch(:description), + created_by_user_id: page.fetch('created_user_id'), + updated_by_user_id: page.fetch('updated_user_id'), + created_at: page.fetch('created_at'), + updated_at: page.fetch('updated_at') }) + end + + pages.each do |page| + page_id = page.fetch('id').to_i + name = page.fetch('name').delete_prefix(PREFIX) + + prepared.fetch(page_id).each do |item| + revision = item.fetch(:revision) + + insert_row('tag_group_versions', { + id: revision.fetch('id'), + tag_group_id: page_id, + version_no: item.fetch(:version_no), + event_type: item.fetch(:version_no) == 1 ? 'create' : 'update', + name:, + description: item.fetch(:description), + members: JSON.generate(item.fetch(:members)), + placements: JSON.generate(item.fetch(:placements)), + created_by_user_id: revision.fetch('created_user_id'), + created_at: revision.fetch('created_at') }) + end + end + + pages.each do |page| + page_id = page.fetch('id').to_i + current = prepared.fetch(page_id).last + revision = current.fetch(:revision) + + current.fetch(:members).each do |member| + if member.key?('tag_id') + insert_row('tag_group_tags', { + tag_group_id: page_id, + tag_id: member.fetch('tag_id'), + created_by_user_id: revision.fetch('created_user_id'), + created_at: revision.fetch('created_at') }) + else + insert_row('tag_group_groups', { + tag_group_id: page_id, + child_tag_group_id: member.fetch('tag_group_id'), + created_by_user_id: revision.fetch('created_user_id'), + created_at: revision.fetch('created_at') }) + end + end + end + + pages.each do |page| + page_id = page.fetch('id').to_i + current = prepared.fetch(page_id).last + revision = current.fetch(:revision) + position = 0 + + append = -> nodes, parent_position do + nodes.each do |node| + position += 1 + own_position = position + + insert_row('tag_group_placements', { + tag_group_id: page_id, + position: own_position, + parent_position:, + section_name: node['section_name'], + description: node['description'], + tag_id: node['tag_id'], + child_tag_group_id: node['tag_group_id'], + created_by_user_id: revision.fetch('created_user_id'), + updated_by_user_id: revision.fetch('created_user_id'), + created_at: revision.fetch('created_at'), + updated_at: revision.fetch('created_at') }) + + append.(node.fetch('children'), own_position) + end + end + + append.(current.fetch(:placements), nil) + end + end + end + + def down + raise ActiveRecord::IrreversibleMigration, '戻せません.' + end + + private + + def assert_empty_destination! + ['tag_groups', 'tag_group_versions', 'tag_group_tags', + 'tag_group_groups', 'tag_group_placements'].each do |table| + count = connection.select_value("SELECT COUNT(*) FROM #{ table }").to_i + raise "#{ table } が空ではありません." if count.positive? + end + end + + # 解決は tag_names.name -> tag_names.tag_id のみ. + # tags.tag_name_id は読まない. + def load_tag_ids + rows = connection.select_all(<<~SQL).to_a + SELECT + name + , tag_id + FROM + tag_names + WHERE + language_code = 'ja' + AND tag_id IS NOT NULL + SQL + + rows.each_with_object({ }) do |row, by_name| + name = row.fetch('name') + id = row.fetch('tag_id').to_i + by_name[name] ||= [] + by_name[name] << id unless by_name[name].include?(id) + end + end + + def revision_body revision_id + connection.select_rows(<<~SQL).flatten.join("\n") + SELECT + wl.body + FROM + wiki_revision_lines wrl + INNER JOIN wiki_lines wl ON wl.id = wrl.wiki_line_id + WHERE + wrl.wiki_revision_id = #{ Integer(revision_id) } + ORDER BY + wrl.position + SQL + end + + def parse_wiki markdown, tag_ids, group_ids, context + root = Kramdown::Document.new(markdown, input: 'GFM').root + placements = [] + description_parts = [] + headings = [] + + root.children.each_with_index do |node, index| + case node.type + when :blank + next + when :header + level = node.options.fetch(:level) + name = inline_text(node).strip + raise "#{ context }: 名前のない見出しがあります." if name.empty? + + headings.pop while headings.any? && headings.last.first >= level + parent = headings.last&.last + section = { 'section_name' => name, 'description' => nil, 'children' => [] } + (parent ? parent.fetch('children') : placements) << section + headings << [level, section] + when :p + text = inline_markdown(node).strip + next if text.empty? + + if headings.any? + heading = headings.last.last + heading['description'] = [heading['description'], text].compact.join("\n\n") + else + description_parts << text + end + when :ul, :ol + if headings.any? && see_also_heading?(headings.last.last.fetch('section_name')) + heading = headings.last.last + raw = original_block(markdown, root.children, index) + heading['description'] = [heading['description'], raw].compact.join("\n\n") + else + target = headings.any? ? headings.last.last.fetch('children') : placements + target.concat(node.children.map { |item| parse_list_item(item, tag_ids, group_ids, context) }) + end + else + raise "#{ context }: 未対応の Markdown 構文 #{ node.type.inspect }" + end + end + + [description_parts.empty? ? nil : description_parts.join("\n\n"), placements] + end + + def see_also_heading? name + ['see also', '関連項目'].include?(name.strip.downcase.sub(/[::]\z/, '')) + end + + def original_block markdown, siblings, index + current = siblings.fetch(index) + start_line = current.options[:location] + raise 'Markdown block の位置が不明です.' unless start_line + + following = siblings[(index + 1)..].find do |node| + node.options[:location] && node.options[:location] > start_line + end + end_line = following ? following.options.fetch(:location) - 1 : markdown.lines.length + markdown.lines[(start_line - 1)...end_line].join.strip + end + + def parse_list_item item, tag_ids, group_ids, context + raise "#{ context }: リスト項目ではありません." if item.type != :li + + nested, own = item.children.partition { |child| [:ul, :ol].include?(child.type) } + own.reject! { |child| child.type == :blank } + unless own.all? { |child| [:p, :text, :a, :codespan].include?(child.type) } + raise "#{ context }: 未対応のリスト項目があります." + end + + label = own.map { |child| inline_text(child) }.join(' ').strip + raise "#{ context }: 空のリスト項目があります." if label.empty? + + node = + if group_ids.key?(label) + { 'tag_group_id' => group_ids.fetch(label) } + elsif tag_ids.key?(label) + ids = tag_ids.fetch(label) + unless ids.one? + raise "#{ context }: タグ名 #{ label.inspect } が複数の ID を指します:#{ ids.inspect }" + end + { 'tag_id' => ids.first } + elsif label.end_with?(':', ':') + name = label.sub(/[::]\z/, '') + raise "#{ context }: 空の区分名があります." if name.empty? + if see_also_heading?(name) && nested.any? + raise "#{ context }: リスト内の See also は説明文への保管処理が未実装です." + end + { 'section_name' => name, 'description' => nil } + else + raise "#{ context }: 解決不能なリスト項目 #{ label.inspect }" + end + + node['children'] = nested.flat_map do |list| + list.children.map { |child| parse_list_item(child, tag_ids, group_ids, context) } + end + + node + end + + def inline_text node + case node.type + when :text, :codespan + node.value.to_s + when :softbreak, :br + ' ' + when :a, :p, :header, :em, :strong, :span, :li + node.children.map { |child| inline_text(child) }.join + else + raise "未対応のインライン構文 #{ node.type.inspect }" + end + end + + def inline_markdown node + case node.type + when :text + node.value.to_s + when :codespan + "`#{ node.value }`" + when :softbreak + "\n" + when :br + " \n" + when :p + node.children.map { |child| inline_markdown(child) }.join + when :a + href = node.attr.fetch('href') + "[#{ node.children.map { |child| inline_markdown(child) }.join }](#{ href })" + when :em + "*#{ node.children.map { |child| inline_markdown(child) }.join }*" + when :strong + "**#{ node.children.map { |child| inline_markdown(child) }.join }**" + else + raise "未対応のインライン構文 #{ node.type.inspect }" + end + end + + def validate_tree! nodes, context, depth = 1 + raise "#{ context }: 配置の深さが 5 を超えてゐます." if depth > 5 && nodes.any? + + nodes.each do |node| + name = node['section_name'] + if name && name.length > 255 + raise "#{ context }: 区分名が 255 文字を超えてゐます." + end + validate_tree!(node.fetch('children'), context, depth + 1) + end + end + + def build_members nodes, date, tag_history, group_history, context + ids_of_tags = [] + ids_of_groups = [] + + visit = -> items do + items.each do |node| + ids_of_tags << node['tag_id'] if node.key?('tag_id') + ids_of_groups << node['tag_group_id'] if node.key?('tag_group_id') + visit.(node.fetch('children')) + end + end + visit.(nodes) + + tags = ids_of_tags.uniq.map do |id| + { 'tag_id' => id, + 'version_no' => version_at(tag_history[id], date, context, "Tag ##{ id }") } + end + + groups = ids_of_groups.uniq.map do |id| + { 'tag_group_id' => id, + 'version_no' => version_at(group_history[id], date, context, "TagGroup ##{ id }") } + end + + tags + groups + end + + def version_at history, date, context, target + raise "#{ context }: #{ target } の履歴が存在しません." unless history&.any? + + timestamp = Time.parse(date.to_s) + candidates = history.select do |row| + Time.parse(row.fetch('created_at').to_s) <= timestamp + end + if candidates.empty? + raise "#{ context }: #{ target } は #{ date } 時点で存在しません." + end + + candidates.max_by { |row| + [Time.parse(row.fetch('created_at').to_s), row.fetch('version_no').to_i] + }.fetch('version_no').to_i + end + + def validate_current_groups! prepared + edges = { } + prepared.each do |group_id, versions| + edges[group_id] = versions.last.fetch(:members) + .filter_map { |member| member['tag_group_id'] } + end + + visiting = { } + visited = { } + visit = -> group_id do + raise "Tag Group の循環参照を検出しました:#{ group_id }" if visiting[group_id] + return if visited[group_id] + + visiting[group_id] = true + edges.fetch(group_id, []).each { |child_id| visit.(child_id) } + visiting.delete(group_id) + visited[group_id] = true + end + + edges.keys.each { |group_id| visit.(group_id) } + end + + def quote(value) = connection.quote(value) + + def insert_row table, values + columns = values.keys.join(', ') + payload = values.values.map { |value| quote(value) }.join(', ') + execute "INSERT INTO #{ table } (#{ columns }) VALUES (#{ payload })" + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 611cced..dda45de 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_05_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_10_08_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 @@ -483,6 +483,80 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do t.check_constraint "`version_no` > 0", name: "chk_tag_descriptions_version_no_positive" end + create_table "tag_group_groups", primary_key: ["tag_group_id", "child_tag_group_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_group_id", null: false + t.bigint "child_tag_group_id", null: false + t.bigint "created_by_user_id" + t.datetime "created_at", null: false + t.index ["child_tag_group_id"], name: "index_tag_group_groups_on_child_tag_group_id" + t.index ["created_by_user_id"], name: "index_tag_group_groups_on_created_by_user_id" + t.check_constraint "`tag_group_id` <> `child_tag_group_id`", name: "chk_tag_group_groups_tag_group_id_not_child_tag_group_id" + end + + create_table "tag_group_placements", primary_key: ["tag_group_id", "position"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_group_id", null: false + t.integer "position", null: false + t.integer "parent_position" + t.string "section_name" + t.text "description" + t.bigint "tag_id" + t.bigint "child_tag_group_id" + 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 ["child_tag_group_id"], name: "index_tag_group_placements_on_child_tag_group_id" + t.index ["created_by_user_id"], name: "index_tag_group_placements_on_created_by_user_id" + t.index ["tag_group_id", "child_tag_group_id"], name: "fk_rails_7342b54ced" + t.index ["tag_group_id", "parent_position"], name: "fk_rails_3af6ee71ca" + t.index ["tag_group_id", "tag_id"], name: "fk_rails_365dc60759" + t.index ["tag_id"], name: "index_tag_group_placements_on_tag_id" + t.index ["updated_by_user_id"], name: "index_tag_group_placements_on_updated_by_user_id" + t.check_constraint "(((`section_name` is not null) and (`tag_id` is null) and (`child_tag_group_id` is null)) or ((`section_name` is null) and (`tag_id` is not null) and (`child_tag_group_id` is null)) or ((`section_name` is null) and (`tag_id` is null) and (`child_tag_group_id` is not null))) and ((`section_name` is not null) or (`description` is null))", name: "chk_tag_group_placements_references_exclusive" + t.check_constraint "`position` > `parent_position`", name: "chk_tag_group_placements_position_gt_parent_position" + end + + create_table "tag_group_tags", primary_key: ["tag_group_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_group_id", null: false + t.bigint "tag_id", null: false + t.bigint "created_by_user_id" + t.datetime "created_at", null: false + t.index ["created_by_user_id"], name: "index_tag_group_tags_on_created_by_user_id" + t.index ["tag_id"], name: "index_tag_group_tags_on_tag_id" + end + + create_table "tag_group_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "tag_group_id", null: false + t.integer "version_no", null: false + t.string "event_type", null: false + t.string "name", null: false + t.text "description" + t.json "members", null: false + t.json "placements", null: false + t.bigint "created_by_user_id" + t.datetime "created_at", null: false + t.index ["created_by_user_id"], name: "index_tag_group_versions_on_created_by_user_id" + t.index ["tag_group_id", "version_no"], name: "index_tag_group_versions_on_tag_group_id_and_version_no", unique: true + t.index ["tag_group_id"], name: "index_tag_group_versions_on_tag_group_id" + t.check_constraint "`version_no` > 0", name: "chk_tag_group_versions_version_no_positive" + t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"maxItems\":0}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}',`placements`)", name: "chk_tag_group_versions_placements_schema" + t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"uniqueItems\":true,\"items\":{\"oneOf\":[{\"type\":\"object\",\"required\":[\"tag_id\",\"version_no\"],\"properties\":{\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1}},\"additionalProperties\":false},{\"type\":\"object\",\"required\":[\"tag_group_id\",\"version_no\"],\"properties\":{\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1}},\"additionalProperties\":false}]}}',`members`)", name: "chk_tag_group_versions_members_schema" + end + + create_table "tag_groups", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.integer "version_no", null: false + t.string "name", null: false + t.text "description" + 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_groups_on_created_by_user_id" + t.index ["name"], name: "index_tag_groups_on_name", unique: true + t.index ["updated_by_user_id"], name: "index_tag_groups_on_updated_by_user_id" + t.check_constraint "`version_no` > 0", name: "chk_tag_groups_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 @@ -823,6 +897,20 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_05_000000) do 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_group_groups", "tag_groups" + add_foreign_key "tag_group_groups", "tag_groups", column: "child_tag_group_id" + add_foreign_key "tag_group_groups", "users", column: "created_by_user_id" + add_foreign_key "tag_group_placements", "tag_group_groups", column: ["tag_group_id", "child_tag_group_id"], primary_key: ["tag_group_id", "child_tag_group_id"] + add_foreign_key "tag_group_placements", "tag_group_placements", column: ["tag_group_id", "parent_position"], primary_key: ["tag_group_id", "position"] + add_foreign_key "tag_group_placements", "tag_group_tags", column: ["tag_group_id", "tag_id"], primary_key: ["tag_group_id", "tag_id"] + add_foreign_key "tag_group_placements", "tag_groups" + add_foreign_key "tag_group_placements", "users", column: "created_by_user_id" + add_foreign_key "tag_group_placements", "users", column: "updated_by_user_id" + add_foreign_key "tag_group_tags", "tag_groups" + add_foreign_key "tag_group_tags", "tags" + add_foreign_key "tag_group_tags", "users", column: "created_by_user_id" + add_foreign_key "tag_groups", "users", column: "created_by_user_id" + add_foreign_key "tag_groups", "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" diff --git a/backend/spec/db/tag_group_constraints_spec.rb b/backend/spec/db/tag_group_constraints_spec.rb new file mode 100644 index 0000000..bdbe417 --- /dev/null +++ b/backend/spec/db/tag_group_constraints_spec.rb @@ -0,0 +1,110 @@ +require 'rails_helper' + +RSpec.describe 'Tag group persistence constraints' do + include TagGroupRecords + + let!(:user) { create_member_user! } + let!(:tag) { group_tag('制約確認') } + let!(:group_id) do + seed_group( + name: '制約確認', + members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }]) + end + + def placement attributes + insert_group_row('tag_group_placements', { + tag_group_id: group_id, position: 1, + created_at: Time.current, updated_at: Time.current }.merge(attributes)) + end + + def reject_statement + before_state = group_state + expect { yield }.to raise_error(ActiveRecord::StatementInvalid) + expect(group_state).to eq(before_state) + end + + it 'allows membership without placement' do + expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1) + expect(group_rows('tag_group_placements', tag_group_id: group_id)).to be_empty + end + + it 'allows repeated Tag placements with unique membership' do + placement(tag_id: tag.id) + placement(tag_id: tag.id, position: 2) + expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1) + expect(group_rows('tag_group_placements', tag_group_id: group_id).size).to eq(2) + end + + it 'rejects duplicate Tag membership' do + reject_statement do + insert_group_row('tag_group_tags', { + tag_group_id: group_id, tag_id: tag.id, created_at: Time.current }) + end + end + + it 'rejects duplicate child membership' do + child_id = seed_group(name: '子') + attributes = { tag_group_id: group_id, child_tag_group_id: child_id, + created_at: Time.current } + insert_group_row('tag_group_groups', attributes) + reject_statement { insert_group_row('tag_group_groups', attributes) } + end + + it 'rejects direct self membership' do + reject_statement do + insert_group_row('tag_group_groups', { + tag_group_id: group_id, child_tag_group_id: group_id, created_at: Time.current }) + end + end + + it 'rejects a placement without a kind' do + reject_statement { placement({ }) } + end + + it 'rejects simultaneous Section and Tag kinds' do + reject_statement { placement(section_name: '区分', tag_id: tag.id) } + end + + it 'rejects a description attached to a Tag placement' do + reject_statement { placement(tag_id: tag.id, description: '不正') } + end + + it 'allows an optional description on a Section' do + placement(section_name: '区分', description: '説明') + expect(group_rows('tag_group_placements', tag_group_id: group_id).sole) + .to include('section_name' => '区分', 'description' => '説明') + end + + it 'rejects a Tag placement absent from membership' do + other = group_tag('非メンバー') + reject_statement { placement(tag_id: other.id) } + end + + it 'rejects a missing parent placement' do + reject_statement { placement(section_name: '子', parent_position: 9) } + end + + it 'rejects a child ordered before its parent' do + placement(section_name: '親', position: 2) + reject_statement { placement(section_name: '子', position: 1, parent_position: 2) } + end + + it 'rejects duplicate group names' do + reject_statement { seed_group(name: '制約確認') } + end + + it 'rejects a missing mandatory group name' do + reject_statement { seed_group(name: nil) } + end + + it 'rejects a six-level snapshot' do + nodes = [] + 6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] } + reject_statement do + insert_group_row('tag_group_versions', { + tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認', + members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json, + placements: nodes.to_json, created_at: Time.current }) + end + end +end diff --git a/backend/spec/db/tag_group_migration_spec.rb b/backend/spec/db/tag_group_migration_spec.rb new file mode 100644 index 0000000..c9e7566 --- /dev/null +++ b/backend/spec/db/tag_group_migration_spec.rb @@ -0,0 +1,119 @@ +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/requests/tag_group_markdown_spec.rb b/backend/spec/requests/tag_group_markdown_spec.rb new file mode 100644 index 0000000..68cc37b --- /dev/null +++ b/backend/spec/requests/tag_group_markdown_spec.rb @@ -0,0 +1,139 @@ +require 'rails_helper' + +RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :request do + include TagGroupRecords + + let!(:user) { create_member_user! } + let!(:series) { group_tag('ぼざろクリーチャーシリーズ') } + let!(:deer) { group_tag('洗澡鹿') } + let!(:nizika) { group_tag('伊地知ニジカ') } + let!(:wanted) { group_tag('タグ希望') } + let!(:character) { group_tag('キャラクター名希望') } + let!(:music) { group_tag('楽曲情報希望') } + let!(:bot) { group_tag('bot操作') } + let!(:unknown) { group_tag('ニジラー情報不詳') } + let!(:child_id) { seed_group(name: '伊地知ニジカ亜種', description: '子の説明') } + + before { I18n.locale = :ja } + + def tag_node tag, children = [] + { 'tag_id' => tag.id, 'children' => children } + end + + def tag_member tag + { 'tag_id' => tag.id, 'version_no' => tag.version_no } + end + + def check_round_trip placements:, members:, markdown:, description: nil + id = seed_group(name: '変換元', placements:, members:, description:) + without_group_legacy_reads { get group_title_path('変換元') } + expect(response).to have_http_status(:ok) + expect(json).to include('id' => id) + body = json.fetch('body') + expect(body).not_to include("\r") + expect(body.lines.map(&:chomp)).to all(satisfy { |line| line == line.rstrip }) + # A trailing LF or a blank line between blocks is semantically irrelevant. + expect(body.lines.map(&:chomp).reject(&:empty?)).to eq( + markdown.lines.map(&:chomp).reject(&:empty?)) + + post '/wiki', params: { title: 'タググループ:再解析', body: }, + headers: { 'X-Transfer-Code' => user.inheritance_code } + expect(response).to have_http_status(:created) + parsed = group_rows('tag_groups', name: '再解析').sole + expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:) + end + + it 'renders a list Section under a Tag with two-space indentation' do + check_round_trip( + members: [series, deer].map { tag_member(_1) }, + placements: [tag_node(series, [ + { 'section_name' => '伊地知ニジカ亜種', 'description' => nil, + 'children' => [tag_node(deer)] }])], + markdown: <<~MARKDOWN) + - ぼざろクリーチャーシリーズ + - 伊地知ニジカ亜種: + - 洗澡鹿 + MARKDOWN + end + + it 'renders a child Tag Group distinctly from its parent Tags' do + check_round_trip( + members: [tag_member(series), tag_member(nizika), + { 'tag_group_id' => child_id, 'version_no' => 1 }], + placements: [tag_node(series, [tag_node(nizika, [ + { 'tag_group_id' => child_id, 'children' => [] }])])], + markdown: <<~MARKDOWN) + - ぼざろクリーチャーシリーズ + - 伊地知ニジカ + - タググループ:伊地知ニジカ亜種 + MARKDOWN + end + + it 'renders top-level heading Sections, descriptions and ordered nested Tags' do + check_round_trip( + description: '全体の説明', + members: [wanted, character, music, bot, unknown].map { tag_member(_1) }, + placements: [ + { 'section_name' => '要求', 'description' => '要求の説明', + 'children' => [tag_node(wanted, [tag_node(character), tag_node(music)])] }, + { 'section_name' => '情報', 'description' => nil, + 'children' => [tag_node(bot), tag_node(unknown)] }], + markdown: <<~MARKDOWN) + 全体の説明 + + ## 要求 + 要求の説明 + + - タグ希望 + - キャラクター名希望 + - 楽曲情報希望 + + ## 情報 + - bot操作 + - ニジラー情報不詳 + MARKDOWN + end + + it 'renders nested heading Sections and all repeated Tag placements' do + check_round_trip( + members: [tag_member(deer)], + placements: [ + { 'section_name' => '親', 'description' => '親の説明', 'children' => [ + tag_node(deer), + { 'section_name' => '子', 'description' => '子の説明', + 'children' => [tag_node(deer)] }] }], + markdown: <<~MARKDOWN) + ## 親 + 親の説明 + + - 洗澡鹿 + + ### 子 + 子の説明 + + - 洗澡鹿 + MARKDOWN + end + + it 'rejects mixed top-level heading Sections and Tags without losing structure' do + id = seed_group( + name: '混在構造', members: [tag_member(series)], + placements: [ + { 'section_name' => '区分', 'description' => nil, 'children' => [] }, + tag_node(series)]) + + get group_title_path('混在構造') + + expect(response).to have_http_status(:unprocessable_entity) + expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id) + end + + it 'rejects membership without a placement instead of omitting it' do + id = seed_group(name: '未配置構造', members: [tag_member(series)]) + + get group_title_path('未配置構造') + + expect(response).to have_http_status(:unprocessable_entity) + expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id) + end +end diff --git a/backend/spec/requests/tag_group_wiki_spec.rb b/backend/spec/requests/tag_group_wiki_spec.rb new file mode 100644 index 0000000..2104beb --- /dev/null +++ b/backend/spec/requests/tag_group_wiki_spec.rb @@ -0,0 +1,304 @@ +require 'rails_helper' + +RSpec.describe 'Tag group Wiki facade reads', type: :request do + include TagGroupRecords + + 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!(:last_revision) do + Wiki::Commit.legacy_content!(page:, body: '- 新タグ', created_user: user) + 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 + + [:id, :title].each do |lookup| + context "with #{ lookup } lookup" do + let(:path) { lookup == :id ? "/wiki/#{ page.id }" : group_title_path('読取り契約') } + + it 'reads current structure without consulting migrated Wiki tables' do + without_group_legacy_reads { get path } + expect(response).to have_http_status(:ok) + expect(json).to include( + 'id' => page.id, 'title' => 'タググループ:読取り契約', + 'revision_id' => last_revision.id, 'pred' => first_revision.id, 'succ' => nil) + expect(json.fetch('body').strip).to eq('- 新タグ') + end + + it 'reads the old snapshot rather than current membership or placement' do + without_group_legacy_reads { get path, params: { version: first_revision.id } } + expect(response).to have_http_status(:ok) + expect(json.fetch('body').strip).to eq('- 旧タグ') + expect(json).to include( + 'revision_id' => first_revision.id, 'pred' => nil, 'succ' => last_revision.id, + 'updated_at' => first_revision.created_at.iso8601(3)) + end + + it 'checks existence without consulting migrated Wiki tables' do + without_group_legacy_reads { get "#{ path }/exists" } + expect(response).to have_http_status(:no_content) + end + + it 'does not fall back to legacy history when a group version is missing' do + group_connection.execute( + "DELETE FROM tag_group_versions WHERE id = #{ first_revision.id }") + without_group_legacy_reads { get path, params: { version: first_revision.id } } + expect(response).to have_http_status(:not_found) + end + end + end + + ['/wiki', '/wiki/search'].each do |path| + it "lists migrated groups once with current titles at #{ path }" do + get path, params: { title: 'タググループ:読取り' } + expect(response).to have_http_status(:ok) + rows = json.select { _1.fetch('title') == 'タググループ:読取り契約' } + expect(rows.size).to eq(1) + expect(rows.sole).to include('id' => page.id) + expect(response.body).not_to include('legacy stale') + end + end + + it 'combines Tag Groups, Help and Tag Descriptions in the unfiltered list' do + help = Wiki::Commit.create_legacy_content!( + 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( + 'タググループ:読取り契約', help.title, '新タグ') + end + + it 'keeps Help readable when its ID collides with a new group' do + help = Wiki::Commit.create_legacy_content!( + 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(help.title, 'タググループ:別 ID重複') + + get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }" + expect(response).to have_http_status(:ok) + expect(json.fetch('body')).to eq('help body') + + get '/wiki/changes' + expect(response).to have_http_status(:ok) + expect(json.map { _1.dig('wiki_page', 'title') }).to include( + help.title, 'タググループ:別 ID重複') + end + + it 'finds groups by a partial prefix of their displayed title' do + get '/wiki', params: { title: 'タググル' } + + expect(response).to have_http_status(:ok) + expect(json.map { _1.fetch('title') }).to include('タググループ:読取り契約') + expect(json.find { _1.fetch('title') == 'タググループ:読取り契約' }) + .to include('updated_at' => be_present) + 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: '正本') + + get group_title_path('旧名称') + expect(response).to have_http_status(:not_found) + get "#{ group_title_path('旧名称') }/exists" + expect(response).to have_http_status(:not_found) + get group_title_path('新名称') + expect(response).to have_http_status(:ok) + expect(json.fetch('body').strip).to eq('正本') + end + + it 'orders mixed resource history by timestamp rather than independent IDs' do + help = Wiki::Commit.create_legacy_content!( + 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' + + expect(response).to have_http_status(:ok) + 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.index('新タグ')).to be < titles.index(help.title) + expect(titles.index(help.title)).to be < titles.index('タググループ:読取り契約') + end + + it 'renders nullable group editors without raising' do + group_connection.execute( + "UPDATE tag_groups SET created_by_user_id = NULL, updated_by_user_id = NULL " + + "WHERE id = #{ page.id }") + group_connection.execute( + "UPDATE tag_group_versions SET created_by_user_id = NULL " + + "WHERE tag_group_id = #{ page.id }") + + get "/tag_groups/#{ page.id }" + expect(response).to have_http_status(:ok) + expect(json).to include('created_by_user' => nil, 'updated_by_user' => nil) + get "/wiki/title/#{ CGI.escapeURIComponent('タググループ:読取り契約') }" + expect(response).to have_http_status(:ok) + end + + it 'reads group version IDs, editor and timestamps in scoped changes' do + without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } } + expect(response).to have_http_status(:ok) + expect(json.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id]) + expect(json.first).to include( + 'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content', + 'timestamp' => last_revision.created_at.iso8601(3), + 'user' => { 'id' => user.id, 'name' => user.name }, + 'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約')) + end + + it 'includes Help and group history but excludes legacy-only migrated revisions' do + extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user) + help = Wiki::Commit.create_legacy_content!( + 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).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 + 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(:ok) + expect(json).to include( + 'wiki_page_id' => page.id, 'older_revision_id' => first_revision.id, + 'newer_revision_id' => last_revision.id) + differences = json.fetch('diff').map { [_1.fetch('type'), _1.fetch('content').strip] } + expect(differences).to include(['removed', '- 旧タグ'], ['added', '- 新タグ']) + end + end + + it 'does not use a version belonging to another group' do + other_id = seed_group(name: '別グループ', description: '別本文') + other_version = group_version(other_id, 1) + get group_title_path('読取り契約'), params: { version: other_version.fetch('id') } + expect(response).to have_http_status(:not_found) + get "/wiki/#{ page.id }/diff", + params: { from: other_version.fetch('id'), to: last_revision.id } + expect(response).to have_http_status(:not_found) + end + + it 'resolves a group without any compatibility Wiki page' do + id = seed_group(name: '独立グループ', description: '独立した説明') + without_group_legacy_reads { get group_title_path('独立グループ') } + expect(response).to have_http_status(:ok) + expect(json).to include('id' => id, 'title' => 'タググループ:独立グループ') + expect(json.fetch('body').strip).to eq('独立した説明') + end + + context 'when Help, description and group share the same numeric ID' do + let(:colliding_help) do + Wiki::Commit.create_legacy_content!( + 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 all three resources without deduplicating their numeric ID' 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_help.id, colliding_help.title], + [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_help.current_revision.id, colliding_help.title], + [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 colliding group, Help and description IDs by title' do + help = Wiki::Commit.create_legacy_content!( + 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', 'Help:ID衝突' => 'help', '新タグ' => '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 + end +end diff --git a/backend/spec/requests/tag_group_wiki_write_spec.rb b/backend/spec/requests/tag_group_wiki_write_spec.rb new file mode 100644 index 0000000..4c80a63 --- /dev/null +++ b/backend/spec/requests/tag_group_wiki_write_spec.rb @@ -0,0 +1,304 @@ +require 'rails_helper' + +RSpec.describe 'Tag group Wiki facade writes', type: :request do + include TagGroupRecords + include ActiveSupport::Testing::TimeHelpers + + let!(:user) { create_member_user! } + let!(:tag) { group_tag('洗澡鹿') } + let!(:other_tag) { group_tag('伊地知ニジカ') } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] } + let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] } + + before { I18n.locale = :ja } + + def create_group title: 'タググループ:新規', body: '- 洗澡鹿', request_headers: headers + post '/wiki', params: { title:, body: }, headers: request_headers + end + + def expect_rejected_without_writes + before_state = group_state + yield + aggregate_failures do + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + expect(response.media_type).to eq('application/json') + expect(response.body).not_to be_empty + next if response.body.empty? + + expect(json).to include( + 'type' => 'validation_error', 'errors' => a_kind_of(Hash), + 'base_errors' => a_kind_of(Array)) + end + end + + it 'creates the group, first snapshot and compatibility Wiki history' do + create_group + expect(response).to have_http_status(:created) + group = group_rows('tag_groups', name: '新規').sole + expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規') + expect_group_snapshot(group.fetch('id'), number: 1, members:, placements:) + 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.strip).to eq('- 洗澡鹿') + expect(page.current_revision.body).to eq(page.body) + expect(page.wiki_versions.sole.body).to eq(page.body) + # New IDs are independently allocated; no global identity assertion here. + end + + it 'keeps repeated placements but only one membership and one snapshot member' do + create_group(body: "- 洗澡鹿\n# 再掲\n- 洗澡鹿") + expect(response).to have_http_status(:created) + id = group_rows('tag_groups', name: '新規').sole.fetch('id') + expect_group_snapshot( + id, number: 1, members:, + placements: placements + [ + { 'section_name' => '再掲', 'description' => nil, 'children' => placements }]) + end + + it 'resolves a Japanese alias through tag_names.tag_id' do + create(:tag_name, :alias, tag:, name: '鹿の別名') + create_group(body: '- 鹿の別名') + expect(response).to have_http_status(:created) + id = group_rows('tag_groups', name: '新規').sole.fetch('id') + expect_group_snapshot(id, number: 1, members:, placements:) + get group_title_path('新規') + expect(response).to have_http_status(:ok) + expect(json.fetch('body').strip).to eq('- 洗澡鹿') + end + + { + 'unknown Tag' => '- 解決不能', + 'unknown Tag Group' => '- タググループ:存在しない', + 'empty Section' => '- :', + 'unsupported placement syntax' => "```\n洗澡鹿\n```", + 'six levels' => "- 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n" + + " - 洗澡鹿\n - 洗澡鹿", + 'blank body' => '', + 'normalised blank body' => "\r\n\r\n" }.each do |label, body| + it "rejects #{ label } without fallback or partial create" do + expect_rejected_without_writes { create_group(body:) } + end + end + + ['', 'タググループ:', "タググループ:#{ '長' * 256 }"].each do |title| + it "rejects an invalid required title of length #{ title.length }" do + expect_rejected_without_writes { create_group(title:) } + end + end + + it 'rejects a duplicate group name even without a matching legacy Wiki' do + seed_group(name: '新規', description: '既存') + expect_rejected_without_writes { create_group } + end + + it 'accepts the existing five-level placement depth boundary' do + body = (0...5).map { "#{ ' ' * _1 }- 洗澡鹿" }.join("\n") + create_group(body:) + expect(response).to have_http_status(:created) + id = group_rows('tag_groups', name: '新規').sole.fetch('id') + tree = [] + 5.times { tree = [{ 'tag_id' => tag.id, 'children' => tree }] } + expect_group_snapshot(id, number: 1, members:, placements: tree) + end + + context 'updating a migrated group' do + let!(:page) { legacy_group('更新', '- 洗澡鹿') } + let!(:old_revision) { page.current_revision } + + before { migrate_groups } + + def update_group body: '- 伊地知ニジカ', title: 'タググループ:更新', request_headers: headers + put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers + end + + [1, 3].each do |repetitions| + it "keeps identical Markdown unchanged after #{ repetitions } Wiki PUTs" do + body = page.body + travel_to(1.hour.from_now) do + expect_noop_group_update(group_id: page.id, page:) do + repetitions.times do + update_group(body:) + expect(response).to have_http_status(:ok) + end + end + end + end + end + + 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) + + travel_to(1.hour.from_now) do + expect_noop_group_update(group_id: page.id, page:) do + put "/wiki/#{ page.id }", + params: { title: page.title, body:, base_revision_id: old_revision.id }, + headers: headers + end + end + end + + it 'keeps the original editor on identical Markdown from another member' do + editor = create(:user, :member) + body = page.body + travel_to(1.hour.from_now) do + expect_noop_group_update(group_id: page.id, page:) do + update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code }) + end + end + end + + it 'does not add history for Wiki content already changed through Native' do + changed_members = [{ 'tag_id' => other_tag.id }] + changed_placements = [{ 'tag_id' => other_tag.id, 'children' => [] }] + put "/tag_groups/#{ page.id }", + params: { name: '更新', description: nil, + members: changed_members, placements: changed_placements }, + headers:, as: :json + expect(response).to have_http_status(:ok) + expect_group_snapshot( + page.id, number: 2, + members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }], + placements: changed_placements) + body = page.reload.body + expect(body).to eq('- 伊地知ニジカ') + + travel_to(1.hour.from_now) do + expect_noop_group_update(group_id: page.id, page:) { update_group(body:) } + end + end + + it 'replaces current membership and placement, retaining the old snapshot' do + old = group_version(page.id, 1) + update_group + expect(response).to have_http_status(:ok) + expect_group_snapshot( + page.id, number: 2, + members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }], + placements: [{ 'tag_id' => other_tag.id, 'children' => [] }]) + 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) + end + + it 'accepts the current base_revision_id used by the existing frontend' do + put "/wiki/#{ page.id }", + params: { title: page.title, 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) + end + + it 'ignores a stale base_revision_id without rejecting the update' do + update_group + expect(response).to have_http_status(:ok) + + put "/wiki/#{ page.id }", + params: { title: page.title, 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('- 洗澡鹿') + end + + it 'records the editor and update time' do + editor = create(:user, :member, name: '編輯者') + travel_to(Time.current + 1.hour) do + update_group(request_headers: { 'X-Transfer-Code' => editor.inheritance_code }) + expect(response).to have_http_status(:ok) + current = group_rows('tag_groups', id: page.id).sole + version = group_version(page.id, 2) + expect(current).to include('updated_by_user_id' => editor.id) + expect(version).to include('created_by_user_id' => editor.id) + expect(current.fetch('updated_at')).to be_within(1.second).of(Time.current) + expect(version.fetch('created_at')).to be_within(1.second).of(Time.current) + end + end + + ['- 不明なタグ', '- タググループ:不明', '', '- :'].each do |body| + it "rejects invalid update #{ body.inspect } atomically" do + expect_rejected_without_writes { update_group(body:) } + end + end + + it 'rejects direct self inclusion' do + expect_rejected_without_writes { update_group(body: '- タググループ:更新') } + end + + it 'rejects indirect cyclic membership' do + seed_group( + name: '子', members: [{ 'tag_group_id' => page.id, 'version_no' => 1 }], + placements: [{ 'tag_group_id' => page.id, 'children' => [] }]) + expect_rejected_without_writes { update_group(body: '- タググループ:子') } + end + + it 'rejects duplicate rename without retaining a partial title or version' do + seed_group(name: '既存', description: '既存') + expect_rejected_without_writes { update_group(title: 'タググループ:既存') } + end + end + + [:create, :update].each do |operation| + context "#{ operation } access and atomicity" do + let!(:page) do + next unless operation == :update + + legacy = legacy_group('更新', '- 洗澡鹿') + seed_group(name: '更新', id: legacy.id, members:, placements:) + legacy + end + let!(:child_id) { seed_group(name: '子グループ', description: '子') } + + def write_group operation, request_headers + body = "- 伊地知ニジカ\n- タググループ:子グループ" + if operation == :create + create_group(body:, request_headers:) + else + put "/wiki/#{ page.id }", + params: { title: 'タググループ:更新', body: }, headers: request_headers + end + end + + [nil, :guest, :banned].each do |role| + it "rejects #{ role || 'anonymous' } without writes" do + actor = case role + when :guest then create(:user, :guest) + when :banned then create(:user, :member, :banned) + end + request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { } + before_state = group_state + write_group(operation, request_headers) + expect(response).to have_http_status(role ? :forbidden : :unauthorized) + expect(group_state).to eq(before_state) + end + end + + it 'rejects a banned IP even for a member' do + IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current) + before_state = group_state + write_group(operation, headers) + expect(response).to have_http_status(:forbidden) + expect(group_state).to eq(before_state) + end + + (TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions']) + .each do |table| + it "rolls back all writes after a real #{ table } write fails" do + expect_atomic_group_write(table) { write_group(operation, headers) } + end + end + end + end +end diff --git a/backend/spec/requests/tag_group_write_integrity_spec.rb b/backend/spec/requests/tag_group_write_integrity_spec.rb new file mode 100644 index 0000000..2d6ce0e --- /dev/null +++ b/backend/spec/requests/tag_group_write_integrity_spec.rb @@ -0,0 +1,329 @@ +require 'rails_helper' + +RSpec.describe 'Tag group write integrity', type: :request do + include TagGroupRecords + include ActiveSupport::Testing::TimeHelpers + + let!(:user) { create_member_user! } + let!(:tag) { group_tag('洗澡鹿') } + let!(:other_tag) { group_tag('伊地知ニジカ') } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] } + let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] } + + before { I18n.locale = :ja } + + def native_payload name: '更新', nodes: placements, member_rows: members + { name:, description: nil, members: member_rows.map do |member| + member.except('version_no') + end, placements: nodes } + end + + def put_wiki id, title:, body: '- 伊地知ニジカ', base: nil + put "/wiki/#{ id }", params: { title:, body:, base_revision_id: base }, headers: headers + end + + def create_help body + Wiki::Commit.create_legacy_content!( + tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user) + end + + def expect_no_writes + before_state = group_state + yield + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + 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:) + + 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('- 伊地知ニジカ') + end + + it 'updates Help with a colliding group ID without altering the group' 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) + + put_wiki(id, title: 'Help:改名', body: 'Help after') + + expect(response).to have_http_status(:ok) + expect(page.reload.body).to eq('Help after') + 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!( + tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user) + id = seed_group(name: '更新', id: page.id, members:, placements:) + group_before = TagGroup.find(id).attributes + + put_wiki(id, title: '洗澡鹿', body: 'description after') + + expect(response).to have_http_status(:ok) + expect(TagDescription.find(id).body).to eq('description after') + expect(page.reload.body).to eq('description after') + 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') + + put_wiki(id, title: 'Help:既存', body: 'Help after') + + expect(response).to have_http_status(:ok) + expect(page.reload.body).to eq('Help after') + expect(description.reload.body).to eq('description') + expect(TagGroup.find(id).version_no).to eq(1) + expect_no_writes { put_wiki(id, title: '洗澡鹿', body: '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 after') + expect(description.reload.body).to eq('description') + 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 + + put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json + + expect(response).to have_http_status(:ok) + page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:改名' }) + expect(page.id).not_to eq(help.id) + expect(page.body).to eq('- 洗澡鹿') + expect(page.current_revision.body).to eq(page.body) + expect(page.wiki_versions.sole.body).to eq(page.body) + 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 + end + + context 'identifier Markdown' do + ['[洗澡鹿](https://example.com)', '*洗澡鹿*', '**洗澡鹿**', '`洗澡鹿`', + '![洗澡鹿](https://example.com/image.png)', + '[タググループ:子](https://example.com)', '*区分*:', + '[区分](https://example.com):'].each do |label| + [:create, :update].each do |operation| + it "rejects decorated #{ label } on #{ operation } without writes" do + seed_group(name: '子', description: '子') + id = seed_group(name: '更新', members:, placements:) + expect_no_writes do + if operation == :create + post '/wiki', params: { title: 'タググループ:新規', body: "- #{ label }" }, + headers: headers + else + put_wiki(id, title: 'タググループ:更新', body: "- #{ label }") + end + end + end + end + end + + ['# [区分](https://example.com)', '# *区分*', '# `区分`', '# 区分 {#custom}', + '# ![区分](https://example.com/image.png)'].each do |heading| + it "rejects a decorated heading #{ heading } without writes" do + expect_no_writes do + post '/wiki', params: { title: 'タググループ:新規', body: "#{ heading }\n- 洗澡鹿" }, + headers: headers + end + end + end + + it 'preserves allowed markup in group and heading descriptions' do + description = '[参照](https://example.com "リンクの説明") と *強調* と ``a`b``' + body = "#{ description }\n\n# 区分\n#{ description }\n\n- 洗澡鹿" + post '/wiki', params: { title: 'タググループ:説明構文', body: }, headers: headers + + expect(response).to have_http_status(:created) + id = json.fetch('id') + version = group_version(id, 1) + expect(version.fetch('description')).to eq(description) + expect(version.fetch('placements').sole.fetch('description')).to eq(description) + end + end + + context 'structure audit information' do + let!(:child_id) { seed_group(name: '子', description: '子') } + let(:members) do + [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }, + { 'tag_group_id' => child_id, 'version_no' => 1 }] + end + let(:placements) do + [{ 'tag_id' => tag.id, 'children' => [ + { 'tag_group_id' => child_id, 'children' => [] }] }] + end + let!(:id) { seed_group(name: '更新', members:, placements:) } + let!(:editor) { create(:user, :member) } + let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } } + + it 'keeps all membership and placement audit fields on a name-only edit' do + before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements') + travel_to(1.hour.from_now) do + put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json + end + + expect(response).to have_http_status(:ok) + expect(group_state.slice(*before_rows.keys)).to eq(before_rows) + expect(group_version(id, 2)).to include('name' => '改名', 'members' => members) + end + + it 'keeps membership and placement audit fields on a description-only edit' do + before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements') + travel_to(1.hour.from_now) do + put "/tag_groups/#{ id }", + params: native_payload.merge(description: '説明を追加'), headers:, as: :json + end + + expect(response).to have_http_status(:ok) + expect(group_state.slice(*before_rows.keys)).to eq(before_rows) + 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 + new_members = [{ 'tag_id' => tag.id }] + new_nodes = [{ 'tag_id' => tag.id, 'children' => [] }] + expect_atomic_group_write('wiki_versions') do + put "/tag_groups/#{ id }", + params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json + end + end + + it 'changes only added or removed membership and keeps placement creation audit' do + tag_row = TagGroupTag.find([id, tag.id]).attributes + placement_row = TagGroupPlacement.find([id, 1]).attributes + new_members = [{ 'tag_id' => tag.id }, { 'tag_id' => other_tag.id }] + new_nodes = [{ 'tag_id' => tag.id, 'children' => [ + { 'tag_id' => other_tag.id, 'children' => [] }] }] + child_row = TagGroupPlacement.find([id, 2]).attributes + travel_to(1.hour.from_now) do + put "/tag_groups/#{ id }", + params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json + end + + expect(response).to have_http_status(:ok) + expect(TagGroupTag.find([id, tag.id]).attributes).to eq(tag_row) + expect(TagGroupGroup.exists?(tag_group_id: id, child_tag_group_id: child_id)).to be(false) + expect(TagGroupTag.find([id, other_tag.id]).created_by_user_id).to eq(editor.id) + expect(TagGroupPlacement.find([id, 1]).attributes).to eq(placement_row) + changed = TagGroupPlacement.find([id, 2]) + expect(changed).to have_attributes( + tag_id: other_tag.id, child_tag_group_id: nil, updated_by_user_id: editor.id, + created_at: child_row.fetch('created_at'), + created_by_user_id: child_row.fetch('created_by_user_id')) + expect(changed.updated_at).to be > child_row.fetch('updated_at') + expect(group_version(id, 1).fetch('members')).to eq(members) + end + end +end diff --git a/backend/spec/requests/tag_groups_spec.rb b/backend/spec/requests/tag_groups_spec.rb new file mode 100644 index 0000000..33ba293 --- /dev/null +++ b/backend/spec/requests/tag_groups_spec.rb @@ -0,0 +1,457 @@ +require 'rails_helper' + +RSpec.describe 'Tag group native API', type: :request do + include TagGroupRecords + include ActiveSupport::Testing::TimeHelpers + + let!(:user) { create_member_user! } + let!(:group_id) { seed_group(name: '専用API', description: '説明') } + + before { I18n.locale = :ja } + + def expect_native_group value + expect(value).to include( + 'id' => group_id, 'name' => '専用API', 'description' => '説明', + 'version_no' => 1, 'members' => [], 'placements' => [], + 'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user)) + current = group_rows('tag_groups', id: group_id).sole + expect(value).to include( + 'created_at' => current.fetch('created_at').iso8601(3), + 'updated_at' => current.fetch('updated_at').iso8601(3)) + end + + it 'serves the collection as JSON without reading Wiki tables' do + without_group_legacy_reads { get '/tag_groups' } + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('application/json') + expect(json).to be_a(Array) + expect_native_group(json.find { _1.fetch('id') == group_id }) + end + + it 'serves an independent group as JSON without reading Wiki tables' do + without_group_legacy_reads { get "/tag_groups/#{ group_id }" } + expect(response).to have_http_status(:ok) + expect(response.media_type).to eq('application/json') + expect_native_group(json) + end + + it 'provides the confirmed PUT update route' do + route = Rails.application.routes.recognize_path("/tag_groups/#{ group_id }", method: :put) + expect(route).to include(action: 'update', id: group_id.to_s) + end + + [:create, :update].each do |operation| + [nil, :guest, :banned].each do |role| + it "rejects #{ role || 'anonymous' } native #{ operation } without writes" do + actor = case role + when :guest then create(:user, :guest) + when :banned then create(:user, :member, :banned) + end + headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { } + before_state = group_state + if operation == :create + post '/tag_groups', params: { }, headers:, as: :json + else + put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json + end + expect(response).to have_http_status(role ? :forbidden : :unauthorized) + expect(group_state).to eq(before_state) + end + end + + it "rejects a banned IP for native #{ operation }" do + IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current) + headers = { 'X-Transfer-Code' => user.inheritance_code } + before_state = group_state + if operation == :create + post '/tag_groups', params: { }, headers:, as: :json + else + put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json + end + expect(response).to have_http_status(:forbidden) + expect(group_state).to eq(before_state) + end + end + + context 'structured membership and placements' do + let!(:tag) { group_tag('掲載タグ') } + let!(:unplaced) { group_tag('未掲載タグ') } + let!(:child_id) { seed_group(name: '子', description: '子の説明') } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + let(:members) do + [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }] + end + let(:snapshot_members) do + [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }, + { 'tag_group_id' => child_id, 'version_no' => 1 }, + { 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }] + end + let(:placements) do + [{ 'section_name' => '区分', 'description' => '区分の説明', 'children' => [ + { 'tag_id' => tag.id, 'children' => [ + { 'tag_group_id' => child_id, 'children' => [] }] }, + { 'tag_id' => tag.id, 'children' => [] }] }] + end + let(:payload) { { name: '構造化', description: '全体の説明', members:, placements: } } + + def native_write operation, attributes = payload + if operation == :create + post '/tag_groups', params: attributes, headers:, as: :json + else + put "/tag_groups/#{ group_id }", params: attributes, headers:, as: :json + end + end + + def expect_structured_response + tag_repr = { 'id' => tag.id, 'name' => '掲載タグ', 'category' => tag.category } + unplaced_repr = { 'id' => unplaced.id, 'name' => '未掲載タグ', + 'category' => unplaced.category } + expect(json.fetch('members')).to contain_exactly( + include('tag' => include(tag_repr)), + include('tag' => include(unplaced_repr)), + include('tag_group' => include('id' => child_id, 'name' => '子'))) + expect(json.fetch('placements')).to match([ + include('section_name' => '区分', 'description' => '区分の説明', 'children' => [ + include('tag' => include(tag_repr), 'children' => [ + include('tag_group' => include('id' => child_id, 'name' => '子'), + 'children' => [])]), + include('tag' => include(tag_repr), 'children' => [])])]) + end + + [:create, :update].each do |operation| + it "#{ operation }s structured data and keeps unplaced membership" do + previous = group_version(group_id, 1) + native_write(operation) + expect(response).to have_http_status(operation == :create ? :created : :ok) + id = operation == :create ? json.fetch('id') : group_id + number = operation == :create ? 1 : 2 + expect_group_snapshot( + id, number:, members: snapshot_members, placements:, description: '全体の説明') + expect(group_version(group_id, 1)).to eq(previous) + get "/tag_groups/#{ id }" + expect(response).to have_http_status(:ok) + expect(json).to include( + 'id' => id, 'name' => '構造化', 'description' => '全体の説明', 'version_no' => number, + 'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user)) + expect_structured_response + end + + { + 'blank name' => { name: '' }, + 'missing name' => { name: nil }, + 'oversized name' => { name: '長' * 256 }, + 'duplicate name' => { name: '子' }, + 'placement without a kind' => { placements: [{ children: [] }] }, + 'empty Section name' => { placements: [{ section_name: '', children: [] }] } } + .each do |label, invalid| + it "rejects #{ label } on #{ operation } without partial writes" do + before_state = group_state + native_write(operation, payload.merge(invalid)) + aggregate_failures do + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + end + end + + it "rejects multiple placement kinds on #{ operation }" do + before_state = group_state + invalid = [{ tag_id: tag.id, section_name: '不正', children: [] }] + native_write(operation, payload.merge(placements: invalid)) + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + + it "rejects a missing referenced Tag on #{ operation }" do + missing = Tag.maximum(:id).to_i + 1 + before_state = group_state + native_write(operation, payload.merge( + members: [{ tag_id: missing }], placements: [{ tag_id: missing, children: [] }])) + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + + it "rejects a missing referenced group on #{ operation }" do + missing = group_rows('tag_groups').map { _1.fetch('id') }.max + 1 + before_state = group_state + native_write(operation, payload.merge( + members: [{ tag_group_id: missing }], + placements: [{ tag_group_id: missing, children: [] }])) + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + + it "rejects six descendant placement levels on #{ operation }" do + nodes = [] + 6.times { nodes = [{ tag_id: tag.id, children: nodes }] } + before_state = group_state + native_write(operation, payload.merge(placements: nodes)) + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + end + + it 'preserves membership-only data in structured reads without inventing placements' do + id = seed_group(name: '未掲載のみ', members: [snapshot_members.last]) + without_group_legacy_reads { get "/tag_groups/#{ id }" } + expect(response).to have_http_status(:ok) + expect(json.fetch('placements')).to eq([]) + expect(json.fetch('members')).to match([ + include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))]) + end + + it 'rejects indirect containment cycles' do + parent = seed_group( + name: '親', members: [{ 'tag_group_id' => group_id, 'version_no' => 1 }]) + before_state = group_state + native_write(:update, payload.merge( + members: [{ tag_group_id: parent }], placements: [])) + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + + it 'rejects direct containment even without a corresponding placement' do + before_state = group_state + native_write(:update, payload.merge( + members: [{ tag_group_id: group_id }], placements: [])) + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + + it 'preserves list Section descriptions in native structured data' do + nodes = [{ tag_id: tag.id, children: [ + { section_name: 'リスト内区分', description: '消してはいけない説明', children: [] }] }] + native_write(:update, payload.merge(placements: nodes)) + expect(response).to have_http_status(:ok) + expect(group_version(group_id, 2).fetch('placements')).to eq( + JSON.parse(nodes.to_json)) + get "/tag_groups/#{ group_id }" + expect(response).to have_http_status(:ok) + section = json.fetch('placements').sole.fetch('children').sole + expect(section).to include( + 'section_name' => 'リスト内区分', 'description' => '消してはいけない説明') + end + + context 'compatibility writes for representable structures' do + let(:group_id) do + page = legacy_group('専用API', '説明') + seed_group(name: '専用API', id: page.id, description: '説明') + end + let(:members) { [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }] } + let(:snapshot_members) do + [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }, + { 'tag_group_id' => child_id, 'version_no' => 1 }] + end + + it 'treats identical native content with unplaced membership as a no-op' do + unplaced_payload = payload.merge( + name: '専用API', members: members + [{ 'tag_id' => unplaced.id }]) + native_write(:update, unplaced_payload) + expect(response).to have_http_status(:ok) + expect_group_snapshot( + 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 + native_write(:update, unplaced_payload) + end + end + end + + context 'no-op updates against synchronised state' do + let(:synchronised_payload) do + { name: '構造化', description: '全体の説明', + 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('- 掲載タグ', '- タググループ:子') + 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 + repetitions.times do + native_write(:update, synchronised_payload) + expect(response).to have_http_status(:ok) + end + end + end + end + end + + 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 + native_write(:update, synchronised_payload.merge( + members: synchronised_payload.fetch(:members).reverse)) + end + end + end + + 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 + put "/tag_groups/#{ group_id }", params: synchronised_payload, + headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json + end + end + end + + it 'does not add history for Native content already changed through Wiki' do + put "/wiki/#{ group_id }", + params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers + expect(response).to have_http_status(:ok) + expect(compatibility_page.reload.body).to eq('- 未掲載タグ') + same_content = { + name: '構造化', description: nil, members: [{ 'tag_id' => unplaced.id }], + placements: [{ 'tag_id' => unplaced.id, 'children' => [] }] } + expect_group_snapshot( + group_id, number: 3, description: nil, + members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }], + placements: same_content.fetch(:placements)) + + travel_to(1.hour.from_now) do + expect_noop_group_update(group_id:, page: compatibility_page) do + native_write(:update, same_content) + end + end + end + + ['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 + changed = synchronised_payload.deep_dup + section = changed.fetch(:placements).first + children = section.fetch('children') + case change + when 'name' then changed[:name] = '実際の改名' + when 'description' then changed[:description] = '実際の説明変更' + when 'membership addition' + expect(changed.fetch(:members)).not_to include('tag_id' => additional_tag.id) + changed[:members] << { 'tag_id' => additional_tag.id } + children << { 'tag_id' => additional_tag.id, 'children' => [] } + when 'membership removal' + changed[:members].reject! { _1.key?('tag_group_id') } + children.first['children'] = [] + when 'Section name' then section['section_name'] = '別区分' + when 'Section description' then section['description'] = '別の区分説明' + when 'placement order' then children.reverse! + when 'placement hierarchy' + children << children.first.fetch('children').pop + end + + 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) + expected_members = changed.fetch(:members).map do |member| + target = if member.key?('tag_id') + Tag.find(member.fetch('tag_id')) + else + TagGroup.find(member.fetch('tag_group_id')) + end + member.merge('version_no' => target.version_no) + end + expect_group_snapshot( + group_id, number: number + 1, members: expected_members, + placements: changed.fetch(:placements), description: changed.fetch(:description)) + expect(group.reload.name).to eq(changed.fetch(:name)) + 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) + end + end + end + + [:create, :update].each do |operation| + it "synchronises Wiki current state and histories on native #{ operation }" do + native_write(operation) + expect(response).to have_http_status(operation == :create ? :created : :ok) + id = operation == :create ? json.fetch('id') : group_id + 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) + get group_title_path('構造化') + expect(response).to have_http_status(:ok) + expect(json.fetch('body')).to eq(page.body) + end + + (TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions']) + .each do |table| + it "rolls back native #{ operation } after #{ table } writes" do + expect_atomic_group_write(table) { native_write(operation) } + end + end + end + + it 'uses AUTO_INCREMENT for a new group instead of supplying a compatibility ID' do + inserts = [] + subscriber = lambda do |*args| + sql = args.last.fetch(:sql) + inserts << sql if sql.match?(/\A\s*INSERT INTO `?tag_groups`?\s/i) + end + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do + native_write(:create) + end + expect(response).to have_http_status(:created) + expect(inserts.size).to eq(1) + columns = inserts.sole[/\((.*?)\)/, 1].split(',').map { _1.strip.delete('`') } + expect(columns).not_to include('id') + end + + it 'updates a native group through Wiki and then reads structured current data' do + native_write(:create) + expect(response).to have_http_status(:created) + id = json.fetch('id') + previous = group_version(id, 1) + put "/wiki/#{ id }", + params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers + expect(response).to have_http_status(:ok) + expect_group_snapshot( + id, number: 2, + members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }], + placements: [{ 'tag_id' => unplaced.id, 'children' => [] }]) + expect(group_version(id, 1)).to eq(previous) + get "/tag_groups/#{ id }" + expect(response).to have_http_status(:ok) + expect(json).to include('version_no' => 2) + expect(json.fetch('members')).to match([ + include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))]) + end + end + end +end diff --git a/backend/spec/support/tag_group_records.rb b/backend/spec/support/tag_group_records.rb new file mode 100644 index 0000000..ed3a929 --- /dev/null +++ b/backend/spec/support/tag_group_records.rb @@ -0,0 +1,220 @@ +require 'cgi' + +# SQL fixtures use the migration contract without inventing application models. +# Include explicitly; these helpers must not affect unrelated examples. +module TagGroupRecords + GROUP_TABLES = [ + '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_descriptions'] + + def group_connection = ApplicationRecord.connection + + def group_rows table, conditions = { } + clauses = conditions.map do |column, value| + "#{ group_connection.quote_column_name(column) } = #{ group_connection.quote(value) }" + end + sql = "SELECT * FROM #{ group_connection.quote_table_name(table) }" + sql += " WHERE #{ clauses.join(' AND ') }" if clauses.any? + group_connection.select_all(sql).to_a.sort_by { |row| row.values.map(&:to_s) } + end + + def insert_group_row table, attributes + columns = attributes.keys.map { group_connection.quote_column_name(_1) }.join(', ') + values = attributes.values.map { group_connection.quote(_1) }.join(', ') + group_connection.insert( + "INSERT INTO #{ group_connection.quote_table_name(table) } (#{ columns }) " + + "VALUES (#{ values })") + end + + def group_state + WRITE_TABLES.to_h { |table| [table, group_rows(table)] } + end + + def expect_noop_group_update group_id:, page: + 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| + sql = args.last.fetch(:sql) + if sql.match?(/\A\s*(?:INSERT INTO|UPDATE|DELETE FROM)\s+`?(?:#{ tables })`?\s/i) + writes << sql + end + end + + aggregate_failures 'no-op current state, history and SQL writes' do + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield } + expect(response).to have_http_status(:ok) + after_state = group_state + before_state.each do |table, rows| + expect(after_state.fetch(table)).to eq(rows), "No-op changed #{ table }" + 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 + + def expect_atomic_group_write table + before_state = group_state + failure = Class.new(StandardError) + injected = false + subscriber = lambda do |*args| + sql = args.last.fetch(:sql) + next unless sql.match?(/\A\s*(?:INSERT INTO|UPDATE)\s+`?#{ table }`?\s/i) + + injected = true + raise failure, 'injected after SQL write' + end + begin + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield } + rescue failure + # Inspect the database after the operation's transaction unwinds. + end + aggregate_failures do + expect(injected).to be(true), "No INSERT/UPDATE reached #{ table }" + expect(group_state).to eq(before_state) + end + end + + def group_version group_id, number + row = group_rows('tag_group_versions', tag_group_id: group_id, version_no: number).sole + row.merge('members' => JSON.parse(row.fetch('members')), + 'placements' => JSON.parse(row.fetch('placements'))) + end + + def group_tag name + tag = create(:tag, primary_name: name) + TagVersioning.ensure_snapshot!(tag, created_by_user: user) + 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 + end + + def seed_group name:, placements: [], members: [], description: nil, id: nil + now = Time.current.change(usec: 0) + attributes = { + name:, description:, version_no: 1, + created_by_user_id: user.id, updated_by_user_id: user.id, + created_at: now, updated_at: now } + attributes[:id] = id if id + group_id = insert_group_row('tag_groups', attributes) + insert_group_row('tag_group_versions', { + tag_group_id: group_id, version_no: 1, event_type: 'create', + name:, description:, members: members.to_json, placements: placements.to_json, + created_by_user_id: user.id, created_at: now }) + members.each do |member| + tag_member = member.key?('tag_id') + table = tag_member ? 'tag_group_tags' : 'tag_group_groups' + key = tag_member ? :tag_id : :child_tag_group_id + value = member.fetch(tag_member ? 'tag_id' : 'tag_group_id') + insert_group_row(table, { + tag_group_id: group_id, key => value, + created_by_user_id: user.id, created_at: now }) + end + position = 0 + append = lambda do |nodes, parent| + nodes.each do |node| + position += 1 + own_position = position + insert_group_row('tag_group_placements', { + tag_group_id: group_id, position:, parent_position: parent, + section_name: node['section_name'], description: node['description'], + tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'], + created_by_user_id: user.id, updated_by_user_id: user.id, + created_at: now, updated_at: now }) + append.call(node.fetch('children'), own_position) + end + end + append.call(placements, nil) + group_id + end + + def without_group_legacy_reads + queries = [] + subscriber = lambda do |*args| + sql = args.last.fetch(:sql) + if (sql.match?(/\bSELECT\b/i) && + sql.match?(/\bwiki_(pages|revisions|versions|lines|revision_lines)\b/i)) + queries << sql + end + end + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield } + expect(queries).to be_empty + end + + def group_title_path name + "/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) + expect(current).to include('version_no' => number, 'description' => description), + @group_migration_messages&.join("\n") + expect(version).to include( + 'name' => current.fetch('name'), 'description' => description, + 'version_no' => number, 'placements' => placements) + expect(version.fetch('members')).to match_array(members) + expect(group_rows('tag_group_tags', tag_group_id: group_id).map { _1.fetch('tag_id') }) + .to match_array(members.filter_map { _1['tag_id'] }) + children = group_rows('tag_group_groups', tag_group_id: group_id) + expect(children.map { _1.fetch('child_tag_group_id') }) + .to match_array(members.filter_map { _1['tag_group_id'] }) + + rows = group_rows('tag_group_placements', tag_group_id: group_id) + tree = lambda do |parent| + rows.select { _1['parent_position'] == parent }.sort_by { _1.fetch('position') } + .map do |row| + node = if row['tag_id'] + { 'tag_id' => row.fetch('tag_id') } + elsif row['child_tag_group_id'] + { 'tag_group_id' => row.fetch('child_tag_group_id') } + else + { 'section_name' => row.fetch('section_name'), 'description' => row['description'] } + end + node.merge('children' => tree.call(row.fetch('position'))) + end + end + expect(tree.call(nil)).to eq(placements) + end +end diff --git a/backend/spec/tasks/nico_export_spec.rb b/backend/spec/tasks/nico_export_spec.rb index 0221f99..93aa770 100644 --- a/backend/spec/tasks/nico_export_spec.rb +++ b/backend/spec/tasks/nico_export_spec.rb @@ -18,8 +18,8 @@ RSpec.describe 'nico:export' do before do task.reenable - allow(ENV).to receive(:fetch).with('MYSQL_USER').and_return('mysql-user') - allow(ENV).to receive(:fetch).with('MYSQL_PASS').and_return('mysql-pass') + allow(ENV).to receive(:fetch).with('NIZIKA_NICO_MYSQL_USER').and_return('mysql-user') + allow(ENV).to receive(:fetch).with('NIZIKA_NICO_MYSQL_PASS').and_return('mysql-pass') allow(ENV).to receive(:fetch).with('NIZIKA_NICO_PATH').and_return('/srv/nizika-nico') end @@ -33,8 +33,8 @@ RSpec.describe 'nico:export' do expect(Open3).to receive(:capture3) do |env, *args, **kwargs| expect(env).to eq( { - 'MYSQL_USER' => 'mysql-user', - 'MYSQL_PASS' => 'mysql-pass', + 'NIZIKA_NICO_MYSQL_USER' => 'mysql-user', + 'NIZIKA_NICO_MYSQL_PASS' => 'mysql-pass', }, )