このコミットが含まれているのは:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)', '*区分*:',
|
||||
'[区分](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}',
|
||||
'# '].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
|
||||
新しいイシューから参照
ユーザーをブロックする