From 7840e15d1a97162be2cde5183e76103b7a35742f Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 10 Oct 2026 11:14:01 +0900 Subject: [PATCH] #425 --- .../app/controllers/tag_groups_controller.rb | 26 ++ .../app/controllers/wiki_pages_controller.rb | 82 +++- .../app/services/tag_group_markdown_parser.rb | 177 +++++++++ .../services/tag_group_markdown_renderer.rb | 4 +- backend/app/services/tag_group_operation.rb | 365 ++++++++++++++++++ backend/config/routes.rb | 2 +- .../requests/tag_group_wiki_write_spec.rb | 13 + .../tag_group_write_integrity_spec.rb | 329 ++++++++++++++++ 8 files changed, 986 insertions(+), 12 deletions(-) create mode 100644 backend/app/services/tag_group_markdown_parser.rb create mode 100644 backend/app/services/tag_group_operation.rb create mode 100644 backend/spec/requests/tag_group_write_integrity_spec.rb diff --git a/backend/app/controllers/tag_groups_controller.rb b/backend/app/controllers/tag_groups_controller.rb index e743c00..8117eea 100644 --- a/backend/app/controllers/tag_groups_controller.rb +++ b/backend/app/controllers/tag_groups_controller.rb @@ -10,4 +10,30 @@ class TagGroupsController < ApplicationController 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 5de1d1e..113b3bb 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -144,6 +144,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| @@ -168,6 +179,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 @@ -180,7 +193,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 @@ -197,11 +229,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 @@ -210,6 +248,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 @@ -253,6 +297,26 @@ class WikiPagesController < ApplicationController private + def legacy_group_page? page + return false unless page + return true if page.title.start_with?(TagGroupRepr::PREFIX) + + prefix = ApplicationRecord.sanitize_sql_like(TagGroupRepr::PREFIX) + page.wiki_versions.where('title LIKE ?', "#{ prefix }%").exists? + end + + def legacy_update_identified? page, title, group + return false unless page + return true if page.title == title + return false if title == group.name + + namespace = page.title.split(':', 2) + return true if namespace.size == 2 && title.start_with?("#{ namespace.first }:") + + base = params[:base_revision_id].presence + base && page.wiki_revisions.exists?(id: base) + end + def render_tag_group group version = if params[:version].present? group_version(group, params[:version]) @@ -274,8 +338,8 @@ class WikiPagesController < ApplicationController TagGroupMarkdownRenderer.render(representation) end - def render_group_representation representation, version - body = TagGroupMarkdownRenderer.render(representation) + 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 @@ -288,7 +352,7 @@ class WikiPagesController < ApplicationController 'revision_id' => version.id, 'pred' => pred, 'succ' => succ, - 'updated_at' => updated_at } + 'updated_at' => updated_at }, status: rescue TagGroupMarkdownRenderer::UnsupportedStructure => error render_unprocessable_entity(error.message) 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 index e9da768..99ba2fe 100644 --- a/backend/app/services/tag_group_markdown_renderer.rb +++ b/backend/app/services/tag_group_markdown_renderer.rb @@ -29,7 +29,7 @@ class TagGroupMarkdownRenderer blocks << render_list(placements, 0) end - blocks.compact.reject(&:empty?).join("\n\n").sub(/\n+\z/, '') + blocks.compact.reject(&:empty?).join("\n").sub(/\n+\z/, '') end private @@ -78,7 +78,7 @@ class TagGroupMarkdownRenderer elsif children.any? blocks << render_list(children, 0) end - blocks.join("\n\n") + blocks.join("\n") end def render_list nodes, depth diff --git a/backend/app/services/tag_group_operation.rb b/backend/app/services/tag_group_operation.rb new file mode 100644 index 0000000..cd26c5c --- /dev/null +++ b/backend/app/services/tag_group_operation.rb @@ -0,0 +1,365 @@ +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:) + 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.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 a6c8a27..0102d01 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -1,5 +1,5 @@ Rails.application.routes.draw do - resources :tag_groups, only: [:index, :show] + resources :tag_groups, only: [:index, :show, :create, :update] resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] diff --git a/backend/spec/requests/tag_group_wiki_write_spec.rb b/backend/spec/requests/tag_group_wiki_write_spec.rb index 0af35f5..9b51092 100644 --- a/backend/spec/requests/tag_group_wiki_write_spec.rb +++ b/backend/spec/requests/tag_group_wiki_write_spec.rb @@ -141,6 +141,19 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do 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 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