コミットを比較

..
14 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 398f4f6b82 #424 2026-10-12 23:43:49 +09:00
みてるぞ 5246ac5c61 #424 2026-10-12 23:23:31 +09:00
みてるぞ 7cf2978f5f #424 2026-10-12 21:53:02 +09:00
みてるぞ 11e6f73287 #424 2026-10-12 21:34:02 +09:00
みてるぞ 2e98ed02e3 #424 2026-10-12 20:10:04 +09:00
みてるぞ 7c25dedac2 #424 2026-10-12 15:43:32 +09:00
みてるぞ 1a4cbe36fb #424 2026-10-12 15:16:58 +09:00
みてるぞ 4813a3a09e #424 2026-10-12 14:46:19 +09:00
みてるぞ e117e20346 #424 2026-10-12 12:45:00 +09:00
みてるぞ 4164c3c3f7 #424 2026-10-12 03:45:11 +09:00
みてるぞ 4c891604b5 #424 2026-10-12 02:49:24 +09:00
みてるぞ 88e691165a #424 2026-10-12 02:37:56 +09:00
みてるぞ 18415d7f55 Merge remote-tracking branch 'origin/main' into feature/424 2026-10-11 03:58:13 +09:00
みてるぞ a219cc350b #424 一時コミット 2026-10-08 02:24:39 +09:00
250個のファイルの変更、5510行の追加、10588行の削除
+1 -23
ファイルの表示
@@ -1,16 +1,10 @@
class ApplicationController < ActionController::API
class UnsupportedLocale < StandardError
;
end
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
rescue_from(UnsupportedLocale) { head :not_found }
before_action :reject_banned_ip_address!
before_action :authenticate_user
before_action :reject_banned_user!
before_action :validate_explicit_locale!
def current_user = @current_user
@@ -99,21 +93,5 @@ class ApplicationController < ActionController::API
value
end
def resolve_locale!
return @resolved_locale if defined?(@resolved_locale)
code = params.fetch(:locale, 'ja')
unless code.is_a?(String) && Locale.codes.value?(code)
raise UnsupportedLocale
end
locale = Locale.find_by(code:)
raise UnsupportedLocale unless locale && locale.deprecated_at.nil?
@resolved_locale = locale
end
def validate_explicit_locale!
resolve_locale! if params.key?(:locale)
end
def resolve_locale! = Locale.find_by(code: params[:locale]) || Locale.nipponese
end
-10
ファイルの表示
@@ -1,10 +0,0 @@
class LocalesController < ApplicationController
def index
locales = Locale.where(deprecated_at: nil).order(:created_at).map do |locale|
{ code: locale.code_before_type_cast, name: locale.name }
end
response.headers['Cache-Control'] = 'no-store'
render json: { locales: }
end
end
+12 -33
ファイルの表示
@@ -1,6 +1,5 @@
class PostVersionsController < ApplicationController
def index
locale = resolve_locale!
post_id = params[:post].presence
tag_id = params[:tag].presence&.to_i
external_tag_id = params[:external_tag].presence&.to_i
@@ -46,12 +45,12 @@ class PostVersionsController < ApplicationController
.limit(limit)
.offset(offset)
render json: { versions: serialise_versions(versions, locale.language_code), count: }
render json: { versions: serialise_versions(versions), count: }
end
private
def serialise_versions rows, language_code
def serialise_versions rows
rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
@@ -59,16 +58,12 @@ class PostVersionsController < ApplicationController
[normalise_json(row.tags_json),
normalise_json(row.attributes['prev_tags_json']) || []]
}
tag_ids = snapshots.flatten.filter_map { _1['tag_id'] }.uniq
tag_names = TagName.where(tag_id: tag_ids, language_code:, primary_flg: true)
.pluck(:tag_id, :name).to_h
external_tag_names = external_tag_names_for(snapshots)
rows.map do |row|
cur_tags = snapshot_tag_literals(
normalise_json(row.tags_json), tag_names, external_tag_names)
cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names)
prev_tags = snapshot_tag_literals(
normalise_json(row.attributes['prev_tags_json']) || [], tag_names, external_tag_names)
normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names)
{ post_id: row.post_id,
version_no: row.version_no,
@@ -80,7 +75,6 @@ class PostVersionsController < ApplicationController
prev: row.attributes['prev_thumbnail_base'] },
video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
tags: build_version_tags(cur_tags, prev_tags),
parent_post_ids: row.parent_post_ids,
original_created_from: {
current: row.original_created_from&.iso8601,
prev: row.attributes['prev_original_created_from']&.iso8601 },
@@ -112,27 +106,17 @@ class PostVersionsController < ApplicationController
names
end
def snapshot_tag_literals snapshots, tag_names, external_tag_names
def snapshot_tag_literals snapshots, external_tag_names
snapshots
.filter_map { |snapshot|
if snapshot.key?('tag_id')
sections = snapshot.fetch('sections', []).map { |section|
[section.fetch('begin_ms'), section['end_ms']]
}.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
name = tag_names[snapshot['tag_id']] || snapshot.fetch('name')
[tag_category_order(snapshot['category']),
{ identity: [:tag, snapshot['tag_id'], sections],
name: Post.tag_snapshot_literal(snapshot.merge('name' => name)),
restore_name: tag_names[snapshot['tag_id']] &&
Post.tag_snapshot_literal(snapshot.merge('name' => name)),
external: false }]
Post.tag_snapshot_literal(snapshot)]
elsif external_tag_names[snapshot['external_tag_id']]
[6, { identity: [:external_tag, snapshot['external_tag_id']],
name: external_tag_names[snapshot['external_tag_id']],
restore_name: nil, external: true }]
[6, external_tag_names[snapshot['external_tag_id']]]
end
}
.sort_by { |order, tag| [order, tag[:name].downcase] }
.sort_by { |order, name| [order, name.downcase] }
.map(&:second)
end
@@ -141,22 +125,17 @@ class PostVersionsController < ApplicationController
end
def build_version_tags cur_tags, prev_tags
current = cur_tags.index_by { _1[:identity] }
previous = prev_tags.index_by { _1[:identity] }
(current.keys | previous.keys).map do |identity|
(cur_tags | prev_tags).map do |name|
type =
if current.key?(identity) && previous.key?(identity)
if cur_tags.include?(name) && prev_tags.include?(name)
'context'
elsif current.key?(identity)
elsif cur_tags.include?(name)
'added'
else
'removed'
end
entry = current[identity] || previous[identity]
{ name: entry[:name], restore_name: entry[:restore_name],
external: entry[:external], type: }
{ name:, type: }
end
end
end
+7 -12
ファイルの表示
@@ -1,18 +1,14 @@
class TagDescriptionsController < ApplicationController
def show
locale = resolve_locale!
description = TagDescription.find_by(tag_id: params[:id], language_code: locale.language_code)
description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja')
return head :not_found unless description
render json: representation(description)
end
def show_by_title
locale = resolve_locale!
tag_name = TagName.find_by(
language_code: locale.language_code, name: params[:name].to_s.strip)
description = tag_name&.tag && TagDescription.find_by(
tag: tag_name.tag, language_code: locale.language_code)
tag_name = TagName.find_by(language_code: 'ja', name: params[:name].to_s.strip)
description = tag_name&.tag && TagDescription.find_by(tag: tag_name.tag, language_code: 'ja')
return head :not_found unless description
render json: representation(description)
@@ -25,19 +21,18 @@ class TagDescriptionsController < ApplicationController
body = params[:body].to_s
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
locale = resolve_locale!
tag = Tag.find_by(id: params[:id])
return head :not_found unless tag
description = TagDescription.find_by(tag:, language_code: locale.language_code)
description = TagDescription.find_by(tag:, language_code: 'ja')
if description
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user)
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user)
description.reload
render json: representation(description)
else
TagDescriptionOperation.create!(locale, tag:, body:, created_by_user: current_user)
description = TagDescription.find_by!(tag:, language_code: locale.language_code)
TagDescriptionOperation.create!(tag:, body:, created_by_user: current_user)
description = TagDescription.find_by!(tag:, language_code: 'ja')
render json: representation(description), status: :created
end
rescue ActiveRecord::RecordInvalid => e
+6 -10
ファイルの表示
@@ -1,37 +1,33 @@
class TagGroupsController < ApplicationController
def index
locale = resolve_locale!
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(locale, groups.to_a)
render json: TagGroupRepr.many(groups.to_a)
end
def show
locale = resolve_locale!
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
return head :not_found unless group
render json: TagGroupRepr.one(locale, group)
render json: TagGroupRepr.one(group)
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
locale = resolve_locale!
group = TagGroupOperation.create!(locale, **write_attributes, actor: current_user)
render json: TagGroupRepr.one(locale, group), status: :created
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?
locale = resolve_locale!
group = TagGroup.find_by(id: params[:id])
return head :not_found unless group
TagGroupOperation.update!(locale, group:, **write_attributes, actor: current_user)
render json: TagGroupRepr.one(locale, group.reload)
TagGroupOperation.update!(group:, **write_attributes, actor: current_user)
render json: TagGroupRepr.one(group.reload)
end
private
+35 -18
ファイルの表示
@@ -368,7 +368,6 @@ class TagsController < ApplicationController
return if performed?
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
@@ -771,15 +770,31 @@ class TagsController < ApplicationController
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
end
current_tag_name&.update!(primary_flg: false, auto_generated: false)
promoted_tag_name.update!(tag:, primary_flg: true, auto_generated: false)
tag.association(:tag_names).reset
current_tag_name.wiki_page&.update!(tag_name_id: promoted_tag_name.id)
current_tag_name.association(:wiki_page).reset
current_tag_name.update!(canonical_id: promoted_tag_name.id,
primary_flg: false, auto_generated: false)
promoted_tag_name.update!(tag:, canonical_id: nil, primary_flg: true, auto_generated: false)
TagName.where(tag:,
language_code: current_tag_name.language_code,
primary_flg: false)
.where.not(id: promoted_tag_name.id)
.find_each do |alias_tag_name|
alias_tag_name.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
end
tag.update!(tag_name_id: promoted_tag_name.id)
current_tag_name.association(:wiki_page).reset
current_tag_name.association(:tag).reset
current_tag_name.reload.update!(canonical_id: promoted_tag_name.id, primary_flg: false)
return unless old_owner_tag && old_owner_tag != tag
record_tag_version!(old_owner_tag.reload, event_type: :update,
created_by_user: current_user,
language_code: promoted_tag_name.language_code)
language_code: current_tag_name.language_code)
end
def update_aliases! locale, tag, alias_names
@@ -787,15 +802,20 @@ class TagsController < ApplicationController
affected_tags = [tag]
primary_tag_name = TagName.find_by!(tag:,
language_code: locale.language_code,
primary_flg: true)
current_aliases = tag.tag_names.where(language_code: locale.language_code,
primary_flg: false).to_a
requested_aliases = TagName.where(
language_code: locale.language_code, name: alias_names)
.includes(:tag).index_by(&:name)
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
affected_tags << alias_tag_name.tag
end
alias_names.each do |alias_name|
alias_tag_name = requested_aliases[alias_name]
alias_tag_name = TagName.find_by(language_code: locale.language_code, name: alias_name)
if alias_tag_name&.primary? && alias_tag_name.tag_id && alias_tag_name.tag_id != tag.id
alias_tag_name.errors.add(:name, 'その名前は既に使はれてゐます.')
raise ActiveRecord::RecordInvalid, alias_tag_name
@@ -803,8 +823,7 @@ class TagsController < ApplicationController
affected_tags << alias_tag_name&.tag
end
affected_tags = affected_tags.compact.uniq(&:id)
affected_tags.each do |affected_tag|
affected_tags.compact.uniq.each do |affected_tag|
TagVersioning.ensure_snapshot!(affected_tag, created_by_user: current_user,
language_code: locale.language_code)
end
@@ -812,21 +831,19 @@ class TagsController < ApplicationController
current_aliases.each do |alias_tag_name|
next if alias_names.include?(alias_tag_name.name)
alias_tag_name.destroy!
alias_tag_name.update!(canonical_id: nil, tag_id: nil)
end
alias_names.each do |alias_name|
alias_tag_name = requested_aliases[alias_name] || TagName.new(
alias_tag_name = TagName.find_or_initialize_by(
language_code: locale.language_code,
name: alias_name)
alias_tag_name.script_code = locale.script_code
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false)
alias_tag_name.update!(tag:, primary_flg: false, auto_generated: false,
canonical_id: primary_tag_name.id)
end
affected_tags.each do |affected_tag|
# update_all records this Tag after its parent relationships are updated.
next if affected_tag.id == tag.id
affected_tags.compact.uniq.each do |affected_tag|
record_tag_version!(affected_tag, event_type: :update,
created_by_user: current_user,
language_code: locale.language_code)
+115 -115
ファイルの表示
@@ -1,15 +1,12 @@
class WikiPagesController < ApplicationController
# 互換性のための Facade API
rescue_from TagDescriptionOperation::Conflict, with: :render_wiki_conflict
rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict
def index
locale = resolve_locale!
# 互換性のための化石リソース
title = params[:title].to_s.strip
descriptions =
TagDescription
.where(language_code: locale.language_code)
TagDescription.where(language_code: 'ja')
.includes(tag: :tag_names)
descriptions = descriptions.order(updated_at: :desc, id: :asc)
@@ -18,14 +15,13 @@ class WikiPagesController < ApplicationController
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions =
descriptions.joins(tag: :tag_names)
.where(tag_names: { language_code: locale.language_code, primary_flg: true })
.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 ? OR CONCAT(?, name) LIKE ?',
TagGroupRepr::PREFIX, pattern, 'tag_group:', pattern).limit(20)
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
end
json = descriptions.map { |description| WikiPageRepr.tag_description(locale, description) }
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
json.sort_by! do |row|
[-row['updated_at'].to_f, row.fetch('resource_type'), row.fetch('id')]
@@ -34,82 +30,48 @@ class WikiPagesController < ApplicationController
render json:
end
def autolink_candidates
locale = resolve_locale!
language = params.fetch(:language, locale.language_code)
return head :not_found unless language.is_a?(String)
target_locale =
if language == 'und'
locale
else
Locale.where(language_code: language, deprecated_at: nil,
code: Locale.codes.values).order(:code).first
end
return head :not_found unless target_locale
candidates = []
unless language == 'und'
names = TagDescription.joins(tag: :tag_names)
.where(language_code: language, tag_names: { language_code: language })
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
candidates = names.map do |tag_id, description_id, name|
WikiPageRepr.autolink_tag(target_locale, tag_id:, description_id:, name:)
end
end
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
WikiPageRepr.autolink_group(target_locale, id:, name:, language:)
end)
render json: candidates
end
def show
locale = resolve_locale!
tag_description = TagDescription.find_by(id: params[:id], language_code: locale.language_code)
tag_description = TagDescription.find_by(id: params[:id])
return render_wiki_page_by_tag_description locale, tag_description if tag_description
return render_wiki_page_by_tag_description tag_description if tag_description
tag_group = TagGroup.find_by(id: params[:id])
return render_tag_group locale, tag_group if tag_group
return render_tag_group tag_group if tag_group
head :not_found
render_wiki_page_or_404 nil
end
def show_by_title
locale = resolve_locale!
title = params[:title].to_s.strip
if (name = TagGroupRepr.name_from_title(title))
if title.start_with?(TagGroupRepr::PREFIX)
name = title.delete_prefix(TagGroupRepr::PREFIX)
tag_group = TagGroup.find_by(name:)
return render_tag_group locale, tag_group if tag_group
return render_tag_group tag_group if tag_group
return head :not_found
end
tag_description = tag_description_by_title(locale, title)
tag_description = tag_description_by_title(title)
return render_wiki_page_by_tag_description locale, tag_description if tag_description
return render_wiki_page_by_tag_description tag_description if tag_description
head :not_found
render_wiki_page_or_404 nil
end
def exists
locale = resolve_locale!
if TagDescription.exists?(id: params[:id], language_code: locale.language_code)
return head :no_content
end
return head :no_content if TagDescription.exists?(id: params[:id])
return head :no_content if TagGroup.exists?(id: params[:id])
head :not_found
end
def exists_by_title
locale = resolve_locale!
title = params[:title].to_s.strip
if (name = TagGroupRepr.name_from_title(title))
exists = TagGroup.exists?(name:)
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(locale, title)
return head :no_content if tag_description_by_title(title)
head :not_found
end
@@ -118,17 +80,15 @@ class WikiPagesController < ApplicationController
id = params[:id]
return render_bad_request('id は必須です.') if id.blank?
locale = resolve_locale!
from = params[:from].presence
to = params[:to].presence
description = TagDescription.find_by(id:, language_code: locale.language_code)
description = TagDescription.find_by(id:)
if description
versions = description_versions(description)
from_rev = from && versions.find(from)
to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no)
resource = WikiPageRepr.tag_description(locale, description)
resource = WikiPageRepr.tag_description(description)
else
tag_group = TagGroup.find_by(id:)
if tag_group
@@ -136,8 +96,8 @@ class WikiPagesController < ApplicationController
to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
return head :not_found if (from && !from_rev) || !to_rev
from_body = from_rev && render_group_version(locale, from_rev)
to_body = render_group_version(locale, 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 }
@@ -171,31 +131,30 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
locale = resolve_locale!
if (name = TagGroupRepr.name_from_title(title))
parsed = TagGroupMarkdownParser.parse(locale, body:, group: nil)
group = TagGroupOperation.create!(
locale, name:, **parsed.except(:compatibility_body), actor: current_user,
require_compatibility: true)
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(locale, group), version,
return render_group_representation(TagGroupRepr.one(group), version,
body: parsed.fetch(:compatibility_body),
status: :created)
end
description =
page =
if title.include?(':')
return render_unprocessable_entity field: :title
else
tag_name = TagName.find_by(language_code: locale.language_code, name: title)
tag_name = TagName.find_by(language_code: 'ja', name: title)
return render_unprocessable_entity field: :title unless tag_name&.tag
TagDescriptionOperation.create!(
locale,
tag: tag_name.tag, body:, created_by_user: current_user, message:)
end
render json: WikiPageRepr.tag_description(locale, description), status: :created
description = TagDescription.find_by!(tag_id: page.tag_name.tag_id, language_code: 'ja')
render json: WikiPageRepr.tag_description(description), status: :created
rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record
rescue ActiveRecord::RecordNotUnique
@@ -214,35 +173,32 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
locale = resolve_locale!
group = TagGroup.find_by(id: params[:id])
if (name = TagGroupRepr.name_from_title(title))
if title.start_with?(TagGroupRepr::PREFIX)
return head :not_found unless group
parsed = TagGroupMarkdownParser.parse(locale, body:, group:)
TagGroupOperation.update!(
locale, group:, name:, **parsed.except(:compatibility_body), actor: current_user,
require_compatibility: true)
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
return render_unprocessable_entity field: :title if title.include?(':')
description = TagDescription.find_by(id: params[:id], language_code: locale.language_code)
description = TagDescription.find_by(id: params[:id])
unless description
return render_unprocessable_entity field: :title if group
return render_unprocessable_entity field: :title if group || WikiPage.exists?(id: params[:id])
return head :not_found
end
tag = description.tag
tag_name = tag.tag_name(locale.language_code)
return render_unprocessable_entity field: :title unless tag_name
return render_unprocessable_entity field: :title if tag_name.name.include?(':')
tag_name = TagDescriptionOperation.compatibility_tag_name(tag)
if tag_name.name.include?(':') || (group && title != tag_name.name)
return render_unprocessable_entity field: :title
end
base_revision_id = params[:base_revision_id].presence
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
old_title = tag_name.name
if title != old_title
@@ -252,7 +208,7 @@ class WikiPagesController < ApplicationController
tag_name.update!(name: title) if title != old_title
message = params[:message].presence
TagDescriptionOperation.update!(locale, tag:, body:, updated_by_user: current_user,
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user,
message:, base_revision_id:)
if title != old_title
@@ -271,12 +227,10 @@ class WikiPagesController < ApplicationController
end
def changes
locale = resolve_locale!
id = params[:id].presence
description = id && TagDescription.find_by(id:, language_code: locale.language_code)
description = id && TagDescription.find_by(id:)
if description
return render json: description_changes(locale, TagDescription.where(id: description.id))
return render json: description_changes(TagDescription.where(id: description.id))
end
tag_group = id && TagGroup.find_by(id:)
@@ -287,8 +241,7 @@ class WikiPagesController < ApplicationController
json = []
unless id
json.concat(tag_group_changes(TagGroupVersion.all))
json.concat(description_changes(locale,
TagDescription.where(language_code: locale.language_code)))
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
json = json.each_with_index.sort_by do |row, index|
[-row.fetch(:timestamp).to_f, index]
end.map(&:first)
@@ -298,7 +251,27 @@ class WikiPagesController < ApplicationController
private
def render_tag_group locale, group
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
@@ -306,17 +279,16 @@ class WikiPagesController < ApplicationController
end
return head :not_found unless version
representation =
if params[:version].present?
TagGroupRepr.historical(locale, version)
else
TagGroupRepr.one(locale, group)
end
representation = if params[:version].present?
TagGroupRepr.historical(version)
else
TagGroupRepr.one(group)
end
render_group_representation(representation, version)
end
def render_group_version locale, version
representation = TagGroupRepr.historical(locale, version)
def render_group_version version
representation = TagGroupRepr.historical(version)
TagGroupMarkdownRenderer.render(representation)
end
@@ -385,7 +357,28 @@ class WikiPagesController < ApplicationController
end
end
def render_wiki_page_by_tag_description locale, description
def render_wiki_page_or_404 page
return head :not_found unless page
rev = find_revision(page)
return head :not_found unless rev
if rev.redirect?
return (
redirect_to wiki_page_by_title_path(title: rev.redirect_page.title),
status: :moved_permanently)
end
body = rev.body
revision_id = rev.id
pred = page.pred_revision_id(revision_id)
succ = page.succ_revision_id(revision_id)
updated_at = rev.created_at
render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:)
end
def render_wiki_page_by_tag_description description
versions = description_versions(description)
historical = params[:version].present?
rev = historical ? versions.find_by(id: params[:version]) :
@@ -396,7 +389,7 @@ class WikiPagesController < ApplicationController
succ = versions.find_by(version_no: rev.version_no + 1)&.id
body = historical ? rev.body : description.body
updated_at = historical ? rev.created_at : description.updated_at
render json: WikiPageRepr.tag_description(locale, description).merge(
render json: WikiPageRepr.tag_description(description).merge(
body:, revision_id: rev.id, pred:, succ:, updated_at:)
end
@@ -405,14 +398,13 @@ class WikiPagesController < ApplicationController
language_code: description.language_code)
end
def tag_description_by_title locale, title
def tag_description_by_title title
TagDescription.joins(tag: :tag_names)
.find_by(language_code: locale.language_code,
tag_names: { language_code: locale.language_code, name: title })
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
end
def description_changes locale, descriptions
versions = TagDescriptionVersion.where(language_code: locale.language_code,
def description_changes descriptions
versions = TagDescriptionVersion.where(language_code: 'ja',
tag_id: descriptions.select(:tag_id))
.order(id: :desc).limit(200).to_a
return [] if versions.empty?
@@ -421,7 +413,7 @@ class WikiPagesController < ApplicationController
.includes(tag: :tag_names).index_by(&:tag_id)
users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id)
predecessors = TagDescriptionVersion.where(
language_code: locale.language_code, tag_id: by_tag.keys,
language_code: 'ja', tag_id: by_tag.keys,
version_no: versions.map { |version| version.version_no - 1 })
.pluck(:tag_id, :version_no, :id)
.to_h { |tag_id, version_no, id| [[tag_id, version_no], id] }
@@ -431,7 +423,7 @@ class WikiPagesController < ApplicationController
{ revision_id: version.id,
pred: predecessors[[version.tag_id, version.version_no - 1]],
succ: nil,
wiki_page: { id: description.id, title: description.tag.name(locale.language_code),
wiki_page: { id: description.id, title: description.tag.name('ja'),
deprecated_at: description.tag.deprecated_at },
user: user && { id: user.id, name: user.name },
kind: 'content',
@@ -440,6 +432,14 @@ class WikiPagesController < ApplicationController
end
end
def find_revision page
if params[:version].present?
page.wiki_revisions.find_by(id: params[:version])
else
page.current_revision
end
end
def render_wiki_conflict err
render json: { error: 'conflict', message: err.message }, status: :conflict
end
+1 -1
ファイルの表示
@@ -1,5 +1,5 @@
class Language < ApplicationRecord
has_many :locales, foreign_key: :language_code, primary_key: :code
enum :code, ja: 'ja', ain: 'ain'
enum :code, ja: 'ja'
end
+15 -15
ファイルの表示
@@ -1,27 +1,27 @@
class Locale < ApplicationRecord
before_save :lock_tag_creation!, if: :will_generate_tag_names?
after_save :generate_tag_names!, if: :needs_tag_names?
after_create_commit :generate_tag_names!
belongs_to :language, foreign_key: :language_code, primary_key: :code
belongs_to :script, foreign_key: :script_code, primary_key: :code
enum :code, ja: 'ja', ain_kana: 'ain-Kana'
enum :code, ja: 'ja', en: 'en', fr: 'fr'
def self.nipponese = Locale.find('ja')
private
def will_generate_tag_names?
deprecated_at.nil? && (will_save_change_to_code? || will_save_change_to_deprecated_at?)
def generate_tag_names!
ApplicationRecord.transaction do
tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id)
Tag.where.not(id: tag_ids).find_each do
TagName.create!(tag_id: _1.id,
language_code:,
name: TagName.generate_name(self, _1, _1.tag_name('ja')),
script_code:,
primary_flg: true,
auto_generated: true,
canonical_id: nil)
end
end
end
def lock_tag_creation!
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
end
def needs_tag_names?
deprecated_at.nil? && (saved_change_to_code? || saved_change_to_deprecated_at?)
end
def generate_tag_names! = TagNameCompletion.for_locale!(self)
end
+1 -1
ファイルの表示
@@ -1,5 +1,5 @@
class Script < ApplicationRecord
has_many :locales, foreign_key: :script_code, primary_key: :code
enum :code, jpan: 'Jpan', kana: 'Kana'
enum :code, Jpan: 'Jpan'
end
+45 -20
ファイルの表示
@@ -91,6 +91,7 @@ class Tag < ApplicationRecord
end
end
def name(language_code) = tag_name(language_code)&.name
def wiki_page = tag_name('ja')&.wiki_page
def system?
tag_name('ja')&.name.in?(SYSTEM_TAG_NAMES)
@@ -110,7 +111,7 @@ class Tag < ApplicationRecord
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def snapshot_aliases language_code
def snapshot_aliases(language_code)
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
end
@@ -259,24 +260,23 @@ class Tag < ApplicationRecord
name = name.to_s.strip
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tn = TagName.lock.find_by(language_code:, name:)
next tn.tag if tn
tn = TagName.find_or_create_by!(language_code:, name:) do
_1.script_code = locale.script_code
_1.primary_flg = true
end
tag = Tag.create!(category:)
TagName.create!(tag:, language_code:, name:,
script_code: locale.script_code, primary_flg: true,
auto_generated: false)
TagNameCompletion.for_tag!(tag)
tag = tn.tag
next tag if tag
tag = Tag.create!(tag_name_id: tn.id, category:)
tn.update!(tag:, primary_flg: true, canonical_id: nil, auto_generated: false)
generate_tag_names!(locale, tag, tn)
tag.association(:tag_names).reset
tag
end
rescue ActiveRecord::RecordNotUnique
existing = TagName.find_by(language_code:, name:)
raise unless existing
existing.tag
retry
end
def merge_tags! target_tag, source_tags, created_by_user: nil
@@ -303,18 +303,20 @@ class Tag < ApplicationRecord
end
end
if source_tag.tag_descriptions.exists?
source_tag.errors.add(:base, :invalid)
raise ActiveRecord::RecordInvalid, source_tag
source_tag_name = source_tag.tag_name('ja')
if source_tag_name.wiki_page.present?
raise ActiveRecord::RecordInvalid.new(source_tag_name)
end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
target_languages = target_tag.tag_names.where(primary_flg: true).pluck(:language_code)
source_tag.tag_names.where(language_code: target_languages)
.update_all(primary_flg: false, auto_generated: false, updated_at: Time.current)
source_tag.tag_names.update_all(tag_id: target_tag.id, updated_at: Time.current)
source_tag.tag_names.update_all(tag_id: target_tag.id, primary_flg: false,
updated_at: Time.current)
source_tag.destroy!
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end
@@ -356,6 +358,29 @@ class Tag < ApplicationRecord
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end
private
def generate_tag_names! locale, tag, base_tag_name
ApplicationRecord.transaction do
locales =
Locale
.where(deprecated_at: nil)
.where.not(language_code: locale.language_code)
.order(:language_code, :created_at, :script_code)
.uniq { _1.language_code }
locales.each do |locale|
language_code = locale.language_code
script_code = locale.script_code
TagName.create!(tag_id: tag.id,
language_code:,
name: TagName.generate_name(locale, tag, base_tag_name),
script_code:,
primary_flg: true,
auto_generated: true,
canonical_id: nil)
end
end
end
end
private
+1 -1
ファイルの表示
@@ -1,3 +1,3 @@
class TagDescriptionVersion < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
;
end
+1 -10
ファイルの表示
@@ -1,6 +1,5 @@
class TagGroup < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
before_destroy :delete_current_structure!
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
@@ -12,16 +11,8 @@ class TagGroup < ApplicationRecord
has_many :placements, -> { order(:position) },
class_name: 'TagGroupPlacement', dependent: :delete_all
has_many :versions, -> { order(:version_no) },
class_name: 'TagGroupVersion'
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 }
private
def delete_current_structure!
placements.reorder(position: :desc).each(&:delete)
tag_group_tags.delete_all
tag_group_groups.delete_all
end
end
-2
ファイルの表示
@@ -1,8 +1,6 @@
class TagGroupVersion < ApplicationRecord
include VersionRecord
before_create { SharedResourceIdAllocator.assign!(self) }
belongs_to :tag_group
scope :ordered, -> { order(:version_no) }
+16 -25
ファイルの表示
@@ -1,12 +1,14 @@
class TagName < ApplicationRecord
belongs_to :language, foreign_key: :language_code, primary_key: :code
belongs_to :tag
belongs_to :tag, optional: true
has_one :wiki_page
validates :name, presence: true,
length: { maximum: 255 },
uniqueness: { scope: :language_code }
validate :alias_name_must_not_have_prefix
validate :alias_must_not_have_wiki_page
validate :name_must_be_sanitised
validate :name_mustnt_start_with_nico
validate :generated_name_must_be_reserved
@@ -19,37 +21,22 @@ class TagName < ApplicationRecord
tns = TagName.where(language_code: locale.language_code, name: names).index_by(&:name)
primary_names = TagName.where(
language_code: locale.language_code, tag_id: tns.values.map(&:tag_id),
primary_flg: true).pluck(:tag_id, :name).to_h
names.map { |name|
tag_name = tns[name]
if !tag_name || tag_name.primary?
if !tag_name || tag_name.primary? || tag_name.tag_id.nil?
name
else
primary_names[tag_name.tag_id] || name
TagName.find_by(language_code: locale.language_code,
tag_id: tag_name.tag_id,
primary_flg: true)&.name || name
end
}.uniq
end
def self.generate_name locale, tag, base_tag_name
fallback = "Tag_##{ tag.id }"
name =
case [locale.language_code, base_tag_name&.language_code]
when ['ain', 'ja']
TagNameTranscription.ainu(base_tag_name.name)
when ['ja', 'ain']
base_tag_name.name
else
return fallback
end
candidate = new(tag:, name:, language_code: locale.language_code,
script_code: locale.script_code,
primary_flg: true, auto_generated: true)
candidate.valid? ? name : fallback
rescue TagNameTranscription::Unconvertible
fallback
# TODO: 言語ごとの自動命名ロジック完成したら書く.
"Tag_##{ tag.id }"
end
private
@@ -66,9 +53,13 @@ class TagName < ApplicationRecord
end
end
def name_must_be_sanitised
return if auto_generated && name == "Tag_##{ tag_id }"
def alias_must_not_have_wiki_page
if !(primary?) && wiki_page
errors.add :primary_flg, 'Wiki 参照がある名前はエーリアスになれません.'
end
end
def name_must_be_sanitised
if name? && name != TagNameSanitisationRule.sanitise(name)
errors.add :name, '名前に使用できない文字が含まれてゐます.'
end
+26 -25
ファイルの表示
@@ -16,36 +16,37 @@ class TagNameSanitisationRule < ApplicationRecord
def apply!
Language.find_each do |language|
TagName.where(language:).find_each do |candidate|
TagName.where(language:).find_each do |tn|
name = sanitise(tn.name)
next if name == tn.name
TagName.transaction do
tn = TagName.find_by(id: candidate.id)
next unless tn
name = sanitise(tn.name)
next if name == tn.name
existing_tn = TagName.find_by(language:, name:)
source_tag = tn.tag
target_tag = existing_tn ? existing_tn.tag : source_tag
[source_tag, target_tag].uniq(&:id).sort_by(&:id).each(&:lock!)
TagVersioning.ensure_snapshot!(source_tag, created_by_user: nil)
if existing_tn && target_tag != source_tag
Tag.merge_tags!(target_tag, source_tag)
tn.reload.destroy!
elsif existing_tn
if tn.primary?
existing_tn.destroy!
tn.update_columns(name:, updated_at: Time.current)
else
tn.destroy!
if existing_tn
unless existing_tn.primary_flg
existing_tn = TagName.find_by!(language:,
tag_id: existing_tn.tag_id,
primary_flg: true)
end
else
# TagName 側の自動サニタイズを回避
tn.update_columns(name:, updated_at: Time.current)
next if existing_tn.id == tn.id
existing_tag = existing_tn.tag
source_tag = tn.tag
if existing_tag
Tag.merge_tags!(existing_tag, source_tag) if tn.tag
elsif source_tag
tn.update_columns(tag_id: nil, updated_at: Time.current)
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
existing_tn.update_columns(tag_id: source_tag.id, updated_at: Time.current)
end
tn.destroy!
next
end
TagVersioning.record!(target_tag, event_type: :update, created_by_user: nil)
# TagName 側の自動サニタイズを回避
tn.update_columns(name:, updated_at: Time.current)
end
end
end
+4 -4
ファイルの表示
@@ -13,10 +13,10 @@ class User < ApplicationRecord
has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy
has_many :viewed_posts, through: :user_post_views, source: :post
has_many :created_tag_descriptions, class_name: 'TagDescription',
foreign_key: :created_by_user_id, dependent: :nullify
has_many :updated_tag_descriptions, class_name: 'TagDescription',
foreign_key: :updated_by_user_id, dependent: :nullify
has_many :created_wiki_pages,
class_name: 'WikiPage', foreign_key: :created_user_id, dependent: :nullify
has_many :updated_wiki_pages,
class_name: 'WikiPage', foreign_key: :updated_user_id, dependent: :nullify
def viewed?(post) = user_post_views.exists?(post_id: post.id)
+15
ファイルの表示
@@ -0,0 +1,15 @@
class WikiLine < ApplicationRecord
has_many :wiki_revision_lines, dependent: :restrict_with_exception
validates :sha256, presence: true, uniqueness: true, length: { is: 64 }
validates :body, presence: true
def self.upsert_by_body! body
sha = Digest::SHA256.hexdigest(body)
now = Time.current
upsert(sha256: sha, body:, created_at: now, updated_at: now)
find_by!(sha256: sha)
end
end
+58
ファイルの表示
@@ -0,0 +1,58 @@
require 'set'
class WikiPage < ApplicationRecord
include MyDiscard
has_many :wiki_revisions, dependent: :destroy
belongs_to :created_user, class_name: 'User'
belongs_to :updated_user, class_name: 'User'
has_many :redirected_from_revisions,
class_name: 'WikiRevision',
foreign_key: :redirect_page_id,
dependent: :nullify
has_many :wiki_versions
attribute :version_no, :integer, default: 1
belongs_to :tag_name
validates :tag_name, presence: true
validates :body, presence: true
def title = tag_name.name
def deprecated_at = tag_name.tag&.deprecated_at
def title= val
(self.tag_name ||= build_tag_name).name = val
end
def current_revision = wiki_revisions.order(id: :desc).first
def resolve_redirect limit: 10
page = self
visited = Set.new
limit.times do
return page if visited.include?(page.id)
visited.add(page.id)
rev = page.current_revision
return page if !(rev&.redirect?) || !(rev.redirect_page)
page = rev.redirect_page
end
page
end
def pred_revision_id revision_id
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
end
def succ_revision_id revision_id
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
end
end
+55
ファイルの表示
@@ -0,0 +1,55 @@
class WikiRevision < ApplicationRecord
belongs_to :wiki_page
belongs_to :base_revision, class_name: 'WikiRevision', optional: true
belongs_to :created_user, class_name: 'User'
belongs_to :redirect_page, class_name: 'WikiPage', optional: true
has_many :wiki_revision_lines, dependent: :delete_all
has_many :wiki_lines, through: :wiki_revision_lines
enum :kind, content: 0, redirect: 1
validates :kind, presence: true
validates :lines_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :tree_sha256, length: { is: 64 }, allow_nil: true
validate :kind_consistency
def body
return unless content?
wiki_revision_lines
.includes(:wiki_line)
.order(:position)
.map { |rev| rev.wiki_line.body }
.join("\n")
end
private
def kind_consistency
if content?
if tree_sha256.blank?
errors.add(:tree_sha256, '種類がページの場合は必須です.')
end
if redirect_page_id.present?
errors.add(:redirect_page_id, '種類がページの場合は空である必要があります.')
end
end
if redirect?
if redirect_page_id.blank?
errors.add(:redirect_page_id, '種類がリダイレクトの場合は必須です.')
end
if tree_sha256.present?
errors.add(:tree_sha256, '種類がリダイレクトの場合は空である必要があります.')
end
if lines_count.to_i > 0
errors.add(:lines_count, '種類がリダイレクトの場合は 0 である必要があります.')
end
end
end
end
+8
ファイルの表示
@@ -0,0 +1,8 @@
class WikiRevisionLine < ApplicationRecord
belongs_to :wiki_revision
belongs_to :wiki_line
validates :position, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :position, uniqueness: { scope: :wiki_revision_id }
end
+8
ファイルの表示
@@ -0,0 +1,8 @@
class WikiVersion < ApplicationRecord
include VersionRecord
belongs_to :wiki_page
validates :title, presence: true
validates :body, presence: true
end
+21 -32
ファイルの表示
@@ -3,12 +3,7 @@ module TagGroupRepr
module_function
def name_from_title title
prefix = [PREFIX, 'tag_group:'].find { title.downcase.start_with?(_1) }
title[prefix.length..] if prefix
end
def many locale, groups
def many groups
ids = groups.map(&:id)
return [] if ids.empty?
@@ -19,20 +14,14 @@ module TagGroupRepr
placements = TagGroupPlacement.where(tag_group_id: ids).order(:position)
.group_by(&:tag_group_id)
target_ids =
tag_members.values.flatten.map(&:tag_id) +
placements.values.flatten.filter_map(&:tag_id)
child_ids =
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: locale.language_code,
primary_flg: true)
.pluck(:tag_id, :name)
.to_h
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|
@@ -43,8 +32,8 @@ module TagGroupRepr
end
end
def one locale, group
many(locale, [group]).first
def one group
many([group]).first
end
def tag_group wiki_page
@@ -59,7 +48,7 @@ module TagGroupRepr
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
end
def historical locale, version
def historical version
members = Array(version.members)
placements = Array(version.placements)
tag_references = members.filter_map { |member| member['tag_id'] } +
@@ -79,10 +68,10 @@ module TagGroupRepr
[member.fetch('tag_group_id'), member.fetch('version_no')]
end.to_h
historical_members = members.map do |member|
historical_member(locale, member, tag_versions, group_versions)
historical_member(member, tag_versions, group_versions)
end
historical_placements = placements.map do |node|
historical_placement(locale, node, tag_versions, group_versions,
historical_placement(node, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
end
{ 'id' => version.tag_group_id,
@@ -100,7 +89,7 @@ module TagGroupRepr
def group_json group, tag_members, group_members, rows, tags, names, child_groups
tree = placement_tree(rows, tags, names, child_groups)
members = tag_members.sort_by(&:tag_id).map do |member|
{ 'tag' => tag_json(tags.fetch(member.tag_id), names[member.tag_id]) }
{ '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)) }
@@ -126,7 +115,7 @@ module TagGroupRepr
build = lambda do |parent|
by_parent.fetch(parent, []).sort_by(&:position).map do |row|
node = if row.tag_id
{ 'tag' => tag_json(tags.fetch(row.tag_id), names[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
@@ -164,11 +153,11 @@ module TagGroupRepr
end
end
def historical_member locale, member, tag_versions, group_versions
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' => version_name(locale, version),
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
'category' => version.category } }
else
id = member.fetch('tag_group_id')
@@ -177,13 +166,13 @@ module TagGroupRepr
end
end
def historical_placement locale, node, tag_versions, group_versions,
tag_version_numbers, group_version_numbers
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' => version_name(locale, version),
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
'category' => version.category } }
elsif node['tag_group_id']
id = node.fetch('tag_group_id')
@@ -194,12 +183,12 @@ module TagGroupRepr
{ 'section_name' => node.fetch('section_name'), 'description' => node['description'] }
end
result.merge('children' => Array(node['children']).map do |child|
historical_placement(locale, child, tag_versions, group_versions,
historical_placement(child, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
end)
end
def version_name locale, version
version.names[locale.language_code]&.fetch('name')
def japanese_version_name version
version.names.fetch('ja').fetch('name')
end
end
+11 -24
ファイルの表示
@@ -2,16 +2,19 @@
module WikiPageRepr
BASE = { methods: [:title, :deprecated_at] }.freeze
module_function
def tag_description locale, description
tag = description.tag
def base wiki_page
wiki_page.as_json(BASE)
end
def tag_description description
tag = description.tag
{ 'id' => description.id,
'resource_type' => 'tag_description',
'tag_id' => tag.id,
'language_code' => description.language_code,
'tag_name_id' => tag.tag_name(locale.language_code)&.id,
'tag_name_id' => tag.tag_name('ja')&.id,
'body' => description.body,
'created_user_id' => description.created_by_user_id,
'updated_user_id' => description.updated_by_user_id,
@@ -19,7 +22,7 @@ module WikiPageRepr
'updated_at' => description.updated_at,
'discarded_at' => nil,
'version_no' => description.version_no,
'title' => tag.name(locale.language_code),
'title' => tag.name('ja'),
'deprecated_at' => tag.deprecated_at }
end
@@ -27,23 +30,7 @@ module WikiPageRepr
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
end
def autolink_tag locale, tag_id:, description_id:, name:
{ resource_type: 'tag_description',
resource_id: description_id,
tag_id:,
tag_description_id: description_id,
name:,
language_code: locale.language_code,
locale_code: locale.code_before_type_cast }
end
def autolink_group locale, id:, name:, language:
{ resource_type: 'tag_group',
resource_id: id,
tag_id: nil,
tag_description_id: nil,
name: "#{ TagGroupRepr::PREFIX }#{ name }",
language_code: language,
locale_code: locale.code_before_type_cast }
def many wiki_pages
wiki_pages.map { |p| base(p) }
end
end
+15 -12
ファイルの表示
@@ -95,10 +95,14 @@ class PostCreatePlan
def build_default_tag_specs locale, direct_tag_specs
default_tag_specs = []
if direct_tag_specs.length < 10 && direct_tag_specs.none? { _1[:name] == TAGME_TAG_NAME }
default_tag_specs << { name: TAGME_TAG_NAME, category: :meta }
default_tag_specs << {
name: TAGME_TAG_NAME,
category: :meta }
end
if direct_tag_specs.none? { deerjikist_tag_spec?(locale, _1) }
default_tag_specs << { name: NO_DEERJIKIST_TAG_NAME, category: :meta }
default_tag_specs << {
name: NO_DEERJIKIST_TAG_NAME,
category: :meta }
end
default_tag_specs
@@ -113,9 +117,9 @@ class PostCreatePlan
end
def validate_new_tag_spec! locale, spec
tag = Tag.new(category: spec[:category])
tag_name = TagName.new(tag:, name: spec[:name], language_code: locale.language_code,
tag_name = TagName.new(name: spec[:name], language_code: locale.language_code,
script_code: locale.script_code, primary_flg: true)
tag = Tag.new(category: spec[:category])
return if tag_name.valid? && tag.valid?
post = Post.new
@@ -136,10 +140,10 @@ class PostCreatePlan
end
expanded_parent_specs =
Tag.expand_parent_tags(existing_snapshot_tags)
.reject(&:deprecated?)
.map { |tag|
{ name: tag.name(locale.language_code),
category: tag.category.to_sym }
.reject(&:deprecated?)
.map { |tag|
{ name: tag.name(locale.language_code),
category: tag.category.to_sym }
}
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
end
@@ -160,10 +164,9 @@ class PostCreatePlan
def existing_tags_by_name locale
@existing_tags_by_name ||= begin
names = tag_names.map { canonical_tag_name_without_sections(locale, _1) }.uniq
TagName
.where(language_code: locale.language_code, primary_flg: true, name: names)
.includes(:tag)
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
TagName.where(language_code: locale.language_code, primary_flg: true, name: names)
.includes(:tag)
.each_with_object({ }) { |tag_name, tags| tags[tag_name.name] = tag_name.tag }
end
end
+14 -62
ファイルの表示
@@ -4,66 +4,29 @@ require 'digest'
class SharedResourceIdAllocator
SPACES = {
'tags' => ['tags', 'external_tags'],
'wiki' => ['tag_descriptions', 'tag_groups'],
'wiki_history' => ['tag_description_versions', 'tag_group_versions'] }.freeze
HISTORY_IDS = {
'tags' => [
['tag_versions', 'tag_id'],
['nico_tag_versions', 'tag_id'],
['tag_description_versions', 'tag_id']],
'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze
'wiki' => ['tag_descriptions', 'tag_groups'] }.freeze
LOCK_TIMEOUT = 10
TRANSACTION_LOCKS = ObjectSpace::WeakMap.new
def self.assign! record
space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) }
raise ArgumentError, 'Unknown shared ID resource' unless tables
connection = record.class.connection
lock_space!(record.class, space)
# 明示-tic な Id. 指定は許可する.
return if record.id.present?
next_id = record.class.uncached do
historical_max = HISTORY_IDS.fetch(space, []).map { |table, column|
quoted_table = connection.quote_table_name(table)
quoted_column = connection.quote_column_name(column)
connection.select_value("SELECT MAX(#{ quoted_column }) FROM #{ quoted_table }").to_i
}.max.to_i
[next_ids(connection, tables).max, historical_max + 1].max
end
record.id = next_id
end
def self.lock_space! record_class, space
raise ArgumentError, 'Unknown shared ID resource' unless SPACES.key?(space)
connection = record_class.connection
transaction = record_class.current_transaction
unless transaction.open?
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction'
end
transaction = record.class.current_transaction
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction' unless transaction.open?
database = connection.pool.db_config.database
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
held_locks = TRANSACTION_LOCKS[transaction] ||= {}
return if held_locks.key?(name)
quoted_name = connection.quote(name)
acquired = record_class.uncached do
acquired = record.class.uncached do
connection.select_value("SELECT GET_LOCK(#{ quoted_name }, #{ LOCK_TIMEOUT })")
end
unless acquired.to_i == 1
raise ActiveRecord::LockWaitTimeout, "Shared ID lock unavailable: #{ space }"
end
held_locks[name] = true
release = lambda do
held_locks.delete(name)
result = record_class.uncached do
result = record.class.uncached do
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
end
unless result.to_i == 1
@@ -77,28 +40,17 @@ class SharedResourceIdAllocator
release.call
raise
end
end
def self.next_ids connection, tables
expiry = connection.select_value('SELECT @@SESSION.information_schema_stats_expiry').to_i
begin
connection.execute('SET SESSION information_schema_stats_expiry = 0')
quoted_tables = tables.map { connection.quote(_1) }.join(', ')
rows = connection.select_all(<<~SQL).to_a
SELECT TABLE_NAME, ENGINE, AUTO_INCREMENT
FROM information_schema.tables
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (#{ quoted_tables })
SQL
persistent = rows.all? do |row|
row['ENGINE'] == 'InnoDB' && row['AUTO_INCREMENT'].to_i.positive?
return if record.id.present?
# Locking reads see committed maxima even after earlier repeatable reads.
maxima = record.class.uncached do
tables.map do |table|
quoted_table = connection.quote_table_name(table)
connection.select_value(
"SELECT id FROM #{ quoted_table } ORDER BY id DESC LIMIT 1 FOR UPDATE").to_i
end
unless rows.size == tables.size && persistent
raise ActiveRecord::ActiveRecordError, 'Persistent InnoDB counters are required'
end
rows.map { _1.fetch('AUTO_INCREMENT').to_i }
ensure
connection.execute("SET SESSION information_schema_stats_expiry = #{ expiry }")
end
record.id = maxima.max + 1
end
private_class_method :next_ids
end
+51 -48
ファイルの表示
@@ -1,69 +1,72 @@
class TagDescriptionOperation
class Conflict < StandardError
;
end
LANGUAGE_CODE = 'ja'
def self.create! locale, tag:, body:, created_by_user:, message: nil
def self.create!(tag:, body:, created_by_user:, message: nil)
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
tag_id: tag.id, language_code: locale.language_code)
tag_id: tag.id, language_code: LANGUAGE_CODE)
description = TagDescription.new(tag:, language_code: locale.language_code)
body = normalise_body!(description, body)
record_version!(description:, body:, user: created_by_user)
description
page = Wiki::CompatibilityWriter.create!(
tag_name: compatibility_tag_name(tag), body:, created_by_user:, message:)
revision = page.wiki_revisions.order(id: :desc).first
record_version!(tag:, revision:, body: revision.body, user: created_by_user,
description: nil)
page
end
end
def self.update! locale, tag:, body:, updated_by_user:, message: nil, base_revision_id: nil
def self.update!(tag:, body:, updated_by_user:, message: nil,
base_revision_id: nil)
ApplicationRecord.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
tag.lock!
description = TagDescription.lock.find_by!(
tag_id: tag.id, language_code: locale.language_code)
body = normalise_body!(description, body)
latest = TagDescriptionVersion.find_by(
tag_id: tag.id, language_code: locale.language_code,
version_no: description.version_no)
if base_revision_id.present? && latest&.id != base_revision_id.to_i
raise Conflict, '競合が発生してゐます.'
tag_id: tag.id, language_code: LANGUAGE_CODE)
tag_name = compatibility_tag_name(tag)
page = WikiPage.lock.find_by(tag_name_id: tag_name.id)
unless page
versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: LANGUAGE_CODE)
if base_revision_id.present? && versions.maximum(:id) != base_revision_id.to_i
raise Wiki::Commit::Conflict, '競合が発生してゐます.'
end
page = Wiki::CompatibilityWriter.create!(
tag_name:, body:, created_by_user: updated_by_user, message:)
revision = page.current_revision
record_version!(tag:, revision:, body: revision.body,
user: updated_by_user, description:)
next revision
end
next if latest && normalise_body(description.body) == body
revision = Wiki::CompatibilityWriter.update!(
page:, body:, updated_by_user:, message:,
base_revision_id:)
next unless revision
record_version!(description:, body:, user: updated_by_user)
record_version!(tag:, revision:, body: revision.body,
user: updated_by_user, description:)
revision
end
end
def self.record_version! description:, body:, user:
creating = description.new_record?
version_no = creating ? 1 : description.version_no + 1
description.created_by_user_id = user.id if creating
description.update!(body:, version_no:, updated_by_user_id: user.id)
def self.compatibility_tag_name tag
tag.tag_name(LANGUAGE_CODE) || TagName.find_by!(id: tag.tag_name_id,
language_code: LANGUAGE_CODE)
end
def self.record_version! tag:, revision:, body:, user:, description:
version_no = description ? description.version_no + 1 : 1
TagDescriptionVersion.create!(
tag_id: description.tag_id, language_code: description.language_code,
version_no:, event_type: creating ? 'create' : 'update', body:,
source_language_code: description.source_language_code,
source_version_no: description.source_version_no,
created_by_user_id: user.id, created_at: Time.current)
id: revision.id, tag_id: tag.id, language_code: LANGUAGE_CODE,
version_no:, event_type: description ? 'update' : 'create', body:,
created_by_user_id: user.id, created_at: revision.created_at)
if description
description.update!(body:, version_no:, updated_by_user_id: user.id)
else
TagDescription.create!(
tag_id: tag.id,
language_code: LANGUAGE_CODE, version_no:, body:,
created_by_user_id: user.id, updated_by_user_id: user.id)
end
end
private_class_method :record_version!
def self.normalise_body body
body.to_s.gsub(/\r\n?/, "\n")
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
.sub(/\n+\z/, '')
end
private_class_method :normalise_body
def self.normalise_body! description, body
normalised = normalise_body(body)
return normalised if normalised.present?
description.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, description
end
private_class_method :normalise_body!
end
+21 -22
ファイルの表示
@@ -7,8 +7,8 @@ class TagGroupMarkdownParser
;
end
def self.parse(locale, body:, group: nil)
new(body:, group:).parse(locale)
def self.parse(body:, group: nil)
new(body:, group:).parse
end
def initialize(body:, group:)
@@ -16,7 +16,7 @@ class TagGroupMarkdownParser
@group = group
end
def parse locale
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 = []
@@ -51,7 +51,7 @@ class TagGroupMarkdownParser
when :ul
invalid!('未対応のリスト項目があります.') if node.attr.any?
target = headings.any? ? headings.last.fetch(:node).fetch('children') : placements
target.concat(node.children.map { parse_list_item(locale, _1) })
target.concat(node.children.map { parse_list_item(_1) })
else
invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.")
end
@@ -66,7 +66,7 @@ class TagGroupMarkdownParser
private
def parse_list_item locale, item
def parse_list_item(item)
invalid!('リスト項目ではありません.') unless item.type == :li
invalid!('未対応のリスト項目があります.') if item.attr.any?
@@ -81,30 +81,29 @@ class TagGroupMarkdownParser
label = own.map { inline_text(_1) }.join(' ').strip
invalid!('空のリスト項目は使用できません.') if label.blank?
node =
if (group_name = TagGroupRepr.name_from_title(label))
group_name = group_name.strip
child_group = TagGroup.find_by(name: group_name)
invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group
invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id
{ 'tag_group_id' => child_group.id }
elsif label.end_with?(':', ':')
section_name = label.sub(/[::]\z/, '').strip
invalid!('Section 名は必須です.') if section_name.blank?
{ 'section_name' => section_name, 'description' => nil }
else
{ 'tag_id' => resolve_tag(locale, label) }
end
node = 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(locale, _1) }
list.children.map { parse_list_item(_1) }
end
node
end
def resolve_tag locale, name
names = TagName.where(language_code: locale.language_code, name:).to_a
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?
+9 -14
ファイルの表示
@@ -19,20 +19,15 @@ class TagGroupMarkdownRenderer
blocks << clean_text(@group['description'])
end
first_section = placements.index { _1.key?('section_name') }
sections = placements.select { _1.key?('section_name') }
if sections.any? && sections.size != placements.size
raise UnsupportedStructure,
'最上位の見出し Section と Placement の混在は Markdown にできません.'
end
if first_section
if placements.drop(first_section).any? { !_1.key?('section_name') }
raise UnsupportedStructure,
'見出し Section の後に最上位 Placement は配置できません.'
end
if first_section.positive?
blocks << render_list(placements.take(first_section), 0)
end
blocks.concat(placements.drop(first_section).map { render_heading(_1, 2) })
elsif placements.any?
if placements.all? { _1.key?('section_name') }
blocks.concat(placements.map { render_heading(_1, 2) })
else
blocks << render_list(placements, 0)
end
@@ -79,7 +74,7 @@ class TagGroupMarkdownRenderer
first_section = section_indexes.first
if children[first_section..].any? { !_1.key?('section_name') }
raise UnsupportedStructure,
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
end
list_nodes = children[0...first_section]
blocks << render_list(list_nodes, 0) if list_nodes.any?
+77 -29
ファイルの表示
@@ -1,51 +1,49 @@
class TagGroupOperation
MAX_PLACEMENT_DEPTH = 5
PREFIX = TagGroupRepr::PREFIX
def self.create!(locale, name:, description:, members:, placements:, actor:,
require_compatibility: false)
ApplicationRecord.transaction do
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!(locale:, group:, name:, description:, members:, placements:)
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:)
validate_compatibility!(locale, group) if require_compatibility
persist_version!(group:, attributes:, actor:, event_type: 'create')
group
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!(locale, group:, name:, description:, members:, placements:, actor:,
require_compatibility: false)
ApplicationRecord.transaction do
def self.update!(group:, name:, description:, members:, placements:, actor:,
require_compatibility: false, compatibility_body: nil)
group, sync_issue = ApplicationRecord.transaction do
group.lock!
attributes = prepare!(locale:, group:, name:, description:, members:, placements:)
if unchanged?(group, attributes)
validate_compatibility!(locale, group) if require_compatibility
next group
end
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:)
validate_compatibility!(locale, group) if require_compatibility
persist_version!(group:, attributes:, actor:, event_type: 'update')
group
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.validate_compatibility! locale, group
TagGroupMarkdownRenderer.render(TagGroupRepr.one(locale, group))
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
invalid!(group, :base, error.message)
end
private_class_method :validate_compatibility!
def self.prepare!(locale:, group:, name:, description:, members:, placements:)
def self.prepare!(group:, name:, description:, members:, placements:)
record = group
normalized_name = name.to_s.strip
invalid!(record, :name, 'グループ名は必須です.') if normalized_name.blank?
@@ -63,7 +61,7 @@ class TagGroupOperation
member_rows = normalize_members(record, members)
placement_tree = normalize_placements(record, placements)
validate_references!(locale, record, member_rows, placement_tree)
validate_references!(record, member_rows, placement_tree)
validate_containment!(record, member_rows)
{ name: normalized_name, description:, members: member_rows,
placements: placement_tree }
@@ -187,7 +185,7 @@ class TagGroupOperation
end
private_class_method :normalize_placement_nodes
def self.validate_references!(locale, record, members, placements)
def self.validate_references!(record, members, placements)
membership = members.to_set
placed = placement_references(placements).uniq
missing = placed - membership.to_a
@@ -206,14 +204,13 @@ class TagGroupOperation
tag_versions = TagVersion.where(tag_id: tag_ids).pluck(:tag_id, :version_no).to_set
group_versions = TagGroupVersion.where(tag_group_id: group_ids)
.pluck(:tag_group_id, :version_no).to_set
tag_names = TagName.where(
tag_id: tag_ids, language_code: locale.language_code, primary_flg: true)
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, :invalid) unless
invalid!(record, :members, "Tag ##{ id } に日本語の正規名がありません.") unless
tag_names.include?(id)
version_no = tags.fetch(id).version_no
unless tag_versions.include?([id, version_no])
@@ -316,6 +313,57 @@ class TagGroupOperation
end
private_class_method :persist_version!
def self.sync_compatibility!(group:, actor:, old_name:,
require_compatibility:, compatibility_body:)
representation = TagGroupRepr.one(group)
body = begin
TagGroupMarkdownRenderer.render(representation)
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
if compatibility_body.present?
compatibility_body
elsif require_compatibility
invalid!(group, :base, error.message)
else
return error.message
end
end
invalid!(group, :base, '本文は必須です.') if body.blank?
title = "#{ PREFIX }#{ group.name }"
old_title = old_name && "#{ PREFIX }#{ old_name }"
page = if old_title
WikiPage.joins(:tag_name).find_by(tag_names: { name: old_title })
else
nil
end
page ||= WikiPage.joins(:tag_name).find_by(tag_names: { name: title })
if page
tag_name = page.tag_name
tag_name.update!(name: title) if tag_name.name != title
Wiki::Commit.legacy_content!(page:, body:, created_user: actor, message: nil,
base_revision_id: nil)
else
tag_name = TagName.find_or_initialize_by(language_code: 'ja', name: title)
tag_name.script_code = 'Jpan'
tag_name.primary_flg = true
tag_name.auto_generated = false
tag_name.save!
Wiki::CompatibilityWriter.create!(tag_name:, body:, created_by_user: actor)
end
nil
end
private_class_method :sync_compatibility!
def self.report_sync_issue(group, actor, reason)
return unless reason
payload = { status: 'unsupported_structure', tag_group_id: group.id,
version_no: group.version_no, user_id: actor&.id, reason: }
Rails.logger.warn("tag_group_wiki_sync_skipped #{ payload.to_json }")
end
private_class_method :report_sync_issue
def self.placement_references(nodes)
nodes.flat_map do |node|
own = if node.key?('tag_id')
-80
ファイルの表示
@@ -1,80 +0,0 @@
class TagNameCompletion
class << self
def for_locale! locale
completed = Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
named_tags =
TagName.where(language_code: locale.language_code, primary_flg: true)
.select(:tag_id)
Tag.where.not(id: named_tags).lock.find_each(order: :asc) do |tag|
for_tag!(tag, locales: [locale])
end
true
end
unless completed
raise ActiveRecord::RecordNotSaved, 'Locale tag completion was rolled back'
end
end
def for_tag! tag, locales: nil
locales ||=
Locale.where(deprecated_at: nil)
.order(:language_code, :created_at, :script_code).lock
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
completed = tag.with_lock do
primary_names = tag.tag_names.where(primary_flg: true).lock.index_by(&:language_code)
missing_locales = locales.to_a.uniq(&:language_code).reject { |locale|
primary_names.key?(locale.language_code)
}
next true if missing_locales.empty?
TagVersioning.ensure_snapshot!(tag, created_by_user: nil)
missing_locales.each do |locale|
source_language = locale.language_code == 'ja' ? 'ain' : 'ja'
name = TagName.generate_name(locale, tag, primary_names[source_language])
primary_names[locale.language_code] = create_name!(locale, tag, name)
end
tag.association(:tag_names).reset
version = TagVersioning.record!(tag, event_type: :update, created_by_user: nil)
unless version&.persisted?
raise ActiveRecord::RecordNotSaved, 'Tag version was not persisted'
end
true
end
unless completed
raise ActiveRecord::RecordNotSaved, 'Tag completion was rolled back'
end
end
end
private
def create_name! locale, tag, name
persist_name!(locale, tag, name)
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
fallback = "Tag_##{ tag.id }"
invalid_name =
if error.is_a?(ActiveRecord::RecordNotUnique)
TagName.where(language_code: locale.language_code, name:).lock.exists?
else
error.record.errors[:name].present? &&
error.record.errors.attribute_names.all? { _1 == :name }
end
raise if name == fallback || !(invalid_name)
persist_name!(locale, tag, fallback)
end
def persist_name! locale, tag, name
tag_name = TagName.transaction(requires_new: true) do
TagName.create!(tag:, name:, language_code: locale.language_code,
script_code: locale.script_code, primary_flg: true, auto_generated: true)
end
unless tag_name&.persisted?
raise ActiveRecord::RecordNotSaved, 'Generated tag name was not persisted'
end
tag_name
end
end
end
-56
ファイルの表示
@@ -1,56 +0,0 @@
module TagNameTranscription
class Unconvertible < StandardError
;
end
def self.ainu name
previous_script = nil
parts = []
NipponeseSegments.split(name).each do |script, text|
output = transcribe_segment(script, text)
if (parts.last && output.first && previous_script && script != previous_script &&
NipponeseSegments.nipponese?(previous_script) && NipponeseSegments.nipponese?(script))
parts << '_'
end
parts.concat(output)
previous_script = script
end
replace_unconvertible(parts)
end
def self.transcribe_segment script, text
return [text] if script == :literal
return [nil] if script == :unconvertible
return transcribe_reading(text) unless script == :kanzi
text.each_char.flat_map do |character|
transcribe_reading(JisReadings.read(character))
rescue Unconvertible
[nil]
end
end
def self.transcribe_reading reading
Phonology.adapt(reading).map do |sound|
sound && Katakana.render([sound])
rescue Unconvertible
nil
end
end
def self.replace_unconvertible parts
grouped = parts.chunk { _1.nil? }.flat_map { |unconvertible, group|
unconvertible ? [nil] : group
}
first = grouped.index { _1&.match?(/[^_]/) }
last = grouped.rindex { _1&.match?(/[^_]/) }
grouped.each_with_index.map { |part, index|
part || (first && first < index && index < last ? '_' : '')
}.join
end
private_class_method :transcribe_segment, :transcribe_reading, :replace_unconvertible
end
-13
ファイルの表示
@@ -1,13 +0,0 @@
module TagNameTranscription
module JisReadings
SOURCE = Rails.root.join('config/tag_name_readings/jis_level_one.tsv')
READINGS = File.foreach(SOURCE, chomp: true).reject { _1.start_with?('#') }.to_h { |line|
character, reading, = line.split("\t")
[character, reading]
}.freeze
def self.read text
text.each_char.map { READINGS.fetch(_1) { raise Unconvertible } }.join
end
end
end
-26
ファイルの表示
@@ -1,26 +0,0 @@
module TagNameTranscription
module Katakana
ROWS = {
'' => 'アイウエオ',
'k' => 'カキクケコ',
's' => 'サシスセソ',
'c' => ['チャ', 'チ', 'チュ', 'チェ', 'チョ'],
't' => ['タ', 'ティ', 'トゥ', 'テ', 'ト'],
'n' => 'ナニヌネノ',
'h' => 'ハヒフヘホ',
'p' => 'パピプペポ',
'm' => 'マミムメモ',
'r' => 'ラリルレロ' }.freeze
SYLLABLES = ROWS.flat_map { |onset, letters|
kana = letters.is_a?(String) ? letters.chars : letters
['a', 'i', 'u', 'e', 'o'].zip(kana).map { |vowel, letter| [onset + vowel, letter] }
}.to_h.merge(
'ya' => 'ヤ', 'yu' => 'ユ', 'yo' => 'ヨ', 'wa' => 'ワ',
'k' => 'ㇰ', 's' => 'ㇱ', 't' => 'ッ', 'n' => 'ン', 'm' => 'ㇺ', 'p' => 'ㇷ゚',
'r:a' => 'ㇻ', 'r:i' => 'ㇼ', 'r:u' => 'ㇽ', 'r:e' => 'ㇾ', 'r:o' => 'ㇿ').freeze
def self.render sounds
sounds.map { SYLLABLES.fetch(_1) { raise Unconvertible } }.join
end
end
end
-30
ファイルの表示
@@ -1,30 +0,0 @@
module TagNameTranscription
module NipponeseSegments
NIPPONESE_SCRIPTS = [:kanzi, :xiragana, :katakana].freeze
def self.nipponese?(script) = NIPPONESE_SCRIPTS.include?(script)
def self.split name
segments = []
name.to_s.scan(/\X/).each do |character|
script = classify(character, segments.last&.first)
if segments.last&.first == script
segments.last[1] << character
else
segments << [script, character.dup]
end
end
segments
end
def self.classify character, previous_script
return previous_script if character == 'ー' && nipponese?(previous_script)
return :kanzi if character.match?(/\A\p{Han}\z/)
return :xiragana if character.match?(/\A\p{Hiragana}\p{M}*\z/)
return :katakana if character.match?(/\A\p{Katakana}\p{M}*\z/)
return :literal if character.match?(/\A(?:\p{Latin}\p{M}*|[!-~])\z/)
:unconvertible
end
end
end
-59
ファイルの表示
@@ -1,59 +0,0 @@
module TagNameTranscription
module Phonology
ROWS = {
'アイウエオ' => ['a', 'i', 'u', 'e', 'o'],
'カキクケコ' => ['ka', 'ki', 'ku', 'ke', 'ko'],
'ガギグゲゴ' => ['ka', 'ki', 'ku', 'ke', 'ko'],
'サシスセソ' => ['sa', 'si', 'su', 'se', 'so'],
'ザジズゼゾ' => ['ca', 'ci', 'cu', 'ce', 'co'],
'タチツテト' => ['ta', 'ci', 'cu', 'te', 'to'],
'ダヂヅデド' => ['ta', 'ci', 'cu', 'te', 'to'],
'ナニヌネノ' => ['na', 'ni', 'nu', 'ne', 'no'],
'ハヒフヘホ' => ['ha', 'hi', 'hu', 'he', 'ho'],
'バビブベボ' => ['pa', 'pi', 'pu', 'pe', 'po'],
'パピプペポ' => ['pa', 'pi', 'pu', 'pe', 'po'],
'マミムメモ' => ['ma', 'mi', 'mu', 'me', 'mo'],
'ヤユヨ' => ['ya', 'yu', 'yo'],
'ラリルレロ' => ['ra', 'ri', 'ru', 're', 'ro'],
'ワ' => ['wa'],
'ン' => ['n'],
'ッ' => ['t'],
'ㇰ' => ['k'],
'ㇱㇲ' => ['s', 's'],
'ㇳ' => ['t'],
'ㇴ' => ['n'],
'ㇺ' => ['m'],
'ㇻㇼㇽㇾㇿ' => ['r:a', 'r:i', 'r:u', 'r:e', 'r:o'] }.freeze
SOUNDS = ROWS.flat_map { |characters, sounds| characters.chars.zip(sounds) }.to_h.freeze
DIGRAPHS = {
'シャ' => 'sa', 'シュ' => 'su', 'シェ' => 'se', 'ショ' => 'so',
'チャ' => 'ca', 'チュ' => 'cu', 'チェ' => 'ce', 'チョ' => 'co',
'ジャ' => 'ca', 'ジュ' => 'cu', 'ジェ' => 'ce', 'ジョ' => 'co',
'ヂャ' => 'ca', 'ヂュ' => 'cu', 'ヂェ' => 'ce', 'ヂョ' => 'co' }.freeze
def self.adapt reading
kana = reading.unicode_normalize(:nfc).tr('ぁ-ゖ', 'ァ-ヶ').delete('ー')
characters = kana.scan(/\X/)
sounds = []
index = 0
while index < characters.length
pair = characters[index, 2].join
if DIGRAPHS.key?(pair)
sounds << DIGRAPHS.fetch(pair)
index += 2
next
end
character = characters[index].tr('ャュョ', 'ヤユヨ')
sounds << (character == 'ㇷ゚' ? 'p' : SOUNDS[character])
index += 1
end
sounds
end
end
end
+4 -10
ファイルの表示
@@ -1,9 +1,6 @@
class TagVersionRecorder < VersionRecorder
def self.record! tag:, event_type:, created_by_user:, **_options
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
new(tag:, event_type:, created_by_user:).record!
end
new(tag:, event_type:, created_by_user:).record!
end
def initialize tag:, event_type:, created_by_user:
@@ -18,21 +15,18 @@ class TagVersionRecorder < VersionRecorder
def snapshot_attributes
names = snapshot_names
compatibility_names = names['ja'] || names.fetch('ain')
parent_ids =
TagImplication.where(tag_id: @record.id).order(:parent_tag_id)
.lock.pluck(:parent_tag_id)
compatibility_names = names.fetch('ja')
{ name: compatibility_names.fetch('name'),
category: @record.category,
deprecated_at: @record.deprecated_at,
aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '),
names:,
parent_tag_ids: parent_ids.join(' ') }
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
end
def snapshot_names
@record.tag_names.order(:language_code, :name).lock.group_by(&:language_code)
@record.tag_names.order(:language_code, :name).group_by(&:language_code)
.transform_values do |tag_names|
primary = tag_names.find(&:primary?)
aliases = tag_names.reject(&:primary?).sort_by(&:name).map { |tag_name|
+4 -13
ファイルの表示
@@ -4,23 +4,14 @@ class TagVersioning
end
def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja'
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
locked_tag = Tag.unscoped.lock.find(tag.id)
next if locked_tag.tag_versions.lock.exists?
return if tag.tag_versions.exists?
TagVersionRecorder.record!(
tag: locked_tag, event_type: :create, created_by_user:, language_code:)
end
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:, language_code:)
end
def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja'
Tag.transaction do
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
locked_tag = Tag.unscoped.lock.find(tag.id)
event_type = locked_tag.tag_versions.lock.exists? ? :update : :create
record!(locked_tag, event_type:, created_by_user:, language_code:)
end
event_type = tag.tag_versions.exists? ? :update : :create
record!(tag, event_type:, created_by_user:, language_code:)
end
def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja'
+1 -1
ファイルの表示
@@ -35,7 +35,7 @@ class VersionRecorder
private
def latest_version = versions.order(version_no: :desc).lock.first
def latest_version = versions.order(version_no: :desc).first
def versions = @record.public_send(version_association)
+164
ファイルの表示
@@ -0,0 +1,164 @@
require 'digest'
module Wiki
class Commit
class Conflict < StandardError
;
end
def self.create_content! tag_name:, body:, created_by_user:, message: nil
if tag_name.tag && tag_name.language_code == 'ja' && !tag_name.name.include?(':')
return TagDescriptionOperation.create!(
tag: tag_name.tag, body:, created_by_user:, message:)
end
tag_name.errors.add(:base, :invalid)
raise ActiveRecord::RecordInvalid, tag_name
end
def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil
normalised = normalise_body(body)
page = WikiPage.new(tag_name:,
body: normalised,
created_user: created_by_user,
updated_user: created_by_user)
if normalised.blank?
page.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, page
end
ActiveRecord::Base.transaction do
page.save!
new(page:, created_user: created_by_user).content!(
body: normalised,
message:,
base_revision_id: nil)
page
end
end
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
description = TagDescription.find_by(tag_id: page.tag_name.tag_id, language_code: 'ja')
description ||= TagDescription.joins(:tag).find_by(
tags: { tag_name_id: page.tag_name_id }, language_code: 'ja')
if description && !page.title.include?(':')
return TagDescriptionOperation.update!(
tag: description.tag, body:, updated_by_user: created_user,
message:, base_revision_id:)
end
page.errors.add(:base, :invalid)
raise ActiveRecord::RecordInvalid, page
end
def self.legacy_content! page:, body:, created_user:, message: nil,
base_revision_id: nil
new(page:, created_user:).content!(body:, message:, base_revision_id:)
end
def self.redirect! page:, redirect_page:, created_user:, message: nil, base_revision_id: nil
new(page:, created_user:).redirect!(redirect_page:, message:, base_revision_id:)
end
def initialize page:, created_user:
@page = page
@created_user = created_user
end
def content! body:, message:, base_revision_id:
normalised = self.class.normalise_body(body)
if normalised.blank?
@page.errors.add(:body, :blank)
raise ActiveRecord::RecordInvalid, @page
end
lines = split_lines(normalised)
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
tree_sha = Digest::SHA256.hexdigest(line_shas.join(','))
ActiveRecord::Base.transaction do
@page.lock!
if base_revision_id.present?
current_id = @page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i
raise Conflict,
"競合が発生してゐます" +
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
end
end
line_id_by_sha = upsert_lines!(lines, line_shas)
line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) }
@page.update!(body: normalised)
WikiVersionRecorder.record!(
page: @page,
event_type: @page.wiki_versions.exists? ? :update : :create,
reason: message,
created_by_user: @created_user)
rev = WikiRevision.create!(
wiki_page: @page,
base_revision_id:,
created_user: @created_user,
kind: :content,
redirect_page_id: nil,
message:,
lines_count: lines.length,
tree_sha256: tree_sha)
rows = line_ids.each_with_index.map do |line_id, pos|
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
end
WikiRevisionLine.insert_all!(rows) if rows.any?
rev
end
end
def redirect!(redirect_page:, message:, base_revision_id:) = raise '廃止しました.'
def self.normalise_body body
s = body.to_s
s.gsub!(/\r\n?/, "\n")
s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '🖕')
s.gsub(/\n+$/, '')
end
private
def split_lines(body) = body.split("\n")
def upsert_lines! lines, line_shas
now = Time.current
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
missing_by_sha = { }
line_shas.each_with_index do |sha, i|
next if id_by_sha.key?(sha)
next if missing_by_sha.key?(sha)
missing_by_sha[sha] = {
sha256: sha,
body: lines[i],
created_at: now,
updated_at: now }
end
if missing_by_sha.any?
WikiLine.upsert_all(missing_by_sha.values)
id_by_sha = WikiLine.where(sha256: line_shas).pluck(:sha256, :id).to_h
end
id_by_sha
end
end
end
+43
ファイルの表示
@@ -0,0 +1,43 @@
module Wiki
class CompatibilityWriter
def self.create! tag_name:, body:, created_by_user:, message: nil
ApplicationRecord.transaction do
tag_name.lock!
page = WikiPage.lock.find_by(tag_name_id: tag_name.id)
if page
update!(page:, body:, updated_by_user: created_by_user, message:)
page
else
Commit.create_legacy_content!(tag_name:, body:, created_by_user:, message:)
end
end
end
def self.update! page:, body:, updated_by_user:, message: nil,
base_revision_id: nil
normalised = Commit.normalise_body(body)
if normalised.blank?
return Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:,
base_revision_id:)
end
if base_revision_id.present?
current_id = page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i
raise Commit::Conflict,
"競合が発生してゐます" +
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
end
end
if Commit.normalise_body(page.body) == normalised && page.wiki_revisions.exists?
return
end
Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:,
base_revision_id:)
end
end
end
+21
ファイルの表示
@@ -0,0 +1,21 @@
class WikiVersionRecorder < VersionRecorder
def self.record! page:, event_type:, reason: nil, created_by_user:
new(page:, event_type:, reason:, created_by_user:).record!
end
def initialize page:, event_type:, reason: nil, created_by_user:
@reason = reason
super(record: page, event_type:, created_by_user:)
end
private
def version_class = WikiVersion
def version_association = :wiki_versions
def record_key = :wiki_page
def snapshot_attributes = {
title: @record.title,
body: @record.body,
reason: @reason }
end
-3
ファイルの表示
@@ -1,6 +1,4 @@
Rails.application.routes.draw do
resources :locales, only: [:index]
resources :tag_groups, only: [:index, :show, :create, :update]
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
@@ -45,7 +43,6 @@ Rails.application.routes.draw do
collection do
get :search, action: :index
get :changes
get :autolink_candidates
scope :title do
get ':title/exists', action: :exists_by_title
ファイル差分が大きすぎるため省略します 差分を読み込み
-9
ファイルの表示
@@ -1,9 +0,0 @@
class DropWikiPages < ActiveRecord::Migration[8.0]
def change
drop_table :wiki_versions
drop_table :wiki_revision_lines
drop_table :wiki_lines
drop_table :wiki_revisions
drop_table :wiki_pages
end
end
-11
ファイルの表示
@@ -1,11 +0,0 @@
class RemoveTagNameIdFromTags < ActiveRecord::Migration[8.0]
def up
remove_foreign_key :tags, column: :tag_name_id
remove_foreign_key :tag_names, column: :canonical_id
remove_column :tags, :tag_name_id
remove_column :tag_names, :canonical_id
end
def down = raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
-15
ファイルの表示
@@ -1,15 +0,0 @@
class DeleteNameForWikiFromTagNames < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
tag_names
WHERE
tag_id IS NULL
SQL
change_column_null :tag_names, :tag_id, false
end
def down = raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
生成ファイル
+89 -2
ファイルの表示
@@ -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_15_010000) 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
@@ -578,15 +578,17 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
end
create_table "tag_names", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.bigint "tag_id"
t.string "language_code", limit: 16, null: false
t.string "name", null: false
t.string "script_code", limit: 4, null: false
t.boolean "primary_flg", null: false
t.boolean "auto_generated", default: false, null: false
t.bigint "canonical_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.virtual "primary_tag_id", type: :bigint, as: "(case when `primary_flg` then `tag_id` else NULL end)"
t.index ["canonical_id"], name: "index_tag_names_on_canonical_id"
t.index ["language_code", "name"], name: "index_tag_names_on_language_code_and_name", unique: true
t.index ["primary_tag_id", "language_code"], name: "index_tag_names_on_primary_tag_id_and_language_code", unique: true
t.index ["script_code"], name: "fk_rails_dd783b3d1c"
@@ -623,6 +625,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
end
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false
t.string "category", default: "general", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false
@@ -630,6 +633,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.integer "post_count", default: 0, null: false
t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
end
@@ -769,6 +773,76 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.index ["banned_at"], name: "index_users_on_banned_at"
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false
t.text "body", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["sha256"], name: "index_wiki_lines_on_sha256", unique: true
end
create_table "wiki_pages", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false
t.text "body", null: false
t.bigint "created_user_id", null: false
t.bigint "updated_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
end
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_revision_id", null: false
t.integer "position", null: false
t.bigint "wiki_line_id", null: false
t.index ["wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_line_id"
t.index ["wiki_revision_id", "position"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_position", unique: true
t.index ["wiki_revision_id", "wiki_line_id"], name: "index_wiki_revision_lines_on_wiki_revision_id_and_wiki_line_id"
t.index ["wiki_revision_id"], name: "index_wiki_revision_lines_on_wiki_revision_id"
end
create_table "wiki_revisions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.bigint "base_revision_id"
t.bigint "created_user_id", null: false
t.integer "kind", default: 0, null: false
t.bigint "redirect_page_id"
t.string "message"
t.integer "lines_count", default: 0, null: false
t.string "tree_sha256", limit: 64
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["base_revision_id"], name: "index_wiki_revisions_on_base_revision_id"
t.index ["created_user_id"], name: "index_wiki_revisions_on_created_user_id"
t.index ["kind"], name: "index_wiki_revisions_on_kind"
t.index ["redirect_page_id"], name: "index_wiki_revisions_on_redirect_page_id"
t.index ["tree_sha256"], name: "index_wiki_revisions_on_tree_sha256"
t.index ["wiki_page_id", "id"], name: "index_wiki_revisions_on_wiki_page_id_and_id"
t.index ["wiki_page_id"], name: "index_wiki_revisions_on_wiki_page_id"
end
create_table "wiki_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "title", null: false
t.text "body", null: false
t.text "reason"
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "gekanator_ai_runs", "gekanator_question_suggestions"
@@ -841,10 +915,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
add_foreign_key "tag_implications", "tags", column: "parent_tag_id"
add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code"
add_foreign_key "tag_names", "scripts", column: "script_code", primary_key: "code"
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_names", "tags"
add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users"
add_foreign_key "theatre_programmes", "posts"
@@ -869,4 +945,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users"
add_foreign_key "user_theme_slots", "users"
add_foreign_key "wiki_pages", "tag_names"
add_foreign_key "wiki_pages", "users", column: "created_user_id"
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
add_foreign_key "wiki_revision_lines", "wiki_lines"
add_foreign_key "wiki_revision_lines", "wiki_revisions"
add_foreign_key "wiki_revisions", "users", column: "created_user_id"
add_foreign_key "wiki_revisions", "wiki_pages"
add_foreign_key "wiki_revisions", "wiki_pages", column: "redirect_page_id"
add_foreign_key "wiki_revisions", "wiki_revisions", column: "base_revision_id"
add_foreign_key "wiki_versions", "users", column: "created_by_user_id"
add_foreign_key "wiki_versions", "wiki_pages"
end
-5
ファイルの表示
@@ -8,11 +8,6 @@
# MovieGenre.find_or_create_by!(name: genre_name)
# end
ApplicationRecord.transaction do
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
end
post_url_sanitisation_rules = [
{ priority: 10,
source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z',
+34
ファイルの表示
@@ -0,0 +1,34 @@
require 'rails_helper'
require_relative '../../db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages'
RSpec.describe 'Tag description migration identity' do
it 'preserves every revision ID and repeated body in an independent sequence' do
user = create_member_user!
tag_name = create(:tag_name, name: 'description_migration')
page = Wiki::Commit.create_legacy_content!(
tag_name:, body: 'A', created_by_user: user)
revisions = [page.current_revision]
['A', 'B', 'B', 'A'].each do |body|
revisions << Wiki::Commit.legacy_content!(
page:, body:, created_user: user, base_revision_id: revisions.last.id)
end
tag = create(:tag, primary_tag_name: tag_name)
legacy_state = [page.reload.attributes, revisions.map(&:attributes)]
expect(page.wiki_versions.count).to eq(3)
migration = MigrateTagDescriptionsFromWikiPages.new
migration.suppress_messages { migration.up }
versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: 'ja')
.order(:version_no)
expect(versions.pluck(:id)).to eq(revisions.map(&:id))
expect(versions.pluck(:version_no)).to eq([1, 2, 3, 4, 5])
expect(versions.pluck(:body)).to eq(['A', 'A', 'B', 'B', 'A'])
expect(versions.pluck(:created_by_user_id)).to eq([user.id] * 5)
expect(versions.pluck(:created_at)).to eq(revisions.map(&:created_at))
expect(TagDescription.find(page.id)).to have_attributes(
tag_id: tag.id, language_code: 'ja', version_no: 5, body: 'A')
expect([page.reload.attributes, revisions.map { |revision| revision.reload.attributes }])
.to eq(legacy_state)
end
end
+119
ファイルの表示
@@ -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
+5 -16
ファイルの表示
@@ -1,17 +1,11 @@
require 'rails_helper'
RSpec.describe 'TagName database invariants' do
def insert_name! **attributes
defaults = {
tag_id: create(:tag, primary_tag_name: nil).id,
name: 'constraint_name',
language_code: 'ja',
script_code: 'Jpan',
primary_flg: true,
created_at: Time.current,
updated_at: Time.current }
TagName.insert_all!([defaults.merge(attributes)])
def insert_name!(**attributes)
TagName.insert_all!([{ name: 'constraint_name', language_code: 'ja',
script_code: 'Jpan', primary_flg: true,
created_at: Time.current, updated_at: Time.current,
**attributes }])
end
it 'defaults persisted names to human-confirmed provenance' do
@@ -39,9 +33,4 @@ RSpec.describe 'TagName database invariants' do
.to raise_error(ActiveRecord::RecordNotUnique)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
end
it 'rejects a name without a tag' do
expect { insert_name!(tag_id: nil) }
.to raise_error(ActiveRecord::NotNullViolation)
end
end
-23
ファイルの表示
@@ -1,23 +0,0 @@
require 'rails_helper'
RSpec.describe 'Schema after Wiki removal' do
it 'has no legacy Wiki tables or name indirection columns' do
connection = ApplicationRecord.connection
['wiki_pages', 'wiki_revisions', 'wiki_lines', 'wiki_revision_lines', 'wiki_versions']
.each do |table|
expect(connection.table_exists?(table)).to be(false)
end
expect(connection.column_exists?(:tags, :tag_name_id)).to be(false)
expect(connection.column_exists?(:tag_names, :canonical_id)).to be(false)
end
it 'requires TagName ownership while keeping the native resources and history' do
connection = ApplicationRecord.connection
expect(TagName.columns_hash.fetch('tag_id').null).to be(false)
expect(connection.foreign_key_exists?(:tag_names, :tags)).to be(true)
['tag_descriptions', 'tag_description_versions', 'tag_groups', 'tag_group_versions']
.each do |table|
expect(connection.table_exists?(table)).to be(true)
end
end
end
-2
ファイルの表示
@@ -6,8 +6,6 @@ FactoryBot.define do
auto_generated { false }
sequence(:name) { |number| "tag-#{ SecureRandom.hex(4) }-#{ number }" }
association :tag, primary_tag_name: nil
trait :alias do
primary_flg { false }
end
+18 -7
ファイルの表示
@@ -4,7 +4,7 @@ FactoryBot.define do
primary_name { nil }
primary_tag_name do
attributes = primary_name.nil? ? { } : { name: primary_name }
build(:tag_name, tag: nil, **attributes)
build(:tag_name, **attributes)
end
end
@@ -12,19 +12,30 @@ FactoryBot.define do
post_count { 0 }
after(:build) do |tag, evaluator|
next unless evaluator.primary_tag_name
evaluator.primary_tag_name.primary_flg = true
evaluator.primary_tag_name.tag = tag
tag.tag_names.target << evaluator.primary_tag_name
end
after(:create) do |tag, evaluator|
next unless evaluator.primary_tag_name
before(:create) do |tag, evaluator|
evaluator.primary_tag_name.save!
# Compatibility write only; ownership is established through tag_id below.
tag.tag_name_id = evaluator.primary_tag_name.id
end
after(:create) do |tag, evaluator|
evaluator.primary_tag_name.update!(tag:, primary_flg: true)
TagNameCompletion.for_tag!(tag)
Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a
.uniq(&:language_code).each do |locale|
next if tag.tag_names.exists?(language_code: locale.language_code,
primary_flg: true)
create(:tag_name,
tag:, language_code: locale.language_code,
script_code: locale.script_code,
name: TagName.generate_name(locale, tag, evaluator.primary_tag_name),
primary_flg: true, auto_generated: true)
end
end
end
end
+9
ファイルの表示
@@ -0,0 +1,9 @@
FactoryBot.define do
factory :wiki_page do
title { "TestPage" }
association :created_user, factory: :user
association :updated_user, factory: :user
body { ' ' }
end
end
+3 -2
ファイルの表示
@@ -6,8 +6,9 @@ RSpec.describe Language, type: :model do
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja'])
end
it 'accepts Ainu when multilingual support is enabled' do
expect { described_class.create!(code: 'ain', name: 'ain') }
it 'accepts English when multilingual support is enabled' do
pending '日本語以外の Language 有効化は別課題で実装する'
expect { described_class.create!(code: 'en', name: 'English') }
.to change(described_class, :count).by(1)
end
-82
ファイルの表示
@@ -1,82 +0,0 @@
require 'rails_helper'
RSpec.describe 'Locale activation', type: :model do
let!(:language) { Language.create!(code: 'ain', name: 'ain') }
let!(:script) { Script.create!(code: 'Kana', name: 'Kana') }
let!(:first) { create(:tag, primary_name: 'activation_first') }
let!(:second) { create(:tag, primary_name: 'activation_second') }
def build_locale deprecated_at: nil
Locale.new(code: 'ain-Kana', language_code: language.code,
script_code: script.code, region_code: 392, variants: '',
name: 'ain-Kana', deprecated_at:)
end
def generated_names
TagName.where(language_code: 'ain', primary_flg: true)
end
def fail_second_generation!
allow(TagName).to receive(:generate_name).and_call_original
allow(TagName).to receive(:generate_name)
.with(kind_of(Locale), second, second.tag_name('ja'))
.and_raise('backfill failed')
end
it 'rolls back a newly created locale and the preceding generated names' do
locale = build_locale
fail_second_generation!
expect { locale.save! }.to raise_error('backfill failed')
expect(Locale.exists?(code: 'ain-Kana')).to be(false)
expect(generated_names).to be_empty
expect(first.reload.name('ja')).to eq('activation_first')
expect(second.reload.name('ja')).to eq('activation_second')
end
it 'does not backfill while the locale is inactive' do
build_locale(deprecated_at: Time.current).save!
expect(generated_names).to be_empty
end
it 'backfills missing primary names when the locale becomes active' do
locale = build_locale(deprecated_at: Time.current)
locale.save!
locale.update!(deprecated_at: nil)
expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id)
expect(generated_names).to all(have_attributes(
script_code: 'Kana', primary_flg: true, auto_generated: true))
end
it 'rolls back activation and all generated names when backfill fails' do
locale = build_locale(deprecated_at: Time.current)
locale.save!
previous_deprecated_at = locale.reload.deprecated_at
fail_second_generation!
expect { locale.update!(deprecated_at: nil) }.to raise_error('backfill failed')
expect(locale.reload.deprecated_at).to eq(previous_deprecated_at)
expect(generated_names).to be_empty
end
it 'preserves a human primary through deactivation and repeated activation' do
locale = build_locale
locale.save!
primary = generated_names.find_by!(tag: first)
primary.update!(name: 'human_confirmed', auto_generated: false)
snapshot = generated_names.order(:id).map(&:attributes)
2.times do
locale.update!(deprecated_at: Time.current)
locale.update!(deprecated_at: nil)
end
expect(generated_names.order(:id).map(&:attributes)).to eq(snapshot)
expect(primary.reload).to have_attributes(name: 'human_confirmed', auto_generated: false)
end
end
+50 -42
ファイルの表示
@@ -1,13 +1,13 @@
require 'rails_helper'
RSpec.describe Locale, type: :model do
include_context 'Ainu locale'
include_context 'English locale'
def create_ainu_locale!
prepare_ainu_reference!
described_class.create!(code: 'ain-Kana', language_code: 'ain',
script_code: 'Kana', region_code: 392,
variants: '', name: 'Ainu')
def create_english_locale!
prepare_english_reference!
described_class.create!(code: 'en', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: '', name: 'English')
end
it 'keeps the active Nipponese locale consistent with its identity tuple' do
@@ -27,6 +27,7 @@ RSpec.describe Locale, type: :model do
end
it 'generates a primary name for every existing tag using the name generator' do
pending '日本語以外の Locale 有効化は別課題で実装する'
first = create(:tag, primary_name: 'first_existing_tag')
second = create(:tag, primary_name: 'second_existing_tag')
allow(TagName).to receive(:generate_name).and_call_original
@@ -34,60 +35,63 @@ RSpec.describe Locale, type: :model do
.with(kind_of(described_class), first, primary_tag_name_for(first, 'ja'))
.and_return('name_from_generator')
locale = create_ainu_locale!
locale = create_english_locale!
expect(TagName).to have_received(:generate_name)
.with(locale, first, primary_tag_name_for(first, 'ja'))
expect(TagName).to have_received(:generate_name)
.with(locale, second, primary_tag_name_for(second, 'ja'))
expect(first.tag_names.find_by!(language_code: 'ain', primary_flg: true))
expect(first.tag_names.find_by!(language_code: 'en', primary_flg: true))
.to have_attributes(name: 'name_from_generator', tag_id: first.id,
language_code: 'ain', script_code: 'Kana',
language_code: 'en', script_code: 'Latn',
primary_flg: true, auto_generated: true)
expect(second.tag_names.find_by!(language_code: 'ain', primary_flg: true))
.to have_attributes(tag_id: second.id, language_code: 'ain',
script_code: 'Kana', primary_flg: true,
expect(second.tag_names.find_by!(language_code: 'en', primary_flg: true))
.to have_attributes(tag_id: second.id, language_code: 'en',
script_code: 'Latn', primary_flg: true,
auto_generated: true)
expect(TagName.where(language_code: 'ain', primary_flg: true,
expect(TagName.where(language_code: 'en', primary_flg: true,
tag_id: [first.id, second.id]).count).to eq(2)
end
it 'does not duplicate an existing primary name in the new language' do
prepare_ainu_reference!
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
tag = create(:tag, primary_name: 'already_named')
existing = create(:tag_name, name: 'existing_ainu', tag:,
language_code: 'ain', script_code: 'Kana')
existing = create(:tag_name, name: 'existing_english', tag:,
language_code: 'en', script_code: 'Latn')
create_ainu_locale!
create_english_locale!
expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true))
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true))
.to contain_exactly(existing)
expect(existing.reload).to have_attributes(
tag_id: tag.id, language_code: 'ain', script_code: 'Kana',
tag_id: tag.id, language_code: 'en', script_code: 'Latn',
primary_flg: true, auto_generated: false)
end
it 'creates a primary name when the language has only an alias' do
prepare_ainu_reference!
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
tag = create(:tag, primary_name: 'alias_only_tag')
alias_name = create(:tag_name, :alias, name: 'alias_en',
tag:,
language_code: 'ain', script_code: 'Kana')
language_code: 'en', script_code: 'Latn')
create_ainu_locale!
create_english_locale!
expect(alias_name.reload).to have_attributes(
tag_id: tag.id,
language_code: 'ain', script_code: 'Kana', primary_flg: false)
generated = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'ain',
script_code: 'Kana', primary_flg: true,
language_code: 'en', script_code: 'Latn', primary_flg: false)
generated = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'en',
script_code: 'Latn', primary_flg: true,
auto_generated: true)
expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true).count).to eq(1)
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true).count).to eq(1)
end
it 'rolls back the locale and all generated names when backfill fails' do
prepare_ainu_reference!
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
first = create(:tag, primary_name: 'atomic_first')
second = create(:tag, primary_name: 'atomic_second')
allow(TagName).to receive(:generate_name).and_call_original
@@ -95,27 +99,31 @@ RSpec.describe Locale, type: :model do
.with(kind_of(described_class), second, primary_tag_name_for(second, 'ja'))
.and_raise('backfill failed')
expect { create_ainu_locale! }.to raise_error('backfill failed')
expect { create_english_locale! }.to raise_error('backfill failed')
expect(described_class.exists?(code: 'ain-Kana')).to be(false)
expect(TagName.where(language_code: 'ain', tag_id: [first.id, second.id])).to be_empty
expect(described_class.exists?(code: 'en')).to be(false)
expect(TagName.where(language_code: 'en', tag_id: [first.id, second.id])).to be_empty
end
it 'rejects a private locale code outside the supported locale set' do
prepare_ainu_reference!
it 'accepts a stable locale code outside the historical enum values' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
expect {
described_class.create!(
code: 'ain-Kana-x-rspec', language_code: 'ain',
script_code: 'Kana', region_code: 392,
variants: 'x-rspec', name: 'Ainu')
}.to raise_error(ArgumentError)
locale = described_class.create!(
code: 'en-x-rspec', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: 'x-rspec', name: 'English')
expect(locale.reload).to have_attributes(
code: 'en-x-rspec', language_code: 'en', script_code: 'Latn',
region_code: 826, variants: 'x-rspec')
end
it 'registers the supported Ainu locale tuple' do
it 'registers the future British English locale tuple' do
pending '日本語以外の Locale 有効化は別課題で実装する'
expect(locale).to have_attributes(code: 'ain-Kana', language_code: 'ain',
script_code: 'Kana', region_code: 392,
expect(locale).to have_attributes(code: 'en', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: '')
end
end
+4 -3
ファイルの表示
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe Script, type: :model do
include_context 'Ainu locale'
include_context 'English locale'
it 'selects locales by script code, not language code' do
expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese)
@@ -25,10 +25,11 @@ RSpec.describe Script, type: :model do
}
end
it 'selects Kana locales when multilingual support is enabled' do
it 'selects Latin locales when multilingual support is enabled' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
expect(described_class.find('Kana').locales).to contain_exactly(locale)
expect(described_class.find('Latn').locales).to contain_exactly(locale)
expect(described_class.find('Jpan').locales).not_to include(locale)
end
end
+70 -23
ファイルの表示
@@ -1,37 +1,84 @@
require 'rails_helper'
RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
it 'allocates distinct increasing IDs across the two tables' do
tag = create(:tag)
external = create(:external_tag)
next_tag = create(:tag)
include TagGroupRecords
let(:user) { create_member_user! }
expect(external.id).to be > tag.id
expect(next_tag.id).to be > external.id
def create_resource resource
if resource == :tag
Tag.find_or_create_by_tag_name!(Locale.nipponese, 'shared_id_new', category: :general)
else
ExternalTag.create!(platform: :nico, name: 'shared_id_new')
end
end
it 'does not reuse physically deleted IDs in either table' do
tag = create(:tag)
external = create(:external_tag)
TagName.where(tag_id: tag.id).delete_all
tag.destroy!
external.destroy!
[:tag, :external_tag].each do |resource|
it "starts #{ resource } at 1 when both tables are empty" do
expect(Tag.count).to eq(0)
expect(ExternalTag.count).to eq(0)
expect(create_resource(resource).id).to eq(1)
end
expect(create(:tag).id).to be > external.id
expect(create(:external_tag).id).to be > external.id
it "allocates #{ resource } above the other table's maximum" do
create(:tag, id: resource == :tag ? 1 : 20)
create(:external_tag, id: resource == :external_tag ? 1 : 20)
expect(create_resource(resource).id).to eq(21)
end
it "uses the current maximum after deleting earlier #{ resource } records" do
tag = create(:tag, id: 100)
TagName.where(tag_id: tag.id).update_all(tag_id: nil)
tag.destroy!
create(:external_tag, id: 200).destroy!
expect(Tag.unscoped.count).to eq(0)
expect(ExternalTag.unscoped.count).to eq(0)
expect(create_resource(resource).id).to eq(1)
end
it "preserves legacy overlaps and gaps when creating #{ resource }" do
[1, 2, 5].each { create(:tag, id: _1) }
[1, 3, 8].each { create(:external_tag, id: _1) }
tags = Tag.order(:id).map(&:attributes)
external_tags = ExternalTag.order(:id).map(&:attributes)
record = create_resource(resource)
aggregate_failures do
expect(record.id).to eq(9)
expect(Tag.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(tags)
expect(ExternalTag.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(external_tags)
expect(Tag.exists?(id: 3)).to be(false)
expect(ExternalTag.exists?(id: 2)).to be(false)
end
end
it "ignores the description and group ID space when creating #{ resource }" do
tag = create(:tag, id: 1)
create(:external_tag, id: 2)
TagDescription.create!(id: 100, tag:, language_code: 'ja', body: '', version_no: 1)
seed_group(name: 'separate_space', id: 200)
expect(create_resource(resource).id).to eq(3)
end
end
it 'retains existing overlapping IDs without renumbering on update or insert' do
tag = create(:tag)
external = create(:external_tag)
external.update_columns(id: tag.id)
it 'preserves overlapping IDs during updates without rewriting the other resource' do
tag = create(:tag, id: 5)
external = create(:external_tag, id: 5)
tag.update!(category: :meta)
external.update!(name: 'updated_external')
before_tag = tag.reload.attributes
before_external = external.reload.attributes
expect(create(:tag).id).to be > tag.id
expect(tag.reload.attributes).to eq(before_tag)
expect(external.reload.attributes).to eq(before_external)
expect(tag.reload.id).to eq(5)
expect(external.reload.id).to eq(5)
expect(tag.category).to eq('meta')
expect(external.name).to eq('updated_external')
expect(Tag.count).to eq(1)
expect(ExternalTag.count).to eq(1)
end
it 'allocates distinct IDs for successive creations across both tables' do
records = [:tag, :external_tag].map { create_resource(_1) }
expect(records.map(&:id)).to eq([1, 2])
end
end
-48
ファイルの表示
@@ -1,48 +0,0 @@
require 'rails_helper'
RSpec.describe TagGroup, type: :model do
let(:actor) { create_member_user! }
def create_group name:, members: [], placements: []
TagGroupOperation.create!(
Locale.nipponese, name:, description: nil, members:, placements:, actor:)
end
it 'retains version IDs and snapshots after destroying an empty group' do
group = create_group(name: 'history owner')
versions = group.versions.order(:version_no).map(&:attributes)
expect { group.destroy! }.to change(described_class, :count).by(-1)
.and change(TagGroupVersion, :count).by(0)
expect(TagGroupVersion.where(tag_group_id: group.id).order(:version_no).map(&:attributes))
.to eq(versions)
expect(TagGroupRepr.historical(
Locale.nipponese, TagGroupVersion.find(versions.first.fetch('id'))))
.to include('id' => group.id, 'name' => 'history owner', 'members' => [])
end
it 'destroys a group with nested placements and child membership, retaining history' do
child = create_group(name: 'child group')
parent = create_group(
name: 'parent group',
members: [{ 'tag_group_id' => child.id }],
placements: [{ 'section_name' => 'section', 'description' => nil,
'children' => [{ 'tag_group_id' => child.id, 'children' => [] }] }])
versions = parent.versions.order(:version_no).map(&:attributes)
expect { parent.destroy! }
.to change(TagGroup, :count).by(-1)
.and change(TagGroupVersion, :count).by(0)
expect(TagGroupVersion.where(tag_group_id: parent.id)
.order(:version_no).map(&:attributes)).to eq(versions)
expect(child.reload).to be_persisted
expect(TagGroupPlacement.where(tag_group_id: parent.id)).to be_empty
expect(TagGroupTag.where(tag_group_id: parent.id)).to be_empty
expect(TagGroupGroup.where(tag_group_id: parent.id)).to be_empty
end
end
+33 -72
ファイルの表示
@@ -34,97 +34,39 @@ RSpec.describe TagNameSanitisationRule, type: :model do
described_class.create!(priority: 10, source_pattern: 'foo_', replacement: 'foo')
end
[:primary, :alias].each do |source_kind|
it "keeps the primary identity when a #{ source_kind } collides within its Tag" do
tag = create(:tag, primary_name: source_kind == :primary ? 'temporary' : 'foobar')
primary = tag.tag_name('ja')
alias_name = create(:tag_name, :alias, tag:,
name: source_kind == :primary ? 'foobar' : 'temporary_alias')
source = source_kind == :primary ? primary : alias_name
source.update_columns(name: 'foo_bar')
expect { described_class.apply! }.to change(TagName, :count).by(-1)
.and change(Tag, :count).by(0)
expect(primary.reload).to have_attributes(
name: 'foobar', primary_flg: true, tag_id: tag.id)
expect(TagName.exists?(alias_name.id)).to be(false)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(primary)
expect(tag.tag_versions.order(:version_no).last.names.fetch('ja'))
.to include('name' => 'foobar', 'aliases' => [])
end
end
it 'deduplicates colliding aliases without changing the primary name' do
tag = create(:tag, primary_name: 'primary')
primary = tag.tag_name('ja')
kept = create(:tag_name, :alias, tag:, name: 'foobar')
removed = create(:tag_name, :alias, tag:, name: 'temporary_alias')
removed.update_columns(name: 'foo_bar')
described_class.apply!
expect(primary.reload).to have_attributes(name: 'primary', primary_flg: true)
expect(kept.reload.tag_id).to eq(tag.id)
expect(TagName.exists?(removed.id)).to be(false)
expect(tag.tag_versions.order(:version_no).last.names.fetch('ja').fetch('aliases'))
.to eq([{ 'name' => 'foobar', 'script_code' => 'Jpan' }])
end
it 'rolls back a collision merge when the source owns a description' do
user = create_member_user!
target = create(:tag, primary_name: 'foobar')
source = create(:tag, primary_name: 'temporary')
TagDescriptionOperation.create!(
Locale.nipponese, tag: source, body: 'retained', created_by_user: user)
source.tag_name('ja').update_columns(name: 'foo_bar')
post = create(:post)
PostTag.create!(post:, tag: source)
models = [Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion, PostTag]
snapshots = models.map { |model| model.order(*Array(model.primary_key)).map(&:attributes) }
expect { described_class.apply! }.to raise_error(ActiveRecord::RecordInvalid)
after_state = models.map do |model|
model.order(*Array(model.primary_key)).map(&:attributes)
end
expect(after_state).to eq(snapshots)
expect(post.reload.tags).to contain_exactly(source)
expect(target.reload.name('ja')).to eq('foobar')
end
context 'when only another language has the sanitised name' do
include_context 'Ainu locale'
include_context 'English locale'
it 'keeps both names when sanitisation produces a cross-language match' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag_name, name: 'foobar')
ainu = create(:tag_name, name: 'temporary',
language_code: 'ain', script_code: 'Kana')
ainu.update_columns(name: 'foo_bar')
english = create(:tag_name, name: 'temporary',
language_code: 'en', script_code: 'Latn')
english.update_columns(name: 'foo_bar')
expect { described_class.apply! }.not_to change(TagName, :count)
expect(ainu.reload).to have_attributes(name: 'foobar', language_code: 'ain')
expect(english.reload).to have_attributes(name: 'foobar', language_code: 'en')
expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja')
end
it 'renames without merging tags or deleting either language identity' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'foobar')
ainu_name = create(:tag_name, name: 'temporary',
language_code: 'ain', script_code: 'Kana')
ainu = create(:tag, primary_tag_name: ainu_name)
ainu_name.update_columns(name: 'foo_bar')
english_name = create(:tag_name, name: 'temporary',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
english_name.update_columns(name: 'foo_bar')
described_class.apply!
expect(ainu_name.reload).to have_attributes(
name: 'foobar', language_code: 'ain', tag_id: ainu.id)
expect(english_name.reload).to have_attributes(
name: 'foobar', language_code: 'en', tag_id: english.id)
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
name: 'foobar', language_code: 'ja', tag_id: nipponese.id)
expect(primary_tag_name_for(ainu.reload, 'ain')).to eq(ainu_name)
expect(primary_tag_name_for(english.reload, 'en')).to eq(english_name)
end
end
@@ -156,6 +98,25 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end
end
context 'when the source tag_name has a tag and the existing one has no tag' do
let!(:existing) { create(:tag_name, name: 'foobar') }
let!(:source_tag) { create(:tag, primary_name: 'tmp', category: :general) }
let!(:source_tag_name_id) { primary_tag_name_for(source_tag, 'ja').id }
before do
primary_tag_name_for(source_tag, 'ja').update_columns(
name: 'foo_bar', updated_at: Time.current)
end
it 'moves the tag to the existing tag_name' do
described_class.apply!
expect(existing.reload).to have_attributes(
tag_id: source_tag.id, language_code: 'ja', primary_flg: true)
expect(primary_tag_name_for(source_tag, 'ja')).to eq(existing)
expect(primary_tag_name_for(source_tag.reload, 'ja')).to eq(existing)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
end
end
context 'when the sanitised name is an alias of an existing tag' do
let!(:existing_tag) { create(:tag) }
+45 -43
ファイルの表示
@@ -1,16 +1,17 @@
require 'rails_helper'
RSpec.describe TagName, type: :model do
include_context 'Ainu locale'
include_context 'English locale'
it 'allows the same name in different languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
create(:tag_name, name: 'shared_name')
ainu = build(:tag_name, name: 'shared_name',
language_code: 'ain', script_code: 'Kana')
english = build(:tag_name, name: 'shared_name',
language_code: 'en', script_code: 'Latn')
expect(ainu).to be_valid
expect { ainu.save! }.to change(described_class, :count).by(1)
expect(english).to be_valid
expect { english.save! }.to change(described_class, :count).by(1)
end
it 'rejects a duplicate name within the same language' do
@@ -21,23 +22,16 @@ RSpec.describe TagName, type: :model do
expect(duplicate.errors.of_kind?(:name, :taken)).to be(true)
end
it 'creates Nipponese factory names with an owning tag' do
it 'creates Nipponese factory names without requiring a tag' do
name = create(:tag_name)
expect(name.reload).to have_attributes(
language_code: 'ja', script_code: 'Jpan', primary_flg: true,
auto_generated: false, tag_id: name.tag.id)
end
it 'rejects an ownerless name at model and database boundaries' do
name = build(:tag_name, tag: nil)
expect(name).to be_invalid
expect { name.save!(validate: false) }.to raise_error(ActiveRecord::NotNullViolation)
auto_generated: false, tag_id: nil)
end
it 'defaults ordinary persisted human names to confirmed provenance' do
name = described_class.create!(tag: create(:tag, primary_tag_name: nil),
name: 'ordinary_human_name', language_code: 'ja',
name = described_class.create!(name: 'ordinary_human_name', language_code: 'ja',
script_code: 'Jpan', primary_flg: true)
expect(name.reload.auto_generated).to be(false)
@@ -52,7 +46,7 @@ RSpec.describe TagName, type: :model do
expect(name.tag).to eq(tag)
expect(described_class.find_by!(tag_id: tag.id, language_code: 'ja', primary_flg: true))
.to eq(name)
expect(tag.reload.tag_name('ja')&.id).to eq(name.id)
expect(tag.reload.tag_name_id).to eq(name.id)
end
it 'creates a complete ordinary tag factory under the active Nipponese language' do
@@ -66,10 +60,11 @@ RSpec.describe TagName, type: :model do
end
it 'completes an ordinary tag factory under future active languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = create(:tag, primary_name: 'future_factory')
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true))
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
.to contain_exactly(have_attributes(auto_generated: true))
end
@@ -101,55 +96,62 @@ RSpec.describe TagName, type: :model do
end
describe '.canonicalise' do
it 'leaves detached aliases unchanged' do
create(:tag_name, :alias, name: 'detached_alias')
expect(described_class.canonicalise(Locale.nipponese, ['detached_alias']))
.to eq(['detached_alias'])
end
it 'resolves an alias to its owning primary name' do
tag = create(:tag, primary_name: 'canonical')
create(:tag_name, :alias, tag:, name: 'alias')
it 'does not resolve a detached alias through another detached primary' do
create(:tag_name, :alias, name: 'detached_alias')
create(:tag_name, name: 'unrelated_detached_primary')
expect(described_class.canonicalise(Locale.nipponese, ['alias', 'unknown']))
.to eq(['canonical', 'unknown'])
expect(described_class.canonicalise(Locale.nipponese, ['detached_alias']))
.to eq(['detached_alias'])
end
it 'resolves the primary name of the alias language on the same tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = create(:tag, primary_name: '日本語正本')
ainu_primary = primary_tag_name_for(tag, 'ain')
ainu_primary.update!(name: 'ainu_primary', auto_generated: false)
ainu_alias = create(:tag_name, :alias, tag:, name: 'ainu_alias',
language_code: 'ain', script_code: 'Kana')
representative_id = tag.tag_name('ja')&.id
english_primary = primary_tag_name_for(tag, 'en')
english_primary.update!(name: 'english_primary', auto_generated: false)
english_alias = create(:tag_name, :alias, tag:, name: 'english_alias',
language_code: 'en', script_code: 'Latn')
representative_id = tag.tag_name_id
primary = described_class.find_by!(tag_id: ainu_alias.tag_id,
language_code: ainu_alias.language_code,
primary = described_class.find_by!(tag_id: english_alias.tag_id,
language_code: english_alias.language_code,
primary_flg: true)
expect(primary).to eq(ainu_primary)
expect(described_class.canonicalise(locale, [ainu_alias.name]))
.to eq([ainu_primary.name])
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
expect(primary).to eq(english_primary)
expect(described_class.canonicalise(locale, [english_alias.name]))
.to eq([english_primary.name])
expect(tag.reload.tag_name_id).to eq(representative_id)
end
it 'resolves only aliases in the requested language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'nipponese_canonical')
ainu_name = create(:tag_name, name: 'ainu_canonical',
language_code: 'ain', script_code: 'Kana')
ainu = create(:tag, primary_tag_name: ainu_name)
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese)
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: ainu,
language_code: 'ain', script_code: 'Kana')
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
expect(ainu_alias.reload).to have_attributes(
tag_id: ainu.id,
primary_flg: false, language_code: 'ain')
expect(english_alias.reload).to have_attributes(
tag_id: english.id,
primary_flg: false, language_code: 'en')
expect(described_class.canonicalise(locale, ['shared_alias']))
.to eq(['ainu_canonical'])
.to eq(['english_canonical'])
expect(described_class.canonicalise(Locale.nipponese, ['shared_alias']))
.to eq(['nipponese_canonical'])
end
+128 -69
ファイルの表示
@@ -1,20 +1,25 @@
require 'rails_helper'
RSpec.describe Tag, type: :model do
include WikiRecords
describe '#has_description?' do
let(:tag) { create(:tag) }
it 'checks the requested language and treats an empty description as present' do
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
expect(tag.has_description?(language_code: 'ja')).to be(true)
expect(tag.has_description?(language_code: 'ain')).to be(false)
expect(tag.has_description?(language_code: 'en')).to be(false)
end
it 'returns false without a description' do
expect(tag.has_description?(language_code: 'ja')).to be(false)
end
it 'does not use a Wiki page as evidence of a description' do
user = create_member_user!
Wiki::Commit.create_legacy_content!(tag_name: tag.tag_name('ja'), body: 'legacy',
created_by_user: user)
expect(tag.has_description?(language_code: 'ja')).to be(false)
end
it 'reuses preloaded descriptions without reading Wiki or querying again' do
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
@@ -23,16 +28,16 @@ RSpec.describe Tag, type: :model do
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
expect(loaded.has_description?(language_code: 'ja')).to be(true)
expect(loaded.has_description?(language_code: 'ain')).to be(false)
expect(loaded.has_description?(language_code: 'en')).to be(false)
end
expect(queries).to be_empty
end
it 'keeps language selection generic without enabling another Language record' do
tag.association(:tag_descriptions).target = [
TagDescription.new(tag:, language_code: 'ain', body: '', version_no: 1)]
TagDescription.new(tag:, language_code: 'en', body: '', version_no: 1)]
tag.association(:tag_descriptions).loaded!
expect(tag.has_description?(language_code: 'ain')).to be(true)
expect(tag.has_description?(language_code: 'en')).to be(true)
expect(tag.has_description?(language_code: 'ja')).to be(false)
end
end
@@ -61,32 +66,33 @@ RSpec.describe Tag, type: :model do
end
describe '.normalise_tags!' do
include_context 'Ainu locale'
include_context 'English locale'
it 'canonicalises aliases in the supplied language only' do
prepare_ainu_reference!
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_reference!
nipponese = create(:tag, primary_name: 'nipponese_canonical')
ainu_name = create(:tag_name, name: 'ainu_canonical',
language_code: 'ain', script_code: 'Kana')
ainu = create(:tag, primary_tag_name: ainu_name)
primary_tag_name_for(ainu, 'ja').update!(
name: 'ainu_in_nipponese', auto_generated: false)
ainu_locale = locale
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name)
primary_tag_name_for(english, 'ja').update!(
name: 'english_in_nipponese', auto_generated: false)
english_locale = locale
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese)
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: ainu,
language_code: 'ain', script_code: 'Kana')
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
expect(ainu_alias.reload).to have_attributes(
tag_id: ainu.id,
primary_flg: false, language_code: 'ain')
expect(english_alias.reload).to have_attributes(
tag_id: english.id,
primary_flg: false, language_code: 'en')
expect(described_class.normalise_tags!(
ainu_locale, ['shared_alias'],
with_tagme: false, with_no_deerjikist: false)).to eq([ainu])
english_locale, ['shared_alias'],
with_tagme: false, with_no_deerjikist: false)).to eq([english])
expect(described_class.normalise_tags!(
Locale.nipponese, ['shared_alias'],
with_tagme: false, with_no_deerjikist: false)).to eq([nipponese])
@@ -252,120 +258,160 @@ RSpec.describe Tag, type: :model do
describe '.find_or_create_by_tag_name!' do
context 'with an explicit locale' do
include_context 'Ainu locale'
include_context 'English locale'
it 'creates the representative name with locale attributes and ownership' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = described_class.find_or_create_by_tag_name!(
locale, 'ainu_name', category: :character)
locale, 'english_name', category: :character)
expect(primary_tag_name_for(tag, 'ain')).to have_attributes(
language_code: 'ain', script_code: 'Kana',
expect(primary_tag_name_for(tag, 'en')).to have_attributes(
language_code: 'en', script_code: 'Latn',
primary_flg: true, tag_id: tag.id)
expect(tag.category).to eq('character')
end
it 'uses a Japanese fallback for an Ainu-origin tag without reverse transcription' do
it 'uses the English primary as the source for a generated Nipponese name' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
allow(TagName).to receive(:generate_name).and_call_original
allow(TagName).to receive(:generate_name).and_return('generated_nipponese')
tag = described_class.find_or_create_by_tag_name!(
locale, 'ainu_origin', category: :general)
locale, 'english_origin', category: :general)
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true))
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
.to contain_exactly(have_attributes(
name: 'ainu_origin', auto_generated: false))
name: 'english_origin', auto_generated: false))
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(have_attributes(
name: "Tag_##{ tag.id }", auto_generated: true))
name: 'generated_nipponese', auto_generated: true))
source_tag_name = TagName.find_by!(tag:, language_code: 'en', primary_flg: true)
expect(TagName).to have_received(:generate_name)
.with(Locale.nipponese, tag, nil)
.with(Locale.nipponese, tag, source_tag_name)
end
it 'gives a new tag one primary name for every active language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'new_multilingual_tag', category: :general)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1)
end
it 'creates one primary per language when multiple locales use Ainu' do
it 'creates one primary per language when multiple locales use English' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
described_class.find_or_create_by_tag_name!(
locale, 'before_second_ainu_locale', category: :general)
locale, 'before_second_english_locale', category: :general)
Locale.create!(code: 'en-x-rspec', language_code: 'en',
script_code: 'Latn', region_code: 826,
variants: 'x-rspec', name: 'English')
tag = described_class.find_or_create_by_tag_name!(
locale, 'one_ainu_primary', category: :general)
TagNameCompletion.for_tag!(tag, locales: [locale, locale.dup, Locale.nipponese])
locale, 'one_english_primary', category: :general)
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
end
it 'rolls back a new tag and requested name if name generation fails' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
original_tag_count = described_class.count
allow(TagName).to receive(:generate_name).and_raise('generation failed')
expect {
described_class.find_or_create_by_tag_name!(
locale, 'atomic_ainu_name', category: :general)
locale, 'atomic_english_name', category: :general)
}.to raise_error('generation failed')
expect(described_class.count).to eq(original_tag_count)
expect(TagName.where(language_code: 'ain', name: 'atomic_ainu_name')).to be_empty
expect(TagName.where(language_code: 'en', name: 'atomic_english_name')).to be_empty
expect(described_class.joins(:tag_names)
.where(tag_names: { name: 'atomic_ainu_name' })).to be_empty
.where(tag_names: { name: 'atomic_english_name' })).to be_empty
end
it 'keeps a detached requested name detached if generation fails' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
detached = create(:tag_name, :alias, name: 'atomic_detached',
language_code: 'en', script_code: 'Latn')
original_tag_count = described_class.count
allow(TagName).to receive(:generate_name).and_raise('generation failed')
expect {
described_class.find_or_create_by_tag_name!(
locale, detached.name, category: :general)
}.to raise_error('generation failed')
expect(described_class.count).to eq(original_tag_count)
expect(detached.reload).to have_attributes(tag_id: nil, primary_flg: false)
end
it 'promotes a detached alias into the requested primary name' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
detached = create(:tag_name, :alias, name: 'detached_english',
language_code: 'en', script_code: 'Latn')
tag = described_class.find_or_create_by_tag_name!(
locale, detached.name, category: :general)
expect(detached.reload).to have_attributes(
tag_id: tag.id, primary_flg: true, canonical_id: nil,
auto_generated: false)
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
.to contain_exactly(detached)
end
it 'creates separate tag identities for the same name in different languages' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'same_name', category: :general)
primary_tag_name_for(nipponese, 'ain').update!(
name: 'other_ainu_name', auto_generated: false)
ainu = described_class.find_or_create_by_tag_name!(
english = described_class.find_or_create_by_tag_name!(
locale, 'same_name', category: :character)
expect(ainu).not_to eq(nipponese)
expect(primary_tag_name_for(ainu, 'ain')).to have_attributes(
name: 'same_name', tag_id: ainu.id, language_code: 'ain', primary_flg: true)
expect(english).not_to eq(nipponese)
expect(primary_tag_name_for(english, 'en')).to have_attributes(
name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true)
expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes(
name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true)
expect(described_class.find_or_create_by_tag_name!(
locale, 'same_name', category: :general)).to eq(ainu)
locale, 'same_name', category: :general)).to eq(english)
expect(described_class.find_or_create_by_tag_name!(
Locale.nipponese, 'same_name', category: :general)).to eq(nipponese)
end
it 'preserves the V1 representative when looking up another primary language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tag = create(:tag, primary_name: '日本語代表名')
representative_id = tag.tag_name('ja')&.id
ainu = primary_tag_name_for(tag, 'ain')
ainu.update!(name: 'ainu_primary', auto_generated: false)
representative_id = tag.tag_name_id
english = primary_tag_name_for(tag, 'en')
english.update!(name: 'english_primary', auto_generated: false)
found = described_class.find_or_create_by_tag_name!(
locale, ainu.name, category: :general)
locale, english.name, category: :general)
expect(found).to eq(tag)
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
expect(ainu.reload.tag_id).to eq(tag.id)
expect(tag.reload.tag_name_id).to eq(representative_id)
expect(english.reload.tag_id).to eq(tag.id)
end
it 'normalises names using the supplied locale' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
tags = described_class.normalise_tags!(
locale, ['character:normalised_ainu'],
locale, ['character:normalised_english'],
with_tagme: false, with_no_deerjikist: false)
expect(tags.length).to eq(1)
expect(primary_tag_name_for(tags.first, 'ain')).to have_attributes(
name: 'normalised_ainu', language_code: 'ain', script_code: 'Kana',
expect(primary_tag_name_for(tags.first, 'en')).to have_attributes(
name: 'normalised_english', language_code: 'en', script_code: 'Latn',
primary_flg: true, tag_id: tags.first.id)
end
end
@@ -384,10 +430,22 @@ RSpec.describe Tag, type: :model do
expect(primary_tag_name_for(tag, 'ja').auto_generated).to be(false)
end
it 'promotes a detached Nipponese alias into a human-confirmed primary' do
detached = create(:tag_name, :alias, name: 'detached_nipponese')
tag = described_class.find_or_create_by_tag_name!(
Locale.nipponese, detached.name, category: :general)
expect(detached.reload).to have_attributes(
tag_id: tag.id, primary_flg: true, canonical_id: nil,
auto_generated: false)
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
.to contain_exactly(detached)
end
it 'reuses the owning tag for an alias without changing its category' do
tag = create(:tag, category: :character)
representative_id = tag.tag_name('ja')&.id
representative_id = tag.tag_name_id
alias_name = create(:tag_name, :alias, name: 'lookup_alias', tag:)
expect(alias_name.reload).to have_attributes(
tag_id: tag.id,
@@ -401,7 +459,7 @@ RSpec.describe Tag, type: :model do
expect(found).to eq(tag)
expect(found.category).to eq('character')
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
expect(tag.reload.tag_name_id).to eq(representative_id)
end
it 'reuses the owning tag through another alias' do
@@ -434,25 +492,26 @@ RSpec.describe Tag, type: :model do
end
context 'with names in multiple languages' do
include_context 'Ainu locale'
include_context 'English locale'
it 'keeps the target primary and moves source names as aliases' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_primary = primary_tag_name_for(target_tag, 'ain')
source_primary = primary_tag_name_for(source_tag, 'ain')
english_primary = primary_tag_name_for(target_tag, 'en')
source_primary = primary_tag_name_for(source_tag, 'en')
source_alias = create(:tag_name, :alias, tag: source_tag,
name: 'source_ainu_alias', language_code: 'ain', script_code: 'Kana')
name: 'source_english_alias', language_code: 'en', script_code: 'Latn')
expect([ainu_primary, source_primary]).to all(have_attributes(
expect([english_primary, source_primary]).to all(have_attributes(
primary_flg: true, auto_generated: true))
described_class.merge_tags!(target_tag, [source_tag])
expect(primary_tag_name_for(target_tag.reload, 'ain')).to eq(ainu_primary)
expect(TagName.where(tag: target_tag, language_code: 'ain', primary_flg: true))
.to contain_exactly(ainu_primary)
expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary)
expect(TagName.where(tag: target_tag, language_code: 'en', primary_flg: true))
.to contain_exactly(english_primary)
expect([source_primary.reload, source_alias.reload]).to all(have_attributes(
tag_id: target_tag.id, language_code: 'ain', primary_flg: false))
tag_id: target_tag.id, language_code: 'en', primary_flg: false))
expect(TagName.where(tag: target_tag, language_code: 'ja', primary_flg: true))
.to contain_exactly(primary_tag_name_for(target_tag, 'ja'))
expect(source_tag_name.reload).to have_attributes(
@@ -624,7 +683,7 @@ RSpec.describe Tag, type: :model do
let!(:wiki_page) do
admin = create_admin_user!
seed_description(
Wiki::Commit.create_content!(
tag_name: source_tag_name,
body: 'source wiki body',
created_by_user: admin,
-25
ファイルの表示
@@ -1,25 +0,0 @@
require 'rails_helper'
RSpec.describe User, type: :model do
it 'nullifies description authorship without deleting native content or history' do
author = create_member_user!
editor = create_member_user!
tag = create(:tag)
description = TagDescriptionOperation.create!(
Locale.nipponese, tag:, body: 'initial', created_by_user: author)
TagDescriptionOperation.update!(
Locale.nipponese, tag:, body: 'edited', updated_by_user: editor)
versions = TagDescriptionVersion.where(tag_id: tag.id).order(:version_no)
snapshots = versions.map(&:attributes)
expect(author.created_tag_descriptions).to contain_exactly(description)
expect(editor.updated_tag_descriptions).to contain_exactly(description)
expect { author.destroy! }.to change(TagDescription, :count).by(0)
.and change(TagDescriptionVersion, :count).by(0)
expect(description.reload).to have_attributes(
body: 'edited', created_by_user_id: nil, updated_by_user_id: editor.id)
expect { editor.destroy! }.not_to change(TagDescription, :count)
expect(description.reload.updated_by_user_id).to be_nil
expect(versions.reload.map(&:attributes)).to eq(snapshots)
end
end
-54
ファイルの表示
@@ -1,54 +0,0 @@
require 'rails_helper'
RSpec.describe 'API locale resolution', type: :request do
let!(:tag) { create(:tag, primary_name: '日本語正本') }
it 'defaults an omitted locale to Japanese' do
get "/tags/#{ tag.id }"
expect(response).to have_http_status(:ok)
expect(json.fetch('name')).to eq('日本語正本')
end
it 'accepts an explicitly requested active Japanese locale' do
get "/tags/#{ tag.id }", params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
expect(json.fetch('name')).to eq('日本語正本')
end
['', 'en', 'ain', 'Jpan', 'JA', 'unknown', ['ja']].each do |code|
it "rejects invalid locale #{ code.inspect } instead of defaulting to Japanese" do
get "/tags/#{ tag.id }", params: { locale: code }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include('日本語正本')
end
end
it 'rejects a supported locale that has not been registered' do
get "/tags/#{ tag.id }", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:not_found)
end
it 'rejects an inactive locale' do
Locale.nipponese.update!(deprecated_at: Time.current)
get "/tags/#{ tag.id }", params: { locale: 'ja' }
expect(response).to have_http_status(:not_found)
end
it 'does not write posts or tag names under an invalid locale' do
sign_in_as(create_member_user!)
expect do
post '/posts', params: {
locale: 'en', title: 'invalid locale', url: 'https://example.com/invalid-locale',
tags: 'unwritten_tag' }
end.to change(Post, :count).by(0).and change(TagName, :count).by(0)
expect(response).to have_http_status(:not_found)
end
end
+78 -61
ファイルの表示
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe 'Locale propagation on write paths', type: :request do
include_context 'Ainu locale'
include_context 'English locale'
let(:member) { create(:user, :member) }
@@ -54,12 +54,13 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
end
['ain-Kana', nil].each do |requested_locale|
['en', nil].each do |requested_locale|
context "with locale #{ requested_locale.inspect }" do
let(:locale_params) { requested_locale ? { locale: requested_locale } : { } }
let(:expected_locale) { requested_locale ? locale : Locale.nipponese }
it 'creates post tags in the requested language or Nipponese fallback' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
post '/posts', params: locale_params.merge(
title: 'Locale post', url: 'https://example.com/locale-post',
@@ -71,6 +72,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
it 'updates post tags in the requested language or Nipponese fallback' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
record = create(:post)
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
@@ -85,6 +87,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
it 'passes the resolved locale to bulk creation' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
# Worker propagation is exercised in post_bulk_creator_spec.
expect_any_instance_of(PostBulkCreator).to receive(:run)
@@ -99,6 +102,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
it 'creates materials with tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
post '/materials', params: locale_params.merge(
tag: 'locale_material', url: 'https://example.com/material')
@@ -109,6 +113,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
it 'updates materials with tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
tag = create(:tag, category: :material)
material = Material.create!(tag:, url: 'https://example.com/material')
@@ -122,6 +127,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
it 'links Nico external tags to internal tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
external = create(:external_tag)
NicoTagVersionRecorder.record!(
@@ -135,6 +141,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
it 'creates parent tags in the resolved locale' do
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
locale if requested_locale
tag = create(:tag)
@@ -150,10 +157,10 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
context 'with existing names in both languages' do
let(:ainu_name) do
create(:tag_name, name: 'shared_name', language_code: 'ain', script_code: 'Kana')
let(:english_name) do
create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn')
end
let(:ainu_tag) { create(:tag, primary_tag_name: ainu_name, category: :material) }
let(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) }
let(:nipponese_tag) do
create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag|
# Isolate lookup from the separately tested uniqueness validation.
@@ -161,163 +168,173 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
end
end
it 'plans and creates the post using the Ainu identity and category' do
it 'plans and creates the post using the English identity and category' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_tag
english_tag
nipponese_tag
post '/posts', params: {
locale: 'ain-Kana', title: 'Ainu identity',
url: 'https://example.com/ainu-identity', tags: 'shared_name', parent_post_ids: '' }
locale: 'en', title: 'English identity',
url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' }
expect(response).to have_http_status(:created), response.body
record = Post.find(json.fetch('id'))
expect(record.tags).to include(ainu_tag)
expect(record.tags).to include(english_tag)
expect(record.tags).not_to include(nipponese_tag)
expect(ainu_tag.reload.category).to eq('material')
expect(english_tag.reload.category).to eq('material')
expect(nipponese_tag.reload.category).to eq('general')
end
it 'updates the post using the Ainu identity' do
it 'updates the post using the English identity' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_tag
english_tag
nipponese_tag
record = create(:post)
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
put "/posts/#{ record.id }", params: {
locale: 'ain-Kana', title: record.title, tags: 'shared_name', parent_post_ids: '',
locale: 'en', title: record.title, tags: 'shared_name', parent_post_ids: '',
base_version_no: record.reload.version_no }
expect(response).to have_http_status(:ok), response.body
expect(record.reload.tags).to include(ainu_tag)
expect(record.reload.tags).to include(english_tag)
expect(record.tags).not_to include(nipponese_tag)
end
it 'resolves the Ainu material tag' do
it 'resolves the English material tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_tag
english_tag
nipponese_tag
post '/materials', params: {
locale: 'ain-Kana', tag: 'shared_name', url: 'https://example.com/ainu-material' }
locale: 'en', tag: 'shared_name', url: 'https://example.com/english-material' }
expect(response).to have_http_status(:created), response.body
expect(Material.find(json.fetch('id')).tag).to eq(ainu_tag)
expect(Material.find(json.fetch('id')).tag).to eq(english_tag)
expect(Material.where(tag: nipponese_tag)).to be_empty
end
it 'resolves the Ainu parent tag' do
it 'resolves the English parent tag' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_tag
english_tag
nipponese_tag
child = create(:tag)
put "/tags/#{ child.id }", params: {
locale: 'ain-Kana', name: primary_tag_name_for(child, 'ja').name,
locale: 'en', name: primary_tag_name_for(child, 'ja').name,
category: child.category, deprecated: false,
aliases: '', parent_tags: 'shared_name' }
expect(response).to have_http_status(:ok), response.body
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([ainu_tag.id])
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([english_tag.id])
end
it 'resolves the Ainu internal tag for a Nico link' do
it 'resolves the English internal tag for a Nico link' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_tag
english_tag
nipponese_tag
external = create(:external_tag)
NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: member)
put "/tags/nico/#{ external.id }", params: { locale: 'ain-Kana', tags: 'shared_name' }
put "/tags/nico/#{ external.id }", params: { locale: 'en', tags: 'shared_name' }
expect(response).to have_http_status(:ok), response.body
expect(external.reload.linked_tags).to contain_exactly(ainu_tag)
expect(external.reload.linked_tags).to contain_exactly(english_tag)
end
end
context 'when renaming or updating aliases in Ainu' do
let(:ainu_name) do
create(:tag_name, name: 'ainu_original', language_code: 'ain', script_code: 'Kana')
context 'when renaming or updating aliases in English' do
let(:english_name) do
create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn')
end
let(:tag) { create(:tag, primary_tag_name: ainu_name) }
let(:tag) { create(:tag, primary_tag_name: english_name) }
it 'marks a human-renamed generated primary as no longer auto-generated' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
generated_tag = create(:tag, primary_name: 'rename_generated_source')
generated_name = primary_tag_name_for(generated_tag, 'ain')
generated_name = primary_tag_name_for(generated_tag, 'en')
expect(generated_name.auto_generated).to be(true)
patch "/tags/#{ generated_tag.id }", params: {
locale: 'ain-Kana', name: 'human_ainu_name' }
locale: 'en', name: 'human_english_name' }
expect(response).to have_http_status(:ok), response.body
expect(generated_name.reload).to have_attributes(
name: 'human_ainu_name', auto_generated: false)
name: 'human_english_name', auto_generated: false)
end
it 'does not steal another tag primary for an alias' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
other_tag = create(:tag, primary_name: 'other_nipponese')
other_primary = primary_tag_name_for(other_tag, 'ain')
other_primary.update!(name: 'other_ainu_primary', auto_generated: false)
create(:tag_name, :alias, tag:, name: 'existing_ainu_alias',
language_code: 'ain', script_code: 'Kana')
primary_name_before = primary_tag_name_for(tag, 'ain').name
other_primary = primary_tag_name_for(other_tag, 'en')
other_primary.update!(name: 'other_english_primary', auto_generated: false)
create(:tag_name, :alias, tag:, name: 'existing_english_alias',
language_code: 'en', script_code: 'Latn')
primary_name_before = primary_tag_name_for(tag, 'en').name
aliases_before =
tag.tag_names.where(language_code: 'ain', primary_flg: false)
tag.tag_names.where(language_code: 'en', primary_flg: false)
.order(:name).pluck(:name)
category_before = tag.category
put "/tags/#{ tag.id }", params: {
locale: 'ain-Kana', name: 'renamed_before_alias_collision',
locale: 'en', name: 'renamed_before_alias_collision',
category: 'character', deprecated: false,
aliases: 'other_ainu_primary', parent_tags: '' }
aliases: 'other_english_primary', parent_tags: '' }
aggregate_failures do
expect(response).to have_http_status(:unprocessable_entity)
expect(other_primary.reload).to have_attributes(
tag_id: other_tag.id, language_code: 'ain', primary_flg: true)
expect(TagName.where(tag: other_tag, language_code: 'ain', primary_flg: true))
tag_id: other_tag.id, language_code: 'en', primary_flg: true)
expect(TagName.where(tag: other_tag, language_code: 'en', primary_flg: true))
.to contain_exactly(other_primary)
expect(primary_tag_name_for(tag.reload, 'ain').name).to eq(primary_name_before)
expect(primary_tag_name_for(tag.reload, 'en').name).to eq(primary_name_before)
expect(tag.category).to eq(category_before)
aliases_after =
tag.tag_names.where(language_code: 'ain', primary_flg: false)
tag.tag_names.where(language_code: 'en', primary_flg: false)
.order(:name).pluck(:name)
expect(aliases_after).to eq(aliases_before)
end
end
it 'allows a rename to a name already used in Nipponese' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'rename_target')
patch "/tags/#{ tag.id }", params: { locale: 'ain-Kana', name: 'rename_target' }
patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' }
expect(response).to have_http_status(:ok), response.body
expect(primary_tag_name_for(tag.reload, 'ain')).to have_attributes(
name: 'rename_target', language_code: 'ain', tag_id: tag.id)
expect(primary_tag_name_for(tag.reload, 'en')).to have_attributes(
name: 'rename_target', language_code: 'en', tag_id: tag.id)
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
name: 'rename_target', language_code: 'ja', tag_id: nipponese.id)
end
it 'creates aliases in the requested language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
put "/tags/#{ tag.id }", params: {
locale: 'ain-Kana', name: ainu_name.name,
locale: 'en', name: english_name.name,
category: tag.category, deprecated: false,
aliases: 'ainu_alias', parent_tags: '' }
aliases: 'english_alias', parent_tags: '' }
expect(response).to have_http_status(:ok), response.body
expect(TagName.find_by!(language_code: 'ain', name: 'ainu_alias'))
expect(TagName.find_by!(language_code: 'en', name: 'english_alias'))
.to have_attributes(
script_code: 'Kana',
tag_id: tag.id, primary_flg: false, language_code: 'ain')
primary = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
expect(primary).to have_attributes(name: 'ainu_original', script_code: 'Kana')
script_code: 'Latn',
tag_id: tag.id, primary_flg: false, language_code: 'en')
primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn')
end
it 'does not take an alias from another language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'nipponese_owner')
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
@@ -327,7 +344,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
primary_flg: false, language_code: 'ja')
put "/tags/#{ tag.id }", params: {
locale: 'ain-Kana', name: ainu_name.name,
locale: 'en', name: english_name.name,
category: tag.category, deprecated: false,
aliases: 'shared_alias', parent_tags: '' }
@@ -335,12 +352,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
expect(TagName.find_by!(language_code: 'ain', name: 'shared_alias'))
expect(TagName.find_by!(language_code: 'en', name: 'shared_alias'))
.to have_attributes(
tag_id: tag.id, script_code: 'Kana',
primary_flg: false, language_code: 'ain')
tag_id: tag.id, script_code: 'Latn',
primary_flg: false, language_code: 'en')
expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id))
.to contain_exactly(['ja', nipponese.id], ['ain', tag.id])
.to contain_exactly(['ja', nipponese.id], ['en', tag.id])
end
end
end
+9 -7
ファイルの表示
@@ -222,26 +222,28 @@ RSpec.describe 'Materials API', type: :request do
expect(queries).to be_empty
end
it 'reads the description even when the stored version has different content' do
page = seed_description(tag_name: tag.tag_name('ja'), body: 'legacy body',
it 'reads the description even when the legacy revision has different content' do
page = seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'legacy body',
created_by_user: member_user)
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
.update!(body: 'description body')
get_material_without_legacy_reads
expect(json.fetch('wiki_page_body')).to eq('description body')
expect(current_description_version(page).body).to eq('legacy body')
expect(page.current_revision.body).to eq('legacy body')
end
it 'uses tag_id rather than description Id. without a Wiki page' do
description = TagDescription.create!(tag:, language_code: 'ja',
it 'uses tag_id rather than description ID without a Wiki page or TagName' do
description = TagDescription.create!(id: tag.id + 1000, tag:, language_code: 'ja',
body: 'independent body', version_no: 1)
tag.tag_names.update_all(tag_id: nil)
get_material_without_legacy_reads
expect(description.id).not_to eq(tag.id)
expect(json.fetch('wiki_page_body')).to eq('independent body')
expect(json.fetch('tag').fetch('has_wiki')).to be(true)
end
it 'returns nil when only description history remains' do
seed_description(tag_name: tag.tag_name('ja'), body: 'legacy only',
it 'returns nil when only legacy Wiki content exists' do
seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'legacy only',
created_by_user: member_user)
TagDescription.where(tag_id: tag.id).delete_all
get_material_without_legacy_reads
+83 -7
ファイルの表示
@@ -4,21 +4,34 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
include WikiRecords
let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'description_contract') }
let!(:tag) { tag_name.tag }
let!(:page) do
seed_wiki_page(
tag_name:, body: 'old-revision', created_by_user: user)
end
let!(:revisions) do
first = page.current_revision
second = Wiki::Commit.legacy_content!(page:, body: 'old-second', created_user: user)
third = Wiki::Commit.legacy_content!(page:, body: 'old-third', created_user: user)
[first, second, third]
end
let!(:tag) { create(:tag, primary_tag_name: tag_name) }
let!(:versions) do
['A', 'A', 'new-version'].each_with_index.map do |body, index|
revisions.each_with_index.map do |revision, index|
TagDescriptionVersion.create!(
tag_id: tag.id, language_code: 'ja', version_no: index + 1,
event_type: index.zero? ? 'create' : 'update', body:,
created_by_user_id: user.id, created_at: Time.current)
id: revision.id, tag_id: tag.id, language_code: 'ja',
version_no: index + 1, event_type: index.zero? ? 'create' : 'update',
body: index == 2 ? 'new-version' : 'A',
created_by_user_id: user.id, created_at: revision.created_at)
end
end
let!(:description) do
TagDescription.create!(
tag:, language_code: 'ja', version_no: 3, body: 'new',
id: page.id, tag:, language_code: 'ja', version_no: 3, body: 'new',
created_by_user_id: user.id, updated_by_user_id: user.id)
end
before { page.update_columns(body: 'old') }
# Observe real SELECTs, including cached queries, only during the request.
def without_legacy_reads
queries = []
@@ -57,7 +70,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
it 'does not fall back to a revision absent from new history' do
versions.first.destroy!
without_legacy_reads do
get "/wiki/#{ description.id }", params: { version: versions.first.id }
get "/wiki/#{ description.id }", params: { version: revisions.first.id }
end
expect(response).to have_http_status(:not_found)
end
@@ -119,6 +132,16 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
'pred' => versions.first.id, 'succ' => versions.last.id)
end
it 'ignores changes to same-title legacy content and revision metadata' do
get "/wiki/title/#{ tag_name.name }"
original = json
page.update_columns(body: 'changed legacy body')
revisions.last.update_columns(message: 'legacy message', created_at: 1.day.ago)
revisions.last.wiki_lines.update_all(body: 'changed legacy revision')
get "/wiki/title/#{ tag_name.name }"
expect(json).to eq(original)
expect(json.fetch('body')).to eq('new')
end
end
describe 'exists' do
@@ -132,6 +155,28 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
expect(response).to have_http_status(:no_content)
end
context 'without a legacy page' do
before do
WikiVersion.where(wiki_page_id: page.id).delete_all
page.destroy!
end
it 'recognises the description ID alone' do
get "/wiki/#{ description.id }/exists"
expect(response).to have_http_status(:no_content)
end
it 'recognises the TagName alone' do
get "/wiki/title/#{ tag_name.name }/exists"
expect(response).to have_http_status(:no_content)
end
it 'shows the description by title' do
get "/wiki/title/#{ tag_name.name }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'new', 'revision_id' => versions.last.id)
end
end
end
describe 'diff' do
@@ -184,8 +229,39 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
'title' => tag_name.name)
end
it 'ignores legacy-only revisions and metadata changes' do
get '/wiki/changes', params: { id: description.id }
original = json
revisions.first.update_columns(message: 'legacy changed', created_at: 1.day.ago)
WikiRevision.create!(
wiki_page: page, created_user: user, kind: :content,
tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0)
get '/wiki/changes', params: { id: description.id }
expect(json).to eq(original)
expect(json.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
end
end
describe 'mixed changes' do
it 'excludes unmigrated history and migrated legacy-only revisions' do
legacy_page = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help_mixed'),
body: 'help', created_by_user: user)
legacy_revision = legacy_page.current_revision
extra = WikiRevision.create!(
wiki_page: page, created_user: user, kind: :content,
tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id }
expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id)
expect(json).not_to include(include(
'revision_id' => legacy_revision.id,
'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed')))
end
end
describe 'index' do
[nil, 'description_contract'].each do |title|
+246 -59
ファイルの表示
@@ -1,94 +1,281 @@
require 'rails_helper'
RSpec.describe 'Tag description Wiki facade writes', type: :request do
RSpec.describe 'Tag description Wiki dual writes', type: :request do
include WikiRecords
let!(:user) { create_member_user! }
let!(:tag) { create(:tag, primary_name: 'description_write') }
let!(:tag_name) { create(:tag_name, name: 'description_write') }
let(:tag) { create(:tag, primary_tag_name: tag_name) }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
def stored_state
[Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].map do |model|
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion,
TagName, TagVersion].map do |model|
model.order(:id).map(&:attributes)
end
end
it 'creates a native description and exposes its version ID as revision ID' do
expect do
post '/wiki', params: { title: tag.name('ja'), body: 'initial' }, headers: headers
end.to change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:created)
description = TagDescription.find(json.fetch('id'))
expect(description).to have_attributes(tag_id: tag.id, body: 'initial', version_no: 1)
get "/wiki/#{ description.id }"
expect(json.fetch('revision_id')).to eq(current_description_version(description).id)
# Fail after a real INSERT/UPDATE, exercising rollback rather than a mock write.
def fail_save model
failure = Class.new(StandardError)
callback = ->(_record) { raise failure, 'injected persistence failure' }
model.set_callback(:save, :after, callback)
expect { yield }.to raise_error(failure)
ensure
model.skip_callback(:save, :after, callback)
end
context 'with an existing description' do
let!(:description) do
TagDescriptionOperation.create!(
Locale.nipponese, tag:, body: 'before', created_by_user: user)
end
let!(:first_version) { current_description_version(description) }
describe 'POST /wiki' do
before { tag }
def update_description body: 'after', title: tag.name('ja'), base: first_version.id
put "/wiki/#{ description.id }", params: { title:, body:, base_revision_id: base },
headers: headers
def create_description
post '/wiki', params: { title: tag_name.name, body: 'initial', message: 'create' },
headers: headers
end
it 'records a body change without changing resource identity or old history' do
snapshot = first_version.attributes
expect { update_description }.to change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
expect(first_version.reload.attributes).to eq(snapshot)
expect(current_description_version(description)).to have_attributes(
body: 'after', event_type: 'update', created_by_user_id: user.id)
it 'creates a description with matching Wiki content and histories' do
expect { create_description }
.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:created)
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
expect(description).to have_attributes(body: 'initial', version_no: 1)
page = WikiPage.find_by!(tag_name:)
expect(page.body).to eq(description.body)
expect(page.current_revision.body).to eq(description.body)
expect(page.wiki_versions.sole.body).to eq(description.body)
expect(json.fetch('id')).to eq(description.id)
expect(TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja'))
.to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id)
end
['before', "before\r\n\r\n"].each do |body|
it "does not write for normalised-equivalent body #{ body.inspect }" do
it 'reuses an existing compatibility page and synchronises its histories' do
page = Wiki::Commit.create_legacy_content!(
tag_name:, body: 'legacy', created_by_user: user)
expect { create_description }
.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created)
description = TagDescription.find_by!(tag_id: tag.id)
expect(page.reload.body).to eq(description.body)
expect(TagDescriptionVersion.find(page.current_revision.id).body).to eq(page.body)
end
['missing_description', 'orphan_description'].each do |title|
it "rejects the bare title #{ title } without writing any resource" do
create(:tag_name, name: title) if title == 'orphan_description'
before_state = stored_state
update_description(body:)
expect(response).to have_http_status(:ok)
post '/wiki', params: { title:, body: 'initial' }, headers: headers
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(stored_state).to eq(before_state)
end
end
it 'records a title-only edit in Tag history without adding body history' do
snapshot = description.attributes
expect { update_description(body: 'before', title: 'renamed') }
.to change(TagVersion, :count).by(2)
.and change(TagDescriptionVersion, :count).by(0)
expect(response).to have_http_status(:ok)
expect(tag.reload.name('ja')).to eq('renamed')
expect(description.reload.attributes).to eq(snapshot)
end
['conflicting body', 'after'].each do |body|
it "rejects a stale revision even for #{ body } without a partial rename" do
update_description
expect(response).to have_http_status(:ok)
[false, true].each do |linked|
it "rejects namespaced creation with Tag present: #{ linked }" do
name = create(:tag_name, name: 'Help:frozen')
create(:tag, primary_tag_name: name) if linked
before_state = stored_state
update_description(body:, title: 'conflicting rename')
expect(response).to have_http_status(:conflict)
post '/wiki', params: { title: name.name, body: 'legacy' }, headers: headers
expect(response.status).to be_between(400, 499)
expect(stored_state).to eq(before_state)
end
end
[TagDescription, TagDescriptionVersion].each do |model|
it "rolls back the rename and body after #{ model.name } persistence fails" do
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
it "rolls back new description creation when #{ model.name } persistence fails" do
before_state = stored_state
failure = Class.new(StandardError)
callback = ->(_record) { raise failure }
model.set_callback(:save, :after, callback)
begin
expect { update_description(title: 'rollback rename') }.to raise_error(failure)
aggregate_failures do
fail_save(model) { create_description }
expect(stored_state).to eq(before_state)
ensure
model.skip_callback(:save, :after, callback)
end
end
end
end
it 'creates missing compatibility data without writing an ID-colliding Help page' do
description = TagDescription.create!(
id: 500, tag:, language_code: 'ja', body: 'before', version_no: 1)
help = WikiPage.create!(id: description.id,
tag_name: create(:tag_name, name: 'Help:unrelated'),
body: 'unrelated', created_user: user, updated_user: user)
Wiki::Commit.legacy_content!(page: help, body: help.body, created_user: user)
before_help = help.attributes
expect do
put "/wiki/#{ description.id }", params: { title: tag_name.name, body: 'after' },
headers: headers
end.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(description.reload).to have_attributes(id: 500, body: 'after')
expect(help.reload.attributes).to eq(before_help)
compatibility = WikiPage.find_by!(tag_name:)
expect(compatibility.body).to eq(description.body)
expect(compatibility.current_revision.body).to eq(description.body)
end
describe 'PUT /wiki/:id' do
let!(:page) do
tag
seed_wiki_page(
tag_name:, body: 'before', created_by_user: user)
end
let!(:first_revision) { page.current_revision }
let!(:description) do
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
end
def update_description body: 'after', title: tag_name.name, base: first_revision.id
put "/wiki/#{ page.id }",
params: { title:, body:, message: 'edit', base_revision_id: base },
headers: headers
end
it 'increments the independent description sequence and shares revision identity' do
expect(page.reload.version_no).to eq(1)
expect(description.version_no).to eq(1)
expect { update_description }
.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok)
revision = page.reload.current_revision
version = TagDescriptionVersion.find(revision.id)
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
expect(version).to have_attributes(
body: 'after', version_no: 2, event_type: 'update', created_by_user_id: user.id)
expect(page).to have_attributes(body: 'after', version_no: 2)
expect(revision.body).to eq(version.body)
expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body)
end
it 'uses description identity even when its Wiki TagName no longer owns the Tag' do
tag_name.update_columns(tag_id: nil)
expect { update_description }
.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
expect(TagDescriptionVersion.find(page.reload.current_revision.id))
.to have_attributes(tag_id: tag.id, body: 'after', version_no: 2)
end
it 'does not add body history for a title-only update' do
revision_count = WikiRevision.count
wiki_version_count = WikiVersion.count
description_version_count = TagDescriptionVersion.count
wiki_version_no = page.reload.version_no
description_version_no = description.version_no
current_revision_id = page.current_revision.id
page_state = page.attributes
description_state = description.attributes
update_description(body: 'before', title: 'description_renamed')
expect(response).to have_http_status(:ok)
expect(tag_name.reload.name).to eq('description_renamed')
expect(WikiRevision.count).to eq(revision_count)
expect(WikiVersion.count).to eq(wiki_version_count)
expect(TagDescriptionVersion.count).to eq(description_version_count)
expect(page.reload).to have_attributes(body: 'before', version_no: wiki_version_no)
expect(page.current_revision.id).to eq(current_revision_id)
expect(page.reload.attributes).to eq(page_state)
expect(description.reload).to have_attributes(
body: 'before', version_no: description_version_no)
expect(description.reload.attributes).to eq(description_state)
end
it 'does not add body history for a TagName rename through tags' do
revision_count = WikiRevision.count
wiki_version_count = WikiVersion.count
description_version_count = TagDescriptionVersion.count
page_version_no = page.reload.version_no
current_revision_id = page.current_revision.id
patch "/tags/#{ tag.id }",
params: { name: 'description_tag_renamed' },
headers: headers
expect(response).to have_http_status(:ok)
expect(tag_name.reload.name).to eq('description_tag_renamed')
expect(WikiRevision.count).to eq(revision_count)
expect(WikiVersion.count).to eq(wiki_version_count)
expect(TagDescriptionVersion.count).to eq(description_version_count)
expect(page.reload.version_no).to eq(page_version_no)
expect(page.current_revision.id).to eq(current_revision_id)
expect(description.reload).to have_attributes(
body: 'before', version_no: 1)
end
it 'keeps both systems and the title unchanged on a stale revision conflict' do
update_description(body: 'current body', base: first_revision.id)
expect(response).to have_http_status(:ok)
stale_revision_id = first_revision.id
before_state = stored_state
update_description(
body: 'conflicting body',
title: 'conflicting_rename',
base: stale_revision_id)
expect(response).to have_http_status(:conflict)
expect(stored_state).to eq(before_state)
expect(tag_name.reload.name).not_to eq('conflicting_rename')
end
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
it "rolls back both systems when #{ model.name } persistence fails" do
before_state = stored_state
aggregate_failures do
fail_save(model) do
update_description(body: 'rollback body', title: 'rollback_rename')
end
expect(stored_state).to eq(before_state)
end
end
end
[['identical body', 'before'],
['normalised-equivalent body', "before\n\n"]].each do |label, body|
it "does not write histories for #{ label }" do
before_state = stored_state
page_state = page.reload.attributes
description_state = description.reload.attributes
current_revision_id = page.current_revision.id
wiki_lines = WikiLine.order(:id).map(&:attributes)
revision_lines = WikiRevisionLine.order(:id).map(&:attributes)
page_version_no = page.version_no
description_version_no = description.version_no
update_description(body:)
expect(response).to have_http_status(:ok)
expect(WikiRevision.count).to eq(before_state[1].length)
expect(WikiVersion.count).to eq(before_state[2].length)
expect(TagDescriptionVersion.count).to eq(before_state[4].length)
expect(page.reload.version_no).to eq(page_version_no)
expect(description.reload.version_no).to eq(description_version_no)
expect(page.current_revision.id).to eq(current_revision_id)
expect(page.reload.attributes).to eq(page_state)
expect(description.reload.attributes).to eq(description_state)
expect(WikiLine.order(:id).map(&:attributes)).to eq(wiki_lines)
expect(WikiRevisionLine.order(:id).map(&:attributes)).to eq(revision_lines)
end
end
end
end
+68 -20
ファイルの表示
@@ -1,8 +1,6 @@
require 'rails_helper'
RSpec.describe 'Tag description native API', type: :request do
include WikiRecords
include TagGroupRecords
let!(:author) { create_member_user! }
let(:editor) { create(:user, :member, name: 'description editor') }
let(:tag_name) { create(:tag_name, name: 'native_description') }
@@ -11,7 +9,8 @@ RSpec.describe 'Tag description native API', type: :request do
let(:endpoint) { "/tags/#{ tag.id }/description" }
def stored_state
[TagDescription, TagDescriptionVersion, TagName, TagVersion].map do |model|
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion,
WikiLine, WikiRevisionLine, TagName, TagVersion].map do |model|
model.order(:id).map(&:attributes)
end
end
@@ -85,7 +84,7 @@ RSpec.describe 'Tag description native API', type: :request do
describe 'native reads' do
let!(:description) do
TagDescription.create!(
tag:, language_code: 'ja', body: 'native current',
id: tag.id + 1_000_000, tag:, language_code: 'ja', body: 'native current',
version_no: 1, created_by_user_id: author.id, updated_by_user_id: editor.id)
end
let!(:version) do
@@ -96,14 +95,15 @@ RSpec.describe 'Tag description native API', type: :request do
let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" }
it 'reads the current resource by Tag ID without a WikiPage' do
expect(description.id).not_to eq(tag.id)
expect(WikiPage.exists?(id: description.id)).to be(false)
without_legacy_reads { native_get(endpoint) }
expect(response).to have_http_status(:ok)
expect_current(description)
end
it 'does not interpret a compatibility handle as a Tag ID' do
other_tag = create(:tag, primary_name: 'handle_collision')
description.update_columns(id: other_tag.id)
other_tag = create(:tag, id: description.id, primary_name: 'handle_collision')
other = TagDescription.create!(
tag: other_tag, language_code: 'ja', body: 'other current', version_no: 1,
created_by_user_id: author.id, updated_by_user_id: author.id)
@@ -153,6 +153,32 @@ RSpec.describe 'Tag description native API', type: :request do
expect_current(description)
end
[:id, :name].each do |lookup|
it "ignores divergent legacy body and history for #{ lookup } lookup" do
page = WikiPage.create!(
id: description.id, tag_name:, body: 'old current',
created_user: author, updated_user: author)
revision = WikiRevision.create!(
wiki_page: page, created_user: author, kind: :content,
lines_count: 1, tree_sha256: Digest::SHA256.hexdigest('old revision'))
line = WikiLine.create!(body: 'old revision', sha256: revision.tree_sha256)
WikiRevisionLine.create!(wiki_revision: revision, wiki_line: line, position: 0)
WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: author)
path = lookup == :id ? endpoint : name_endpoint
action = lookup == :id ? 'show' : 'show_by_title'
without_legacy_reads { native_get(path, action:) }
expect(response).to have_http_status(:ok)
expect_current(description)
original = json
page.update_columns(body: 'changed old current')
line.update_columns(body: 'changed old revision')
revision.update_columns(message: 'changed old history', created_at: 1.day.ago)
without_legacy_reads { native_get(path, action:) }
expect(response).to have_http_status(:ok)
expect(json).to eq(original)
end
end
end
describe 'missing native resources' do
@@ -180,24 +206,44 @@ RSpec.describe 'Tag description native API', type: :request do
context 'without an existing description' do
before { tag }
it 'creates native history that is immediately available through the facade' do
it 'creates a description with synchronised Wiki content and histories' do
unrelated = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:unrelated_native_description'),
body: 'unrelated body', created_by_user: author)
unrelated_state = [unrelated.attributes,
unrelated.wiki_revisions.map(&:attributes),
unrelated.wiki_versions.map(&:attributes)]
expect { native_write(body: 'created natively') }
.to change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
.and change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created)
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
expect_current(description)
version = current_description_version(description)
page = WikiPage.find_by!(tag_name: tag.tag_name('ja'))
revision = page.current_revision
version = TagDescriptionVersion.find(revision.id)
expect(description).to have_attributes(
body: 'created natively', version_no: 1,
created_by_user_id: editor.id, updated_by_user_id: editor.id)
expect(version).to have_attributes(
body: 'created natively', version_no: 1, event_type: 'create',
created_by_user_id: editor.id)
tag_id: tag.id, language_code: 'ja', body: description.body,
version_no: 1, event_type: 'create', created_by_user_id: editor.id)
expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body)
expect(revision.body).to eq(version.body)
expect(page.wiki_versions.last.body).to eq(version.body)
unrelated.reload
expect([unrelated.attributes, unrelated.wiki_revisions.map(&:attributes),
unrelated.wiki_versions.map(&:attributes)]).to eq(unrelated_state)
expect_current(description)
get "/wiki/#{ description.id }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => description.body, 'revision_id' => version.id)
end
[TagDescription, TagDescriptionVersion].each do |model|
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
it "rolls back native create when #{ model.name } persistence fails" do
headers
before_state = stored_state
@@ -212,7 +258,7 @@ RSpec.describe 'Tag description native API', type: :request do
context 'with an existing description' do
let!(:page) do
tag
seed_description(tag_name:, body: 'before', created_by_user: author)
Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author)
end
let!(:description) do
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
@@ -222,20 +268,22 @@ RSpec.describe 'Tag description native API', type: :request do
expect(page.reload.version_no).to eq(1)
expect(description.version_no).to eq(1)
expect { native_write(body: 'after') }
.to change(TagDescriptionVersion, :count).by(1)
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(description.reload).to have_attributes(
body: 'after', version_no: 2,
created_by_user_id: author.id, updated_by_user_id: editor.id)
expect(page.reload).to have_attributes(body: 'after', version_no: 2)
revision = current_description_version(page)
revision = page.current_revision
version = TagDescriptionVersion.find(revision.id)
expect(version).to have_attributes(
tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update',
body: 'after', created_by_user_id: editor.id)
expect(revision.body).to eq(version.body)
expect(description_versions(page).order(:version_no).last.body).to eq(version.body)
expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body)
get "/wiki/#{ description.id }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'after', 'revision_id' => version.id)
@@ -246,7 +294,7 @@ RSpec.describe 'Tag description native API', type: :request do
before_state = stored_state
page_state = page.reload.attributes
description_state = description.reload.attributes
current_revision_id = current_description_version(page).id
current_revision_id = page.current_revision.id
native_write(body:)
@@ -254,11 +302,11 @@ RSpec.describe 'Tag description native API', type: :request do
expect(stored_state).to eq(before_state)
expect(page.reload.attributes).to eq(page_state)
expect(description.reload.attributes).to eq(description_state)
expect(current_description_version(page).id).to eq(current_revision_id)
expect(page.current_revision.id).to eq(current_revision_id)
end
end
[TagDescription, TagDescriptionVersion].each do |model|
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
it "rolls back native update when #{ model.name } persistence fails" do
headers
before_state = stored_state
-123
ファイルの表示
@@ -1,123 +0,0 @@
require 'rails_helper'
RSpec.describe 'Tag group locale contracts', type: :request do
include TagGroupRecords
include_context 'Ainu locale contract'
let!(:user) { create_member_user! }
let!(:tag) { create(:tag, primary_name: 'japanese_member') }
let!(:ainu_name) { add_ainu_name(tag, 'ainu_member') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
let(:members) { [{ 'tag_id' => tag.id }] }
let(:placements) do
[{ 'section_name' => 'section', 'description' => nil, 'children' => [
{ 'tag_id' => tag.id, 'children' => [
{ 'tag_id' => tag.id, 'children' => [] }] }] }]
end
let!(:group) do
TagVersioning.ensure_snapshot!(tag, created_by_user: user)
TagGroupOperation.create!(
ainu_locale, name: 'locale group', description: nil, members:, placements:, actor: user)
end
it 'uses selected-language names in native collection and nested placements' do
["/tag_groups/#{ group.id }", '/tag_groups'].each do |path|
get path, params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
representation = json.is_a?(Array) ? json.sole : json
expect(representation.fetch('members').sole.fetch('tag').fetch('name'))
.to eq('ainu_member')
node = representation.fetch('placements').sole.fetch('children').sole
expect(node.fetch('tag').fetch('name')).to eq('ainu_member')
expect(node.fetch('children').sole.fetch('tag').fetch('name')).to eq('ainu_member')
expect(response.body).not_to include('japanese_member')
end
end
it 'uses snapshot names recursively for history and current names for the latest view' do
version = group.versions.sole
ainu_name.update!(name: 'renamed_ainu')
get "/wiki/#{ group.id }", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to include('renamed_ainu')
get "/wiki/#{ group.id }", params: { locale: 'ain-Kana', version: version.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('body').scan('ainu_member').length).to eq(2)
expect(json.fetch('body')).not_to include('renamed_ainu', 'japanese_member')
get "/wiki/#{ group.id }/diff", params: { locale: 'ain-Kana', from: version.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('diff').map { _1.fetch('type') }.uniq).to eq(['context'])
end
['タググループ:', 'tag_group:', 'TAG_GROUP:'].each do |prefix|
it "round trips #{ prefix } through create, lookup, search and update" do
post '/wiki', params: {
locale: 'ain-Kana', title: "#{ prefix }parent", body: '- tag_group:locale group' },
headers: headers
expect(response).to have_http_status(:created)
id = json.fetch('id')
expect(json.fetch('body')).to include('タググループ:locale group')
path = "/wiki/title/#{ CGI.escapeURIComponent("#{ prefix }parent") }"
get path, params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
expect(json.fetch('id')).to eq(id)
get "#{ path }/exists", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:no_content)
get '/wiki/search', params: { locale: 'ain-Kana', title: "#{ prefix }parent" }
expect(json.map { _1.fetch('id') }).to eq([id])
put "/wiki/#{ id }", params: {
locale: 'ain-Kana', title: "#{ prefix }renamed", body: '- ainu_member' },
headers: headers
expect(response).to have_http_status(:ok)
get "/tag_groups/#{ id }", params: { locale: 'ain-Kana' }
expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member')
end
end
it 'resolves Markdown names in the requested language and rolls back invalid input' do
parsed = TagGroupMarkdownParser.parse(ainu_locale, body: '- ainu_member')
expect(parsed.fetch(:members)).to eq(members)
expect do
TagGroupMarkdownParser.parse(ainu_locale, body: '- japanese_member')
end.to raise_error(TagGroupMarkdownParser::InvalidDocument)
before_state = group_state
put "/wiki/#{ group.id }", params: {
locale: 'ain-Kana', title: 'タググループ:partial rename', body: '- japanese_member' },
headers: headers
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it 'passes locale into native validation and facade compatibility checks' do
post '/tag_groups', params: {
locale: 'ain-Kana', name: 'native', description: nil, members:, placements: },
headers:, as: :json
expect(response).to have_http_status(:created)
id = json.fetch('id')
expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member')
get "/wiki/#{ id }", params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
body = json.fetch('body')
put "/wiki/#{ id }", params: { locale: 'ain-Kana', title: 'tag_group:native', body: },
headers: headers
expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).version_no).to eq(1)
end
it 'does not substitute current or Japanese names for a missing historical language' do
other = create(:tag, primary_name: 'historical_japanese')
TagVersioning.ensure_snapshot!(other, created_by_user: user)
old_group = TagGroupOperation.create!(
Locale.nipponese, name: 'old group', description: nil,
members: [{ 'tag_id' => other.id }],
placements: [{ 'tag_id' => other.id, 'children' => [] }], actor: user)
add_ainu_name(other, 'current_ainu')
version = old_group.versions.sole
representation = TagGroupRepr.historical(ainu_locale, version)
expect(representation.fetch('members').sole.fetch('tag').fetch('name')).to be_nil
expect(representation.fetch('placements').sole.fetch('tag').fetch('name')).to be_nil
get "/wiki/#{ old_group.id }", params: { locale: 'ain-Kana', version: version.id }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).not_to include('historical_japanese', 'current_ainu')
end
end
-9
ファイルの表示
@@ -115,15 +115,6 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :
MARKDOWN
end
it 'round trips a top-level list followed by heading Sections' do
check_round_trip(
members: [tag_member(series), tag_member(deer)],
placements: [tag_node(series),
{ 'section_name' => '区分', 'description' => nil,
'children' => [tag_node(deer)] }],
markdown: "- ぼざろクリーチャーシリーズ\n\n## 区分\n\n- 洗澡鹿")
end
it 'rejects mixed top-level heading Sections and Tags without losing structure' do
id = seed_group(
name: '混在構造', members: [tag_member(series)],
+147 -17
ファイルの表示
@@ -7,18 +7,21 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
let!(:user) { create_member_user! }
let!(:old_tag) { group_tag('旧タグ') }
let!(:new_tag) { group_tag('新タグ') }
let!(:page) { create_markdown_group('読取り契約', '- 旧タグ') }
let!(:first_revision) { page.versions.sole }
let!(:page) { legacy_group('読取り契約', '- 旧タグ') }
let!(:first_revision) { page.current_revision }
let!(:last_revision) do
parsed = TagGroupMarkdownParser.parse(Locale.nipponese, body: '- 新タグ')
TagGroupOperation.update!(
Locale.nipponese,
group: page, name: page.name, actor: user,
**parsed.slice(:description, :members, :placements))
page.versions.order(:version_no).last
Wiki::Commit.legacy_content!(page:, body: '- 新タグ', created_user: user)
end
before { I18n.locale = :ja }
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
@@ -68,14 +71,36 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end
it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do
TagDescriptionOperation.create!(
Locale.nipponese, tag: new_tag, body: 'tag description', created_by_user: user)
help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:混在'), body: 'help', created_by_user: user)
TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user)
get '/wiki'
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include(
'タググループ:読取り契約', '新タグ')
expect(json.map { _1.fetch('title') }).not_to include(help.title)
end
it 'excludes Help when its ID collides with a new group' do
help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body',
created_by_user: user)
seed_group(name: '別 ID重複', id: help.id, description: 'group body')
get '/wiki'
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include('タググループ:別 ID重複')
expect(json.map { _1.fetch('title') }).not_to include(help.title)
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
expect(response).to have_http_status(:not_found)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
expect(json.map { _1.dig('wiki_page', 'title') }).to include(
'タググループ:別 ID重複')
expect(json.map { _1.dig('wiki_page', 'title') }).not_to include(help.title)
end
it 'finds groups by a partial prefix of their displayed title' do
get '/wiki', params: { title: 'タググル' }
@@ -87,10 +112,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end
it 'does not restore a stale namespaced Wiki page after a group rename' do
group = create_markdown_group('旧名称', '正本')
TagGroupOperation.update!(
Locale.nipponese,
group:, name: '新名称', description: '正本', members: [], placements: [], actor: user)
old_page = legacy_group('旧名称', '- 旧本文')
seed_group(name: '新名称', id: old_page.id, description: '正本')
get group_title_path('旧名称')
expect(response).to have_http_status(:not_found)
@@ -102,9 +125,12 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end
it 'orders mixed resource history by timestamp rather than independent IDs' do
TagDescriptionOperation.create!(
Locale.nipponese, tag: new_tag, body: 'description', created_by_user: user)
help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help',
created_by_user: user)
TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user)
TagGroupVersion.update_all(created_at: 3.days.ago)
WikiRevision.where(wiki_page_id: help.id).update_all(created_at: 2.days.ago)
TagDescriptionVersion.update_all(created_at: 1.day.ago)
get '/wiki/changes'
@@ -113,6 +139,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
expect(timestamps).to eq(timestamps.sort.reverse)
titles = json.map { _1.dig('wiki_page', 'title') }
expect(titles).not_to include(help.title)
expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約')
end
@@ -142,6 +169,19 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
end
it 'includes group history but excludes all legacy-only revisions' do
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' }
expect(rows.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
expect(json).not_to include(include(
'revision_id' => help.current_revision.id,
'wiki_page' => include('title' => help.title)))
end
[false, true].each do |explicit|
it "diffs structured snapshots with #{ explicit ? 'explicit' : 'current' } target" do
@@ -175,5 +215,95 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
expect(json.fetch('body').strip).to eq('独立した説明')
end
context 'when Help, description and group share the same numeric ID' do
let(:colliding_help) do
seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:三者ID衝突'),
body: 'help', created_by_user: user)
end
let(:colliding_group_id) do
seed_group(name: '三者ID衝突', id: colliding_help.id, description: 'group')
end
let(:colliding_description) do
TagDescription.create!(
id: colliding_help.id, tag: new_tag, language_code: 'ja',
body: 'description', version_no: 1)
end
let(:colliding_description_version) do
TagDescriptionVersion.create!(
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
event_type: 'create', body: 'description', created_by_user_id: user.id,
created_at: Time.current)
end
before do
colliding_group_id
colliding_description
colliding_description_version
end
it 'lists both new resources sharing a numeric ID without legacy Help' do
get '/wiki'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('id') == colliding_help.id }
expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly(
[colliding_description.id, '新タグ'],
[colliding_group_id, 'タググループ:三者ID衝突'])
end
it 'lists each resource history without deduplicating the shared resource ID' do
get '/wiki/changes'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('wiki_page').fetch('id') == colliding_help.id }
histories = rows.map do |row|
[row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')]
end
expect(histories).to contain_exactly(
[colliding_description_version.id, '新タグ'],
[group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突'])
end
it 'still excludes legacy copies of an actually migrated group and its history' do
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
get '/wiki'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('id') == page.id }
expect(rows.map { _1.fetch('title') }).to eq(['タググループ:読取り契約'])
get '/wiki/changes'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('wiki_page').fetch('id') == page.id }
expect(rows.map { _1.fetch('revision_id') })
.to contain_exactly(first_revision.id, last_revision.id)
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
expect(rows.map { _1.fetch('wiki_page').fetch('title') })
.to all(eq('タググループ:読取り契約'))
end
end
it 'resolves new resources by title while excluding colliding legacy Help' do
help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
seed_group(name: 'ID衝突', id: help.id, description: 'group')
TagDescription.create!(
id: help.id, tag: new_tag, language_code: 'ja', body: 'description', version_no: 1)
TagDescriptionVersion.create!(
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
event_type: 'create', body: 'description', created_at: Time.current)
{ 'タググループ:ID衝突' => 'group', '新タグ' => 'description' }
.each do |title, body|
get "/wiki/title/#{ CGI.escapeURIComponent(title) }"
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq(body)
end
without_group_legacy_reads do
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
end
expect(response).to have_http_status(:not_found)
end
end
+33 -24
ファイルの表示
@@ -33,10 +33,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
end
end
it 'creates the group, snapshot and native version history' do
it 'creates the group, snapshot and matching compatibility histories' do
expect { create_group }
.to change(TagGroup, :count).by(1)
.and change(TagGroupVersion, :count).by(1)
.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created)
group = group_rows('tag_groups', name: '新規').sole
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
@@ -44,9 +45,10 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
version = group_version(group.fetch('id'), 1)
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
get group_title_path('新規')
expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to eq('- 洗澡鹿')
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' })
expect(page.body).to eq('- 洗澡鹿')
expect(page.current_revision.body).to eq(page.body)
expect(page.wiki_versions.sole.body).to eq(page.body)
end
it 'keeps repeated placements but only one membership and one snapshot member' do
@@ -106,9 +108,10 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
end
context 'updating a migrated group' do
let!(:page) { create_markdown_group('更新', '- 洗澡鹿') }
let!(:old_revision) { page.versions.order(:version_no).last }
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
@@ -116,9 +119,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
[1, 3].each do |repetitions|
it "keeps identical Markdown unchanged after #{ repetitions } Wiki PUTs" do
body = group_markdown(page)
body = page.body
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id: page.id) do
expect_noop_group_update(group_id: page.id, page:) do
repetitions.times do
update_group(body:)
expect(response).to have_http_status(:ok)
@@ -131,13 +134,13 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
it 'keeps identical Markdown unchanged with a stale base_revision_id' do
update_group
expect(response).to have_http_status(:ok)
body = group_markdown(page)
expect(page.versions.order(:version_no).last.id).not_to eq(old_revision.id)
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) do
expect_noop_group_update(group_id: page.id, page:) do
put "/wiki/#{ page.id }",
params: { title: "タググループ:#{ page.name }", body:, base_revision_id: old_revision.id },
params: { title: page.title, body:, base_revision_id: old_revision.id },
headers: headers
end
end
@@ -145,9 +148,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
it 'keeps the original editor on identical Markdown from another member' do
editor = create(:user, :member)
body = group_markdown(page)
body = page.body
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id: page.id) do
expect_noop_group_update(group_id: page.id, page:) do
update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
end
end
@@ -165,11 +168,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
page.id, number: 2,
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
placements: changed_placements)
body = group_markdown(page)
body = page.reload.body
expect(body).to eq('- 伊地知ニジカ')
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id: page.id) { update_group(body:) }
expect_noop_group_update(group_id: page.id, page:) { update_group(body:) }
end
end
@@ -184,13 +187,16 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
expect(group_version(page.id, 1)).to eq(old)
expect(group_version(page.id, 2)).to include(
'event_type' => 'update', 'created_by_user_id' => user.id)
expect(group_markdown(page).strip).to eq('- 伊地知ニジカ')
expect(page.versions.count).to eq(2)
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.name }", body: '- 伊地知ニジカ',
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)
@@ -201,12 +207,12 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
expect(response).to have_http_status(:ok)
put "/wiki/#{ page.id }",
params: { title: "タググループ:#{ page.name }", body: '- 洗澡鹿',
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(group_markdown(page).strip).to eq('- 洗澡鹿')
expect(page.reload.body.strip).to eq('- 洗澡鹿')
end
it 'records the editor and update time' do
@@ -251,7 +257,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
let!(:page) do
next unless operation == :update
create_markdown_group('更新', '- 洗澡鹿')
legacy = legacy_group('更新', '- 洗澡鹿')
seed_group(name: '更新', id: legacy.id, members:, placements:)
legacy
end
let!(:child_id) { seed_group(name: '子グループ', description: '子') }
@@ -288,6 +296,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
end
tables = TagGroupRecords::GROUP_TABLES
tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions']
tables.each do |table|
it "rolls back all writes after a real #{ table } write fails" do
expect_atomic_group_write(table) { write_group(operation, headers) }
+145 -18
ファイルの表示
@@ -24,6 +24,11 @@ RSpec.describe 'Tag group write integrity', type: :request do
put "/wiki/#{ id }", params: { title:, body:, base_revision_id: base }, headers: headers
end
def create_help body
seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user)
end
def expect_no_writes
before_state = group_state
yield
@@ -32,28 +37,66 @@ RSpec.describe 'Tag group write integrity', type: :request do
end
context 'resource identity' do
it 'rejects removing the prefix from a migrated group atomically' do
page = legacy_group('更新', '- 洗澡鹿')
seed_group(name: '更新', id: page.id, members:, placements:)
expect_no_writes do
put_wiki(page.id, title: '更新', base: page.current_revision.id)
end
end
it 'rejects an unprefixed update to an independent group' do
id = seed_group(name: '独立', members:, placements:)
expect_no_writes { put_wiki(id, title: '独立') }
end
it 'does not revive legacy writes when the compatibility title is stale' do
page = legacy_group('更新', '- 洗澡鹿')
seed_group(name: '更新', id: page.id, members:, placements:)
page.tag_name.update!(name: '古い別名')
it 'renames the group and its facade title through the operation' do
page = create_markdown_group('更新', '- 洗澡鹿')
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.name }").to eq('タググループ:改名')
expect(group_markdown(page)).to eq('- 伊地知ニジカ')
expect(page.reload.title).to eq('タググループ:改名')
expect(page.body).to eq('- 伊地知ニジカ')
end
it 'rejects a standalone Help update despite a colliding group ID' do
page = create_help('Help body')
id = seed_group(name: '別グループ', id: page.id, members:, placements:)
group_before = TagGroup.find(id).attributes
version_before = group_version(id, 1)
expect_no_writes { put_wiki(id, title: 'Help:改名', body: 'Help after') }
expect(page.reload.body).to eq('Help body')
expect(page.title).to eq('Help:既存')
expect(TagGroup.find(id).attributes).to eq(group_before)
expect(group_version(id, 1)).to eq(version_before)
end
it 'rejects an ambiguous unprefixed rename when Help and group IDs collide' do
page = create_help('Help body')
seed_group(name: '更新', id: page.id, members:, placements:)
expect_no_writes { put_wiki(page.id, title: '更新') }
expect_no_writes do
put_wiki(page.id, title: '更新', base: page.current_revision.id)
end
end
it 'keeps a description update separate from a colliding group' do
page = seed_description(
page = Wiki::Commit.create_content!(
tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user)
description = TagDescription.find_by!(tag_id: tag.id)
id = seed_group(name: '更新', id: description.id, members:, placements:)
@@ -67,22 +110,106 @@ RSpec.describe 'Tag group write integrity', type: :request do
expect(TagGroup.find(id).attributes).to eq(group_before)
end
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')
expect_no_writes { put_wiki(id, title: 'Help:既存', body: 'Help after') }
it 'rolls back a facade create when its saved structure cannot be rendered' do
allow(TagGroupMarkdownRenderer).to receive(:render)
.and_raise(TagGroupMarkdownRenderer::UnsupportedStructure, 'unsupported')
expect_no_writes do
post '/wiki', params: { title: 'タググループ:新規', body: '- 洗澡鹿' }, headers: headers
expect(page.reload.body).to eq('Help before')
expect(description.reload.body).to eq('description')
expect(TagGroup.find(id).version_no).to eq(1)
put_wiki(id, title: '洗澡鹿', body: 'description after')
expect(response).to have_http_status(:ok)
expect(description.reload.body).to eq('description after')
put_wiki(id, title: 'タググループ:更新')
expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).version_no).to eq(2)
expect(page.reload.body).to eq('Help before')
expect(description.reload.body).to eq('description after')
end
end
it 'rolls back a facade update when its saved structure cannot be rendered' do
id = seed_group(name: '更新', members:, placements:)
allow(TagGroupMarkdownRenderer).to receive(:render)
.and_raise(TagGroupMarkdownRenderer::UnsupportedStructure, 'unsupported')
expect_no_writes { put_wiki(id, title: 'タググループ:改名') }
context 'compatibility synchronisation' do
it 'synchronises a native rename without modifying an ID-colliding Help page' do
help = create_help('Help before')
id = seed_group(name: '更新', id: help.id, members:, placements:)
help_before = help.attributes
expect do
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
end.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).name).to eq('改名')
expect(help.reload.attributes).to eq(help_before)
end
['wiki_lines', 'wiki_revision_lines'].each do |table|
it "rolls back native and compatibility state on #{ table } failure" do
expect_atomic_group_write(table) do
post '/tag_groups', params: native_payload, headers:, as: :json
end
end
end
it 'rolls back a renderer failure instead of treating it as unsupported structure' do
allow(TagGroupMarkdownRenderer).to receive(:render).and_raise('renderer failure')
before_state = group_state
expect do
post '/tag_groups', params: native_payload, headers:, as: :json
end.to raise_error(RuntimeError, 'renderer failure')
expect(group_state).to eq(before_state)
end
it 'preserves unplaced membership and reports the skipped sync with its version' do
page = legacy_group('更新', '- 洗澡鹿')
id = seed_group(name: '更新', id: page.id, members:, placements:)
wiki_before = group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5))
warnings = []
allow(Rails.logger).to receive(:warn) { warnings << _1 }
put "/tag_groups/#{ id }",
params: native_payload(member_rows: members + [{ 'tag_id' => other_tag.id }]),
headers:, as: :json
expect(response).to have_http_status(:ok)
expect(json.fetch('members').map { _1.fetch('tag').fetch('id') })
.to contain_exactly(tag.id, other_tag.id)
expect(group_version(id, 2).fetch('members')).to include(
{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no })
expect(group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5))).to eq(wiki_before)
payload = JSON.parse(warnings.grep(/tag_group_wiki_sync_skipped/).sole.split(' ', 2).last)
expect(payload).to include(
'status' => 'unsupported_structure', 'tag_group_id' => id,
'version_no' => 2, 'user_id' => user.id,
'reason' => '掲載位置のない membership は Markdown にできません.')
end
it 'preserves a list Section description and records why no Wiki was created' do
nodes = [{ 'tag_id' => tag.id, 'children' => [
{ 'section_name' => '区分', 'description' => '構造化の説明', 'children' => [] }] }]
warnings = []
allow(Rails.logger).to receive(:warn) { warnings << _1 }
before_count = WikiPage.count
post '/tag_groups', params: native_payload(nodes:), headers:, as: :json
expect(response).to have_http_status(:created)
id = json.fetch('id')
expect(group_version(id, 1).fetch('placements')).to eq(nodes)
expect(json.fetch('placements').sole.fetch('children').sole)
.to include('description' => '構造化の説明')
expect(WikiPage.count).to eq(before_count)
expect(warnings.grep(/tag_group_wiki_sync_skipped/).sole)
.to include('リスト内 Section の説明文は Markdown にできません.')
end
end
context 'identifier Markdown' do
@@ -166,10 +293,10 @@ RSpec.describe 'Tag group write integrity', type: :request do
expect(group_version(id, 2)).to include('description' => '説明を追加', 'members' => members)
end
it 'rolls back membership removal and placement edits on a version persistence failure' do
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('tag_group_versions') do
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
+39 -19
ファイルの表示
@@ -234,7 +234,8 @@ RSpec.describe 'Tag group native API', type: :request do
context 'compatibility writes for representable structures' do
let(:group_id) do
seed_group(name: '専用API', description: '説明')
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
@@ -251,9 +252,10 @@ RSpec.describe 'Tag group native API', type: :request do
group_id, number: 2, description: '全体の説明', placements:,
members: snapshot_members + [
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }])
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:専用API' })
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:) do
expect_noop_group_update(group_id:, page:) do
native_write(:update, unplaced_payload)
end
end
@@ -265,18 +267,20 @@ RSpec.describe 'Tag group native API', type: :request do
members: [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }], placements: }
end
let(:additional_tag) { group_tag('追加対象タグ') }
let(:compatibility_page) do
WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' })
end
before do
native_write(:update, synchronised_payload)
expect(response).to have_http_status(:ok)
expect(group_markdown(TagGroup.find(group_id)))
.to include('- 掲載タグ', '- タググループ:子')
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:) 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)
@@ -288,7 +292,7 @@ RSpec.describe 'Tag group native API', type: :request do
it 'treats membership order alone as a no-op' do
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:) do
expect_noop_group_update(group_id:, page: compatibility_page) do
native_write(:update, synchronised_payload.merge(
members: synchronised_payload.fetch(:members).reverse))
end
@@ -298,7 +302,7 @@ RSpec.describe 'Tag group native API', type: :request do
it 'keeps the original editor when another member submits identical content' do
editor = create(:user, :member)
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:) 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
@@ -309,7 +313,7 @@ RSpec.describe 'Tag group native API', type: :request do
put "/wiki/#{ group_id }",
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
expect(response).to have_http_status(:ok)
expect(group_markdown(TagGroup.find(group_id))).to eq('- 未掲載タグ')
expect(compatibility_page.reload.body).to eq('- 未掲載タグ')
same_content = {
name: '構造化', description: nil, members: [{ 'tag_id' => unplaced.id }],
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }] }
@@ -319,7 +323,7 @@ RSpec.describe 'Tag group native API', type: :request do
placements: same_content.fetch(:placements))
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:) do
expect_noop_group_update(group_id:, page: compatibility_page) do
native_write(:update, same_content)
end
end
@@ -328,7 +332,7 @@ RSpec.describe 'Tag group native API', type: :request do
['name', 'description', 'membership addition', 'membership removal',
'Section name', 'Section description', 'placement order', 'placement hierarchy']
.each do |change|
it "records a real #{ change } change in native history" do
it "records a real #{ change } change in both histories" do
changed = synchronised_payload.deep_dup
section = changed.fetch(:placements).first
children = section.fetch('children')
@@ -352,6 +356,11 @@ RSpec.describe 'Tag group native API', type: :request do
old_versions = group_rows('tag_group_versions', tag_group_id: group_id)
group = TagGroup.find(group_id)
number = group.version_no
page = compatibility_page
old_revision_id = page.current_revision.id
old_wiki_version_id = page.wiki_versions.order(version_no: :desc).first.id
revision_count = page.wiki_revisions.count
version_count = page.wiki_versions.count
travel_to(1.hour.from_now) { native_write(:update, changed) }
expect(response).to have_http_status(:ok)
@@ -370,17 +379,23 @@ RSpec.describe 'Tag group native API', type: :request do
versions = group_rows('tag_group_versions', tag_group_id: group_id)
expect(versions.size).to eq(old_versions.size + 1)
expect(versions.select { _1.fetch('version_no') <= number }).to eq(old_versions)
expect(page.wiki_revisions.count).to eq(revision_count + 1)
expect(page.wiki_versions.count).to eq(version_count + 1)
expect(page.reload.current_revision.id).not_to eq(old_revision_id)
expect(page.wiki_versions.order(version_no: :desc).first.id)
.not_to eq(old_wiki_version_id)
get group_title_path(changed.fetch(:name))
expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to eq(group_markdown(group))
expect(json.fetch('body')).to eq(page.body)
end
end
end
it 'creates native state and its native history' do
it 'creates native state and its compatibility histories' do
expect { native_write(:create) }
.to change(TagGroup, :count).by(1)
.and change(TagGroupVersion, :count).by(1)
.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created)
expect_group_snapshot(
json.fetch('id'), number: 1, members: snapshot_members,
@@ -395,13 +410,18 @@ RSpec.describe 'Tag group native API', type: :request do
number = operation == :create ? 1 : 2
expect_group_snapshot(
id, number:, members: snapshot_members, placements:, description: '全体の説明')
expect(TagGroup.find(id).versions.count).to eq(number)
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 include('全体の説明', '# 区分', '- 掲載タグ')
expect(json.fetch('body')).to eq(page.body)
end
TagGroupRecords::GROUP_TABLES
(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) }
@@ -409,12 +429,12 @@ RSpec.describe 'Tag group native API', type: :request do
end
end
it 'assigns the next group ID above the existing maximum' do
it 'assigns the next group ID from the existing maximum' do
child_id
expected_id = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1
native_write(:create)
expect(response).to have_http_status(:created)
expect(json.fetch('id')).to be >= expected_id
expect(json.fetch('id')).to eq(expected_id)
end
TagGroupRecords::GROUP_TABLES.each do |table|
+80 -40
ファイルの表示
@@ -1,61 +1,101 @@
require 'rails_helper'
RSpec.describe 'Tag resource ID allocation', type: :request do
include TagGroupRecords
include WikiRecords
let!(:user) { create_member_user! }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
def create_description name
tag = create(:tag, primary_name: name)
post '/wiki', params: { title: tag.name('ja'), body: 'description' }, headers: headers
expect(response).to have_http_status(:created)
TagDescription.find(json.fetch('id'))
def seed_description id
tag = create(:tag, primary_name: "description_id_#{ id }")
TagDescription.create!(id:, tag:, language_code: 'ja', body: 'existing', version_no: 1)
end
def create_group name
post '/tag_groups', params: { name:, members: [], placements: [] }, headers:, as: :json
def create_resource resource
if resource == :description
tag = create(:tag, primary_name: 'new_description')
post '/wiki', params: { title: tag.name('ja'), body: 'new description' }, headers: headers
else
post '/tag_groups', params: {
name: 'new_group', description: 'new group', members: [], placements: [] },
headers:, as: :json
end
expect(response).to have_http_status(:created)
TagGroup.find(json.fetch('id'))
end
it 'shares resource and revision IDs while keeping independent version numbers' do
description = create_description('first')
group = create_group('second')
other = create_description('third')
expect([description.id, group.id, other.id]).to eq(
[description.id, group.id, other.id].sort.uniq)
versions = [current_description_version(description), group.versions.sole,
current_description_version(other)]
expect(versions.map(&:id)).to eq(versions.map(&:id).sort.uniq)
expect(versions.map(&:version_no)).to eq([1, 1, 1])
[description, group, other].zip(versions).each do |resource, version|
get "/wiki/#{ resource.id }"
expect(response).to have_http_status(:ok)
expect(json.fetch('revision_id')).to eq(version.id)
[:description, :group].each do |resource|
it "keeps #{ resource } independent of the Tag and ExternalTag ID space" do
create(:tag, id: 100)
create(:external_tag, id: 200)
seed_description(1)
seed_group(name: 'shared_space', id: 2)
create_resource(resource)
expect(json.fetch('id')).to eq(3)
end
it "uses the other resource's larger maximum when allocating #{ resource }" do
seed_description(resource == :description ? 1 : 20)
seed_group(name: 'other_space', id: resource == :group ? 1 : 20)
create_resource(resource)
expect(json.fetch('id')).to eq(21)
end
it "starts #{ resource } at 1 even after previous allocated rows were removed" do
expect(TagDescription.count).to eq(0)
expect(TagGroup.count).to eq(0)
if resource == :description
seed_description(100).destroy!
else
id = seed_group(name: 'removed', id: 100)
TagGroup.find(id).destroy!
end
create_resource(resource)
expect(json.fetch('id')).to eq(1)
end
it "allocates #{ resource } above both maxima, preserving legacy overlaps and gaps" do
[1, 2, 5].each { seed_description(_1) }
[1, 3, 8].each { seed_group(name: "group_#{ _1 }", id: _1) }
descriptions = TagDescription.order(:id).map(&:attributes)
groups = TagGroup.order(:id).map(&:attributes)
create_resource(resource)
expect(json.fetch('id')).to eq(9)
expect(TagDescription.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(descriptions)
expect(TagGroup.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(groups)
expect(TagDescription.exists?(id: 3)).to be(false)
expect(TagGroup.exists?(id: 2)).to be(false)
end
end
it 'does not reuse a deleted description ID for a group or vice versa' do
description = create_description('deleted')
description.destroy!
group = create_group('deleted group')
expect(group.id).to be > description.id
group.destroy!
expect(create_description('replacement').id).to be > group.id
it 'preserves group ID and unrelated rows on update' do
id = seed_group(name: 'existing', id: 5, description: 'before')
other_id = seed_group(name: 'other', id: 8, description: 'other')
other = TagGroup.find(other_id).attributes
put "/tag_groups/#{ id }", params: {
name: 'renamed', description: 'after', members: [], placements: [] }, headers:, as: :json
expect(response).to have_http_status(:ok)
expect(json.fetch('id')).to eq(id)
expect(TagGroup.find(id).description).to eq('after')
expect(TagGroup.find(other_id).attributes).to eq(other)
expect(TagGroup.count).to eq(2)
end
it 'preserves resource and old revision identity on an update' do
description = create_description('stable')
original_resource_id = description.id
original = current_description_version(description)
snapshot = original.attributes
put "/wiki/#{ description.id }",
params: { title: 'stable', body: 'after', base_revision_id: original.id },
it 'preserves description identity on a compatibility update' do
tag = create(:tag, primary_name: 'existing_description')
page = seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user)
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
id = description.id
put "/wiki/#{ id }", params: {
title: tag.name('ja'), body: 'after', base_revision_id: page.current_revision.id },
headers: headers
expect(response).to have_http_status(:ok)
expect(description.reload.id).to eq(original_resource_id)
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
expect(original.reload.attributes).to eq(snapshot)
expect(current_description_version(description).id).to be > original.id
expect(description.reload.id).to eq(id)
expect(description.body).to eq('after')
expect(TagDescription.count).to eq(1)
end
end
+5 -7
ファイルの表示
@@ -1,7 +1,6 @@
require 'rails_helper'
RSpec.describe 'TagVersions API', type: :request do
include WikiRecords
let(:member) { create(:user, :member, name: 'version member') }
let!(:tag) { create(:tag, primary_name: 'tag_versions_target', category: :general) }
@@ -241,19 +240,18 @@ RSpec.describe 'TagVersions API', type: :request do
wiki_tag = create(:tag, primary_tag_name: wiki_tag_name, category: :general)
wiki_page =
seed_description(
Wiki::Commit.create_content!(
tag_name: wiki_tag_name,
body: 'before',
created_by_user: member,
message: 'init')
TagDescriptionOperation.update!(
Locale.nipponese,
tag: wiki_page.tag,
Wiki::Commit.content!(
page: wiki_page,
body: 'after',
updated_by_user: member,
created_user: member,
message: 'edit',
base_revision_id: current_description_version(wiki_page).id)
base_revision_id: wiki_page.current_revision.id)
get '/tags/versions', params: { id: wiki_tag.id }
+15 -15
ファイルの表示
@@ -14,7 +14,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
end
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
seed_description(
seed_wiki_page(
tag_name: primary_tag_name_for(tag, 'ja'),
body:,
created_by_user: user,
@@ -29,7 +29,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
it 'renames the current Wiki resource without adding body history' do
tag = create_tag!(name: 'patch_tag_wiki_before')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
wiki_history = description_versions(wiki_page).map(&:attributes)
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect {
patch "/tags/#{ tag.id }", params: {
@@ -37,7 +37,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
}
}
.to change(TagVersion, :count).by(2)
.and change(TagDescriptionVersion, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok)
@@ -45,16 +45,16 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
wiki_page.reload
expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after')
expect(wiki_page.tag.name('ja')).to eq('patch_tag_wiki_after')
expect(wiki_page.title).to eq('patch_tag_wiki_after')
expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history)
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
end
it 'does not record wiki_version when only category changes' do
tag = create_tag!(name: 'patch_tag_category_only')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
before_wiki_versions = description_versions(wiki_page).count
before_wiki_versions = wiki_page.wiki_versions.count
expect {
patch "/tags/#{ tag.id }", params: {
@@ -70,7 +70,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_category_only')
expect(tag.category).to eq('meme')
expect(description_versions(wiki_page).count).to eq(before_wiki_versions)
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
end
end
@@ -78,7 +78,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
it 'renames the current Wiki resource without adding body history' do
tag = create_tag!(name: 'put_tag_wiki_before')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
wiki_history = description_versions(wiki_page).map(&:attributes)
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect {
put "/tags/#{ tag.id }", params: {
@@ -90,7 +90,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
}
}
.to change(TagVersion, :count).by(2)
.and change(TagDescriptionVersion, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok)
@@ -98,16 +98,16 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
wiki_page.reload
expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after')
expect(wiki_page.tag.name('ja')).to eq('put_tag_wiki_after')
expect(wiki_page.title).to eq('put_tag_wiki_after')
expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history)
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
end
it 'does not record wiki_version when only category changes' do
tag = create_tag!(name: 'put_tag_category_only')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
before_wiki_versions = description_versions(wiki_page).count
before_wiki_versions = wiki_page.wiki_versions.count
expect {
put "/tags/#{ tag.id }", params: {
@@ -127,14 +127,14 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_category_only')
expect(tag.category).to eq('meme')
expect(description_versions(wiki_page).count).to eq(before_wiki_versions)
expect(wiki_page.wiki_versions.count).to eq(before_wiki_versions)
end
it 'does not record wiki_version when only aliases change' do
tag = create_tag!(name: 'put_tag_alias_only')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
before_wiki_versions = description_versions(wiki_page).count
before_wiki_versions = wiki_page.wiki_versions.count
expect {
put "/tags/#{ tag.id }", params: {
@@ -148,7 +148,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
expect(description_versions(wiki_page).count).to eq(before_wiki_versions)
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_versions)
end
end
end
+37 -34
ファイルの表示
@@ -2,7 +2,6 @@ require 'cgi'
require 'rails_helper'
RSpec.describe 'Tags API', type: :request do
include WikiRecords
let!(:tn) { create(:tag_name, name: 'spec_tag') }
let!(:tag) { create(:tag, primary_tag_name: tn, category: :general) }
let!(:alias_tn) { create(:tag_name, :alias, name: 'unko', tag:) }
@@ -746,13 +745,13 @@ RSpec.describe 'Tags API', type: :request do
it 'PATCH の名称変更で Wiki の本文履歴を追加しない' do
wiki_page =
seed_description(
Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'),
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
wiki_history = description_versions(wiki_page).map(&:attributes)
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect {
patch "/tags/#{ tag.id }", params: {
@@ -760,23 +759,23 @@ RSpec.describe 'Tags API', type: :request do
}
}
.to change(TagVersion, :count).by(2)
.and change(TagDescriptionVersion, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok)
expect(wiki_page.reload.tag.name('ja')).to eq('patch_wiki_renamed_tag')
expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history)
expect(wiki_page.reload.title).to eq('patch_wiki_renamed_tag')
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
end
it 'tag の category だけを変更しても wiki version は作成しない' do
wiki_page =
seed_description(
Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'),
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
before_wiki_version_count = description_versions(wiki_page).count
before_wiki_version_count = wiki_page.reload.wiki_versions.count
expect {
patch "/tags/#{ tag.id }", params: {
@@ -785,7 +784,7 @@ RSpec.describe 'Tags API', type: :request do
}.to change(TagVersion, :count).by(2)
expect(response).to have_http_status(:ok)
expect(description_versions(wiki_page).count).to eq(before_wiki_version_count)
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count)
end
it 'full update で旧名 alias が残った tag を PATCH で旧名へ戻せる' do
@@ -816,7 +815,7 @@ RSpec.describe 'Tags API', type: :request do
it '別 tag の正規名には変更できない' do
wiki_page =
seed_description(
Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'),
body: 'patch collision wiki',
created_by_user: member_user,
@@ -832,7 +831,7 @@ RSpec.describe 'Tags API', type: :request do
expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag')
expect(TagName.where(tag_id: tag.id, language_code: 'ja', primary_flg: false).pluck(:name))
.to contain_exactly('unko')
expect(wiki_page.reload.tag.tag_name('ja')).to eq(primary_tag_name_for(tag, 'ja'))
expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja'))
end
it 'system tag の name は変更できない' do
@@ -1210,7 +1209,7 @@ RSpec.describe 'Tags API', type: :request do
expect(old_name_alias).to have_attributes(
tag_id: tag.id, language_code: 'ja', primary_flg: false)
expect(TagName.exists?(alias_tn.id)).to be(false)
expect(alias_tn.reload.tag_id).to be_nil
expect(TagName.where(tag_id: tag.parents.select(:id),
language_code: 'ja', primary_flg: true).pluck(:name))
@@ -1395,17 +1394,20 @@ RSpec.describe 'Tags API', type: :request do
it 'Wiki を持つ Tag の名称変更で本文履歴を追加しない' do
wiki_page =
seed_description(
Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'),
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
TagDescriptionOperation.update!(
Locale.nipponese, tag: wiki_page.tag, body: 'wiki body before',
updated_by_user: member_user, message: 'init')
Wiki::Commit.content!(
page: wiki_page,
body: 'wiki body before',
created_user: member_user,
message: 'init'
)
wiki_history = description_versions(wiki_page).map(&:attributes)
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect {
put "/tags/#{ tag.id }", params: {
@@ -1417,22 +1419,22 @@ RSpec.describe 'Tags API', type: :request do
}
}
.to change(TagVersion, :count).by(2)
.and change(TagDescriptionVersion, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok)
expect(wiki_page.reload.tag.name('ja')).to eq('put_wiki_version_tag')
expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history)
expect(wiki_page.reload.title).to eq('put_wiki_version_tag')
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
end
it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do
wiki_page =
seed_description(
Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'),
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
wiki_history = description_versions(wiki_page).map(&:attributes)
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect {
put "/tags/#{ tag.id }", params: {
@@ -1452,19 +1454,20 @@ RSpec.describe 'Tags API', type: :request do
}
}
.to change(TagVersion, :count).by(3)
.and change(TagDescriptionVersion, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok)
tag.reload
expect(wiki_page.reload.tag.tag_name('ja')).to eq(primary_tag_name_for(tag, 'ja'))
expect(wiki_page.reload.tag_id).to eq(tag.id)
expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja'))
expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b').wiki_page)
.to be_nil
expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b'))
.to have_attributes(tag_id: tag.id, primary_flg: false)
expect(wiki_page.tag.name('ja')).to eq('spec_tag')
expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history)
expect(wiki_page.title).to eq('spec_tag')
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
end
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
@@ -1517,7 +1520,7 @@ RSpec.describe 'Tags API', type: :request do
name: 'put_alias_collision_name',
tag: old_owner)
wiki_page =
seed_description(
Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'),
body: 'put collision wiki',
created_by_user: member_user,
@@ -1551,7 +1554,7 @@ RSpec.describe 'Tags API', type: :request do
expect(old_owner_versions.last.event_type).to eq('update')
expect(version_alias_names(old_owner_versions.last, 'ja'))
.not_to include('put_alias_collision_name')
expect(wiki_page.reload.tag.tag_name('ja')).to eq(primary_tag_name_for(tag, 'ja'))
expect(wiki_page.reload.tag_name).to eq(primary_tag_name_for(tag, 'ja'))
end
it 'parent_tags に指定すると循環する tag は 422 にする' do
@@ -1576,13 +1579,13 @@ RSpec.describe 'Tags API', type: :request do
it 'Tag の名称変更で Wiki の本文履歴を追加しない' do
wiki_page =
seed_description(
Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'),
body: 'wiki body before',
created_by_user: member_user,
message: 'init')
wiki_history = description_versions(wiki_page).map(&:attributes)
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect {
put "/tags/#{ tag.id }", params: {
@@ -1594,12 +1597,12 @@ RSpec.describe 'Tags API', type: :request do
}
}
.to change(TagVersion, :count).by(2)
.and change(TagDescriptionVersion, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok)
expect(wiki_page.reload.tag.name('ja')).to eq('put_wiki_renamed_tag')
expect(description_versions(wiki_page).reload.map(&:attributes)).to eq(wiki_history)
expect(wiki_page.reload.title).to eq('put_wiki_renamed_tag')
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
end
end
end
+6 -7
ファイルの表示
@@ -10,19 +10,18 @@ RSpec.describe 'Wiki conflict handling', type: :request do
it 'returns 409 when base_revision_id is stale' do
page =
seed_description(
seed_wiki_page(
tag_name: create(:tag, primary_name: 'wiki_conflict_request').tag_name('ja'),
body: 'first',
created_by_user: user,
message: 'init')
stale_id = current_description_version(page).id
stale_id = page.current_revision.id
TagDescriptionOperation.update!(
Locale.nipponese,
tag: page.tag,
Wiki::Commit.content!(
page:,
body: 'second',
updated_by_user: user,
created_user: user,
message: 'other edit',
base_revision_id: stale_id)
@@ -37,6 +36,6 @@ RSpec.describe 'Wiki conflict handling', type: :request do
page.reload
expect(page.body).to eq('second')
expect(current_description_version(page)).to have_attributes(body: 'second', version_no: 2)
expect(page.current_revision.message).to eq('other edit')
end
end
+17 -1
ファイルの表示
@@ -7,11 +7,27 @@ RSpec.describe 'Wiki creation freeze', type: :request do
let!(:user) { create_member_user! }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
it 'creates missing compatibility history when updating an independent group' do
id = seed_group(name: 'independent', description: 'before')
expect do
put "/tag_groups/#{ id }", params: {
name: 'independent', description: 'after', members: [], placements: [] },
headers:, as: :json
end.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).description).to eq('after')
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:independent' })
expect(page.body).to include('after')
expect(page.current_revision.body).to eq(page.body)
expect(page.wiki_versions.sole.body).to eq(page.body)
end
context 'existing description write protection' do
let!(:tag) { create(:tag, primary_name: 'protected_description') }
let!(:page) do
seed_description(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user)
seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user)
end
[nil, :guest, :banned].each do |role|
+170
ファイルの表示
@@ -0,0 +1,170 @@
require 'cgi'
require 'rails_helper'
RSpec.describe 'Wiki history integrity', type: :request do
include WikiRecords
let!(:user) { create_member_user! }
def auth_headers user
{ 'X-Transfer-Code' => user.inheritance_code }
end
def create_wiki_page title:, body: 'body', message: 'init', user: self.user
seed_wiki_page(
tag_name: create(:tag, primary_name: title).tag_name('ja'),
body:,
created_by_user: user,
message:)
end
describe 'POST /wiki' do
it 'rejects frozen creation without partial page or history' do
expect do
post '/wiki', params: { title: 'Help:wiki_history_create_atomic', body: 'initial' },
headers: auth_headers(user)
end.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response.status).to be_between(400, 499)
end
it 'returns 422 and creates nothing when normalised body is blank' do
expect {
post '/wiki',
params: {
title: 'Help:wiki_history_blank_body',
body: "\r\n\r\n",
message: 'blank',
},
headers: auth_headers(user)
}
.not_to change(WikiPage, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(WikiPage.joins(:tag_name)
.where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist
end
it 'rejects frozen creation without modifying an existing page or history' do
create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first')
expect do
post '/wiki',
params: { title: 'Help:wiki_history_duplicate_title',
body: 'second', message: 'duplicate' },
headers: auth_headers(user)
end.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response.status).to be_between(400, 499)
expect(WikiPage.joins(:tag_name)
.where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1)
end
end
describe 'PUT /wiki/:id' do
it 'updates body and records wiki_revision and wiki_version' do
page = create_wiki_page(title: 'wiki_history_update_body', body: 'before')
current_id = page.current_revision.id
expect {
put "/wiki/#{ page.id }",
params: {
title: 'wiki_history_update_body',
body: 'after',
message: 'edit body',
base_revision_id: current_id,
},
headers: auth_headers(user)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
page.reload
version = page.wiki_versions.order(:version_no).last
expect(page.title).to eq('wiki_history_update_body')
expect(page.body).to eq('after')
expect(version).to have_attributes(
event_type: 'update',
title: 'wiki_history_update_body',
body: 'after',
reason: 'edit body',
created_by_user_id: user.id
)
end
it 'renames title and records wiki_version with new title' do
page = create_wiki_page(title: 'wiki_history_rename_before', body: 'before')
current_id = page.current_revision.id
expect {
put "/wiki/#{ page.id }",
params: {
title: 'wiki_history_rename_after',
body: 'after',
message: 'rename',
base_revision_id: current_id,
},
headers: auth_headers(user)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
page.reload
version = page.wiki_versions.order(:version_no).last
expect(page.title).to eq('wiki_history_rename_after')
expect(page.body).to eq('after')
expect(version).to have_attributes(
event_type: 'update',
title: 'wiki_history_rename_after',
body: 'after',
reason: 'rename',
created_by_user_id: user.id
)
end
it 'does not change title, body, revision, or version on stale base_revision_id' do
page = create_wiki_page(title: 'wiki_history_conflict_page', body: 'first')
stale_id = page.current_revision.id
Wiki::Commit.content!(
page:,
body: 'second',
created_user: user,
message: 'other edit',
base_revision_id: stale_id)
page.reload
current_title = page.title
current_body = page.body
revision_count = page.wiki_revisions.count
version_count = page.wiki_versions.count
put "/wiki/#{ page.id }",
params: {
title: 'wiki_history_conflict_renamed',
body: 'third',
message: 'stale edit',
base_revision_id: stale_id,
},
headers: auth_headers(user)
expect(response).to have_http_status(:conflict)
page.reload
expect(page.title).to eq(current_title)
expect(page.body).to eq(current_body)
expect(page.wiki_revisions.count).to eq(revision_count)
expect(page.wiki_versions.count).to eq(version_count)
end
end
end
+3 -3
ファイルの表示
@@ -28,15 +28,15 @@ RSpec.describe 'Wiki resource ordering', type: :request do
it 'breaks timestamp ties by resource kind and ID while retaining legacy overlaps' do
time = Time.utc(2026, 1, 1)
tag = create(:tag, primary_name: 'tied')
description = TagDescription.create!(tag:, language_code: 'ja', body: 'current',
TagDescription.create!(id: 51, tag:, language_code: 'ja', body: 'current',
version_no: 1, updated_at: time)
group = TagGroup.find(seed_group(name: 'tied', id: description.id))
group = TagGroup.find(seed_group(name: 'tied', id: 51))
group.update_columns(updated_at: time)
2.times do
get '/wiki', params: { title: 'tied' }
expect(json.map { [_1.fetch('resource_type'), _1.fetch('id')] })
.to eq([['tag_description', description.id], ['tag_group', description.id]])
.to eq([['tag_description', 51], ['tag_group', 51]])
end
end
end
-158
ファイルの表示
@@ -1,158 +0,0 @@
require 'rails_helper'
RSpec.describe 'Wiki and native API locale contracts', type: :request do
include WikiRecords
include TagGroupRecords
include_context 'Ainu locale contract'
let!(:user) { create_member_user! }
let!(:tag) { create(:tag, primary_name: 'japanese_title') }
let!(:ainu_name) { add_ainu_name(tag, 'ainu_title') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
let!(:japanese) do
TagDescriptionOperation.create!(
Locale.nipponese, tag:, body: 'Japanese body', created_by_user: user)
end
let!(:ainu) do
TagDescriptionOperation.create!(
ainu_locale, tag:, body: 'Ainu body', created_by_user: user)
end
['/wiki', '/wiki/search'].each do |path|
it "filters #{ path } by the selected language" do
get path, params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('id') }).to eq([ainu.id])
expect(json.sole).to include('title' => 'ainu_title', 'body' => 'Ainu body')
get path, params: { locale: 'ain-Kana', title: 'japanese' }
expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
end
it 'uses the existing default locale when no locale is specified' do
get '/wiki'
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('id') }).to eq([japanese.id])
end
it 'resolves titles and aliases only in the selected language in both APIs' do
['/wiki/title/japanese_title', '/tags/name/japanese_title/description'].each do |path|
get path, params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:not_found)
end
['/wiki/title/ainu_title', '/tags/name/ainu_title/description'].each do |path|
get path, params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to eq('Ainu body')
end
get '/wiki/title/ainu_title/exists', params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:no_content)
get '/wiki/title/japanese_title/exists', params: { locale: 'ain-Kana' }
expect(response).to have_http_status(:not_found)
end
it 'does not disclose another language through ID, existence, diff or history' do
revision = current_description_version(japanese)
["/wiki/#{ japanese.id }", "/wiki/#{ japanese.id }/exists",
"/wiki/#{ japanese.id }/diff"].each do |path|
get path, params: { locale: 'ain-Kana', from: revision.id }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include('Japanese body')
end
get '/wiki/changes', params: { locale: 'ain-Kana', id: japanese.id }
expect(response).to have_http_status(:ok)
expect(json).to eq([])
get '/wiki/changes', params: { locale: 'ain-Kana' }
expect(json.map { _1.fetch('revision_id') })
.to eq([current_description_version(ainu).id])
end
it 'does not update a description addressed through another language ID' do
snapshot = [japanese.attributes, ainu.attributes, ainu_name.attributes]
expect do
put "/wiki/#{ japanese.id }", params: {
locale: 'ain-Kana', title: 'renamed', body: 'wrong target' }, headers: headers
end.not_to change(TagDescriptionVersion, :count)
expect(response).to have_http_status(:not_found)
expect([japanese.reload.attributes, ainu.reload.attributes, ainu_name.reload.attributes])
.to eq(snapshot)
end
[:wiki, :native].each do |api|
it "creates the selected language through #{ api } without changing Japanese history" do
other = create(:tag, primary_name: 'other_japanese')
add_ainu_name(other, 'other_ainu')
original = TagDescriptionOperation.create!(
Locale.nipponese, tag: other, body: 'original', created_by_user: user)
snapshot = original.attributes
expect do
if api == :wiki
post '/wiki', params: { locale: 'ain-Kana', title: 'other_ainu', body: 'created' },
headers: headers
else
put "/tags/#{ other.id }/description", params: { locale: 'ain-Kana', body: 'created' },
headers: headers
end
end.to change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:created)
created = TagDescription.find_by!(tag: other, language_code: 'ain')
expect(created).to have_attributes(body: 'created', version_no: 1)
expect(current_description_version(created)).to have_attributes(language_code: 'ain')
expect(original.reload.attributes).to eq(snapshot)
end
end
it 'shares selected-language edits and revision identity across both APIs' do
snapshot = japanese.attributes
first = current_description_version(ainu)
put "/tags/#{ tag.id }/description", params: { locale: 'ain-Kana', body: 'native edit' },
headers: headers
expect(response).to have_http_status(:ok)
second = current_description_version(ainu)
expect(second).to have_attributes(language_code: 'ain', version_no: 2)
expect(second.id).not_to eq(first.id)
get "/wiki/#{ ainu.id }", params: { locale: 'ain-Kana' }
expect(json).to include('body' => 'native edit', 'revision_id' => second.id)
put "/wiki/#{ ainu.id }", params: {
locale: 'ain-Kana', title: 'ainu_title', body: 'facade edit',
base_revision_id: second.id }, headers: headers
expect(response).to have_http_status(:ok)
get "/tags/#{ tag.id }/description", params: { locale: 'ain-Kana' }
expect(json).to include('body' => 'facade edit', 'language_code' => 'ain', 'version_no' => 3)
expect(japanese.reload.attributes).to eq(snapshot)
end
it 'rejects a stale selected-language revision without a partial title or body edit' do
stale = current_description_version(ainu)
TagDescriptionOperation.update!(
ainu_locale, tag:, body: 'current', updated_by_user: user)
snapshot = [ainu.reload.attributes, ainu_name.attributes, japanese.attributes]
expect do
put "/wiki/#{ ainu.id }", params: {
locale: 'ain-Kana', title: 'conflicting_name', body: 'current',
base_revision_id: stale.id }, headers: headers
end.to change(TagDescriptionVersion, :count).by(0).and change(TagVersion, :count).by(0)
expect(response).to have_http_status(:conflict)
expect([ainu.reload.attributes, ainu_name.reload.attributes, japanese.reload.attributes])
.to eq(snapshot)
end
it 'scopes historical show and diff to the selected description' do
first = current_description_version(ainu)
TagDescriptionOperation.update!(
ainu_locale, tag:, body: 'later', updated_by_user: user)
get "/wiki/#{ ainu.id }", params: { locale: 'ain-Kana', version: first.id }
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'Ainu body', 'revision_id' => first.id)
get "/wiki/#{ ainu.id }/diff", params: { locale: 'ain-Kana', from: first.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('newer_revision_id')).to eq(current_description_version(ainu).id)
get "/wiki/#{ ainu.id }/diff", params: {
locale: 'ain-Kana', from: current_description_version(japanese).id }
expect(response).to have_http_status(:not_found)
end
end
+64 -41
ファイルの表示
@@ -19,7 +19,7 @@ RSpec.describe 'Wiki API', type: :request do
deprecated_at: Time.zone.local(2026, 6, 1))
end
let!(:page) do
seed_description(
seed_wiki_page(
tag_name: tn,
body: 'init',
created_by_user: user,
@@ -113,9 +113,9 @@ RSpec.describe 'Wiki API', type: :request do
it "rejects frozen creation of #{ title } without history writes" do
expect do
post endpoint, params: { title:, body: 'initial' }, headers: auth_headers(member)
end.to change(TagDescription, :count).by(0)
.and change(TagDescriptionVersion, :count).by(0)
end.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response.status).to be_between(400, 499)
end
end
@@ -132,7 +132,7 @@ RSpec.describe 'Wiki API', type: :request do
let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') }
let!(:page) do
seed_description(
seed_wiki_page(
tag_name: test_tag_name,
body: "a\nb",
created_by_user: member,
@@ -166,7 +166,7 @@ RSpec.describe 'Wiki API', type: :request do
context 'when success' do
it 'creates new revision and returns 200' do
current_id = description_versions(page).maximum(:id)
current_id = page.wiki_revisions.maximum(:id)
expect do
put "/wiki/#{page.id}",
@@ -174,22 +174,26 @@ RSpec.describe 'Wiki API', type: :request do
base_revision_id: current_id },
headers: auth_headers(member)
end
.to change(TagDescriptionVersion, :count).by(1)
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
version = description_versions(page).order(:version_no).last
version = page.wiki_versions.order(:version_no).last
expect(version).to have_attributes(
event_type: 'update',
title: 'TestPage',
body: "x\ny",
created_by_user_id: member.id)
created_by_user_id: member.id
)
expect(response).to have_http_status(:ok)
page.reload
rev = current_description_version(page)
rev = page.current_revision
expect(rev).to be_content
expect(rev.message).to eq('edit')
expect(page.body).to eq("x\ny")
expect(rev.version_no).to eq(2)
expect(rev.base_revision_id).to eq(current_id)
end
it 'wiki body だけを変更しても tag version は作成しない' do
@@ -202,13 +206,13 @@ RSpec.describe 'Wiki API', type: :request do
created_by_user: member)
linked_page =
seed_description(
seed_wiki_page(
tag_name: linked_tag_name,
body: 'before',
created_by_user: member,
message: 'init')
current_id = current_description_version(linked_page).id
current_id = linked_page.current_revision.id
before_count = linked_tag.reload.tag_versions.count
expect {
@@ -221,8 +225,8 @@ RSpec.describe 'Wiki API', type: :request do
},
headers: auth_headers(member)
}
.to change(TagDescriptionVersion, :count).by(1)
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(linked_tag.reload.tag_versions.count).to eq(before_count)
@@ -232,12 +236,10 @@ RSpec.describe 'Wiki API', type: :request do
context 'when conflict' do
it 'returns 409 when base_revision_id mismatches' do
# 先に別ユーザ(同じ member でもOK)が 1 回更新して先頭を進める
TagDescriptionOperation.update!(
Locale.nipponese, tag: page.tag, body: 'zzz',
updated_by_user: member, message: 'other edit')
Wiki::Commit.content!(page: page, body: "zzz", created_user: member, message: 'other edit')
page.reload
stale_id = description_versions(page).order(:id).first.id # わざと古い id
stale_id = page.wiki_revisions.order(:id).first.id # わざと古い id
put "/wiki/#{page.id}",
params: { title: 'TestPage', body: 'x', base_revision_id: stale_id },
headers: auth_headers(member)
@@ -279,13 +281,13 @@ RSpec.describe 'Wiki API', type: :request do
describe 'GET /wiki/search' do
before do
search_tag = create(:tag, primary_name: 'spec_wiki_title_2')
seed_description(
seed_wiki_page(
tag_name: search_tag.tag_name('ja'),
body: 'search body 2',
created_by_user: user,
message: 'init')
seed_description(
seed_wiki_page(
tag_name: create(:tag_name, name: 'unrelated_title'),
body: 'unrelated body',
created_by_user: user,
@@ -316,15 +318,13 @@ RSpec.describe 'Wiki API', type: :request do
describe 'GET /wiki/changes' do
let!(:rev1) do
TagDescriptionOperation.update!(
Locale.nipponese, tag: page.tag, body: "a\nb", updated_by_user: user, message: 'r1')
current_description_version(page)
Wiki::Commit.content!(page: page, body: "a\nb", created_user: user, message: 'r1')
page.current_revision
end
let!(:rev2) do
TagDescriptionOperation.update!(
Locale.nipponese, tag: page.tag, body: "a\nc", updated_by_user: user, message: 'r2')
current_description_version(page)
Wiki::Commit.content!(page: page, body: "a\nc", created_user: user, message: 'r2')
page.current_revision
end
it 'returns latest revisions (optionally filtered by page id)' do
@@ -358,9 +358,12 @@ RSpec.describe 'Wiki API', type: :request do
it 'returns empty array when page has no revisions and filtered by id' do
# 別ページを作って revision 無し
tn2 = create(:tag_name, name: 'spec_no_rev')
# 異常データ: Version のない TagDescription を直接作る
p2 = TagDescription.create!(
tag: tn2.tag, language_code: 'ja', body: 'init', version_no: 1)
# 異常データ: revision 無し WikiPage を直接作る
p2 = WikiPage.create!(
tag_name: tn2,
body: 'init',
created_user: user,
updated_user: user)
get "/wiki/changes?id=#{p2.id}"
expect(response).to have_http_status(:ok)
@@ -396,15 +399,13 @@ RSpec.describe 'Wiki API', type: :request do
describe 'GET /wiki/:id/diff' do
let!(:rev_a) do
TagDescriptionOperation.update!(
Locale.nipponese, tag: page.tag, body: "a\nb\nc", updated_by_user: user, message: 'A')
current_description_version(page)
Wiki::Commit.content!(page: page, body: "a\nb\nc", created_user: user, message: 'A')
page.current_revision
end
let!(:rev_b) do
TagDescriptionOperation.update!(
Locale.nipponese, tag: page.tag, body: "a\nx\nc", updated_by_user: user, message: 'B')
current_description_version(page)
Wiki::Commit.content!(page: page, body: "a\nx\nc", created_user: user, message: 'B')
page.current_revision
end
it 'returns diff json between revisions' do
@@ -430,23 +431,44 @@ RSpec.describe 'Wiki API', type: :request do
expect(response).to have_http_status(:ok)
expect(json['older_revision_id']).to eq(rev_a.id)
expect(json['newer_revision_id']).to eq(current_description_version(page).id)
expect(json['newer_revision_id']).to eq(page.current_revision.id)
end
end
describe 'Wiki::Commit.redirect!' do
it 'raises because redirect revisions are deprecated' do
target_tag_name = create(:tag_name, name: 'redirect_deprecated_target')
target =
seed_wiki_page(
tag_name: target_tag_name,
body: 'target',
created_by_user: user,
message: 'init')
expect {
Wiki::Commit.redirect!(
page: page,
redirect_page: target,
created_user: user,
message: 'redirect',
base_revision_id: page.current_revision.id
)
}.to raise_error(RuntimeError, '廃止しました.')
end
end
it 'wiki title を変更すると対応する tag の version を作成する' do
linked_tag_name = create(:tag_name, name: 'wiki_linked_tag_for_version')
linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general)
linked_page =
seed_description(
seed_wiki_page(
tag_name: linked_tag_name,
body: 'before',
created_by_user: user,
message: 'init')
current_id = current_description_version(linked_page).id
current_id = linked_page.current_revision.id
expect {
put "/wiki/#{ linked_page.id }",
@@ -458,7 +480,8 @@ RSpec.describe 'Wiki API', type: :request do
},
headers: auth_headers(user)
}
.to change(TagDescriptionVersion, :count).by(1)
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change { linked_tag.reload.tag_versions.count }.by(2)
expect(response).to have_http_status(:ok)
+11 -10
ファイルの表示
@@ -9,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do
end
def create_wiki_page title:, body:
seed_description(
seed_wiki_page(
tag_name: create(:tag_name, name: title),
body:,
created_by_user: user,
@@ -20,9 +20,9 @@ RSpec.describe 'Wiki title collision', type: :request do
source = create_wiki_page(title: 'wiki_collision_source', body: 'source body')
create_wiki_page(title: 'wiki_collision_target', body: 'target body')
source_revision_count = description_versions(source).count
source_version_count = description_versions(source).count
old_title = source.tag.reload.name('ja')
source_revision_count = source.wiki_revisions.count
source_version_count = source.wiki_versions.count
old_title = source.title
old_body = source.body
put "/wiki/#{ source.id }",
@@ -30,7 +30,7 @@ RSpec.describe 'Wiki title collision', type: :request do
title: 'wiki_collision_target',
body: 'new body',
message: 'rename collision',
base_revision_id: current_description_version(source).id,
base_revision_id: source.current_revision.id,
},
headers: auth_headers(user)
@@ -38,10 +38,10 @@ RSpec.describe 'Wiki title collision', type: :request do
source.reload
expect(source.tag.reload.name('ja')).to eq(old_title)
expect(source.title).to eq(old_title)
expect(source.body).to eq(old_body)
expect(description_versions(source).count).to eq(source_revision_count)
expect(description_versions(source).count).to eq(source_version_count)
expect(source.wiki_revisions.count).to eq(source_revision_count)
expect(source.wiki_versions.count).to eq(source_version_count)
end
it 'rejects frozen creation with an existing title without adding history' do
@@ -52,8 +52,9 @@ RSpec.describe 'Wiki title collision', type: :request do
params: { title: 'Help:wiki_collision_create', body: 'new body',
message: 'duplicate create' },
headers: auth_headers(user)
end.to change(TagDescription, :count).by(0)
.and change(TagDescriptionVersion, :count).by(0)
end.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response.status).to be_between(400, 499)
end
+70 -17
ファイルの表示
@@ -1,25 +1,78 @@
require 'rails_helper'
RSpec.describe 'Removed standalone Wiki resources', type: :request do
include TagGroupRecords
let(:user) { create_member_user! }
RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
include WikiRecords
let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'Help_contract') }
let!(:page) do
seed_wiki_page(tag_name:, body: 'before', created_by_user: user)
end
let!(:first_revision) { page.current_revision }
let!(:last_revision) do
Wiki::Commit.legacy_content!(page:, body: 'after', created_user: user,
message: 'legacy edit', base_revision_id: first_revision.id)
end
['Help:old', 'ヘルプ:旧ページ'].each do |title|
it "does not resolve #{ title } as a Wiki resource" do
path = "/wiki/title/#{ CGI.escapeURIComponent(title) }"
without_group_legacy_reads { get path }
expect(response).to have_http_status(:not_found)
get "#{ path }/exists"
expect(response).to have_http_status(:not_found)
before { expect(TagDescription.exists?(id: page.id)).to be(false) }
it 'rejects standalone updates even after a Tag is linked to the legacy page' do
create(:tag, primary_tag_name: tag_name)
original = page.reload.attributes
expect do
put "/wiki/#{ page.id }",
params: { title: 'legacy_tag_renamed', body: 'changed legacy body',
message: 'legacy rename', base_revision_id: last_revision.id },
headers: { 'X-Transfer-Code' => user.inheritance_code }
end.to change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
.and change(TagDescription, :count).by(0)
.and change(TagDescriptionVersion, :count).by(0)
expect(response).to have_http_status(:unprocessable_entity)
expect(page.reload.attributes).to eq(original)
expect(page.current_revision).to have_attributes(message: 'legacy edit')
end
include TagGroupRecords
[:id, :title].each do |lookup|
[nil, :historical].each do |edition|
it "rejects legacy #{ lookup } #{ edition || 'current' } without legacy SELECTs" do
path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }"
params = edition ? { version: first_revision.id } : { }
without_group_legacy_reads { get path, params: }
expect(response).to have_http_status(:not_found)
end
end
it "rejects standalone creation of #{ title } without writes" do
user
before_state = group_state
post '/wiki', params: { title:, body: 'body' },
headers: { 'X-Transfer-Code' => user.inheritance_code }
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
it "does not recognise legacy #{ lookup } existence" do
path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }"
without_group_legacy_reads { get "#{ path }/exists" }
expect(response).to have_http_status(:not_found)
end
end
['/wiki', '/wiki/search', '/wiki/changes'].each do |path|
it "excludes unmigrated content from #{ path } without legacy SELECTs" do
without_group_legacy_reads { get path }
expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
end
it 'returns empty scoped history without legacy SELECTs' do
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
[false, true].each do |explicit|
it "rejects legacy diff with #{ explicit ? 'explicit' : 'current' } target" do
params = { from: first_revision.id }
params[:to] = last_revision.id if explicit
without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: }
expect(response).to have_http_status(:not_found)
end
end
end
+3 -2
ファイルの表示
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe PostBulkCreator do
include_context 'Ainu locale'
include_context 'English locale'
it 'limits workers to two and keeps failures in their request slots' do
locale = Locale.nipponese
@@ -67,7 +67,8 @@ RSpec.describe PostBulkCreator do
expect(results[3].dig(:post, :id)).to eq(4)
end
it 'passes a future Ainu locale to preflight and creation in every worker' do
it 'passes a future English locale to preflight and creation in every worker' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
actor = instance_double(User, id: 123)
allow(User).to receive(:find).with(123).and_return(actor)
+21 -19
ファイルの表示
@@ -11,22 +11,23 @@ RSpec.describe PostCreatePlan do
end
context 'with an explicit locale' do
include_context 'Ainu locale'
include_context 'English locale'
it 'resolves the matching language identity and its parents' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
ainu_name = create(:tag_name, name: 'shared_name',
language_code: 'ain', script_code: 'Kana')
ainu = create(:tag, primary_tag_name: ainu_name, category: :character)
english_name = create(:tag_name, name: 'shared_name',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name, category: :character)
nipponese = create(:tag, primary_name: 'temporary_nipponese', category: :general)
# The DB permits this identity; validation has its own contract spec.
primary_tag_name_for(nipponese, 'ja').update_columns(name: 'shared_name')
ainu_parent_name = create(:tag_name, name: 'ainu_parent',
language_code: 'ain', script_code: 'Kana')
ainu_parent = create(:tag, primary_tag_name: ainu_parent_name,
english_parent_name = create(:tag_name, name: 'english_parent',
language_code: 'en', script_code: 'Latn')
english_parent = create(:tag, primary_tag_name: english_parent_name,
category: :material)
nipponese_parent = create(:tag, primary_name: 'nipponese_parent', category: :general)
TagImplication.create!(tag: ainu, parent_tag: ainu_parent)
TagImplication.create!(tag: english, parent_tag: english_parent)
TagImplication.create!(tag: nipponese, parent_tag: nipponese_parent)
plan = described_class.new(attributes: { tags: 'shared_name' }).build!(locale)
@@ -34,34 +35,35 @@ RSpec.describe PostCreatePlan do
expect(plan[:direct_tag_specs]).to eq(
[{ name: 'shared_name', category: :character }])
expect(plan[:post_tag_specs]).to include(
{ name: primary_tag_name_for(ainu_parent, 'ain').name, category: :material })
{ name: primary_tag_name_for(english_parent, 'en').name, category: :material })
expect(plan[:post_tag_specs].pluck(:name))
.not_to include(primary_tag_name_for(nipponese_parent, 'ja').name)
end
it 'resolves aliases within the requested language' do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
nipponese = create(:tag, primary_name: 'nipponese_canonical')
ainu_name = create(:tag_name, name: 'ainu_canonical',
language_code: 'ain', script_code: 'Kana')
ainu = create(:tag, primary_tag_name: ainu_name, category: :character)
english_name = create(:tag_name, name: 'english_canonical',
language_code: 'en', script_code: 'Latn')
english = create(:tag, primary_tag_name: english_name, category: :character)
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: nipponese)
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: ainu,
language_code: 'ain', script_code: 'Kana')
english_alias = create(:tag_name, :alias, name: 'shared_alias',
tag: english,
language_code: 'en', script_code: 'Latn')
expect(nipponese_alias.reload).to have_attributes(
tag_id: nipponese.id,
primary_flg: false, language_code: 'ja')
expect(ainu_alias.reload).to have_attributes(
tag_id: ainu.id,
primary_flg: false, language_code: 'ain')
expect(english_alias.reload).to have_attributes(
tag_id: english.id,
primary_flg: false, language_code: 'en')
plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale)
expect(plan[:direct_tag_specs]).to eq(
[{ name: ainu_name.name, category: :character }])
[{ name: english_name.name, category: :character }])
end
end
+4 -3
ファイルの表示
@@ -77,10 +77,11 @@ RSpec.describe PostCreator do
end
context 'with an explicit locale' do
include_context 'Ainu locale'
include_context 'English locale'
[false, true].each do |planned|
it "passes locale to tag creation with planned attributes: #{ planned }" do
pending '日本語以外の Locale 有効化は別課題で実装する'
locale
attributes = {
title: 'Locale propagation',
@@ -96,8 +97,8 @@ RSpec.describe PostCreator do
expect(Tag).to have_received(:find_or_create_by_tag_name!)
.with(locale, 'locale_character', category: :character).at_least(:once)
tag_name = TagName.find_by!(language_code: 'ain', name: 'locale_character')
expect(tag_name).to have_attributes(script_code: 'Kana', primary_flg: true)
tag_name = TagName.find_by!(language_code: 'en', name: 'locale_character')
expect(tag_name).to have_attributes(script_code: 'Latn', primary_flg: true)
expect(post.tags).to include(tag_name.tag)
end
end
+13 -26
ファイルの表示
@@ -9,9 +9,9 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
expect(ApplicationRecord.connection.adapter_name).to match(/mysql/i)
expect(ApplicationRecord.connection_pool.size).to be >= 3
@prefix = "shared_ids_#{ SecureRandom.hex(8) }"
@tag = create(:tag, primary_name: "#{ @prefix }_owner")
@group = TagGroup.create!(name: "#{ @prefix }_owner", version_no: 1)
@tag_ids = Queue.new
@names = [0, 1].map { create(:tag_name, name: "#{ @prefix }_#{ _1 }") }
@tag = Tag.create!(tag_name_id: @names.first.id, category: :general)
@names.first.update!(tag_id: @tag.id)
@threads = []
@release = Queue.new
end
@@ -23,21 +23,17 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
raise 'Shared ID worker did not finish' unless thread.join(15)
end
ensure
TagDescriptionVersion.where(tag_id: @tag.id).delete_all if @tag
if @group
TagGroupVersion.where(tag_group_id: @group.id).delete_all
@group.delete
end
if @prefix
ExternalTag.where(name: "#{ @prefix }_external").delete_all
TagGroup.where(name: "#{ @prefix }_group").delete_all
end
TagDescription.where(tag_id: @tag.id).delete_all if @tag
ids = []
ids << @tag_ids.pop until @tag_ids.empty? if @tag_ids
ids << @tag.id if @tag
TagName.where(tag_id: ids).delete_all
Tag.where(id: ids).delete_all
if @names
ids = @names.map(&:id)
TagName.where(id: ids).update_all(tag_id: nil)
Tag.where(tag_name_id: ids).delete_all
TagName.where(id: ids).delete_all
end
end
end
@@ -55,19 +51,11 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
def create_resource kind
case kind
when :tag
Tag.create!(category: :general).tap { @tag_ids << _1.id }
Tag.create!(tag_name_id: @names.last.id, category: :general)
when :external
ExternalTag.create!(platform: :nico, name: "#{ @prefix }_external")
when :description
TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1)
when :description_version
TagDescriptionVersion.create!(
tag_id: @tag.id, language_code: 'ja', body: 'body', version_no: 1,
event_type: 'create', created_at: Time.current)
when :group_version
TagGroupVersion.create!(
tag_group: @group, name: @group.name, version_no: 1, event_type: 'create',
members: [], placements: [], created_at: Time.current)
when :group
TagGroup.create!(name: "#{ @prefix }_group", version_no: 1)
end
@@ -100,8 +88,7 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
thread.value
end
{ tags: [:external, :tag], wiki: [:description, :group],
wiki_history: [:description_version, :group_version] }.each do |space, kinds|
{ tags: [:external, :tag], wiki: [:description, :group] }.each do |space, kinds|
[false, true].each do |rollback|
it "serialises #{ space } inserts through #{ rollback ? 'rollback' : 'commit' }" do
tables = SharedResourceIdAllocator::SPACES.fetch(space.to_s)
@@ -109,7 +96,7 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
ApplicationRecord.connection.select_value("SELECT MAX(id) FROM #{ table }").to_i
end
first, (first_id, connection_id, lock_name) = hold_creation(kinds.first, rollback:)
expect(first_id).to be > maxima.max
expect(first_id).to eq(maxima.max + 1)
connection = ApplicationRecord.connection
quoted_lock = connection.quote(lock_name)
expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i)
@@ -123,7 +110,7 @@ RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
expect(await_queue(started)).not_to eq(connection_id)
@release << true
expect(result(first)).to eq(first_id)
expect(result(second)).to be > first_id
expect(result(second)).to eq(rollback ? first_id : first_id + 1)
expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i).to eq(1)
end
end
+7 -112
ファイルの表示
@@ -1,103 +1,6 @@
require 'rails_helper'
RSpec.describe SharedResourceIdAllocator do
let(:tag) { create(:tag) }
def resource table, id: nil, number: 1
case table
when 'tags'
Tag.new(id:, category: :general)
when 'external_tags'
ExternalTag.new(id:, platform: :nico, name: "external #{ number }")
when 'tag_descriptions'
TagDescription.new(id:, tag:, language_code: 'ja', body: 'body', version_no: number)
when 'tag_groups'
TagGroup.new(id:, name: "group #{ number }", version_no: number)
when 'tag_description_versions'
TagDescriptionVersion.new(
id:, tag_id: tag.id, language_code: 'ja', body: 'body', version_no: number,
event_type: 'create', created_at: Time.current)
when 'tag_group_versions'
TagGroupVersion.new(
id:, tag_group_id: group.id, name: group.name, version_no: number,
event_type: 'create', members: [], placements: [], created_at: Time.current)
end
end
let(:group) { TagGroup.create!(name: 'history owner', version_no: 1) }
described_class::SPACES.each do |space, tables|
tables.permutation.each do |first_table, second_table|
it "keeps deleted #{ first_table } IDs reserved for #{ second_table }" do
first = resource(first_table)
first.save!
used_id = first.id
first.class.where(id: first.id).delete_all
second = resource(second_table)
second.save!
expect(second.id).to be > used_id
end
it "advances #{ space } after an explicit INSERT in #{ first_table }" do
first = resource(first_table)
first.save!
high = first.id + 100
explicit = resource(first_table, id: high, number: 2)
first.class.where(id: first.id).delete_all
explicit.save!
explicit.class.where(id: explicit.id).delete_all
second = resource(second_table)
second.save!
expect(second.id).to be > high
end
end
it "allows explicit overlapping Id.s in #{ space }" do
first = resource(tables.first)
first.save!
second = resource(tables.last, id: first.id)
second.save!
expect(second.id).to eq(first.id)
expect(first.reload.id).to eq(first.id)
end
it "preserves historical #{ space } overlaps without repairing them" do
first = resource(tables.first)
first.save!
second = resource(tables.last)
second.save!
second.class.where(id: second.id).update_all(id: first.id)
second = second.class.find(first.id)
snapshots = [first.reload.attributes, second.reload.attributes]
next_record = resource(tables.last, number: 2)
next_record.save!
expect(next_record.id).to be > first.id
expect(first.reload.attributes).to eq(snapshots.first)
expect(second.reload.attributes).to eq(snapshots.last)
end
end
it 'reserves a Tag ID referenced only by remaining description history' do
high = [Tag.maximum(:id).to_i, ExternalTag.maximum(:id).to_i].max + 1000
TagDescriptionVersion.create!(
tag_id: high, language_code: 'ja', version_no: 1, body: 'history',
event_type: 'create', created_at: Time.current)
expect(create(:tag).id).to be > high
expect(create(:external_tag).id).to be > high
end
it 'reserves a group ID referenced only by remaining group history' do
high = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1000
version = resource('tag_group_versions')
version.save!
TagGroupVersion.where(id: version.id).update_all(tag_group_id: high)
expect(TagGroup.create!(name: 'replacement', version_no: 1).id).to be > high
end
end
RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
let(:record) { build(:external_tag) }
let(:connection) { double('connection') }
let(:transaction) { double('transaction', open?: true) }
@@ -108,14 +11,6 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
config = double('config', database: 'test_shared_ids')
allow(connection).to receive_message_chain(:pool, :db_config).and_return(config)
allow(connection).to receive(:quote) { "'#{ _1 }'" }
allow(connection).to receive(:quote_column_name) { "`#{ _1 }`" }
allow(connection).to receive(:execute) { sql << _1 }
allow(connection).to receive(:select_all) do |statement|
tables = described_class::SPACES.values.flatten.select do |table|
statement.include?("'#{ table }'")
end
tables.map { { 'TABLE_NAME' => _1, 'ENGINE' => 'InnoDB', 'AUTO_INCREMENT' => 9 } }
end
allow(connection).to receive(:quote_table_name) { "`#{ _1 }`" }
allow(connection).to receive(:select_value) do |statement|
sql << statement
@@ -140,6 +35,7 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
described_class.assign!(record)
expect(record.id).to eq(9)
expect(sql.grep(/RELEASE_LOCK/)).to be_empty
expect(sql.grep(/FOR UPDATE/).size).to eq(2)
callbacks.fetch(event).call
expect(sql.grep(/RELEASE_LOCK/).size).to eq(1)
end
@@ -153,22 +49,22 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
end
it 'releases a successfully acquired lock when allocation fails and rolls back' do
allow(connection).to receive(:select_all).and_raise('query failed')
allow(connection).to receive(:select_value).with(/FOR UPDATE/).and_raise('query failed')
expect { described_class.assign!(record) }.to raise_error(RuntimeError, 'query failed')
callbacks.fetch(:after_rollback).call
expect(sql.grep(/RELEASE_LOCK/).size).to eq(1)
end
it 'accepts an explicit ID above the reserved position' do
record.id = 50
it 'preserves explicitly supplied historical IDs' do
record.id = 5
described_class.assign!(record)
expect(record.id).to eq(50)
expect(record.id).to eq(5)
expect(sql.grep(/FOR UPDATE/)).to be_empty
callbacks.fetch(:after_commit).call
end
it 'uses the same named lock within each space and different locks between spaces' do
locks = [Tag, ExternalTag, TagDescription, TagGroup, TagDescriptionVersion, TagGroupVersion].map do |model|
locks = [Tag, ExternalTag, TagDescription, TagGroup].map do |model|
allow(model).to receive(:connection).and_return(connection)
allow(model).to receive(:current_transaction).and_return(transaction)
allow(model).to receive(:uncached).and_yield
@@ -180,8 +76,7 @@ RSpec.describe SharedResourceIdAllocator, 'lock lifecycle' do
end
expect(locks[0]).to eq(locks[1])
expect(locks[2]).to eq(locks[3])
expect(locks[4]).to eq(locks[5])
expect([locks[0], locks[2], locks[4]].uniq.size).to eq(3)
expect(locks[0]).not_to eq(locks[2])
end
it 'reports lock release failures' do
-68
ファイルの表示
@@ -1,68 +0,0 @@
require 'rails_helper'
RSpec.describe TagNameCompletion do
let!(:language) { Language.create!(code: 'ain', name: 'ain') }
let!(:script) { Script.create!(code: 'Kana', name: 'Kana') }
let(:locale) do
Locale.create!(code: 'ain-Kana', language_code: 'ain', script_code: 'Kana',
region_code: 392, variants: '', name: 'ain-Kana')
end
it 'generates a phonetic primary with the required identity and provenance' do
tag = create(:tag, primary_name: '漢字あカ')
locale
expect(tag.reload.tag_name('ain')).to have_attributes(
name: 'カンチ_ア_カ', language_code: 'ain', script_code: 'Kana',
primary_flg: true, auto_generated: true, tag_id: tag.id)
end
it 'uses the exact fallback when the input cannot be converted' do
tag = create(:tag, primary_name: '😀')
locale
expect(tag.reload.name('ain')).to eq("Tag_##{ tag.id }")
end
it 'uses the exact fallback when a generated name collides with a human name' do
other = create(:tag, primary_name: 'other')
create(:tag_name, tag: other, name: 'AbC', language_code: 'ain', script_code: 'Kana')
tag = create(:tag, primary_name: 'AbC')
locale
expect(tag.reload.name('ain')).to eq("Tag_##{ tag.id }")
expect(other.reload.name('ain')).to eq('AbC')
end
it 'uses the exact fallback for a transcription that fails name validation' do
tag = create(:tag, primary_name: 'base')
source = TagName.new(name: 'nico:invalid', language_code: 'ja')
expect(TagName.generate_name(locale, tag, source)).to eq("Tag_##{ tag.id }")
end
it 'preserves human confirmation and is idempotent across repeated completion' do
locale
tag = Tag.find_or_create_by_tag_name!(Locale.nipponese, 'あいぬ', category: :general)
primary = tag.tag_name('ain')
primary.update!(name: 'human_confirmed', auto_generated: false)
snapshot = tag.tag_names.order(:id).map(&:attributes)
2.times { described_class.for_tag!(tag) }
expect(tag.tag_names.order(:id).map(&:attributes)).to eq(snapshot)
end
it 'creates one primary per active language through the production creation path' do
locale
tag = Tag.find_or_create_by_tag_name!(Locale.nipponese, 'アイヌ', category: :general)
expect(tag.tag_names.where(primary_flg: true).pluck(:language_code, :name))
.to contain_exactly(['ja', 'アイヌ'], ['ain', 'アイヌ'])
expect(Tag.find_or_create_by_tag_name!(locale, 'アイヌ', category: :general)).to eq(tag)
end
end
-34
ファイルの表示
@@ -1,34 +0,0 @@
require 'rails_helper'
RSpec.describe TagNameTranscription do
{
'AbCdXYZ' => 'AbCdXYZ',
'あいぬ' => 'アイヌ',
'アイヌ' => 'アイヌ',
'日本語' => 'ニチホンコ',
'漢字' => 'カンチ',
'漢あカ' => 'カン_ア_カ',
'漢字あいうカナ' => 'カンチ_アイウ_カナ',
'Ab漢あカZ' => 'Abカン_ア_カZ',
'りゃリュリョ' => 'リヤ_リユリヨ',
'キャット' => 'キヤット',
'コーヒー' => 'コヒ',
'コウコオケイケエカア' => 'コウコオケイケエカア',
'ガギグゲゴダヂヅデド' => 'カキクケコタチチュテト',
'バビブベボパピプペポ' => 'パピプペポパピプペポ',
'ザジズゼゾ' => 'チャチチュチェチョ',
'ジャジュジョ' => 'チヤチユチヨ',
'アㇰアㇱアッアンアㇺアㇷ゚' => 'アㇰアㇱアッアンアㇺアㇷ゚',
'アㇻイㇼウㇽエㇾオㇿ' => 'アㇻイㇼウㇽエㇾオㇿ',
'漢Aあ' => 'カンAア' }.each do |source, expected|
it "transcribes #{ source } as #{ expected }" do
expect(described_class.ainu(source)).to eq(expected)
end
end
['😀', 'ヴ', 'ティ', 'ㇵ', '魑', '𠮷', 'ー', '々'].each do |source|
it "rejects unsupported input #{ source }" do
expect { described_class.ainu(source) }.to raise_error(described_class::Unconvertible)
end
end
end
+17 -14
ファイルの表示
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe TagVersionRecorder do
include_context 'Ainu locale'
include_context 'English locale'
let(:user) { create_member_user! }
let!(:tag) { create(:tag, primary_name: '日本語名') }
@@ -17,11 +17,11 @@ RSpec.describe TagVersionRecorder do
{ 'name' => 'alias_z', 'script_code' => 'Jpan' }] }
end
def prepare_ainu_names!
def prepare_english_names!
locale
primary_tag_name_for(tag, 'ain').update!(name: 'Ainu name', auto_generated: false)
create(:tag_name, :alias, tag:, language_code: 'ain', script_code: 'Kana',
name: 'Ainu alias')
primary_tag_name_for(tag, 'en').update!(name: 'English name', auto_generated: false)
create(:tag_name, :alias, tag:, language_code: 'en', script_code: 'Latn',
name: 'English alias')
end
it 'snapshots the Nipponese primary and sorted aliases in grouped names' do
@@ -62,32 +62,35 @@ RSpec.describe TagVersionRecorder do
end
it 'snapshots future languages together without a request locale argument' do
prepare_ainu_names!
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_names!
version = described_class.record!(tag:, event_type: :create, created_by_user: user)
expect(version.names).to eq(
'ja' => nipponese_snapshot,
'ain' => { 'name' => 'Ainu name', 'script_code' => 'Kana',
'en' => { 'name' => 'English name', 'script_code' => 'Latn',
'auto_generated' => false,
'aliases' => [{ 'name' => 'Ainu alias', 'script_code' => 'Kana' }] })
'aliases' => [{ 'name' => 'English alias', 'script_code' => 'Latn' }] })
end
it 'creates a version for an Ainu-only change and preserves prior names' do
prepare_ainu_names!
it 'creates a version for an English-only change and preserves prior names' do
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_names!
original = described_class.record!(tag:, event_type: :create, created_by_user: user)
primary_tag_name_for(tag, 'ain').update!(name: 'Renamed')
primary_tag_name_for(tag, 'en').update!(name: 'Renamed')
updated = described_class.record!(tag:, event_type: :update, created_by_user: user)
expect(updated.version_no).to eq(2)
expect(original.reload.names.fetch('ain')).to include('name' => 'Ainu name')
expect(updated.names.fetch('ain')).to include('name' => 'Renamed')
expect(original.reload.names.fetch('en')).to include('name' => 'English name')
expect(updated.names.fetch('en')).to include('name' => 'Renamed')
expect(updated.names.fetch('ja')).to eq(nipponese_snapshot)
end
it 'does not create a version for an unchanged multilingual snapshot' do
prepare_ainu_names!
pending '日本語以外の Locale 有効化は別課題で実装する'
prepare_english_names!
described_class.record!(tag:, event_type: :create, created_by_user: user)
unchanged = described_class.record!(tag:, event_type: :update, created_by_user: user)
+185
ファイルの表示
@@ -0,0 +1,185 @@
require 'digest'
require 'rails_helper'
RSpec.describe Wiki::Commit do
include WikiRecords
let(:user) { create_member_user! }
def create_page title:, body: 'initial body'
seed_wiki_page(
tag_name: create(:tag, primary_name: title).tag_name('ja'),
body:,
created_by_user: user,
message: 'init')
end
describe '.create_content!' do
it 'rejects standalone Help creation without adding page or history' do
name = create(:tag_name, name: 'Help:frozen_service')
counts = [WikiPage.count, WikiRevision.count, WikiVersion.count]
expect do
described_class.create_content!(tag_name: name, body: 'initial', created_by_user: user)
end.to raise_error(ActiveRecord::RecordInvalid)
expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts)
end
it 'keeps the internal compatibility writer available' do
name = create(:tag_name, name: 'タググループ:compatibility_service')
page = nil
expect do
page = described_class.create_legacy_content!(
tag_name: name, body: 'initial', created_by_user: user)
end.to change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(page.current_revision.body).to eq('initial')
expect(page.wiki_versions.sole.body).to eq('initial')
end
it 'rejects body that becomes blank after normalisation' do
tag_name = create(:tag, primary_name: 'commit_integrity_blank').tag_name('ja')
expect {
described_class.create_content!(
tag_name:,
body: "\r\n\r\n",
created_by_user: user,
message: 'blank')
}
.to raise_error(ActiveRecord::RecordInvalid)
expect(WikiPage.where(tag_name:)).not_to exist
end
end
describe '.content!' do
it 'rejects standalone Help updates without modifying histories' do
page = seed_wiki_page(tag_name: create(:tag_name, name: 'Help:standalone'),
body: 'before', created_by_user: user)
counts = [WikiRevision.count, WikiVersion.count]
expect do
described_class.content!(page:, body: 'after', created_user: user)
end.to raise_error(ActiveRecord::RecordInvalid)
expect(page.reload.body).to eq('before')
expect([WikiRevision.count, WikiVersion.count]).to eq(counts)
end
it 'updates page body, revision, and version' do
page = create_page(title: 'commit_integrity_update', body: 'before')
current_id = page.current_revision.id
expect {
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: current_id)
}
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
page.reload
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq('after')
expect(version.body).to eq('after')
expect(version.reason).to eq('edit')
end
it 'does not record tag_version on body-only wiki update' do
tag_name = create(:tag_name, name: 'commit_integrity_linked_tag')
tag = create(:tag, primary_tag_name: tag_name, category: :general)
page =
seed_wiki_page(
tag_name:,
body: 'before',
created_by_user: user,
message: 'init')
TagVersionRecorder.record!(
tag:,
event_type: :create,
created_by_user: user)
before_count = tag.reload.tag_versions.count
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: page.current_revision.id)
expect(tag.reload.tag_versions.count).to eq(before_count)
end
it 'raises conflict and leaves page, revision, and version unchanged' do
page = create_page(title: 'commit_integrity_conflict', body: 'first')
stale_id = page.current_revision.id
described_class.content!(
page:,
body: 'second',
created_user: user,
message: 'second',
base_revision_id: stale_id)
page.reload
before_body = page.body
before_revision_count = page.wiki_revisions.count
before_version_count = page.wiki_versions.count
expect {
described_class.content!(
page:,
body: 'third',
created_user: user,
message: 'stale',
base_revision_id: stale_id)
}
.to raise_error(Wiki::Commit::Conflict)
page.reload
expect(page.body).to eq(before_body)
expect(page.wiki_revisions.count).to eq(before_revision_count)
expect(page.wiki_versions.count).to eq(before_version_count)
end
it 'deduplicates duplicated missing wiki lines' do
page = create_page(title: 'commit_integrity_dedup', body: 'before')
duplicated = 'commit_integrity_duplicate_line'
described_class.content!(
page:,
body: "#{ duplicated }\n#{ duplicated }",
created_user: user,
message: 'dedup',
base_revision_id: page.current_revision.id)
revision = page.reload.current_revision
expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(revision.wiki_revision_lines.count).to eq(2)
expect(revision.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
end
end
describe '.redirect!' do
it 'raises because redirect revisions are deprecated' do
page = create_page(title: 'commit_integrity_redirect_source', body: 'source')
target = create_page(title: 'commit_integrity_redirect_target', body: 'target')
expect {
described_class.redirect!(
page:,
redirect_page: target,
created_user: user,
message: 'redirect',
base_revision_id: page.current_revision.id)
}
.to raise_error(RuntimeError, '廃止しました.')
end
end
end
+151
ファイルの表示
@@ -0,0 +1,151 @@
require 'rails_helper'
RSpec.describe Wiki::Commit do
include WikiRecords
let(:user) { create_member_user! }
def create_page(title: 'commit_spec_page', body: 'initial body')
tag_name = create(:tag, primary_name: title).tag_name('ja')
seed_wiki_page(
tag_name:,
body:,
created_by_user: user,
message: 'init')
end
describe '.content!' do
it 'stores normalised body in wiki_pages and wiki_versions' do
page = create_page(title: 'commit_normalised_page')
described_class.content!(
page:,
body: "a\r\nb\r\n\r\n",
created_user: user,
message: 'init'
)
page.reload
version = page.wiki_versions.order(:version_no).last
expect(page.body).to eq("a\nb")
expect(version.body).to eq("a\nb")
expect(page.current_revision.lines_count).to eq(2)
end
it 'deduplicates duplicated missing wiki lines before upsert' do
page = create_page(title: 'commit_duplicate_line_page')
duplicated = 'commit_duplicate_line'
described_class.content!(
page:,
body: "#{ duplicated }\n#{ duplicated }",
created_user: user,
message: 'init'
)
page.reload
expect(WikiLine.where(body: duplicated).count).to eq(1)
expect(page.current_revision.lines_count).to eq(2)
expect(page.current_revision.wiki_revision_lines.count).to eq(2)
end
it 'raises conflict when base_revision_id is stale' do
page = create_page(title: 'commit_conflict_page')
first = described_class.content!(
page:,
body: 'first',
created_user: user,
message: 'first'
)
described_class.content!(
page:,
body: 'second',
created_user: user,
message: 'second',
base_revision_id: first.id
)
expect {
described_class.content!(
page:,
body: 'third',
created_user: user,
message: 'third',
base_revision_id: first.id
)
}.to raise_error(Wiki::Commit::Conflict)
end
it 'does not record tag version when corresponding tag has no versions' do
tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions')
tag = create(:tag, primary_tag_name: tag_name, category: :general)
page =
seed_wiki_page(
tag_name:,
body: 'before',
created_by_user: user,
message: 'init')
expect(tag.reload.tag_versions.count).to eq(0)
current_revision_id = page.current_revision.id
expect {
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: current_revision_id)
}.to change(WikiVersion, :count).by(1)
expect(tag.reload.tag_versions.count).to eq(0)
end
it 'does not record tag version when corresponding tag has no versions' do
tag_name = create(:tag_name, name: 'commit_linked_tag_without_versions')
tag = create(:tag, primary_tag_name: tag_name, category: :general)
page =
seed_wiki_page(
tag_name:,
body: 'before',
created_by_user: user,
message: 'init')
current_revision_id = page.current_revision.id
expect {
described_class.content!(
page:,
body: 'after',
created_user: user,
message: 'edit',
base_revision_id: current_revision_id)
}.to change(WikiVersion, :count).by(1)
expect(tag.reload.tag_versions.count).to eq(0)
end
end
describe '.redirect!' do
it 'raises because redirect revisions are deprecated' do
page = create_page(title: 'commit_redirect_source')
target = create_page(title: 'commit_redirect_target')
expect {
described_class.redirect!(
page:,
redirect_page: target,
created_user: user,
message: 'redirect'
)
}.to raise_error(RuntimeError, '廃止しました.')
end
end
end

変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示