このコミットが含まれているのは:
@@ -9,7 +9,8 @@ class WikiPagesController < ApplicationController
|
||||
TagDescription.where(language_code: 'ja')
|
||||
.includes(tag: :tag_names)
|
||||
|
||||
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 =
|
||||
@@ -22,7 +23,9 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
||||
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
||||
json.sort! { |a, b| b['updated_at'] <=> a['updated_at'] }
|
||||
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
|
||||
@@ -99,7 +102,7 @@ class WikiPagesController < ApplicationController
|
||||
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
||||
'deprecated_at' => nil }
|
||||
else
|
||||
raise 'お前やりませんねぇスギ!'
|
||||
return head :not_found
|
||||
end
|
||||
end
|
||||
|
||||
@@ -141,7 +144,7 @@ class WikiPagesController < ApplicationController
|
||||
|
||||
page =
|
||||
if title.include?(':')
|
||||
raise 'お前やりませんねぇスギ!'
|
||||
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
|
||||
@@ -150,7 +153,8 @@ class WikiPagesController < ApplicationController
|
||||
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
|
||||
@@ -180,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
|
||||
@@ -306,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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ require 'set'
|
||||
|
||||
|
||||
class Tag < ApplicationRecord
|
||||
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||
|
||||
class NicoTagNormalisationError < ArgumentError
|
||||
;
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -25,6 +26,12 @@ RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
|
||||
expect(create_resource(resource).id).to eq(21)
|
||||
end
|
||||
|
||||
it "uses the current maximum after deleting earlier #{ resource } records" do
|
||||
create(:tag, id: 100).destroy!
|
||||
create(:external_tag, id: 200).destroy!
|
||||
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) }
|
||||
|
||||
@@ -10,8 +10,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||
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) }
|
||||
|
||||
@@ -32,20 +32,38 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
headers: headers
|
||||
end
|
||||
|
||||
it 'creates a description without implicitly creating Wiki records' do
|
||||
it 'creates a description with matching Wiki content and histories' do
|
||||
expect { create_description }
|
||||
.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(0)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
.and change(TagDescription, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
expect(description).to have_attributes(body: 'initial', version_no: 1)
|
||||
page = WikiPage.find_by!(tag_name:)
|
||||
expect(page.body).to eq(description.body)
|
||||
expect(page.current_revision.body).to eq(description.body)
|
||||
expect(page.wiki_versions.sole.body).to eq(description.body)
|
||||
expect(json.fetch('id')).to eq(description.id)
|
||||
expect(TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja'))
|
||||
.to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id)
|
||||
end
|
||||
|
||||
it 'reuses an existing compatibility page and synchronises its histories' do
|
||||
page = Wiki::Commit.create_legacy_content!(
|
||||
tag_name:, body: 'legacy', created_by_user: user)
|
||||
expect { create_description }
|
||||
.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
description = TagDescription.find_by!(tag_id: tag.id)
|
||||
expect(page.reload.body).to eq(description.body)
|
||||
expect(TagDescriptionVersion.find(page.current_revision.id).body).to eq(page.body)
|
||||
end
|
||||
|
||||
['missing_description', 'orphan_description'].each do |title|
|
||||
it "rejects the bare title #{ title } without writing any resource" do
|
||||
create(:tag_name, name: title) if title == 'orphan_description'
|
||||
@@ -70,7 +88,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
[TagDescription, TagDescriptionVersion].each do |model|
|
||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
|
||||
it "rolls back new description creation when #{ model.name } persistence fails" do
|
||||
before_state = stored_state
|
||||
aggregate_failures do
|
||||
@@ -81,6 +99,28 @@ 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
|
||||
@@ -144,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)
|
||||
|
||||
@@ -33,11 +33,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates the group and first snapshot without new Wiki records' do
|
||||
it 'creates the group, snapshot and matching compatibility histories' do
|
||||
expect { create_group }
|
||||
.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(0)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
.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' => 'タググループ:新規')
|
||||
@@ -45,6 +45,10 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
version = group_version(group.fetch('id'), 1)
|
||||
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
|
||||
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
|
||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' })
|
||||
expect(page.body).to eq('- 洗澡鹿')
|
||||
expect(page.current_revision.body).to eq(page.body)
|
||||
expect(page.wiki_versions.sole.body).to eq(page.body)
|
||||
end
|
||||
|
||||
it 'keeps repeated placements but only one membership and one snapshot member' do
|
||||
@@ -292,7 +296,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
end
|
||||
|
||||
tables = TagGroupRecords::GROUP_TABLES
|
||||
tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions'] if operation == :update
|
||||
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) }
|
||||
|
||||
@@ -71,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
|
||||
@@ -99,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')
|
||||
@@ -116,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
|
||||
|
||||
@@ -139,9 +140,9 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
||||
|
||||
expect do
|
||||
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
|
||||
end.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(0)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
end.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagGroup.find(id).name).to eq('改名')
|
||||
|
||||
@@ -391,18 +391,18 @@ RSpec.describe 'Tag group native API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates native state without adding compatibility Wiki records' do
|
||||
it 'creates native state and its compatibility histories' do
|
||||
expect { native_write(:create) }
|
||||
.to change(WikiPage, :count).by(0)
|
||||
.and change(WikiRevision, :count).by(0)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
.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
|
||||
|
||||
[:update].each do |operation|
|
||||
[:create, :update].each do |operation|
|
||||
it "synchronises Wiki current state and histories on native #{ operation }" do
|
||||
native_write(operation)
|
||||
expect(response).to have_http_status(operation == :create ? :created : :ok)
|
||||
|
||||
@@ -11,7 +11,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do
|
||||
it 'returns 409 when base_revision_id is stale' do
|
||||
page =
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: 'wiki_conflict_request'),
|
||||
tag_name: create(:tag, primary_name: 'wiki_conflict_request').tag_name('ja'),
|
||||
body: 'first',
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
@@ -26,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)
|
||||
|
||||
@@ -7,17 +7,21 @@ RSpec.describe 'Wiki creation freeze', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
|
||||
it 'updates an independent group without implicitly creating a legacy page' do
|
||||
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(0)
|
||||
.and change(WikiRevision, :count).by(0)
|
||||
.and change(WikiVersion, :count).by(0)
|
||||
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
|
||||
|
||||
@@ -11,7 +11,7 @@ RSpec.describe 'Wiki history integrity', type: :request do
|
||||
|
||||
def create_wiki_page title:, body: 'body', message: 'init', user: self.user
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: title),
|
||||
tag_name: create(:tag, primary_name: title).tag_name('ja'),
|
||||
body:,
|
||||
created_by_user: user,
|
||||
message:)
|
||||
|
||||
@@ -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
|
||||
@@ -129,7 +129,7 @@ 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
|
||||
seed_wiki_page(
|
||||
|
||||
@@ -9,29 +9,29 @@ RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
|
||||
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
|
||||
|
||||
include TagGroupRecords
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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
|
||||
end
|
||||
@@ -7,29 +7,37 @@ RSpec.describe Wiki::Commit do
|
||||
|
||||
def create_page title:, body: 'initial body'
|
||||
seed_wiki_page(
|
||||
tag_name: create(:tag_name, name: title),
|
||||
tag_name: create(:tag, primary_name: title).tag_name('ja'),
|
||||
body:,
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
end
|
||||
|
||||
describe '.create_content!' do
|
||||
[:create_content!, :create_legacy_content!].each do |operation|
|
||||
it "freezes #{ operation } without adding page or history" do
|
||||
name = create(:tag_name, name: 'Help:frozen_service')
|
||||
counts = [WikiPage.count, WikiRevision.count, WikiVersion.count]
|
||||
begin
|
||||
described_class.public_send(operation, tag_name: name, body: 'initial',
|
||||
created_by_user: user)
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
# A validation rejection is allowed; persistence is not.
|
||||
end
|
||||
expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts)
|
||||
end
|
||||
it 'rejects standalone Help creation without adding page or history' do
|
||||
name = create(:tag_name, name: 'Help:frozen_service')
|
||||
counts = [WikiPage.count, WikiRevision.count, WikiVersion.count]
|
||||
expect do
|
||||
described_class.create_content!(tag_name: name, body: 'initial', created_by_user: user)
|
||||
end.to raise_error(ActiveRecord::RecordInvalid)
|
||||
expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts)
|
||||
end
|
||||
|
||||
it 'keeps the internal compatibility writer available' do
|
||||
name = create(:tag_name, name: 'タググループ:compatibility_service')
|
||||
page = nil
|
||||
expect do
|
||||
page = described_class.create_legacy_content!(
|
||||
tag_name: name, body: 'initial', created_by_user: user)
|
||||
end.to change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
expect(page.current_revision.body).to eq('initial')
|
||||
expect(page.wiki_versions.sole.body).to eq('initial')
|
||||
end
|
||||
|
||||
it 'rejects body that becomes blank after normalisation' do
|
||||
tag_name = create(:tag_name, name: 'commit_integrity_blank')
|
||||
tag_name = create(:tag, primary_name: 'commit_integrity_blank').tag_name('ja')
|
||||
|
||||
expect {
|
||||
described_class.create_content!(
|
||||
@@ -45,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
|
||||
|
||||
@@ -5,7 +5,7 @@ RSpec.describe Wiki::Commit do
|
||||
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')
|
||||
|
||||
seed_wiki_page(
|
||||
tag_name:,
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする