画面上でのテストは完了. 移行にす早さが求められるので,準備ができたらマージする. Reviewed-on: #430 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #430 でマージされました。
このコミットが含まれているのは:
@@ -70,7 +70,10 @@ class MaterialsController < ApplicationController
|
||||
.find_by(id: params[:id])
|
||||
return head :not_found unless material
|
||||
|
||||
wiki_page_body = material.tag&.tag_name('ja')&.wiki_page&.current_revision&.body
|
||||
wiki_page_body =
|
||||
TagDescription
|
||||
.find_by(tag_id: material.tag_id, language_code: locale.language_code)
|
||||
&.body
|
||||
|
||||
render json: MaterialRepr.base(material, host: request.base_url,
|
||||
language_code: locale.language_code).merge(wiki_page_body:)
|
||||
|
||||
@@ -26,7 +26,7 @@ class NicoTagsController < ApplicationController
|
||||
ExternalTag
|
||||
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
|
||||
'ON post_tag_max.external_tag_id = external_tags.id')
|
||||
.includes(linked_tags: { tag_names: :wiki_page })
|
||||
.includes(linked_tags: [:tag_names, :tag_descriptions])
|
||||
if name
|
||||
q = q.where(('external_tags.name LIKE ? ' +
|
||||
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
|
||||
|
||||
@@ -53,7 +53,7 @@ class PostsController < ApplicationController
|
||||
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
||||
.preload(:external_tags, :uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_names: :wiki_page }] }])
|
||||
:tag_names, :tag_descriptions] }])
|
||||
.with_attached_thumbnail
|
||||
|
||||
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
||||
@@ -111,7 +111,7 @@ class PostsController < ApplicationController
|
||||
filtered_posts(locale)
|
||||
.preload(:uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_names: :wiki_page }] }])
|
||||
:tag_names, :tag_descriptions] }])
|
||||
.with_attached_thumbnail
|
||||
.order('RAND()')
|
||||
.first
|
||||
@@ -198,7 +198,7 @@ class PostsController < ApplicationController
|
||||
Post
|
||||
.includes(:uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_names: :wiki_page }] }])
|
||||
:tag_names, :tag_descriptions] }])
|
||||
.with_attached_thumbnail
|
||||
.find_by(id: params[:id])
|
||||
return head :not_found unless post
|
||||
|
||||
@@ -51,7 +51,7 @@ class TagVersionsController < ApplicationController
|
||||
|
||||
tags_by_id =
|
||||
Tag
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.where(id: all_parent_tag_ids)
|
||||
.index_by(&:id)
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ class TagsController < ApplicationController
|
||||
tags_by_id =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
|
||||
id: tag_ids)
|
||||
.index_by(&:id)
|
||||
@@ -238,7 +238,7 @@ class TagsController < ApplicationController
|
||||
base =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.where(tag_names: { language_code: locale.language_code, primary_flg: true },
|
||||
deprecated_at: nil)
|
||||
base = base.where('tags.post_count > 0') if present_only
|
||||
@@ -282,7 +282,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(id: params[:id],
|
||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
|
||||
@@ -302,7 +302,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||
return render json: TagRepr.base(tag, language_code: locale.language_code) if tag
|
||||
|
||||
@@ -320,7 +320,7 @@ class TagsController < ApplicationController
|
||||
|
||||
tag =
|
||||
Tag.joins(:tag_names)
|
||||
.includes(:tag_names, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(id: params[:id],
|
||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
@@ -340,7 +340,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
|
||||
@@ -359,7 +359,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(id: params[:id],
|
||||
tag_names: { language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
@@ -424,7 +424,7 @@ class TagsController < ApplicationController
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_names)
|
||||
.includes(:tag_names, :materials, tag_names: :wiki_page)
|
||||
.includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions)
|
||||
.find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true })
|
||||
return head :not_found unless tag
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ class WikiPagesController < ApplicationController
|
||||
descriptions =
|
||||
TagDescription.where(language_code: 'ja')
|
||||
.includes(tag: :tag_names)
|
||||
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
|
||||
|
||||
tag_groups = TagGroup.all
|
||||
descriptions = descriptions.order(updated_at: :desc, id: :asc)
|
||||
tag_groups = TagGroup.order(updated_at: :desc, id: :asc)
|
||||
if title.present?
|
||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||
descriptions =
|
||||
@@ -19,12 +19,13 @@ class WikiPagesController < ApplicationController
|
||||
.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||
tag_groups = tag_groups.where(
|
||||
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
|
||||
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||
end
|
||||
|
||||
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
||||
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
||||
json.concat(WikiPageRepr.base(pages))
|
||||
json.sort_by! do |row|
|
||||
[-row['updated_at'].to_f, row.fetch('resource_type'), row.fetch('id')]
|
||||
end
|
||||
json = json.first(20) if title.present?
|
||||
render json:
|
||||
end
|
||||
@@ -37,11 +38,7 @@ class WikiPagesController < ApplicationController
|
||||
tag_group = TagGroup.find_by(id: params[:id])
|
||||
return render_tag_group tag_group if tag_group
|
||||
|
||||
page = WikiPage.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.find_by(id: params[:id])
|
||||
|
||||
render_wiki_page_or_404 page
|
||||
render_wiki_page_or_404 nil
|
||||
end
|
||||
|
||||
def show_by_title
|
||||
@@ -58,20 +55,14 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
return render_wiki_page_by_tag_description tag_description if tag_description
|
||||
|
||||
page =
|
||||
WikiPage
|
||||
.joins(:tag_name)
|
||||
.includes(tag_name: :tag)
|
||||
.find_by(tag_name: { name: title })
|
||||
|
||||
render_wiki_page_or_404 page
|
||||
render_wiki_page_or_404 nil
|
||||
end
|
||||
|
||||
def exists
|
||||
return head :no_content if TagDescription.exists?(id: params[:id])
|
||||
return head :no_content if TagGroup.exists?(id: params[:id])
|
||||
|
||||
head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def exists_by_title
|
||||
@@ -82,8 +73,7 @@ class WikiPagesController < ApplicationController
|
||||
end
|
||||
return head :no_content if tag_description_by_title(title)
|
||||
|
||||
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
|
||||
head exists ? :no_content : :not_found
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def diff
|
||||
@@ -112,15 +102,7 @@ class WikiPagesController < ApplicationController
|
||||
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
||||
'deprecated_at' => nil }
|
||||
else
|
||||
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
||||
from_rev = from && page.wiki_revisions.find(from)
|
||||
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
||||
if ((from_rev && !from_rev.content?) || !to_rev&.content?)
|
||||
return render_unprocessable_entity('差分を表示できない版です.')
|
||||
end
|
||||
from_body = from_rev&.body
|
||||
to_body = to_rev.body
|
||||
resource = WikiPageRepr.base(page)
|
||||
return head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -160,26 +142,19 @@ class WikiPagesController < ApplicationController
|
||||
status: :created)
|
||||
end
|
||||
|
||||
page = if title.include?(':')
|
||||
ApplicationRecord.transaction do
|
||||
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
|
||||
name.script_code = 'Jpan'
|
||||
name.primary_flg = true
|
||||
name.auto_generated = false
|
||||
page =
|
||||
if title.include?(':')
|
||||
return render_unprocessable_entity field: :title
|
||||
else
|
||||
tag_name = TagName.find_by(language_code: 'ja', name: title)
|
||||
return render_unprocessable_entity field: :title unless tag_name&.tag
|
||||
|
||||
TagDescriptionOperation.create!(
|
||||
tag: tag_name.tag, body:, created_by_user: current_user, message:)
|
||||
end
|
||||
|
||||
Wiki::Commit.create_legacy_content!(
|
||||
tag_name:, body:, created_by_user: current_user, message:)
|
||||
end
|
||||
else
|
||||
tag_name = TagName.find_by(language_code: 'ja', name: title)
|
||||
return render_unprocessable_entity field: :title unless tag_name&.tag
|
||||
|
||||
TagDescriptionOperation.create!(
|
||||
tag: tag_name.tag, body:, created_by_user: current_user, message:)
|
||||
end
|
||||
|
||||
render json: WikiPageRepr.base(page), status: :created
|
||||
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
|
||||
@@ -209,44 +184,34 @@ class WikiPagesController < ApplicationController
|
||||
return head :ok
|
||||
end
|
||||
|
||||
page = WikiPage.find_by(id: params[:id])
|
||||
if legacy_group_page?(page)
|
||||
return render_unprocessable_entity field: :title if title.include?(':')
|
||||
description = TagDescription.find_by(id: params[:id])
|
||||
unless description
|
||||
return render_unprocessable_entity field: :title if group || WikiPage.exists?(id: params[:id])
|
||||
return head :not_found
|
||||
end
|
||||
tag = description.tag
|
||||
tag_name = TagDescriptionOperation.compatibility_tag_name(tag)
|
||||
if tag_name.name.include?(':') || (group && title != tag_name.name)
|
||||
return render_unprocessable_entity field: :title
|
||||
end
|
||||
if group && !legacy_update_identified?(page, title, group)
|
||||
return render_unprocessable_entity field: :title
|
||||
end
|
||||
return head :not_found unless page
|
||||
|
||||
base_revision_id = params[:base_revision_id].presence
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
page.lock!
|
||||
|
||||
old_title = page.title
|
||||
|
||||
tag = page.tag_name.tag
|
||||
|
||||
if tag && title != old_title
|
||||
tag.lock!
|
||||
old_title = tag_name.name
|
||||
if title != old_title
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||
end
|
||||
|
||||
page.tag_name.update!(name: title) if title != old_title
|
||||
tag_name.update!(name: title) if title != old_title
|
||||
|
||||
message = params[:message].presence
|
||||
description = TagDescription.find_by(id: page.id)
|
||||
description_page = description && (
|
||||
tag&.id == description.tag_id ||
|
||||
(tag.nil? && !old_title.include?(':')))
|
||||
if description && !description_page
|
||||
Wiki::Commit.legacy_content!(page:, body:, created_user: current_user,
|
||||
message:, base_revision_id:)
|
||||
else
|
||||
Wiki::Commit.content!(page:, body:, created_user: current_user,
|
||||
message:, base_revision_id:)
|
||||
end
|
||||
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user,
|
||||
message:, base_revision_id:)
|
||||
|
||||
if tag && title != old_title
|
||||
if title != old_title
|
||||
tag.reload
|
||||
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
||||
end
|
||||
@@ -273,23 +238,7 @@ class WikiPagesController < ApplicationController
|
||||
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
|
||||
end
|
||||
|
||||
pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages
|
||||
revisions = WikiRevision.joins(wiki_page: :tag_name)
|
||||
.where(wiki_page_id: pages.select(:id))
|
||||
.includes(:created_user, wiki_page: { tag_name: :tag })
|
||||
.order(id: :desc).limit(200)
|
||||
json = revisions.map { |rev|
|
||||
{ revision_id: rev.id,
|
||||
pred: rev.base_revision_id,
|
||||
succ: nil,
|
||||
wiki_page: { id: rev.wiki_page_id,
|
||||
title: rev.wiki_page.title,
|
||||
deprecated_at: rev.wiki_page.deprecated_at },
|
||||
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
|
||||
kind: rev.kind,
|
||||
message: rev.message,
|
||||
timestamp: rev.created_at }
|
||||
}
|
||||
json = []
|
||||
unless id
|
||||
json.concat(tag_group_changes(TagGroupVersion.all))
|
||||
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
|
||||
@@ -351,6 +300,7 @@ class WikiPagesController < ApplicationController
|
||||
updated_at = version.created_at
|
||||
render json: {
|
||||
'id' => version.tag_group_id,
|
||||
'resource_type' => 'tag_group',
|
||||
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
|
||||
'deprecated_at' => nil,
|
||||
'body' => body,
|
||||
@@ -453,16 +403,6 @@ class WikiPagesController < ApplicationController
|
||||
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
|
||||
end
|
||||
|
||||
def unmigrated_wiki_pages
|
||||
descriptions = TagDescription.where(language_code: 'ja')
|
||||
|
||||
WikiPage
|
||||
.joins(:tag_name)
|
||||
.where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%")
|
||||
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
|
||||
descriptions.select(:tag_id))
|
||||
end
|
||||
|
||||
def description_changes descriptions
|
||||
versions = TagDescriptionVersion.where(language_code: 'ja',
|
||||
tag_id: descriptions.select(:tag_id))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class ExternalTag < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
enum :platform, nico: 'nico'
|
||||
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
|
||||
|
||||
|
||||
+10
-1
@@ -2,6 +2,8 @@ require 'set'
|
||||
|
||||
|
||||
class Tag < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
class NicoTagNormalisationError < ArgumentError
|
||||
;
|
||||
end
|
||||
@@ -51,6 +53,7 @@ class Tag < ApplicationRecord
|
||||
has_many :tag_versions
|
||||
|
||||
has_many :tag_names
|
||||
has_many :tag_descriptions
|
||||
|
||||
attribute :version_no, :integer, default: 1
|
||||
|
||||
@@ -96,7 +99,13 @@ class Tag < ApplicationRecord
|
||||
|
||||
def deprecated? = deprecated_at?
|
||||
|
||||
def has_wiki = wiki_page.present?
|
||||
def has_description? language_code:
|
||||
if tag_descriptions.loaded?
|
||||
tag_descriptions.any? { _1.language_code == language_code }
|
||||
else
|
||||
tag_descriptions.exists?(language_code:)
|
||||
end
|
||||
end
|
||||
|
||||
def material_id = materials.first&.id
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
class TagDescription < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
belongs_to :tag
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class TagGroup < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
||||
|
||||
|
||||
@@ -3,21 +3,28 @@
|
||||
|
||||
module TagRepr
|
||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at],
|
||||
methods: [:has_wiki, :material_id, :has_deerjikists] }.freeze
|
||||
methods: [:material_id, :has_deerjikists] }.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def base tag, language_code: 'ja'
|
||||
tag.as_json(BASE).merge(name: name_for(tag, language_code),
|
||||
tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:),
|
||||
name: name_for(tag, language_code),
|
||||
aliases: tag.snapshot_aliases(language_code),
|
||||
parents: tag.parents.map { inline(_1, language_code:) })
|
||||
end
|
||||
|
||||
def inline tag, language_code: 'ja'
|
||||
tag.as_json(BASE).merge(name: name_for(tag, language_code), aliases: [], parents: [])
|
||||
tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:),
|
||||
name: name_for(tag, language_code), aliases: [], parents: [])
|
||||
end
|
||||
|
||||
def many(tags, language_code: 'ja') = tags.map { |tag| base(tag, language_code:) }
|
||||
def many tags, language_code: 'ja'
|
||||
records = tags.to_a
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records:, associations: [:tag_descriptions, { parents: :tag_descriptions }]).call
|
||||
records.map { |tag| base(tag, language_code:) }
|
||||
end
|
||||
|
||||
def name_for tag, language_code
|
||||
tag.name(language_code) || (tag.system? ? tag.name('ja') : nil)
|
||||
|
||||
@@ -13,6 +13,7 @@ module WikiPageRepr
|
||||
def tag_description description
|
||||
tag = description.tag
|
||||
{ 'id' => description.id,
|
||||
'resource_type' => 'tag_description',
|
||||
'tag_name_id' => tag.tag_name('ja')&.id,
|
||||
'body' => description.body,
|
||||
'created_user_id' => description.created_by_user_id,
|
||||
@@ -26,7 +27,7 @@ module WikiPageRepr
|
||||
end
|
||||
|
||||
def tag_group tag_group
|
||||
TagGroupRepr.tag_group(tag_group)
|
||||
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
|
||||
end
|
||||
|
||||
def many wiki_pages
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
require 'digest'
|
||||
|
||||
|
||||
class SharedResourceIdAllocator
|
||||
SPACES = {
|
||||
'tags' => ['tags', 'external_tags'],
|
||||
'wiki' => ['tag_descriptions', 'tag_groups'] }.freeze
|
||||
LOCK_TIMEOUT = 10
|
||||
|
||||
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
|
||||
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 }"
|
||||
quoted_name = connection.quote(name)
|
||||
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
|
||||
|
||||
release = lambda do
|
||||
result = record.class.uncached do
|
||||
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
|
||||
end
|
||||
unless result.to_i == 1
|
||||
raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }"
|
||||
end
|
||||
end
|
||||
begin
|
||||
transaction.after_commit(&release)
|
||||
transaction.after_rollback(&release)
|
||||
rescue StandardError
|
||||
release.call
|
||||
raise
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
record.id = maxima.max + 1
|
||||
end
|
||||
end
|
||||
@@ -8,9 +8,9 @@ class TagDescriptionOperation
|
||||
tag_id: tag.id, language_code: LANGUAGE_CODE)
|
||||
|
||||
page = Wiki::CompatibilityWriter.create!(
|
||||
tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:)
|
||||
tag_name: compatibility_tag_name(tag), body:, created_by_user:, message:)
|
||||
revision = page.wiki_revisions.order(id: :desc).first
|
||||
record_version!(tag:, revision:, body:, user: created_by_user,
|
||||
record_version!(tag:, revision:, body: revision.body, user: created_by_user,
|
||||
description: nil)
|
||||
page
|
||||
end
|
||||
@@ -22,7 +22,20 @@ class TagDescriptionOperation
|
||||
tag.lock!
|
||||
description = TagDescription.lock.find_by!(
|
||||
tag_id: tag.id, language_code: LANGUAGE_CODE)
|
||||
page = WikiPage.lock.find(description.id)
|
||||
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
|
||||
revision = Wiki::CompatibilityWriter.update!(
|
||||
page:, body:, updated_by_user:, message:,
|
||||
base_revision_id:)
|
||||
@@ -34,6 +47,11 @@ class TagDescriptionOperation
|
||||
end
|
||||
end
|
||||
|
||||
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!(
|
||||
@@ -44,7 +62,7 @@ class TagDescriptionOperation
|
||||
description.update!(body:, version_no:, updated_by_user_id: user.id)
|
||||
else
|
||||
TagDescription.create!(
|
||||
id: revision.wiki_page_id, tag_id: tag.id,
|
||||
tag_id: tag.id,
|
||||
language_code: LANGUAGE_CODE, version_no:, body:,
|
||||
created_by_user_id: user.id, updated_by_user_id: user.id)
|
||||
end
|
||||
|
||||
@@ -349,7 +349,7 @@ class TagGroupOperation
|
||||
tag_name.primary_flg = true
|
||||
tag_name.auto_generated = false
|
||||
tag_name.save!
|
||||
Wiki::Commit.create_legacy_content!(tag_name:, body:, created_by_user: actor)
|
||||
Wiki::CompatibilityWriter.create!(tag_name:, body:, created_by_user: actor)
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -8,12 +8,13 @@ module Wiki
|
||||
end
|
||||
|
||||
def self.create_content! tag_name:, body:, created_by_user:, message: nil
|
||||
if tag_name.tag && tag_name.language_code == 'ja'
|
||||
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
|
||||
|
||||
create_legacy_content!(tag_name:, body:, created_by_user:, message:)
|
||||
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
|
||||
@@ -42,14 +43,17 @@ module Wiki
|
||||
end
|
||||
|
||||
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
|
||||
description = TagDescription.find_by(id: page.id)
|
||||
if description
|
||||
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
|
||||
|
||||
legacy_content!(page:, body:, created_user:, message:, base_revision_id:)
|
||||
page.errors.add(:base, :invalid)
|
||||
raise ActiveRecord::RecordInvalid, page
|
||||
end
|
||||
|
||||
def self.legacy_content! page:, body:, created_user:, message: nil,
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
module Wiki
|
||||
class CompatibilityWriter
|
||||
def self.create! tag_name:, body:, created_by_user:, message: nil
|
||||
Commit.create_legacy_content!(
|
||||
tag_name:, body:, created_by_user:, message:)
|
||||
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,
|
||||
@@ -23,7 +31,9 @@ module Wiki
|
||||
end
|
||||
end
|
||||
|
||||
return if Commit.normalise_body(page.body) == normalised
|
||||
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:,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace :help do
|
||||
desc 'ヘルプ移行'
|
||||
task migrate: :environment do
|
||||
help_path = ENV.fetch('BTRC_HUB_HELP_PATH')
|
||||
abort 'ヘルプ置場指定してね.' if help_path.blank?
|
||||
|
||||
# ファイルがすでに存在する場合は通さなぃ.
|
||||
abort 'ファイル消してね.' if Dir.glob("#{ help_path }/*").present?
|
||||
|
||||
# 現状物理削除はなぃはず
|
||||
pages =
|
||||
WikiPage
|
||||
.joins(:tag_name)
|
||||
.where(tag_names: { tag_id: nil, language_code: 'ja' })
|
||||
.where.not("tag_names.name LIKE 'タググループ:%'")
|
||||
|
||||
puts "移行対象: #{pages.count} 件"
|
||||
abort '移行対象がありません.' if pages.empty?
|
||||
|
||||
pages.find_each do |page|
|
||||
puts page.body
|
||||
puts page.title
|
||||
|
||||
puts 'この identifier は何にする?'
|
||||
identifier = $stdin.gets.chomp
|
||||
Dir.mkdir "#{ help_path }/#{ identifier }"
|
||||
Dir.mkdir "#{ help_path }/#{ identifier }/editions"
|
||||
Dir.mkdir "#{ help_path }/#{ identifier }/editions/ja"
|
||||
|
||||
last_file_name = ''
|
||||
WikiVersion.where(wiki_page_id: page.id).order(:version_no).each do |ver|
|
||||
last_file_name, last_file_name_no = last_file_name.split('.')
|
||||
|
||||
file_name = ver.created_at.strftime('%Y-%m-%d')
|
||||
if file_name == last_file_name
|
||||
file_name += ".#{ (last_file_name_no || 1).to_i + 1 }"
|
||||
end
|
||||
|
||||
body = "# #{ ver.title.sub(/^ヘルプ:/, '') }\n\n#{ ver.body }"
|
||||
File.write "#{ help_path }/#{ identifier }/editions/ja/#{ file_name }.mdx", body
|
||||
|
||||
last_file_name = file_name
|
||||
end
|
||||
|
||||
article_meta = { 'identifier' => identifier,
|
||||
'canonical_language' => 'ja',
|
||||
'languages' => { 'ja' => { 'current_edition' => last_file_name } } }
|
||||
File.write "#{ help_path }/#{ identifier }/article.yml", article_meta.to_yaml
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,11 +5,11 @@ RSpec.describe 'Tag description migration identity' do
|
||||
it 'preserves every revision ID and repeated body in an independent sequence' do
|
||||
user = create_member_user!
|
||||
tag_name = create(:tag_name, name: 'description_migration')
|
||||
page = Wiki::Commit.create_content!(
|
||||
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.content!(
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
|
||||
include TagGroupRecords
|
||||
let(:user) { create_member_user! }
|
||||
|
||||
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
|
||||
|
||||
[: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
|
||||
|
||||
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 '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')
|
||||
|
||||
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
|
||||
@@ -0,0 +1,50 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagRepr do
|
||||
let(:tag) { create(:tag) }
|
||||
|
||||
def description_for record
|
||||
TagDescription.create!(tag: record, language_code: 'ja', body: '', version_no: 1)
|
||||
end
|
||||
|
||||
it 'keeps has_wiki boolean for the selected language in base and inline' do
|
||||
description_for(tag)
|
||||
[:base, :inline].each do |method|
|
||||
expect(described_class.public_send(method, tag).as_json.fetch('has_wiki')).to be(true)
|
||||
expect(described_class.public_send(method, tag, language_code: 'en')
|
||||
.as_json.fetch('has_wiki')).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses the same language for a tag and its parents' do
|
||||
parent = create(:tag)
|
||||
description_for(tag)
|
||||
description_for(parent)
|
||||
TagImplication.create!(tag:, parent_tag: parent)
|
||||
['ja', 'en'].each do |language_code|
|
||||
row = described_class.base(tag, language_code:).as_json
|
||||
expected = language_code == 'ja'
|
||||
expect(row.fetch('has_wiki')).to be(expected)
|
||||
expect(row.fetch('parents').sole.fetch('has_wiki')).to be(expected)
|
||||
end
|
||||
end
|
||||
|
||||
it 'preloads existence checks for many tags and their parents without Wiki reads' do
|
||||
parent = create(:tag)
|
||||
description_for(parent)
|
||||
records = Array.new(3) do
|
||||
create(:tag).tap { TagImplication.create!(tag: _1, parent_tag: parent) }
|
||||
end
|
||||
description_for(records.first)
|
||||
queries = []
|
||||
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
|
||||
rows = nil
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
rows = described_class.many(records).as_json
|
||||
end
|
||||
expect(rows.map { _1.fetch('has_wiki') }).to eq([true, false, false])
|
||||
expect(rows.map { _1.fetch('parents').sole.fetch('has_wiki') }).to eq([true] * 3)
|
||||
expect(queries.grep(/\bSELECT\b.*\btag_descriptions\b/im).size).to be <= 2
|
||||
expect(queries.grep(/\bwiki_(pages|revisions|versions)\b/i)).to be_empty
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,47 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tag, type: :model do
|
||||
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: '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)
|
||||
loaded = Tag.includes(:tag_descriptions).find(tag.id)
|
||||
queries = []
|
||||
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: '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: 'en', body: '', version_no: 1)]
|
||||
tag.association(:tag_descriptions).loaded!
|
||||
expect(tag.has_description?(language_code: 'en')).to be(true)
|
||||
expect(tag.has_description?(language_code: 'ja')).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'external tag separation' do
|
||||
['nico:reserved', 'NiCo:reserved'].each do |name|
|
||||
it "rejects the reserved prefix #{ name } for internal tags" do
|
||||
|
||||
@@ -178,6 +178,7 @@ RSpec.describe 'Materials API', type: :request do
|
||||
end
|
||||
|
||||
describe 'GET /materials/:id' do
|
||||
include WikiRecords
|
||||
let!(:tag) do
|
||||
create(:tag, primary_name: 'material_show', category: :material)
|
||||
end
|
||||
@@ -205,6 +206,56 @@ RSpec.describe 'Materials API', type: :request do
|
||||
get '/materials/999999999'
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
def get_material_without_legacy_reads
|
||||
queries = []
|
||||
subscriber = lambda do |*args|
|
||||
sql = args.last.fetch(:sql)
|
||||
if sql.match?(/\bSELECT\b/i) && sql.match?(/\bwiki_(pages|revisions|versions)\b/i)
|
||||
queries << sql
|
||||
end
|
||||
end
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
get "/materials/#{ material.id }"
|
||||
end
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(queries).to be_empty
|
||||
end
|
||||
|
||||
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(page.current_revision.body).to eq('legacy body')
|
||||
end
|
||||
|
||||
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 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
|
||||
expect(json.fetch('wiki_page_body')).to be_nil
|
||||
expect(json.fetch('tag').fetch('has_wiki')).to be(false)
|
||||
end
|
||||
|
||||
it 'returns nil for an untagged material' do
|
||||
material.update!(tag: nil)
|
||||
get_material_without_legacy_reads
|
||||
expect(json.fetch('wiki_page_body')).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /materials' do
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
require 'rails_helper'
|
||||
|
||||
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!(:page) do
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name:, body: 'old-revision', created_by_user: user)
|
||||
end
|
||||
let!(:revisions) do
|
||||
first = page.current_revision
|
||||
second = Wiki::Commit.content!(page:, body: 'old-second', created_user: user)
|
||||
third = Wiki::Commit.content!(page:, body: 'old-third', created_user: user)
|
||||
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) }
|
||||
@@ -242,8 +243,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||
end
|
||||
|
||||
describe 'mixed changes' do
|
||||
it 'includes unmigrated history and ignores migrated legacy-only revisions' do
|
||||
legacy_page = Wiki::Commit.create_content!(
|
||||
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
|
||||
@@ -256,7 +257,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||
migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id }
|
||||
expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
|
||||
expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id)
|
||||
expect(json).to include(include(
|
||||
expect(json).not_to include(include(
|
||||
'revision_id' => legacy_revision.id,
|
||||
'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed')))
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag_name) { create(:tag_name, name: 'description_write') }
|
||||
let(:tag) { create(:tag, primary_tag_name: tag_name) }
|
||||
@@ -31,7 +32,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
headers: headers
|
||||
end
|
||||
|
||||
it 'creates both current records and histories with a shared revision ID' do
|
||||
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)
|
||||
@@ -39,17 +40,28 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
.and change(TagDescription, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
|
||||
page = WikiPage.find(json.fetch('id'))
|
||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
version = TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
expect(description).to have_attributes(id: page.id, body: 'initial', version_no: 1)
|
||||
expect(version).to have_attributes(
|
||||
id: page.current_revision.id, body: 'initial', version_no: 1,
|
||||
event_type: 'create', created_by_user_id: user.id)
|
||||
expect(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(version.body)
|
||||
expect(page.wiki_versions.last.body).to eq(version.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
|
||||
|
||||
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|
|
||||
@@ -65,48 +77,19 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates a namespaced legacy Wiki resource without a Tag' do
|
||||
expect do
|
||||
post '/wiki', params: { title: 'Help:description_write', body: 'legacy' },
|
||||
headers: headers
|
||||
end.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
.and change(TagDescription, :count).by(0)
|
||||
.and change(TagDescriptionVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
page = WikiPage.find(json.fetch('id'))
|
||||
expect(page.tag_name.tag).to be_nil
|
||||
expect(page.body).to eq('legacy')
|
||||
expect(page.current_revision.body).to eq('legacy')
|
||||
end
|
||||
|
||||
it 'creates only legacy Wiki for a namespaced title with an existing Tag' do
|
||||
namespaced_name = create(:tag_name, name: 'help:foo')
|
||||
namespaced_tag = create(:tag, primary_tag_name: namespaced_name)
|
||||
|
||||
expect do
|
||||
post '/wiki', params: { title: namespaced_name.name, body: 'legacy' },
|
||||
headers: headers
|
||||
end.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
.and change(TagName, :count).by(0)
|
||||
.and change(TagDescription, :count).by(0)
|
||||
.and change(TagDescriptionVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
page = WikiPage.find(json.fetch('id'))
|
||||
expect(page).to have_attributes(tag_name_id: namespaced_name.id, body: 'legacy')
|
||||
expect(page.tag_name.tag).to eq(namespaced_tag)
|
||||
expect(page.current_revision.body).to eq('legacy')
|
||||
expect(page.wiki_versions.last.body).to eq('legacy')
|
||||
[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
|
||||
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
|
||||
|
||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
|
||||
it "rolls back both systems when #{ model.name } persistence fails" do
|
||||
tag_name
|
||||
it "rolls back new description creation when #{ model.name } persistence fails" do
|
||||
before_state = stored_state
|
||||
aggregate_failures do
|
||||
fail_save(model) { create_description }
|
||||
@@ -116,10 +99,32 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
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
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name:, body: 'before', created_by_user: user)
|
||||
end
|
||||
let!(:first_revision) { page.current_revision }
|
||||
@@ -138,7 +143,8 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
expect(page.reload.version_no).to eq(1)
|
||||
expect(description.version_no).to eq(1)
|
||||
expect { update_description }
|
||||
.to change(WikiRevision, :count).by(1)
|
||||
.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)
|
||||
@@ -157,7 +163,8 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
tag_name.update_columns(tag_id: nil)
|
||||
|
||||
expect { update_description }
|
||||
.to change(WikiRevision, :count).by(1)
|
||||
.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
|
||||
@@ -177,10 +184,10 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
page_state = page.attributes
|
||||
description_state = description.attributes
|
||||
|
||||
update_description(body: 'before', title: 'Help:description_renamed')
|
||||
update_description(body: 'before', title: 'description_renamed')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(tag_name.reload.name).to eq('Help:description_renamed')
|
||||
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)
|
||||
|
||||
@@ -206,7 +206,13 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
context 'without an existing description' do
|
||||
before { tag }
|
||||
|
||||
it 'creates both current resources and histories with matching compatibility IDs' 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)
|
||||
@@ -215,7 +221,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
page = WikiPage.find(description.id)
|
||||
page = WikiPage.find_by!(tag_name: tag.tag_name('ja'))
|
||||
revision = page.current_revision
|
||||
version = TagDescriptionVersion.find(revision.id)
|
||||
expect(description).to have_attributes(
|
||||
@@ -227,6 +233,9 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
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 }"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||
include WikiRecords
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
@@ -69,34 +70,36 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'combines Tag Groups, Help and Tag Descriptions in the unfiltered list' do
|
||||
help = Wiki::Commit.create_legacy_content!(
|
||||
it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do
|
||||
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(
|
||||
'タググループ:読取り契約', help.title, '新タグ')
|
||||
'タググループ:読取り契約', '新タグ')
|
||||
expect(json.map { _1.fetch('title') }).not_to include(help.title)
|
||||
end
|
||||
|
||||
it 'keeps Help readable when its ID collides with a new group' do
|
||||
help = Wiki::Commit.create_legacy_content!(
|
||||
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(help.title, 'タググループ:別 ID重複')
|
||||
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(:ok)
|
||||
expect(json.fetch('body')).to eq('help body')
|
||||
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(
|
||||
help.title, 'タググループ:別 ID重複')
|
||||
'タググループ:別 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
|
||||
@@ -122,7 +125,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||
end
|
||||
|
||||
it 'orders mixed resource history by timestamp rather than independent IDs' do
|
||||
help = Wiki::Commit.create_legacy_content!(
|
||||
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)
|
||||
@@ -136,8 +139,8 @@ 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.index('新タグ')).to be < titles.index(help.title)
|
||||
expect(titles.index(help.title)).to be < titles.index('タググループ:読取り契約')
|
||||
expect(titles).not_to include(help.title)
|
||||
expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約')
|
||||
end
|
||||
|
||||
it 'renders nullable group editors without raising' do
|
||||
@@ -166,16 +169,16 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
|
||||
end
|
||||
|
||||
it 'includes Help and group history but excludes legacy-only migrated revisions' do
|
||||
it 'includes group history but excludes all legacy-only revisions' do
|
||||
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
|
||||
help = Wiki::Commit.create_legacy_content!(
|
||||
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).to include(include(
|
||||
expect(json).not_to include(include(
|
||||
'revision_id' => help.current_revision.id,
|
||||
'wiki_page' => include('title' => help.title)))
|
||||
end
|
||||
@@ -214,7 +217,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||
|
||||
context 'when Help, description and group share the same numeric ID' do
|
||||
let(:colliding_help) do
|
||||
Wiki::Commit.create_legacy_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: 'Help:三者ID衝突'),
|
||||
body: 'help', created_by_user: user)
|
||||
end
|
||||
@@ -239,13 +242,12 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||
colliding_description_version
|
||||
end
|
||||
|
||||
it 'lists all three resources without deduplicating their numeric ID' do
|
||||
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_help.id, colliding_help.title],
|
||||
[colliding_description.id, '新タグ'],
|
||||
[colliding_group_id, 'タググループ:三者ID衝突'])
|
||||
end
|
||||
@@ -259,7 +261,6 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||
[row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')]
|
||||
end
|
||||
expect(histories).to contain_exactly(
|
||||
[colliding_help.current_revision.id, colliding_help.title],
|
||||
[colliding_description_version.id, '新タグ'],
|
||||
[group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突'])
|
||||
end
|
||||
@@ -285,8 +286,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'resolves colliding group, Help and description IDs by title' do
|
||||
help = Wiki::Commit.create_legacy_content!(
|
||||
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!(
|
||||
@@ -294,11 +295,15 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||
TagDescriptionVersion.create!(
|
||||
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
|
||||
event_type: 'create', body: 'description', created_at: Time.current)
|
||||
{ 'タググループ:ID衝突' => 'group', 'Help:ID衝突' => 'help', '新タグ' => 'description' }
|
||||
{ 'タググループ: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,8 +33,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates the group, first snapshot and compatibility Wiki history' do
|
||||
create_group
|
||||
it 'creates the group, snapshot and matching compatibility histories' do
|
||||
expect { create_group }
|
||||
.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' => 'タググループ:新規')
|
||||
@@ -43,10 +46,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
|
||||
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
|
||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' })
|
||||
expect(page.body.strip).to eq('- 洗澡鹿')
|
||||
expect(page.body).to eq('- 洗澡鹿')
|
||||
expect(page.current_revision.body).to eq(page.body)
|
||||
expect(page.wiki_versions.sole.body).to eq(page.body)
|
||||
# New IDs are independently allocated; no global identity assertion here.
|
||||
end
|
||||
|
||||
it 'keeps repeated placements but only one membership and one snapshot member' do
|
||||
@@ -293,8 +295,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
(TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
|
||||
.each do |table|
|
||||
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) }
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group write integrity', type: :request do
|
||||
include WikiRecords
|
||||
include TagGroupRecords
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
@@ -24,7 +25,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
||||
end
|
||||
|
||||
def create_help body
|
||||
Wiki::Commit.create_legacy_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user)
|
||||
end
|
||||
|
||||
@@ -70,17 +71,16 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
||||
expect(page.body).to eq('- 伊地知ニジカ')
|
||||
end
|
||||
|
||||
it 'updates Help with a colliding group ID without altering the group' do
|
||||
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)
|
||||
|
||||
put_wiki(id, title: 'Help:改名', body: 'Help after')
|
||||
expect_no_writes { put_wiki(id, title: 'Help:改名', body: 'Help after') }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(page.reload.body).to eq('Help after')
|
||||
expect(page.title).to eq('Help:改名')
|
||||
expect(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
|
||||
@@ -98,7 +98,8 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
||||
it 'keeps a description update separate from a colliding group' do
|
||||
page = Wiki::Commit.create_content!(
|
||||
tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user)
|
||||
id = seed_group(name: '更新', id: page.id, members:, placements:)
|
||||
description = TagDescription.find_by!(tag_id: tag.id)
|
||||
id = seed_group(name: '更新', id: description.id, members:, placements:)
|
||||
group_before = TagGroup.find(id).attributes
|
||||
|
||||
put_wiki(id, title: '洗澡鹿', body: 'description after')
|
||||
@@ -115,18 +116,19 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
||||
description = TagDescription.create!(
|
||||
id:, tag:, language_code: 'ja', version_no: 1, body: 'description')
|
||||
|
||||
put_wiki(id, title: 'Help:既存', body: 'Help after')
|
||||
expect_no_writes { put_wiki(id, title: 'Help:既存', body: 'Help after') }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(page.reload.body).to eq('Help after')
|
||||
expect(page.reload.body).to eq('Help before')
|
||||
expect(description.reload.body).to eq('description')
|
||||
expect(TagGroup.find(id).version_no).to eq(1)
|
||||
expect_no_writes { put_wiki(id, title: '洗澡鹿', body: 'description after') }
|
||||
put_wiki(id, title: '洗澡鹿', 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 after')
|
||||
expect(description.reload.body).to eq('description')
|
||||
expect(page.reload.body).to eq('Help before')
|
||||
expect(description.reload.body).to eq('description after')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -136,14 +138,14 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
||||
id = seed_group(name: '更新', id: help.id, members:, placements:)
|
||||
help_before = help.attributes
|
||||
|
||||
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
|
||||
expect 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)
|
||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:改名' })
|
||||
expect(page.id).not_to eq(help.id)
|
||||
expect(page.body).to eq('- 洗澡鹿')
|
||||
expect(page.current_revision.body).to eq(page.body)
|
||||
expect(page.wiki_versions.sole.body).to eq(page.body)
|
||||
expect(TagGroup.find(id).name).to eq('改名')
|
||||
expect(help.reload.attributes).to eq(help_before)
|
||||
end
|
||||
|
||||
|
||||
@@ -391,6 +391,17 @@ RSpec.describe 'Tag group native API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates native state and its compatibility histories' do
|
||||
expect { native_write(:create) }
|
||||
.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,
|
||||
placements:, description: '全体の説明')
|
||||
end
|
||||
|
||||
[:create, :update].each do |operation|
|
||||
it "synchronises Wiki current state and histories on native #{ operation }" do
|
||||
native_write(operation)
|
||||
@@ -418,19 +429,18 @@ RSpec.describe 'Tag group native API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses AUTO_INCREMENT for a new group instead of supplying a compatibility ID' do
|
||||
inserts = []
|
||||
subscriber = lambda do |*args|
|
||||
sql = args.last.fetch(:sql)
|
||||
inserts << sql if sql.match?(/\A\s*INSERT INTO `?tag_groups`?\s/i)
|
||||
end
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
native_write(:create)
|
||||
end
|
||||
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(inserts.size).to eq(1)
|
||||
columns = inserts.sole[/\((.*?)\)/, 1].split(',').map { _1.strip.delete('`') }
|
||||
expect(columns).not_to include('id')
|
||||
expect(json.fetch('id')).to eq(expected_id)
|
||||
end
|
||||
|
||||
TagGroupRecords::GROUP_TABLES.each do |table|
|
||||
it "rolls back native creation after #{ table } writes" do
|
||||
expect_atomic_group_write(table) { native_write(:create) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'updates a native group through Wiki and then reads structured current data' do
|
||||
|
||||
@@ -0,0 +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 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_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)
|
||||
end
|
||||
|
||||
[: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 '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 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(id)
|
||||
expect(description.body).to eq('after')
|
||||
expect(TagDescription.count).to eq(1)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag and wiki history integrity', type: :request do
|
||||
include WikiRecords
|
||||
let(:member_user) { create(:user, role: 'member') }
|
||||
|
||||
def stub_current_user user
|
||||
@@ -13,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
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: primary_tag_name_for(tag, 'ja'),
|
||||
body:,
|
||||
created_by_user: user,
|
||||
|
||||
@@ -38,6 +38,22 @@ RSpec.describe 'Tags API', type: :request do
|
||||
end
|
||||
|
||||
describe 'GET /tags' do
|
||||
it 'serialises descriptions and parents without loading legacy Wiki pages' do
|
||||
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
|
||||
TagDescription.create!(tag: tag2, language_code: 'ja', body: '', version_no: 1)
|
||||
TagImplication.create!(tag:, parent_tag: tag2)
|
||||
queries = []
|
||||
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
get '/tags', params: { name: 'spec_tag' }
|
||||
end
|
||||
expect(response).to have_http_status(:ok)
|
||||
row = response_tags.sole
|
||||
expect(row.fetch('has_wiki')).to be(true)
|
||||
expect(row.fetch('parents').sole.fetch('has_wiki')).to be(true)
|
||||
expect(queries.grep(/\bSELECT\b.*\bwiki_(pages|revisions|versions)\b/im)).to be_empty
|
||||
end
|
||||
|
||||
it 'includes legacy external JSON alongside an internal tag with the same id' do
|
||||
external = create(:external_tag, id: tag.id, name: 'spec_external', post_count: 3)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki conflict handling', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
@@ -9,8 +10,8 @@ RSpec.describe 'Wiki conflict handling', type: :request do
|
||||
|
||||
it 'returns 409 when base_revision_id is stale' do
|
||||
page =
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: create(:tag_name, name: 'wiki_conflict_request'),
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag, primary_name: 'wiki_conflict_request').tag_name('ja'),
|
||||
body: 'first',
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
@@ -25,12 +26,10 @@ RSpec.describe 'Wiki conflict handling', type: :request do
|
||||
base_revision_id: stale_id)
|
||||
|
||||
put "/wiki/#{ page.id }",
|
||||
params: {
|
||||
title: 'wiki_conflict_request',
|
||||
body: 'third',
|
||||
message: 'stale',
|
||||
base_revision_id: stale_id,
|
||||
},
|
||||
params: { title: 'wiki_conflict_request',
|
||||
body: 'third',
|
||||
message: 'stale',
|
||||
base_revision_id: stale_id },
|
||||
headers: auth_headers(user)
|
||||
|
||||
expect(response).to have_http_status(:conflict)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki creation freeze', type: :request do
|
||||
include TagGroupRecords
|
||||
include WikiRecords
|
||||
|
||||
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_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user)
|
||||
end
|
||||
|
||||
[nil, :guest, :banned].each do |role|
|
||||
it "rejects #{ role || 'anonymous' } without changing either history" do
|
||||
actor = role && create(:user, role == :banned ? :member : role)
|
||||
actor.update!(banned_at: Time.current) if role == :banned
|
||||
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
before_state = group_state
|
||||
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' },
|
||||
headers: request_headers
|
||||
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects banned IPs without writes' do
|
||||
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
|
||||
before_state = group_state
|
||||
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' }, headers: headers
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
['', "\r\n\r\n"].each do |body|
|
||||
it "rejects invalid description body #{ body.inspect } atomically" do
|
||||
before_state = group_state
|
||||
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: }, headers: headers
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@ require 'cgi'
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki history integrity', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
@@ -9,49 +10,22 @@ RSpec.describe 'Wiki history integrity', type: :request do
|
||||
end
|
||||
|
||||
def create_wiki_page title:, body: 'body', message: 'init', user: self.user
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: create(:tag_name, name: title),
|
||||
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 'creates wiki_page, wiki_revision, and wiki_version atomically' do
|
||||
expect {
|
||||
post '/wiki',
|
||||
params: {
|
||||
title: 'Help:wiki_history_create_atomic',
|
||||
body: "a\nb\nc",
|
||||
message: 'initial commit',
|
||||
},
|
||||
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)
|
||||
}
|
||||
.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)
|
||||
|
||||
page = WikiPage.find(json.fetch('id'))
|
||||
revision = page.current_revision
|
||||
version = page.wiki_versions.order(:version_no).last
|
||||
|
||||
expect(page.title).to eq('Help:wiki_history_create_atomic')
|
||||
expect(page.body).to eq("a\nb\nc")
|
||||
|
||||
expect(revision).to be_content
|
||||
expect(revision.message).to eq('initial commit')
|
||||
expect(revision.lines_count).to eq(3)
|
||||
|
||||
expect(version).to have_attributes(
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
title: 'Help:wiki_history_create_atomic',
|
||||
body: "a\nb\nc",
|
||||
reason: 'initial commit',
|
||||
created_by_user_id: user.id
|
||||
)
|
||||
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
|
||||
@@ -71,21 +45,19 @@ RSpec.describe 'Wiki history integrity', type: :request do
|
||||
.where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist
|
||||
end
|
||||
|
||||
it 'returns 422 and creates no partial page when title already exists' do
|
||||
it 'rejects frozen creation without modifying an existing page or history' do
|
||||
create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first')
|
||||
|
||||
expect {
|
||||
expect do
|
||||
post '/wiki',
|
||||
params: {
|
||||
title: 'Help:wiki_history_duplicate_title',
|
||||
body: 'second',
|
||||
message: 'duplicate',
|
||||
},
|
||||
params: { title: 'Help:wiki_history_duplicate_title',
|
||||
body: 'second', message: 'duplicate' },
|
||||
headers: auth_headers(user)
|
||||
}
|
||||
.not_to change(WikiPage, :count)
|
||||
end.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(0)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki resource ordering', type: :request do
|
||||
include TagGroupRecords
|
||||
let(:user) { create_member_user! }
|
||||
|
||||
it 'selects the latest twenty across both resources before applying the global limit' do
|
||||
start = Time.utc(2026, 1, 1)
|
||||
descriptions = 24.times.map do |index|
|
||||
tag = create(:tag, primary_name: "rank_#{ index }")
|
||||
TagDescription.create!(tag:, language_code: 'ja', body: 'current', version_no: 1,
|
||||
updated_at: start + index.hours)
|
||||
end
|
||||
groups = [24, 25].map do |hour|
|
||||
group = TagGroup.find(seed_group(name: "rank_group_#{ hour }"))
|
||||
group.update_columns(updated_at: start + hour.hours)
|
||||
group
|
||||
end
|
||||
|
||||
get '/wiki', params: { title: 'rank' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expected = groups.reverse.map { ['tag_group', _1.id] } +
|
||||
descriptions.last(18).reverse.map { ['tag_description', _1.id] }
|
||||
expect(json.map { [_1.fetch('resource_type'), _1.fetch('id')] }).to eq(expected)
|
||||
end
|
||||
|
||||
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')
|
||||
TagDescription.create!(id: 51, tag:, language_code: 'ja', body: 'current',
|
||||
version_no: 1, updated_at: time)
|
||||
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', 51], ['tag_group', 51]])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ require 'securerandom'
|
||||
|
||||
|
||||
RSpec.describe 'Wiki API', type: :request do
|
||||
include WikiRecords
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
end
|
||||
@@ -18,7 +19,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
deprecated_at: Time.zone.local(2026, 6, 1))
|
||||
end
|
||||
let!(:page) do
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: tn,
|
||||
body: 'init',
|
||||
created_by_user: user,
|
||||
@@ -108,94 +109,14 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when success' do
|
||||
it 'creates wiki_page and first content revision' do
|
||||
['Help:TestPage', 'ヘルプ:ホーム'].each do |title|
|
||||
it "rejects frozen creation of #{ title } without history writes" do
|
||||
expect do
|
||||
post endpoint, params: { title: 'Help:TestPage', body: "a\nb\nc", message: 'init' },
|
||||
headers: auth_headers(member)
|
||||
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(:created)
|
||||
|
||||
page_id = json.fetch('id')
|
||||
expect(json.fetch('title')).to eq('Help:TestPage')
|
||||
|
||||
created_page = WikiPage.find(page_id)
|
||||
version = created_page.wiki_versions.order(:version_no).last
|
||||
|
||||
expect(version).to have_attributes(
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
title: 'Help:TestPage',
|
||||
body: "a\nb\nc",
|
||||
created_by_user_id: member.id
|
||||
)
|
||||
|
||||
rev = created_page.current_revision
|
||||
expect(rev).to be_present
|
||||
expect(rev).to be_content
|
||||
expect(rev.message).to eq('init')
|
||||
|
||||
expect(created_page.body).to eq("a\nb\nc")
|
||||
|
||||
expect(rev.lines_count).to eq(3)
|
||||
expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2])
|
||||
expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c'])
|
||||
end
|
||||
|
||||
it 'reuses existing WikiLine rows by sha256' do
|
||||
# 先に同じ行を作っておく
|
||||
WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current)
|
||||
|
||||
post endpoint,
|
||||
params: { title: 'Help:Reuse', body: "a\na" },
|
||||
headers: auth_headers(member)
|
||||
|
||||
page = WikiPage.find(JSON.parse(response.body).fetch('id'))
|
||||
rev = page.current_revision
|
||||
expect(rev.lines_count).to eq(2)
|
||||
|
||||
# "a" の WikiLine が増殖しない(1行のはず)
|
||||
expect(WikiLine.where(body: 'a').count).to eq(1)
|
||||
end
|
||||
|
||||
it 'deduplicates duplicated new lines before upsert' do
|
||||
duplicated = 'duplicated_line_for_wiki_line_upsert_spec'
|
||||
|
||||
post endpoint,
|
||||
params: { title: 'Help:DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" },
|
||||
headers: auth_headers(member)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
|
||||
page = WikiPage.find(json.fetch('id'))
|
||||
rev = page.current_revision
|
||||
|
||||
expect(rev.lines_count).to eq(2)
|
||||
expect(WikiLine.where(body: duplicated).count).to eq(1)
|
||||
expect(rev.wiki_revision_lines.count).to eq(2)
|
||||
expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
|
||||
end
|
||||
|
||||
it 'normalises CRLF and strips trailing newlines' do
|
||||
post endpoint,
|
||||
params: { title: 'Help:NormalisedBody',
|
||||
body: "a\r\nb\r\n\r\n", message: 'normalise' },
|
||||
headers: auth_headers(member)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
|
||||
page = WikiPage.find(json.fetch('id'))
|
||||
rev = page.current_revision
|
||||
version = page.wiki_versions.order(:version_no).last
|
||||
|
||||
expect(page.body).to eq("a\nb")
|
||||
expect(version.body).to eq("a\nb")
|
||||
expect(rev.lines_count).to eq(2)
|
||||
expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b'])
|
||||
post endpoint, params: { title:, body: 'initial' }, headers: auth_headers(member)
|
||||
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
|
||||
end
|
||||
@@ -208,10 +129,10 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
end
|
||||
|
||||
let!(:test_tag_name) { create(:tag_name, name: 'TestPage') }
|
||||
let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') }
|
||||
|
||||
let!(:page) do
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: test_tag_name,
|
||||
body: "a\nb",
|
||||
created_by_user: member,
|
||||
@@ -249,7 +170,8 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
|
||||
expect do
|
||||
put "/wiki/#{page.id}",
|
||||
params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id },
|
||||
params: { title: 'TestPage', body: "x\ny", message: 'edit',
|
||||
base_revision_id: current_id },
|
||||
headers: auth_headers(member)
|
||||
end
|
||||
.to change(WikiRevision, :count).by(1)
|
||||
@@ -284,7 +206,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
created_by_user: member)
|
||||
|
||||
linked_page =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: linked_tag_name,
|
||||
body: 'before',
|
||||
created_by_user: member,
|
||||
@@ -358,13 +280,14 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
|
||||
describe 'GET /wiki/search' do
|
||||
before do
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: create(:tag_name, name: 'spec_wiki_title_2'),
|
||||
search_tag = create(:tag, primary_name: 'spec_wiki_title_2')
|
||||
seed_wiki_page(
|
||||
tag_name: search_tag.tag_name('ja'),
|
||||
body: 'search body 2',
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: 'unrelated_title'),
|
||||
body: 'unrelated body',
|
||||
created_by_user: user,
|
||||
@@ -516,7 +439,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
it 'raises because redirect revisions are deprecated' do
|
||||
target_tag_name = create(:tag_name, name: 'redirect_deprecated_target')
|
||||
target =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: target_tag_name,
|
||||
body: 'target',
|
||||
created_by_user: user,
|
||||
@@ -539,7 +462,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general)
|
||||
|
||||
linked_page =
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: linked_tag_name,
|
||||
body: 'before',
|
||||
created_by_user: user,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki title collision', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
@@ -8,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do
|
||||
end
|
||||
|
||||
def create_wiki_page title:, body:
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: title),
|
||||
body:,
|
||||
created_by_user: user,
|
||||
@@ -43,20 +44,18 @@ RSpec.describe 'Wiki title collision', type: :request do
|
||||
expect(source.wiki_versions.count).to eq(source_version_count)
|
||||
end
|
||||
|
||||
it 'returns 422 when creating wiki with existing title' do
|
||||
it 'rejects frozen creation with an existing title without adding history' do
|
||||
create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists')
|
||||
|
||||
expect {
|
||||
expect do
|
||||
post '/wiki',
|
||||
params: {
|
||||
title: 'Help:wiki_collision_create',
|
||||
body: 'new body',
|
||||
message: 'duplicate create',
|
||||
},
|
||||
params: { title: 'Help:wiki_collision_create', body: 'new body',
|
||||
message: 'duplicate create' },
|
||||
headers: auth_headers(user)
|
||||
}
|
||||
.not_to change(WikiPage, :count)
|
||||
end.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(0)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.status).to be_between(400, 499)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,115 +1,78 @@
|
||||
require 'rails_helper'
|
||||
|
||||
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
|
||||
Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: user)
|
||||
seed_wiki_page(tag_name:, body: 'before', created_by_user: user)
|
||||
end
|
||||
let!(:first_revision) { page.current_revision }
|
||||
let!(:last_revision) do
|
||||
Wiki::Commit.content!(page:, body: 'after', created_user: user,
|
||||
message: 'legacy edit', base_revision_id: first_revision.id)
|
||||
Wiki::Commit.legacy_content!(page:, body: 'after', created_user: user,
|
||||
message: 'legacy edit', base_revision_id: first_revision.id)
|
||||
end
|
||||
|
||||
before { expect(TagDescription.exists?(id: page.id)).to be(false) }
|
||||
|
||||
it 'keeps a Tag-linked legacy page in the legacy domain when its title changes' do
|
||||
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(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
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(:ok)
|
||||
expect(page.reload).to have_attributes(body: 'changed legacy body')
|
||||
expect(page.title).to eq('legacy_tag_renamed')
|
||||
expect(page.current_revision).to have_attributes(message: 'legacy rename')
|
||||
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
|
||||
|
||||
it 'shows legacy current and historical content by ID' do
|
||||
get "/wiki/#{ page.id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('body' => 'after', 'revision_id' => last_revision.id,
|
||||
'pred' => first_revision.id, 'succ' => nil)
|
||||
get "/wiki/#{ page.id }", params: { version: first_revision.id }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('body' => 'before', 'revision_id' => first_revision.id,
|
||||
'pred' => nil, 'succ' => last_revision.id)
|
||||
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 "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
|
||||
|
||||
it 'shows legacy current and historical content by title' do
|
||||
get "/wiki/title/#{ tag_name.name }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('body' => 'after', 'revision_id' => last_revision.id)
|
||||
get "/wiki/title/#{ tag_name.name }", params: { version: first_revision.id }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('body' => 'before', 'revision_id' => first_revision.id)
|
||||
end
|
||||
|
||||
it 'recognises legacy IDs' do
|
||||
get "/wiki/#{ page.id }/exists"
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'recognises legacy titles' do
|
||||
get "/wiki/title/#{ tag_name.name }/exists"
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'diffs legacy revisions with explicit and current target' do
|
||||
[last_revision.id, nil].each do |to|
|
||||
get "/wiki/#{ page.id }/diff", params: { from: first_revision.id, to: }
|
||||
['/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 include(
|
||||
'wiki_page_id' => page.id, 'title' => tag_name.name,
|
||||
'older_revision_id' => first_revision.id, 'newer_revision_id' => last_revision.id,
|
||||
'diff' => [{ 'type' => 'removed', 'content' => 'before' },
|
||||
{ 'type' => 'added', 'content' => 'after' }])
|
||||
expect(json).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'revision resource scope' do
|
||||
let!(:other_page) do
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: create(:tag_name, name: 'Help_other_contract'),
|
||||
body: 'other page history', created_by_user: user)
|
||||
end
|
||||
let(:other_revision) { other_page.current_revision }
|
||||
|
||||
it 'returns 404 when show specifies another page revision' do
|
||||
get "/wiki/#{ page.id }", params: { version: other_revision.id }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns 404 when diff from belongs to another page' do
|
||||
get "/wiki/#{ page.id }/diff",
|
||||
params: { from: other_revision.id, to: last_revision.id }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns 404 when diff to belongs to another page' do
|
||||
get "/wiki/#{ page.id }/diff",
|
||||
params: { from: first_revision.id, to: other_revision.id }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
it 'retains legacy history metadata and ordering' do
|
||||
get '/wiki/changes', params: { id: page.id }
|
||||
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.map { |row| row.fetch('revision_id') }).to eq(
|
||||
[last_revision.id, first_revision.id])
|
||||
expect(json.first).to include(
|
||||
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
|
||||
'message' => 'legacy edit', 'timestamp' => last_revision.created_at.iso8601(3),
|
||||
'wiki_page' => { 'id' => page.id, 'title' => tag_name.name, 'deprecated_at' => nil },
|
||||
'user' => { 'id' => user.id, 'name' => user.name })
|
||||
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
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
require 'rails_helper'
|
||||
require 'timeout'
|
||||
|
||||
RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do
|
||||
self.use_transactional_tests = false
|
||||
|
||||
before do
|
||||
expect(Rails.env.test?).to be(true)
|
||||
expect(ApplicationRecord.connection.adapter_name).to match(/mysql/i)
|
||||
expect(ApplicationRecord.connection_pool.size).to be >= 3
|
||||
@prefix = "shared_ids_#{ SecureRandom.hex(8) }"
|
||||
@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
|
||||
|
||||
after do
|
||||
@release << true if @release
|
||||
begin
|
||||
Array(@threads).each do |thread|
|
||||
raise 'Shared ID worker did not finish' unless thread.join(15)
|
||||
end
|
||||
ensure
|
||||
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
|
||||
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
|
||||
|
||||
def await_queue queue
|
||||
Timeout.timeout(15) { queue.pop }
|
||||
end
|
||||
|
||||
def worker &block
|
||||
Thread.new do
|
||||
Thread.current.report_on_exception = false
|
||||
ApplicationRecord.connection_pool.with_connection(&block)
|
||||
end.tap { @threads << _1 }
|
||||
end
|
||||
|
||||
def create_resource kind
|
||||
case kind
|
||||
when :tag
|
||||
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 :group
|
||||
TagGroup.create!(name: "#{ @prefix }_group", version_no: 1)
|
||||
end
|
||||
end
|
||||
|
||||
def hold_creation kind, rollback: false
|
||||
ready = Queue.new
|
||||
thread = worker do |connection|
|
||||
id = nil
|
||||
connection.transaction do
|
||||
lock_name = nil
|
||||
subscriber = lambda do |*args|
|
||||
match = args.last.fetch(:sql).match(/GET_LOCK\('([^']+)'/)
|
||||
lock_name = match[1] if match
|
||||
end
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
id = create_resource(kind).id
|
||||
end
|
||||
ready << [id, connection.select_value('SELECT CONNECTION_ID()').to_i, lock_name]
|
||||
await_queue(@release)
|
||||
raise ActiveRecord::Rollback if rollback
|
||||
end
|
||||
id
|
||||
end
|
||||
[thread, await_queue(ready)]
|
||||
end
|
||||
|
||||
def result thread
|
||||
raise 'Shared ID worker did not finish' unless thread.join(15)
|
||||
thread.value
|
||||
end
|
||||
|
||||
{ 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)
|
||||
maxima = tables.map do |table|
|
||||
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 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)
|
||||
.to eq(connection_id)
|
||||
|
||||
started = Queue.new
|
||||
second = worker do |other|
|
||||
started << other.select_value('SELECT CONNECTION_ID()').to_i
|
||||
create_resource(kinds.last).id
|
||||
end
|
||||
expect(await_queue(started)).not_to eq(connection_id)
|
||||
@release << true
|
||||
expect(result(first)).to eq(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
|
||||
end
|
||||
|
||||
it 'allows the other ID space to commit while the first space remains locked' do
|
||||
first, (_id, connection_id, lock_name) = hold_creation(:external)
|
||||
second = worker { create_resource(:group).id }
|
||||
expect(result(second)).to be_positive
|
||||
connection = ApplicationRecord.connection
|
||||
quoted_lock = connection.quote(lock_name)
|
||||
expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i)
|
||||
.to eq(connection_id)
|
||||
@release << true
|
||||
result(first)
|
||||
end
|
||||
|
||||
it 'raises on a real lock acquisition failure without inserting a record' do
|
||||
first, (_id, _connection_id, lock_name) = hold_creation(:external, rollback: true)
|
||||
stub_const('SharedResourceIdAllocator::LOCK_TIMEOUT', 0)
|
||||
expect { create_resource(:external) }.to raise_error(ActiveRecord::LockWaitTimeout)
|
||||
expect(ExternalTag.where(name: "#{ @prefix }_external")).not_to exist
|
||||
@release << true
|
||||
result(first)
|
||||
connection = ApplicationRecord.connection
|
||||
quoted_lock = connection.quote(lock_name)
|
||||
expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i)
|
||||
.to eq(1)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe SharedResourceIdAllocator do
|
||||
let(:record) { build(:external_tag) }
|
||||
let(:connection) { double('connection') }
|
||||
let(:transaction) { double('transaction', open?: true) }
|
||||
let(:callbacks) { { } }
|
||||
let(:sql) { [] }
|
||||
|
||||
before 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_table_name) { "`#{ _1 }`" }
|
||||
allow(connection).to receive(:select_value) do |statement|
|
||||
sql << statement
|
||||
if statement.include?('GET_LOCK') || statement.include?('RELEASE_LOCK')
|
||||
1
|
||||
elsif statement.include?('`tags`')
|
||||
8
|
||||
else
|
||||
3
|
||||
end
|
||||
end
|
||||
allow(ExternalTag).to receive(:connection).and_return(connection)
|
||||
allow(ExternalTag).to receive(:current_transaction).and_return(transaction)
|
||||
allow(ExternalTag).to receive(:uncached).and_yield
|
||||
[:after_commit, :after_rollback].each do |event|
|
||||
allow(transaction).to receive(event) { |&callback| callbacks[event] = callback }
|
||||
end
|
||||
end
|
||||
|
||||
[:after_commit, :after_rollback].each do |event|
|
||||
it "holds the shared lock until #{ event }" 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
|
||||
end
|
||||
|
||||
it 'does not silently proceed after an acquisition timeout' do
|
||||
allow(connection).to receive(:select_value).and_return(0)
|
||||
expect { described_class.assign!(record) }.to raise_error(ActiveRecord::LockWaitTimeout)
|
||||
expect(record.id).to be_nil
|
||||
expect(callbacks).to be_empty
|
||||
end
|
||||
|
||||
it 'releases a successfully acquired lock when allocation fails and rolls back' do
|
||||
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 'preserves explicitly supplied historical IDs' do
|
||||
record.id = 5
|
||||
described_class.assign!(record)
|
||||
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].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
|
||||
record = model.new(id: 50)
|
||||
described_class.assign!(record)
|
||||
statement = sql.grep(/GET_LOCK/).last
|
||||
callbacks.fetch(:after_commit).call
|
||||
statement
|
||||
end
|
||||
expect(locks[0]).to eq(locks[1])
|
||||
expect(locks[2]).to eq(locks[3])
|
||||
expect(locks[0]).not_to eq(locks[2])
|
||||
end
|
||||
|
||||
it 'reports lock release failures' do
|
||||
described_class.assign!(record)
|
||||
allow(connection).to receive(:select_value).with(/RELEASE_LOCK/).and_return(nil)
|
||||
expect { callbacks.fetch(:after_commit).call }
|
||||
.to raise_error(ActiveRecord::ActiveRecordError, /lock release failed/)
|
||||
end
|
||||
end
|
||||
@@ -2,41 +2,42 @@ require 'digest'
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Wiki::Commit do
|
||||
include WikiRecords
|
||||
let(:user) { create_member_user! }
|
||||
|
||||
def create_page title:, body: 'initial body'
|
||||
described_class.create_content!(
|
||||
tag_name: create(:tag_name, name: title),
|
||||
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 'creates page, revision, and version with normalised body' do
|
||||
expect {
|
||||
described_class.create_content!(
|
||||
tag_name: create(:tag_name, name: 'commit_integrity_create'),
|
||||
body: "a\r\nb\r\n\r\n",
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
}
|
||||
.to change(WikiPage, :count).by(1)
|
||||
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)
|
||||
|
||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' })
|
||||
revision = page.current_revision
|
||||
version = page.wiki_versions.order(:version_no).last
|
||||
|
||||
expect(page.body).to eq("a\nb")
|
||||
expect(revision.lines_count).to eq(2)
|
||||
expect(version.body).to eq("a\nb")
|
||||
expect(version.reason).to eq('init')
|
||||
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_name, name: 'commit_integrity_blank')
|
||||
tag_name = create(:tag, primary_name: 'commit_integrity_blank').tag_name('ja')
|
||||
|
||||
expect {
|
||||
described_class.create_content!(
|
||||
@@ -52,6 +53,17 @@ RSpec.describe Wiki::Commit do
|
||||
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
|
||||
@@ -80,7 +92,7 @@ RSpec.describe Wiki::Commit do
|
||||
tag = create(:tag, primary_tag_name: tag_name, category: :general)
|
||||
|
||||
page =
|
||||
described_class.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name:,
|
||||
body: 'before',
|
||||
created_by_user: user,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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_name, name: title)
|
||||
tag_name = create(:tag, primary_name: title).tag_name('ja')
|
||||
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name:,
|
||||
body:,
|
||||
created_by_user: user,
|
||||
@@ -84,7 +85,7 @@ RSpec.describe Wiki::Commit do
|
||||
tag = create(:tag, primary_tag_name: tag_name, category: :general)
|
||||
|
||||
page =
|
||||
described_class.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name:,
|
||||
body: 'before',
|
||||
created_by_user: user,
|
||||
@@ -111,7 +112,7 @@ RSpec.describe Wiki::Commit do
|
||||
tag = create(:tag, primary_tag_name: tag_name, category: :general)
|
||||
|
||||
page =
|
||||
described_class.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name:,
|
||||
body: 'before',
|
||||
created_by_user: user,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe WikiVersionRecorder do
|
||||
include WikiRecords
|
||||
let(:user) { create_member_user! }
|
||||
|
||||
def create_page title:, body: 'body'
|
||||
Wiki::Commit.create_content!(
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: title),
|
||||
body:,
|
||||
created_by_user: user,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Historical records must remain seedable after application creation is frozen.
|
||||
module WikiRecords
|
||||
def seed_wiki_page(tag_name:, body:, created_by_user:, message: nil)
|
||||
page = WikiPage.create!(tag_name:, body:,
|
||||
created_user: created_by_user, updated_user: created_by_user)
|
||||
revision = Wiki::Commit.legacy_content!(
|
||||
page:, body:, created_user: created_by_user, message:)
|
||||
if tag_name.tag && tag_name.language_code == 'ja'
|
||||
TagDescription.create!(
|
||||
id: page.id, tag: tag_name.tag, language_code: 'ja', body: page.reload.body,
|
||||
version_no: 1, created_by_user_id: created_by_user.id,
|
||||
updated_by_user_id: created_by_user.id)
|
||||
TagDescriptionVersion.create!(
|
||||
id: revision.id, tag_id: tag_name.tag_id, language_code: 'ja',
|
||||
version_no: 1, event_type: 'create', body: page.body,
|
||||
created_by_user_id: created_by_user.id, created_at: revision.created_at)
|
||||
end
|
||||
page
|
||||
end
|
||||
end
|
||||
新しいイシューから参照
ユーザーをブロックする