このコミットが含まれているのは:
@@ -9,7 +9,8 @@ class WikiPagesController < ApplicationController
|
|||||||
TagDescription.where(language_code: 'ja')
|
TagDescription.where(language_code: 'ja')
|
||||||
.includes(tag: :tag_names)
|
.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?
|
if title.present?
|
||||||
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
|
||||||
descriptions =
|
descriptions =
|
||||||
@@ -22,7 +23,9 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
||||||
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
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?
|
json = json.first(20) if title.present?
|
||||||
render json:
|
render json:
|
||||||
end
|
end
|
||||||
@@ -99,7 +102,7 @@ class WikiPagesController < ApplicationController
|
|||||||
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
||||||
'deprecated_at' => nil }
|
'deprecated_at' => nil }
|
||||||
else
|
else
|
||||||
raise 'お前やりませんねぇスギ!'
|
return head :not_found
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -141,7 +144,7 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
page =
|
page =
|
||||||
if title.include?(':')
|
if title.include?(':')
|
||||||
raise 'お前やりませんねぇスギ!'
|
return render_unprocessable_entity field: :title
|
||||||
else
|
else
|
||||||
tag_name = TagName.find_by(language_code: 'ja', name: title)
|
tag_name = TagName.find_by(language_code: 'ja', name: title)
|
||||||
return render_unprocessable_entity field: :title unless tag_name&.tag
|
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:)
|
tag: tag_name.tag, body:, created_by_user: current_user, message:)
|
||||||
end
|
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
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
render_validation_error e.record
|
render_validation_error e.record
|
||||||
rescue ActiveRecord::RecordNotUnique
|
rescue ActiveRecord::RecordNotUnique
|
||||||
@@ -180,44 +184,34 @@ class WikiPagesController < ApplicationController
|
|||||||
return head :ok
|
return head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
page = WikiPage.find_by(id: params[:id])
|
return render_unprocessable_entity field: :title if title.include?(':')
|
||||||
if legacy_group_page?(page)
|
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
|
return render_unprocessable_entity field: :title
|
||||||
end
|
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
|
base_revision_id = params[:base_revision_id].presence
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
page.lock!
|
tag.lock!
|
||||||
|
old_title = tag_name.name
|
||||||
old_title = page.title
|
if title != old_title
|
||||||
|
|
||||||
tag = page.tag_name.tag
|
|
||||||
|
|
||||||
if tag && title != old_title
|
|
||||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
page.tag_name.update!(name: title) if title != old_title
|
tag_name.update!(name: title) if title != old_title
|
||||||
|
|
||||||
message = params[:message].presence
|
message = params[:message].presence
|
||||||
description = TagDescription.find_by(id: page.id)
|
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user,
|
||||||
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:)
|
message:, base_revision_id:)
|
||||||
else
|
|
||||||
Wiki::Commit.content!(page:, body:, created_user: current_user,
|
|
||||||
message:, base_revision_id:)
|
|
||||||
end
|
|
||||||
|
|
||||||
if tag && title != old_title
|
if title != old_title
|
||||||
tag.reload
|
tag.reload
|
||||||
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
@@ -306,6 +300,7 @@ class WikiPagesController < ApplicationController
|
|||||||
updated_at = version.created_at
|
updated_at = version.created_at
|
||||||
render json: {
|
render json: {
|
||||||
'id' => version.tag_group_id,
|
'id' => version.tag_group_id,
|
||||||
|
'resource_type' => 'tag_group',
|
||||||
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
|
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
|
||||||
'deprecated_at' => nil,
|
'deprecated_at' => nil,
|
||||||
'body' => body,
|
'body' => body,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class ExternalTag < ApplicationRecord
|
class ExternalTag < ApplicationRecord
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
enum :platform, nico: 'nico'
|
enum :platform, nico: 'nico'
|
||||||
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
|
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ require 'set'
|
|||||||
|
|
||||||
|
|
||||||
class Tag < ApplicationRecord
|
class Tag < ApplicationRecord
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
class NicoTagNormalisationError < ArgumentError
|
class NicoTagNormalisationError < ArgumentError
|
||||||
;
|
;
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
class TagDescription < ApplicationRecord
|
class TagDescription < ApplicationRecord
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
belongs_to :tag
|
belongs_to :tag
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class TagGroup < ApplicationRecord
|
class TagGroup < ApplicationRecord
|
||||||
|
before_create { SharedResourceIdAllocator.assign!(self) }
|
||||||
|
|
||||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||||
belongs_to :updated_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
|
def tag_description description
|
||||||
tag = description.tag
|
tag = description.tag
|
||||||
{ 'id' => description.id,
|
{ 'id' => description.id,
|
||||||
|
'resource_type' => 'tag_description',
|
||||||
'tag_name_id' => tag.tag_name('ja')&.id,
|
'tag_name_id' => tag.tag_name('ja')&.id,
|
||||||
'body' => description.body,
|
'body' => description.body,
|
||||||
'created_user_id' => description.created_by_user_id,
|
'created_user_id' => description.created_by_user_id,
|
||||||
@@ -26,7 +27,7 @@ module WikiPageRepr
|
|||||||
end
|
end
|
||||||
|
|
||||||
def tag_group tag_group
|
def tag_group tag_group
|
||||||
TagGroupRepr.tag_group(tag_group)
|
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
|
||||||
end
|
end
|
||||||
|
|
||||||
def many wiki_pages
|
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)
|
tag_id: tag.id, language_code: LANGUAGE_CODE)
|
||||||
|
|
||||||
page = Wiki::CompatibilityWriter.create!(
|
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
|
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)
|
description: nil)
|
||||||
page
|
page
|
||||||
end
|
end
|
||||||
@@ -22,7 +22,20 @@ class TagDescriptionOperation
|
|||||||
tag.lock!
|
tag.lock!
|
||||||
description = TagDescription.lock.find_by!(
|
description = TagDescription.lock.find_by!(
|
||||||
tag_id: tag.id, language_code: LANGUAGE_CODE)
|
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!(
|
revision = Wiki::CompatibilityWriter.update!(
|
||||||
page:, body:, updated_by_user:, message:,
|
page:, body:, updated_by_user:, message:,
|
||||||
base_revision_id:)
|
base_revision_id:)
|
||||||
@@ -34,6 +47,11 @@ class TagDescriptionOperation
|
|||||||
end
|
end
|
||||||
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:
|
def self.record_version! tag:, revision:, body:, user:, description:
|
||||||
version_no = description ? description.version_no + 1 : 1
|
version_no = description ? description.version_no + 1 : 1
|
||||||
TagDescriptionVersion.create!(
|
TagDescriptionVersion.create!(
|
||||||
@@ -44,7 +62,7 @@ class TagDescriptionOperation
|
|||||||
description.update!(body:, version_no:, updated_by_user_id: user.id)
|
description.update!(body:, version_no:, updated_by_user_id: user.id)
|
||||||
else
|
else
|
||||||
TagDescription.create!(
|
TagDescription.create!(
|
||||||
id: revision.wiki_page_id, tag_id: tag.id,
|
tag_id: tag.id,
|
||||||
language_code: LANGUAGE_CODE, version_no:, body:,
|
language_code: LANGUAGE_CODE, version_no:, body:,
|
||||||
created_by_user_id: user.id, updated_by_user_id: user.id)
|
created_by_user_id: user.id, updated_by_user_id: user.id)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ class TagGroupOperation
|
|||||||
tag_name.primary_flg = true
|
tag_name.primary_flg = true
|
||||||
tag_name.auto_generated = false
|
tag_name.auto_generated = false
|
||||||
tag_name.save!
|
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
|
end
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ module Wiki
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.create_content! tag_name:, body:, created_by_user:, message: nil
|
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!(
|
return TagDescriptionOperation.create!(
|
||||||
tag: tag_name.tag, body:, created_by_user:, message:)
|
tag: tag_name.tag, body:, created_by_user:, message:)
|
||||||
end
|
end
|
||||||
|
|
||||||
create_legacy_content!(tag_name:, body:, created_by_user:, message:)
|
tag_name.errors.add(:base, :invalid)
|
||||||
|
raise ActiveRecord::RecordInvalid, tag_name
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil
|
def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil
|
||||||
@@ -42,14 +43,17 @@ module Wiki
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
|
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
|
||||||
description = TagDescription.find_by(id: page.id)
|
description = TagDescription.find_by(tag_id: page.tag_name.tag_id, language_code: 'ja')
|
||||||
if description
|
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!(
|
return TagDescriptionOperation.update!(
|
||||||
tag: description.tag, body:, updated_by_user: created_user,
|
tag: description.tag, body:, updated_by_user: created_user,
|
||||||
message:, base_revision_id:)
|
message:, base_revision_id:)
|
||||||
end
|
end
|
||||||
|
|
||||||
legacy_content!(page:, body:, created_user:, message:, base_revision_id:)
|
page.errors.add(:base, :invalid)
|
||||||
|
raise ActiveRecord::RecordInvalid, page
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.legacy_content! page:, body:, created_user:, message: nil,
|
def self.legacy_content! page:, body:, created_user:, message: nil,
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
module Wiki
|
module Wiki
|
||||||
class CompatibilityWriter
|
class CompatibilityWriter
|
||||||
def self.create! tag_name:, body:, created_by_user:, message: nil
|
def self.create! tag_name:, body:, created_by_user:, message: nil
|
||||||
Commit.create_legacy_content!(
|
ApplicationRecord.transaction do
|
||||||
tag_name:, body:, created_by_user:, message:)
|
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
|
end
|
||||||
|
|
||||||
def self.update! page:, body:, updated_by_user:, message: nil,
|
def self.update! page:, body:, updated_by_user:, message: nil,
|
||||||
@@ -23,7 +31,9 @@ module Wiki
|
|||||||
end
|
end
|
||||||
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!(
|
Commit.legacy_content!(
|
||||||
page:, body: normalised, created_user: updated_by_user, message:,
|
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
|
it 'preserves every revision ID and repeated body in an independent sequence' do
|
||||||
user = create_member_user!
|
user = create_member_user!
|
||||||
tag_name = create(:tag_name, name: 'description_migration')
|
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)
|
tag_name:, body: 'A', created_by_user: user)
|
||||||
revisions = [page.current_revision]
|
revisions = [page.current_revision]
|
||||||
['A', 'B', 'B', 'A'].each do |body|
|
['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)
|
page:, body:, created_user: user, base_revision_id: revisions.last.id)
|
||||||
end
|
end
|
||||||
tag = create(:tag, primary_tag_name: tag_name)
|
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
|
RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
|
||||||
include TagGroupRecords
|
include TagGroupRecords
|
||||||
|
let(:user) { create_member_user! }
|
||||||
|
|
||||||
def create_resource resource
|
def create_resource resource
|
||||||
if resource == :tag
|
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)
|
expect(create_resource(resource).id).to eq(21)
|
||||||
end
|
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
|
it "preserves legacy overlaps and gaps when creating #{ resource }" do
|
||||||
[1, 2, 5].each { create(:tag, id: _1) }
|
[1, 2, 5].each { create(:tag, id: _1) }
|
||||||
[1, 3, 8].each { create(:external_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
|
end
|
||||||
let!(:revisions) do
|
let!(:revisions) do
|
||||||
first = page.current_revision
|
first = page.current_revision
|
||||||
second = Wiki::Commit.content!(page:, body: 'old-second', created_user: user)
|
second = Wiki::Commit.legacy_content!(page:, body: 'old-second', created_user: user)
|
||||||
third = Wiki::Commit.content!(page:, body: 'old-third', created_user: user)
|
third = Wiki::Commit.legacy_content!(page:, body: 'old-third', created_user: user)
|
||||||
[first, second, third]
|
[first, second, third]
|
||||||
end
|
end
|
||||||
let!(:tag) { create(:tag, primary_tag_name: tag_name) }
|
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
|
headers: headers
|
||||||
end
|
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 }
|
expect { create_description }
|
||||||
.to change(WikiPage, :count).by(0)
|
.to change(WikiPage, :count).by(1)
|
||||||
.and change(WikiRevision, :count).by(0)
|
.and change(WikiRevision, :count).by(1)
|
||||||
.and change(WikiVersion, :count).by(0)
|
.and change(WikiVersion, :count).by(1)
|
||||||
.and change(TagDescription, :count).by(1)
|
.and change(TagDescription, :count).by(1)
|
||||||
.and change(TagDescriptionVersion, :count).by(1)
|
.and change(TagDescriptionVersion, :count).by(1)
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||||
expect(description).to have_attributes(body: 'initial', version_no: 1)
|
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'))
|
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)
|
.to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id)
|
||||||
end
|
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|
|
['missing_description', 'orphan_description'].each do |title|
|
||||||
it "rejects the bare title #{ title } without writing any resource" do
|
it "rejects the bare title #{ title } without writing any resource" do
|
||||||
create(:tag_name, name: title) if title == 'orphan_description'
|
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
|
||||||
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
|
it "rolls back new description creation when #{ model.name } persistence fails" do
|
||||||
before_state = stored_state
|
before_state = stored_state
|
||||||
aggregate_failures do
|
aggregate_failures do
|
||||||
@@ -81,6 +99,28 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'creates missing compatibility data without writing an ID-colliding Help page' do
|
||||||
|
description = TagDescription.create!(
|
||||||
|
id: 500, tag:, language_code: 'ja', body: 'before', version_no: 1)
|
||||||
|
help = WikiPage.create!(id: description.id,
|
||||||
|
tag_name: create(:tag_name, name: 'Help:unrelated'),
|
||||||
|
body: 'unrelated', created_user: user, updated_user: user)
|
||||||
|
Wiki::Commit.legacy_content!(page: help, body: help.body, created_user: user)
|
||||||
|
before_help = help.attributes
|
||||||
|
expect do
|
||||||
|
put "/wiki/#{ description.id }", params: { title: tag_name.name, body: 'after' },
|
||||||
|
headers: headers
|
||||||
|
end.to change(WikiPage, :count).by(1)
|
||||||
|
.and change(WikiRevision, :count).by(1)
|
||||||
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(description.reload).to have_attributes(id: 500, body: 'after')
|
||||||
|
expect(help.reload.attributes).to eq(before_help)
|
||||||
|
compatibility = WikiPage.find_by!(tag_name:)
|
||||||
|
expect(compatibility.body).to eq(description.body)
|
||||||
|
expect(compatibility.current_revision.body).to eq(description.body)
|
||||||
|
end
|
||||||
|
|
||||||
describe 'PUT /wiki/:id' do
|
describe 'PUT /wiki/:id' do
|
||||||
let!(:page) do
|
let!(:page) do
|
||||||
tag
|
tag
|
||||||
@@ -144,10 +184,10 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
|||||||
page_state = page.attributes
|
page_state = page.attributes
|
||||||
description_state = description.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(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(WikiRevision.count).to eq(revision_count)
|
||||||
expect(WikiVersion.count).to eq(wiki_version_count)
|
expect(WikiVersion.count).to eq(wiki_version_count)
|
||||||
expect(TagDescriptionVersion.count).to eq(description_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
|
||||||
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 }
|
expect { create_group }
|
||||||
.to change(WikiPage, :count).by(0)
|
.to change(WikiPage, :count).by(1)
|
||||||
.and change(WikiRevision, :count).by(0)
|
.and change(WikiRevision, :count).by(1)
|
||||||
.and change(WikiVersion, :count).by(0)
|
.and change(WikiVersion, :count).by(1)
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
group = group_rows('tag_groups', name: '新規').sole
|
group = group_rows('tag_groups', name: '新規').sole
|
||||||
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
|
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)
|
version = group_version(group.fetch('id'), 1)
|
||||||
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
|
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)
|
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
|
end
|
||||||
|
|
||||||
it 'keeps repeated placements but only one membership and one snapshot member' do
|
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
|
end
|
||||||
|
|
||||||
tables = TagGroupRecords::GROUP_TABLES
|
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|
|
tables.each do |table|
|
||||||
it "rolls back all writes after a real #{ table } write fails" do
|
it "rolls back all writes after a real #{ table } write fails" do
|
||||||
expect_atomic_group_write(table) { write_group(operation, headers) }
|
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('- 伊地知ニジカ')
|
expect(page.body).to eq('- 伊地知ニジカ')
|
||||||
end
|
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')
|
page = create_help('Help body')
|
||||||
id = seed_group(name: '別グループ', id: page.id, members:, placements:)
|
id = seed_group(name: '別グループ', id: page.id, members:, placements:)
|
||||||
group_before = TagGroup.find(id).attributes
|
group_before = TagGroup.find(id).attributes
|
||||||
version_before = group_version(id, 1)
|
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 body')
|
||||||
expect(page.reload.body).to eq('Help after')
|
expect(page.title).to eq('Help:既存')
|
||||||
expect(page.title).to eq('Help:改名')
|
|
||||||
expect(TagGroup.find(id).attributes).to eq(group_before)
|
expect(TagGroup.find(id).attributes).to eq(group_before)
|
||||||
expect(group_version(id, 1)).to eq(version_before)
|
expect(group_version(id, 1)).to eq(version_before)
|
||||||
end
|
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
|
it 'keeps a description update separate from a colliding group' do
|
||||||
page = Wiki::Commit.create_content!(
|
page = Wiki::Commit.create_content!(
|
||||||
tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user)
|
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
|
group_before = TagGroup.find(id).attributes
|
||||||
|
|
||||||
put_wiki(id, title: '洗澡鹿', body: 'description after')
|
put_wiki(id, title: '洗澡鹿', body: 'description after')
|
||||||
@@ -116,18 +116,19 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
|||||||
description = TagDescription.create!(
|
description = TagDescription.create!(
|
||||||
id:, tag:, language_code: 'ja', version_no: 1, body: 'description')
|
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 before')
|
||||||
expect(page.reload.body).to eq('Help after')
|
|
||||||
expect(description.reload.body).to eq('description')
|
expect(description.reload.body).to eq('description')
|
||||||
expect(TagGroup.find(id).version_no).to eq(1)
|
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: 'タググループ:更新')
|
put_wiki(id, title: 'タググループ:更新')
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(TagGroup.find(id).version_no).to eq(2)
|
expect(TagGroup.find(id).version_no).to eq(2)
|
||||||
expect(page.reload.body).to eq('Help after')
|
expect(page.reload.body).to eq('Help before')
|
||||||
expect(description.reload.body).to eq('description')
|
expect(description.reload.body).to eq('description after')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -139,9 +140,9 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
|||||||
|
|
||||||
expect do
|
expect do
|
||||||
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
|
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
|
||||||
end.to change(WikiPage, :count).by(0)
|
end.to change(WikiPage, :count).by(1)
|
||||||
.and change(WikiRevision, :count).by(0)
|
.and change(WikiRevision, :count).by(1)
|
||||||
.and change(WikiVersion, :count).by(0)
|
.and change(WikiVersion, :count).by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(TagGroup.find(id).name).to eq('改名')
|
expect(TagGroup.find(id).name).to eq('改名')
|
||||||
|
|||||||
@@ -391,18 +391,18 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
end
|
end
|
||||||
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) }
|
expect { native_write(:create) }
|
||||||
.to change(WikiPage, :count).by(0)
|
.to change(WikiPage, :count).by(1)
|
||||||
.and change(WikiRevision, :count).by(0)
|
.and change(WikiRevision, :count).by(1)
|
||||||
.and change(WikiVersion, :count).by(0)
|
.and change(WikiVersion, :count).by(1)
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
expect_group_snapshot(
|
expect_group_snapshot(
|
||||||
json.fetch('id'), number: 1, members: snapshot_members,
|
json.fetch('id'), number: 1, members: snapshot_members,
|
||||||
placements:, description: '全体の説明')
|
placements:, description: '全体の説明')
|
||||||
end
|
end
|
||||||
|
|
||||||
[:update].each do |operation|
|
[:create, :update].each do |operation|
|
||||||
it "synchronises Wiki current state and histories on native #{ operation }" do
|
it "synchronises Wiki current state and histories on native #{ operation }" do
|
||||||
native_write(operation)
|
native_write(operation)
|
||||||
expect(response).to have_http_status(operation == :create ? :created : :ok)
|
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
|
it 'returns 409 when base_revision_id is stale' do
|
||||||
page =
|
page =
|
||||||
seed_wiki_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',
|
body: 'first',
|
||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
@@ -26,12 +26,10 @@ RSpec.describe 'Wiki conflict handling', type: :request do
|
|||||||
base_revision_id: stale_id)
|
base_revision_id: stale_id)
|
||||||
|
|
||||||
put "/wiki/#{ page.id }",
|
put "/wiki/#{ page.id }",
|
||||||
params: {
|
params: { title: 'wiki_conflict_request',
|
||||||
title: 'wiki_conflict_request',
|
|
||||||
body: 'third',
|
body: 'third',
|
||||||
message: 'stale',
|
message: 'stale',
|
||||||
base_revision_id: stale_id,
|
base_revision_id: stale_id },
|
||||||
},
|
|
||||||
headers: auth_headers(user)
|
headers: auth_headers(user)
|
||||||
|
|
||||||
expect(response).to have_http_status(:conflict)
|
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!(:user) { create_member_user! }
|
||||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
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')
|
id = seed_group(name: 'independent', description: 'before')
|
||||||
expect do
|
expect do
|
||||||
put "/tag_groups/#{ id }", params: {
|
put "/tag_groups/#{ id }", params: {
|
||||||
name: 'independent', description: 'after', members: [], placements: [] },
|
name: 'independent', description: 'after', members: [], placements: [] },
|
||||||
headers:, as: :json
|
headers:, as: :json
|
||||||
end.to change(WikiPage, :count).by(0)
|
end.to change(WikiPage, :count).by(1)
|
||||||
.and change(WikiRevision, :count).by(0)
|
.and change(WikiRevision, :count).by(1)
|
||||||
.and change(WikiVersion, :count).by(0)
|
.and change(WikiVersion, :count).by(1)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(TagGroup.find(id).description).to eq('after')
|
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
|
end
|
||||||
|
|
||||||
context 'existing description write protection' do
|
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
|
def create_wiki_page title:, body: 'body', message: 'init', user: self.user
|
||||||
seed_wiki_page(
|
seed_wiki_page(
|
||||||
tag_name: create(:tag_name, name: title),
|
tag_name: create(:tag, primary_name: title).tag_name('ja'),
|
||||||
body:,
|
body:,
|
||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
message:)
|
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 }
|
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||||
end
|
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
|
let!(:page) do
|
||||||
seed_wiki_page(
|
seed_wiki_page(
|
||||||
|
|||||||
@@ -9,29 +9,29 @@ RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
|
|||||||
end
|
end
|
||||||
let!(:first_revision) { page.current_revision }
|
let!(:first_revision) { page.current_revision }
|
||||||
let!(:last_revision) do
|
let!(:last_revision) do
|
||||||
Wiki::Commit.content!(page:, body: 'after', created_user: user,
|
Wiki::Commit.legacy_content!(page:, body: 'after', created_user: user,
|
||||||
message: 'legacy edit', base_revision_id: first_revision.id)
|
message: 'legacy edit', base_revision_id: first_revision.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
before { expect(TagDescription.exists?(id: page.id)).to be(false) }
|
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)
|
create(:tag, primary_tag_name: tag_name)
|
||||||
|
original = page.reload.attributes
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
put "/wiki/#{ page.id }",
|
put "/wiki/#{ page.id }",
|
||||||
params: { title: 'legacy_tag_renamed', body: 'changed legacy body',
|
params: { title: 'legacy_tag_renamed', body: 'changed legacy body',
|
||||||
message: 'legacy rename', base_revision_id: last_revision.id },
|
message: 'legacy rename', base_revision_id: last_revision.id },
|
||||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||||
end.to change(WikiRevision, :count).by(1)
|
end.to change(WikiRevision, :count).by(0)
|
||||||
.and change(WikiVersion, :count).by(1)
|
.and change(WikiVersion, :count).by(0)
|
||||||
.and change(TagDescription, :count).by(0)
|
.and change(TagDescription, :count).by(0)
|
||||||
.and change(TagDescriptionVersion, :count).by(0)
|
.and change(TagDescriptionVersion, :count).by(0)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
expect(page.reload).to have_attributes(body: 'changed legacy body')
|
expect(page.reload.attributes).to eq(original)
|
||||||
expect(page.title).to eq('legacy_tag_renamed')
|
expect(page.current_revision).to have_attributes(message: 'legacy edit')
|
||||||
expect(page.current_revision).to have_attributes(message: 'legacy rename')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
include TagGroupRecords
|
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'
|
def create_page title:, body: 'initial body'
|
||||||
seed_wiki_page(
|
seed_wiki_page(
|
||||||
tag_name: create(:tag_name, name: title),
|
tag_name: create(:tag, primary_name: title).tag_name('ja'),
|
||||||
body:,
|
body:,
|
||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
message: 'init')
|
message: 'init')
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '.create_content!' do
|
describe '.create_content!' do
|
||||||
[:create_content!, :create_legacy_content!].each do |operation|
|
it 'rejects standalone Help creation without adding page or history' do
|
||||||
it "freezes #{ operation } without adding page or history" do
|
|
||||||
name = create(:tag_name, name: 'Help:frozen_service')
|
name = create(:tag_name, name: 'Help:frozen_service')
|
||||||
counts = [WikiPage.count, WikiRevision.count, WikiVersion.count]
|
counts = [WikiPage.count, WikiRevision.count, WikiVersion.count]
|
||||||
begin
|
expect do
|
||||||
described_class.public_send(operation, tag_name: name, body: 'initial',
|
described_class.create_content!(tag_name: name, body: 'initial', created_by_user: user)
|
||||||
created_by_user: user)
|
end.to raise_error(ActiveRecord::RecordInvalid)
|
||||||
rescue ActiveRecord::RecordInvalid
|
|
||||||
# A validation rejection is allowed; persistence is not.
|
|
||||||
end
|
|
||||||
expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts)
|
expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
it 'rejects body that becomes blank after normalisation' do
|
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 {
|
expect {
|
||||||
described_class.create_content!(
|
described_class.create_content!(
|
||||||
@@ -45,6 +53,17 @@ RSpec.describe Wiki::Commit do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '.content!' do
|
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
|
it 'updates page body, revision, and version' do
|
||||||
page = create_page(title: 'commit_integrity_update', body: 'before')
|
page = create_page(title: 'commit_integrity_update', body: 'before')
|
||||||
current_id = page.current_revision.id
|
current_id = page.current_revision.id
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ RSpec.describe Wiki::Commit do
|
|||||||
let(:user) { create_member_user! }
|
let(:user) { create_member_user! }
|
||||||
|
|
||||||
def create_page(title: 'commit_spec_page', body: 'initial body')
|
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(
|
seed_wiki_page(
|
||||||
tag_name:,
|
tag_name:,
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ const validateEdition = (edition: string): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const generateHelpAssets = async (root: string) => {
|
export const generateHelpAssets = async (
|
||||||
|
root: string,
|
||||||
|
): Promise<{ modules: Map<string, string>; files: string[] }> => {
|
||||||
const modules = new Map<string, string> ()
|
const modules = new Map<string, string> ()
|
||||||
const files: string[] = [root]
|
const files: string[] = [root]
|
||||||
const articles: Record<string, Article> = Object.create (null)
|
const articles: Record<string, Article> = Object.create (null)
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ export const menuOutline = (
|
|||||||
{ name: '検索', to: '/help' },
|
{ name: '検索', to: '/help' },
|
||||||
{ name: '全体履歴', to: '/help/changes' },
|
{ name: '全体履歴', to: '/help/changes' },
|
||||||
{ component: <Separator/>, visible: helpVsbl },
|
{ component: <Separator/>, visible: helpVsbl },
|
||||||
{ name: '履歴', to: `/help/changes?identifier=${ helpIdentifier }`, visible: helpVsbl }] },
|
{ name: '履歴',
|
||||||
|
to: `/help/changes?identifier=${ helpIdentifier }`,
|
||||||
|
visible: helpVsbl }] },
|
||||||
{ name: 'おたのしみ', visible: false, subMenu: [
|
{ name: 'おたのしみ', visible: false, subMenu: [
|
||||||
{ name: '上映会 (β)', to: '/theatres/1' },
|
{ name: '上映会 (β)', to: '/theatres/1' },
|
||||||
{ name: 'グカネータ (β)', to: '/gekanator' }] },
|
{ name: 'グカネータ (β)', to: '/gekanator' }] },
|
||||||
|
|||||||
+25
-18
@@ -6,51 +6,58 @@ import { createHelpFixture } from '@/test/helpFixtures'
|
|||||||
describe ('Help file catalogue', () => {
|
describe ('Help file catalogue', () => {
|
||||||
it ('uses current_edition rather than the newest file, without YAML titles', async () => {
|
it ('uses current_edition rather than the newest file, without YAML titles', async () => {
|
||||||
const { api } = createHelpFixture ()
|
const { api } = createHelpFixture ()
|
||||||
expect (api.getHelpArticle ('guide')?.languages.ja.currentEdition).toBe ('2026-10-02')
|
expect ((await api.getHelpArticle ('guide'))?.languages.ja.currentEdition)
|
||||||
|
.toBe ('2026-10-02.10')
|
||||||
expect (await api.getHelpSearchEntries ('ja')).toEqual ([
|
expect (await api.getHelpSearchEntries ('ja')).toEqual ([
|
||||||
{ identifier: 'guide', edition: '2026-10-02',
|
{ identifier: 'guide', edition: '2026-10-02.10',
|
||||||
title: 'ホーム', body: '\nタグを検索する' },
|
title: 'ホーム第十版', body: '\nタグを検索する 第十版本文' },
|
||||||
{ identifier: '設定 手順', edition: '2026-10-03',
|
{ identifier: '設定 手順', edition: '2026-10-03',
|
||||||
title: '設定', body: '\nタグを設定する' }])
|
title: '設定', body: '\nタグを設定する' }])
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('orders same-day editions numerically and separates languages', () => {
|
it ('orders published editions numerically and separates languages', async () => {
|
||||||
const { api } = createHelpFixture ()
|
const { api } = createHelpFixture ()
|
||||||
expect (api.getHelpEditions ('guide', 'ja')).toEqual ([
|
expect (await api.getHelpEditions ('guide', 'ja')).toEqual ([
|
||||||
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'])
|
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'])
|
||||||
expect (api.getHelpEditions ('guide', 'en')).toEqual (['2026-10-02'])
|
expect (await api.getHelpEditions ('guide', 'en')).toEqual (['2026-10-02'])
|
||||||
|
expect (await api.getHelpPublishedEditions ('guide', 'ja'))
|
||||||
|
.not.toContain ('2026-10-02.11')
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('derives each edition title from its first H1', async () => {
|
it ('uses generated edition titles without reparsing raw sources', async () => {
|
||||||
const { api, raw } = createHelpFixture ()
|
const { api, raw } = createHelpFixture ()
|
||||||
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01'))
|
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01'))
|
||||||
.toEqual ({ title: '旧ホーム', body: '\n過去限定本文' })
|
.toEqual ({ title: '旧ホーム', body: '\n過去限定本文' })
|
||||||
raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||||
.mockResolvedValue ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し')
|
.mockResolvedValue ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し')
|
||||||
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-02'))
|
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-02'))
|
||||||
.toEqual ({ title: '改題', body: '\r\n本文\r\n# 別見出し' })
|
.toEqual ({ title: 'ホーム', body: '\nタグを検索する' })
|
||||||
|
expect (raw['../../help/guide/editions/ja/2026-10-02.mdx']).not.toHaveBeenCalled ()
|
||||||
|
expect ((await api.getHelpEditionSource ('guide', 'ja', '2026-10-02'))?.source)
|
||||||
|
.toBe ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し')
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('returns missing resources without substituting another edition', async () => {
|
it ('returns missing resources without substituting another edition', async () => {
|
||||||
const { api } = createHelpFixture ()
|
const { api } = createHelpFixture ()
|
||||||
expect (api.getHelpArticle ('absent')).toBeNull ()
|
expect (await api.getHelpArticle ('absent')).toBeNull ()
|
||||||
expect (api.getHelpEditions ('absent', 'ja')).toEqual ([])
|
expect (await api.getHelpEditions ('absent', 'ja')).toEqual ([])
|
||||||
expect (await api.getHelpEdition ('guide', 'ja', 'missing')).toBeNull ()
|
expect (await api.getHelpEdition ('guide', 'ja', 'missing')).toBeNull ()
|
||||||
expect (await api.getHelpEditionText ('guide', 'fr', '2026-10-02')).toBeNull ()
|
expect (await api.getHelpEditionText ('guide', 'fr', '2026-10-02')).toBeNull ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('rejects a missing current file instead of silently hiding an article', async () => {
|
it ('reports generated search asset failures instead of hiding articles', async () => {
|
||||||
const { api, raw } = createHelpFixture ()
|
const { api, loads } = createHelpFixture ()
|
||||||
delete raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
loads.search.mockImplementationOnce (() => { throw new Error ('asset failure') })
|
||||||
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ()
|
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ('asset failure')
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('reports raw loading failures and missing leading H1', async () => {
|
it ('reports raw loading failures and excludes unpublished editions', async () => {
|
||||||
const { api, raw } = createHelpFixture ()
|
const { api, raw } = createHelpFixture ()
|
||||||
const loader = raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
const loader = raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
||||||
loader.mockRejectedValueOnce (new Error ('fixture failure'))
|
loader.mockRejectedValueOnce (new Error ('fixture failure'))
|
||||||
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ('fixture failure')
|
await expect (api.getHelpEditionSource ('guide', 'ja', '2026-10-02'))
|
||||||
loader.mockResolvedValueOnce ('本文\n# 遅い見出し')
|
.rejects.toThrow ('fixture failure')
|
||||||
await expect (api.getHelpEditionText ('guide', 'ja', '2026-10-02')).rejects.toThrow ()
|
expect (await api.getHelpEdition ('guide', 'ja', '2026-10-02.11')).toBeNull ()
|
||||||
|
expect (await api.getHelpEditionSource ('guide', 'ja', '2026-10-02.11')).toBeNull ()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ const Location = () => {
|
|||||||
|
|
||||||
describe ('Help prefetch integration', () => {
|
describe ('Help prefetch integration', () => {
|
||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
|
helpFixture.reset ()
|
||||||
settings.linkPreload = 'intent'
|
settings.linkPreload = 'intent'
|
||||||
vi.clearAllMocks ()
|
vi.clearAllMocks ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each (['/help', '/help/guide', '/help/changes',
|
it.each (['/help', '/help?title=ホーム&text=検索&page=2', '/help/guide', '/help/changes',
|
||||||
|
'/help/guide/diff?from=2026-10-01&to=2026-10-02.10',
|
||||||
'/help/%E8%A8%AD%E5%AE%9A%20%E6%89%8B%E9%A0%86?edition=2026-10-03']) (
|
'/help/%E8%A8%AD%E5%AE%9A%20%E6%89%8B%E9%A0%86?edition=2026-10-03']) (
|
||||||
'registers a unique prefetcher for %s', path => {
|
'registers a unique prefetcher for %s', path => {
|
||||||
const url = new URL (path, window.location.origin)
|
const url = new URL (path, window.location.origin)
|
||||||
@@ -43,13 +45,13 @@ describe ('Help prefetch integration', () => {
|
|||||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</>)
|
</>)
|
||||||
const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.mdx']
|
const loader = helpFixture.moduleLoads['../../help/guide/editions/ja/2026-10-02.10.mdx']
|
||||||
await Promise.all ([
|
await Promise.all ([
|
||||||
prefetchers.prefetchForURL (queryClient, '/help/guide'),
|
prefetchers.prefetchForURL (queryClient, '/help/guide'),
|
||||||
prefetchers.prefetchForURL (queryClient, '/help/guide')])
|
prefetchers.prefetchForURL (queryClient, '/help/guide')])
|
||||||
expect (loader).toHaveBeenCalledTimes (1)
|
expect (loader).toHaveBeenCalledTimes (1)
|
||||||
fireEvent.click (screen.getByRole ('link', { name: 'Help' }))
|
fireEvent.click (screen.getByRole ('link', { name: 'Help' }))
|
||||||
await screen.findByRole ('heading', { name: 'ホーム' })
|
await screen.findByRole ('heading', { name: 'ホーム第十版' })
|
||||||
expect (loader).toHaveBeenCalledTimes (1)
|
expect (loader).toHaveBeenCalledTimes (1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -69,11 +71,12 @@ describe ('Help prefetch integration', () => {
|
|||||||
const article = vi.spyOn (helpFixture.api, 'getHelpArticle')
|
const article = vi.spyOn (helpFixture.api, 'getHelpArticle')
|
||||||
await prefetchers.prefetchForURL (new QueryClient (), '/help/changes')
|
await prefetchers.prefetchForURL (new QueryClient (), '/help/changes')
|
||||||
expect (article).not.toHaveBeenCalledWith ('changes')
|
expect (article).not.toHaveBeenCalledWith ('changes')
|
||||||
expect (helpFixture.raw['../../help/guide/editions/ja/2026-10-01.mdx'])
|
expect (helpFixture.loads.changes).toHaveBeenCalledTimes (1)
|
||||||
.toHaveBeenCalled ()
|
Object.values (helpFixture.raw).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||||
|
Object.values (helpFixture.modules).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each (['intent', 'off', 'click']) ('respects %s intent settings and still prefetches clicks',
|
it.each (['intent', 'off']) ('respects %s intent settings and still prefetches clicks',
|
||||||
async mode => {
|
async mode => {
|
||||||
settings.linkPreload = mode
|
settings.linkPreload = mode
|
||||||
const run = vi.spyOn (prefetchers, 'prefetchForURL').mockResolvedValue ()
|
const run = vi.spyOn (prefetchers, 'prefetchForURL').mockResolvedValue ()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, screen, within } from '@testing-library/react'
|
import { fireEvent, screen, within } from '@testing-library/react'
|
||||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { Route, Routes } from 'react-router-dom'
|
import { Route, Routes } from 'react-router-dom'
|
||||||
|
|
||||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||||
@@ -21,6 +21,8 @@ const tableSource = [
|
|||||||
|
|
||||||
|
|
||||||
describe ('Help MDX with the application compiler', () => {
|
describe ('Help MDX with the application compiler', () => {
|
||||||
|
beforeEach (() => helpFixture.reset ())
|
||||||
|
|
||||||
it ('compiles GFM table structure, links and emphasis', async () => {
|
it ('compiles GFM table structure, links and emphasis', async () => {
|
||||||
const Content = await compileMdx (tableSource)
|
const Content = await compileMdx (tableSource)
|
||||||
renderWithProviders (<Content/>)
|
renderWithProviders (<Content/>)
|
||||||
@@ -44,7 +46,8 @@ describe ('Help MDX with the application compiler', () => {
|
|||||||
|
|
||||||
it ('keeps tables inside the Help dark-mode typography container', async () => {
|
it ('keeps tables inside the Help dark-mode typography container', async () => {
|
||||||
const Content = await compileMdx (tableSource)
|
const Content = await compileMdx (tableSource)
|
||||||
vi.spyOn (helpFixture.api, 'getHelpEdition').mockResolvedValue (Content)
|
helpFixture.modules['../../help/guide/editions/ja/2026-10-02.10.mdx']
|
||||||
|
.mockResolvedValue ({ default: Content })
|
||||||
renderWithProviders (
|
renderWithProviders (
|
||||||
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
|
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
|
||||||
{ route: '/help/guide' })
|
{ route: '/help/guide' })
|
||||||
@@ -52,9 +55,12 @@ describe ('Help MDX with the application compiler', () => {
|
|||||||
expect (table.closest ('article')).toHaveClass ('prose', 'dark:prose-invert')
|
expect (table.closest ('article')).toHaveClass ('prose', 'dark:prose-invert')
|
||||||
})
|
})
|
||||||
|
|
||||||
// jsdom cannot measure overflow. Requiring a particular Tailwind class rejects
|
it ('preserves long cell contents when rendering a wide table', async () => {
|
||||||
// valid CSS layouts, including the existing overflow-y-auto container.
|
const cell = 'wide'.repeat (100)
|
||||||
it.todo ('keeps wide tables within the viewport in a browser layout test')
|
const Content = await compileMdx (`| 項目 |\n| --- |\n| ${ cell } |`)
|
||||||
|
renderWithProviders (<Content/>)
|
||||||
|
expect (screen.getByRole ('cell')).toHaveTextContent (cell)
|
||||||
|
})
|
||||||
|
|
||||||
it.each (['.mdx?raw', '.mdx?url']) ('preserves the %s transform bypass', async suffix => {
|
it.each (['.mdx?raw', '.mdx?url']) ('preserves the %s transform bypass', async suffix => {
|
||||||
expect (await transformMdx (tableSource, suffix)).toBeNull ()
|
expect (await transformMdx (tableSource, suffix)).toBeNull ()
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import { HelmetProvider } from 'react-helmet-async'
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
import App from '@/App'
|
import App from '@/App'
|
||||||
|
import { helpFixture } from '@/test/helpFixtures'
|
||||||
|
|
||||||
vi.hoisted (() => {
|
vi.hoisted (() => {
|
||||||
window.history.replaceState (null, '', '/help/changes')
|
window.history.replaceState (null, '', '/help/changes')
|
||||||
@@ -20,6 +21,8 @@ vi.mock ('@/lib/useClientBehaviourSettings', () => ({
|
|||||||
|
|
||||||
|
|
||||||
describe ('Help global edition history', () => {
|
describe ('Help global edition history', () => {
|
||||||
|
beforeEach (() => helpFixture.reset ())
|
||||||
|
|
||||||
it ('routes /help/changes to published editions in date and numeric order', async () => {
|
it ('routes /help/changes to published editions in date and numeric order', async () => {
|
||||||
const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } })
|
const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } })
|
||||||
const { container } = render (
|
const { container } = render (
|
||||||
@@ -35,6 +38,15 @@ describe ('Help global edition history', () => {
|
|||||||
'/help/guide?edition=2026-10-02.2', '/help/guide?edition=2026-10-02',
|
'/help/guide?edition=2026-10-02.2', '/help/guide?edition=2026-10-02',
|
||||||
'/help/guide?edition=2026-10-01'])
|
'/help/guide?edition=2026-10-01'])
|
||||||
expect (container).toHaveTextContent ('旧ホーム')
|
expect (container).toHaveTextContent ('旧ホーム')
|
||||||
|
expect (screen.getAllByRole ('columnheader').map (cell => cell.textContent))
|
||||||
|
.toEqual (['差分', 'タイトル', '有効開始年月日'])
|
||||||
|
expect (container).not.toHaveTextContent ('未公開ホーム')
|
||||||
|
expect (screen.getAllByRole ('link', { name: '差分' }).map (link =>
|
||||||
|
link.getAttribute ('href'))).toEqual ([
|
||||||
|
'/help/guide/diff?from=2026-10-02.3&to=2026-10-02.10',
|
||||||
|
'/help/guide/diff?from=2026-10-02.2&to=2026-10-02.3',
|
||||||
|
'/help/guide/diff?from=2026-10-02&to=2026-10-02.2',
|
||||||
|
'/help/guide/diff?from=2026-10-01&to=2026-10-02'])
|
||||||
expect (apiGet).not.toHaveBeenCalled ()
|
expect (apiGet).not.toHaveBeenCalled ()
|
||||||
expect (window.location.pathname).toBe ('/help/changes')
|
expect (window.location.pathname).toBe ('/help/changes')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||||
import { helpFixture } from '@/test/helpFixtures'
|
import { helpFixture } from '@/test/helpFixtures'
|
||||||
@@ -35,12 +35,15 @@ const renderDetail = (route = '/help/guide') => renderWithProviders (
|
|||||||
|
|
||||||
|
|
||||||
describe ('Help details', () => {
|
describe ('Help details', () => {
|
||||||
|
beforeEach (() => helpFixture.reset ())
|
||||||
|
|
||||||
it ('displays current_edition H1 and body even when newer files exist', async () => {
|
it ('displays current_edition H1 and body even when newer files exist', async () => {
|
||||||
renderDetail ()
|
renderDetail ()
|
||||||
expect (await screen.findByRole ('heading', { name: 'ホーム' })).toBeInTheDocument ()
|
expect (await screen.findByRole ('heading', { name: 'ホーム第十版' }))
|
||||||
expect (screen.getByText ('タグを検索する')).toBeInTheDocument ()
|
.toBeInTheDocument ()
|
||||||
expect (screen.queryByText ('第十版本文')).not.toBeInTheDocument ()
|
expect (screen.getByText ('タグを検索する 第十版本文')).toBeInTheDocument ()
|
||||||
await waitFor (() => expect (document.title).toContain ('ホーム ヘルプ'))
|
expect (screen.queryByText ('未公開限定本文')).not.toBeInTheDocument ()
|
||||||
|
await waitFor (() => expect (document.title).toContain ('ホーム第十版 ヘルプ'))
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('uses the historical H1 in both page and document titles', async () => {
|
it ('uses the historical H1 in both page and document titles', async () => {
|
||||||
@@ -66,7 +69,8 @@ describe ('Help details', () => {
|
|||||||
expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument ()
|
expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each (['/help/missing', '/help/guide?edition=missing']) (
|
it.each (['/help/missing', '/help/guide?edition=missing',
|
||||||
|
'/help/guide?edition=2026-10-02.11']) (
|
||||||
'shows NotFound without redirecting %s', async route => {
|
'shows NotFound without redirecting %s', async route => {
|
||||||
renderDetail (route)
|
renderDetail (route)
|
||||||
expect (await screen.findByText ('404')).toBeInTheDocument ()
|
expect (await screen.findByText ('404')).toBeInTheDocument ()
|
||||||
@@ -81,9 +85,9 @@ describe ('Help details', () => {
|
|||||||
helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx']
|
helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx']
|
||||||
.mockReturnValueOnce (pending)
|
.mockReturnValueOnce (pending)
|
||||||
renderDetail ()
|
renderDetail ()
|
||||||
await screen.findByRole ('heading', { name: 'ホーム' })
|
await screen.findByRole ('heading', { name: 'ホーム第十版' })
|
||||||
fireEvent.click (screen.getByRole ('button', { name: 'switch article' }))
|
fireEvent.click (screen.getByRole ('button', { name: 'switch article' }))
|
||||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
expect (screen.queryByText ('タグを検索する 第十版本文')).not.toBeInTheDocument ()
|
||||||
await waitFor (() => expect (document.title).not.toContain ('ホーム'))
|
await waitFor (() => expect (document.title).not.toContain ('ホーム'))
|
||||||
await act (async () => finish ({ default: () => <h1>設定</h1> }))
|
await act (async () => finish ({ default: () => <h1>設定</h1> }))
|
||||||
await screen.findByRole ('heading', { name: '設定' })
|
await screen.findByRole ('heading', { name: '設定' })
|
||||||
@@ -95,9 +99,10 @@ describe ('Help details', () => {
|
|||||||
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
|
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
|
||||||
finish = resolve
|
finish = resolve
|
||||||
})
|
})
|
||||||
helpFixture.modules['../../help/guide/editions/ja/2026-10-02.mdx']
|
const loader = helpFixture.modules['../../help/guide/editions/ja/2026-10-02.10.mdx']
|
||||||
.mockReturnValueOnce (pending)
|
loader.mockReturnValueOnce (pending)
|
||||||
renderDetail ()
|
renderDetail ()
|
||||||
|
await waitFor (() => expect (loader).toHaveBeenCalled ())
|
||||||
fireEvent.click (screen.getByRole ('button', { name: 'switch edition' }))
|
fireEvent.click (screen.getByRole ('button', { name: 'switch edition' }))
|
||||||
await screen.findByRole ('heading', { name: 'ホーム第三版' })
|
await screen.findByRole ('heading', { name: 'ホーム第三版' })
|
||||||
await act (async () => finish ({ default: () => <h1>遅延した旧本文</h1> }))
|
await act (async () => finish ({ default: () => <h1>遅延した旧本文</h1> }))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, screen } from '@testing-library/react'
|
import { fireEvent, screen } from '@testing-library/react'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import HelpListPage from '@/pages/help/HelpListPage'
|
import HelpListPage from '@/pages/help/HelpListPage'
|
||||||
import { helpFixture } from '@/test/helpFixtures'
|
import { helpFixture } from '@/test/helpFixtures'
|
||||||
@@ -9,23 +9,25 @@ vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFi
|
|||||||
|
|
||||||
|
|
||||||
describe ('Help search', () => {
|
describe ('Help search', () => {
|
||||||
|
beforeEach (() => helpFixture.reset ())
|
||||||
|
|
||||||
it ('initially lists each current article once with encoded detail links', async () => {
|
it ('initially lists each current article once with encoded detail links', async () => {
|
||||||
renderWithProviders (<HelpListPage/>, { route: '/help' })
|
renderWithProviders (<HelpListPage/>, { route: '/help' })
|
||||||
expect (await screen.findByRole ('link', { name: 'ホーム' }))
|
expect (await screen.findByRole ('link', { name: 'ホーム第十版' }))
|
||||||
.toHaveAttribute ('href', '/help/guide')
|
.toHaveAttribute ('href', '/help/guide')
|
||||||
expect (screen.getByRole ('link', { name: '設定' }))
|
expect (screen.getByRole ('link', { name: '設定' }))
|
||||||
.toHaveAttribute ('href', `/help/${encodeURIComponent ('設定 手順')}`)
|
.toHaveAttribute ('href', `/help/${encodeURIComponent ('設定 手順')}`)
|
||||||
expect (screen.getByText ('2 件')).toBeInTheDocument ()
|
expect (screen.getByText ('2 件')).toBeInTheDocument ()
|
||||||
expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument ()
|
expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument ()
|
||||||
expect (screen.queryByText ('ホーム第十版')).not.toBeInTheDocument ()
|
expect (screen.queryByText ('未公開ホーム')).not.toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each ([
|
it.each ([
|
||||||
['ホーム', '', ['ホーム']],
|
['ホーム', '', ['ホーム第十版']],
|
||||||
['', '検索', ['ホーム']],
|
['', '検索', ['ホーム第十版']],
|
||||||
['', 'タグ', ['ホーム', '設定']],
|
['', 'タグ', ['設定', 'ホーム第十版']],
|
||||||
['ホーム', '設定', []],
|
['ホーム', '設定', []],
|
||||||
['ホーム', '検索', ['ホーム']],
|
['ホーム', '検索', ['ホーム第十版']],
|
||||||
['旧ホーム', '', []],
|
['旧ホーム', '', []],
|
||||||
['', '過去限定本文', []]]) (
|
['', '過去限定本文', []]]) (
|
||||||
'searches title %s and body %s together', async (title, body, expected) => {
|
'searches title %s and body %s together', async (title, body, expected) => {
|
||||||
@@ -43,10 +45,11 @@ describe ('Help search', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it ('displays loading failures without stale results', async () => {
|
it ('displays loading failures without stale results', async () => {
|
||||||
helpFixture.raw['../../help/guide/editions/ja/2026-10-02.mdx']
|
helpFixture.loads.search.mockImplementationOnce (() => {
|
||||||
.mockRejectedValueOnce (new Error ('fixture loading failed'))
|
throw new Error ('fixture loading failed')
|
||||||
|
})
|
||||||
renderWithProviders (<HelpListPage/>)
|
renderWithProviders (<HelpListPage/>)
|
||||||
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
|
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
|
||||||
expect (screen.queryByRole ('link', { name: 'ホーム' })).not.toBeInTheDocument ()
|
expect (screen.queryByRole ('link', { name: 'ホーム第十版' })).not.toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { apiGet } from '@/lib/api'
|
|||||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||||
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
|
||||||
import { buildWikiPage } from '@/test/factories'
|
import { buildWikiPage } from '@/test/factories'
|
||||||
import { editionRows, helpFixture } from '@/test/helpFixtures'
|
import { helpFixture } from '@/test/helpFixtures'
|
||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
|
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
|
||||||
@@ -38,10 +38,7 @@ const renderWiki = (title: string) => {
|
|||||||
|
|
||||||
describe ('Legacy Wiki to Help compatibility', () => {
|
describe ('Legacy Wiki to Help compatibility', () => {
|
||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
editionRows.forEach (([id, language, edition, title, body]) => {
|
helpFixture.reset ()
|
||||||
helpFixture.raw[`../../help/${id}/editions/${language}/${edition}.mdx`]
|
|
||||||
.mockReset ().mockResolvedValue (`# ${title}\n\n${body}`)
|
|
||||||
})
|
|
||||||
vi.mocked (apiGet).mockReset ()
|
vi.mocked (apiGet).mockReset ()
|
||||||
vi.mocked (apiGet).mockImplementation (async path => {
|
vi.mocked (apiGet).mockImplementation (async path => {
|
||||||
if (path === '/wiki')
|
if (path === '/wiki')
|
||||||
@@ -57,31 +54,41 @@ describe ('Legacy Wiki to Help compatibility', () => {
|
|||||||
await screen.findByText ('Wiki の現行本文')
|
await screen.findByText ('Wiki の現行本文')
|
||||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
||||||
|
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each (['ホーム', 'ヘルプ:ホーム', '旧ホーム', 'ヘルプ:旧ホーム']) (
|
it.each (['ホーム', 'ヘルプ:ホーム', '旧ホーム', 'ヘルプ:旧ホーム', 'ホーム第十版']) (
|
||||||
'redirects a unique current or historical Japanese title: %s', async title => {
|
'redirects a unique current or historical Japanese title: %s', async title => {
|
||||||
renderWiki (title)
|
renderWiki (title)
|
||||||
await waitFor (() => {
|
await waitFor (() => {
|
||||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/help/guide')
|
expect (screen.getByTestId ('location')).toHaveTextContent ('/help/guide')
|
||||||
})
|
})
|
||||||
expect (await screen.findByRole ('heading', { name: 'ホーム' })).toBeInTheDocument ()
|
expect (await screen.findByRole ('heading', { name: 'ホーム第十版' }))
|
||||||
|
.toBeInTheDocument ()
|
||||||
|
Object.values (helpFixture.raw).forEach (load => expect (load).not.toHaveBeenCalled ())
|
||||||
expect (apiGet).toHaveBeenCalledWith (
|
expect (apiGet).toHaveBeenCalledWith (
|
||||||
`/wiki/title/${encodeURIComponent (title)}`, expect.anything ())
|
`/wiki/title/${encodeURIComponent (title)}`, expect.anything ())
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('keeps the original URL and displays NotFound when no title matches', async () => {
|
it ('keeps the original URL and displays NotFound when no title matches', async () => {
|
||||||
const { route } = renderWiki ('該当なし')
|
const { route, queryClient } = renderWiki ('該当なし')
|
||||||
await screen.findByText ('404')
|
await screen.findByText ('404')
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (helpFixture.loads.titles).toHaveBeenCalled ()
|
||||||
|
expect (queryClient.isFetching ()).toBe (0)
|
||||||
|
})
|
||||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||||
expect (screen.queryByText ('unexpected redirect')).not.toBeInTheDocument ()
|
expect (screen.queryByText ('unexpected redirect')).not.toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('does not choose between articles with the same title', async () => {
|
it ('does not choose between articles with the same title', async () => {
|
||||||
vi.spyOn (helpFixture.raw, '../../help/設定 手順/editions/ja/2026-10-03.mdx')
|
helpFixture.assets.titles.default.ja['ホーム'] = ['guide', '設定 手順']
|
||||||
.mockResolvedValue ('# ホーム\n\n別の記事')
|
const { route, queryClient } = renderWiki ('ホーム')
|
||||||
const { route } = renderWiki ('ホーム')
|
|
||||||
await screen.findByText ('404')
|
await screen.findByText ('404')
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (helpFixture.loads.titles).toHaveBeenCalled ()
|
||||||
|
expect (queryClient.isFetching ()).toBe (0)
|
||||||
|
})
|
||||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,5 +101,6 @@ describe ('Legacy Wiki to Help compatibility', () => {
|
|||||||
})
|
})
|
||||||
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
expect (screen.getByTestId ('location')).toHaveTextContent (route)
|
||||||
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
|
||||||
|
expect (helpFixture.loads.titles).not.toHaveBeenCalled ()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,62 +1,58 @@
|
|||||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { apiGet } from '@/lib/api'
|
||||||
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
||||||
import { buildWikiPage } from '@/test/factories'
|
import { buildWikiPage } from '@/test/factories'
|
||||||
import { renderWithProviders } from '@/test/render'
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
const api = vi.hoisted (() => ({
|
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
|
||||||
apiGet: vi.fn (),
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock ('@/lib/api', () => api)
|
|
||||||
|
|
||||||
describe ('WikiSearchPage', () => {
|
describe ('Wiki resource identity', () => {
|
||||||
|
beforeEach (() => vi.mocked (apiGet).mockReset ())
|
||||||
|
|
||||||
it ('loads initial wiki pages and renders links', async () => {
|
it ('loads initial wiki pages and renders links', async () => {
|
||||||
api.apiGet.mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
|
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({ title: '虹夏' })])
|
||||||
|
|
||||||
renderWithProviders (<WikiSearchPage/>)
|
renderWithProviders (<WikiSearchPage/>)
|
||||||
|
expect (await screen.findByRole ('link', { name: '虹夏' }))
|
||||||
expect (await screen.findByRole ('link', { name: '虹夏' })).toHaveAttribute (
|
.toHaveAttribute ('href', '/wiki/%E8%99%B9%E5%A4%8F')
|
||||||
'href',
|
expect (apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } })
|
||||||
'/wiki/%E8%99%B9%E5%A4%8F',
|
|
||||||
)
|
|
||||||
expect (api.apiGet).toHaveBeenCalledWith ('/wiki', { params: { title: '' } })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('searches by title on submit', async () => {
|
it ('searches by title on submit', async () => {
|
||||||
api.apiGet
|
vi.mocked (apiGet).mockResolvedValueOnce ([])
|
||||||
.mockResolvedValueOnce ([])
|
|
||||||
.mockResolvedValueOnce ([buildWikiPage ({ title: '検索結果' })])
|
.mockResolvedValueOnce ([buildWikiPage ({ title: '検索結果' })])
|
||||||
|
|
||||||
renderWithProviders (<WikiSearchPage/>)
|
renderWithProviders (<WikiSearchPage/>)
|
||||||
|
await waitFor (() => expect (apiGet).toHaveBeenCalledTimes (1))
|
||||||
fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } })
|
fireEvent.change (screen.getAllByRole ('textbox')[0], { target: { value: '検索' } })
|
||||||
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
|
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
|
||||||
|
await waitFor (() => expect (apiGet).toHaveBeenLastCalledWith (
|
||||||
await waitFor (() => {
|
'/wiki', { params: { title: '検索' } }))
|
||||||
expect (api.apiGet).toHaveBeenLastCalledWith (
|
|
||||||
'/wiki',
|
|
||||||
{ params: { title: '検索' } },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
|
expect (await screen.findByRole ('link', { name: '検索結果' })).toBeInTheDocument ()
|
||||||
})
|
})
|
||||||
|
|
||||||
it ('marks deprecated wiki tags in the result title', async () => {
|
it ('marks deprecated wiki tags in the result title', async () => {
|
||||||
api.apiGet.mockResolvedValueOnce ([
|
vi.mocked (apiGet).mockResolvedValueOnce ([buildWikiPage ({
|
||||||
buildWikiPage ({
|
title: '旧タグ', deprecatedAt: '2026-06-01T00:00:00.000Z' })])
|
||||||
title: '旧タグ',
|
renderWithProviders (<WikiSearchPage/>)
|
||||||
deprecatedAt: '2026-06-01T00:00:00.000Z',
|
expect (await screen.findByRole ('link', { name: '旧タグ' })).toBeInTheDocument ()
|
||||||
}),
|
expect (screen.getByText ('(廃止)').closest ('a')).toBeNull ()
|
||||||
])
|
})
|
||||||
|
|
||||||
|
it ('renders colliding description and group IDs with stable distinct keys', async () => {
|
||||||
|
const errors = vi.spyOn (console, 'error').mockImplementation (() => {})
|
||||||
|
const description = buildWikiPage ({ id: 51, title: '説明', resourceType: 'tag_description' })
|
||||||
|
const group = buildWikiPage ({ id: 51, title: 'タググループ:一覧', resourceType: 'tag_group' })
|
||||||
|
vi.mocked (apiGet).mockResolvedValue ([description, group])
|
||||||
renderWithProviders (<WikiSearchPage/>)
|
renderWithProviders (<WikiSearchPage/>)
|
||||||
|
|
||||||
const link = await screen.findByRole ('link', { name: '旧タグ' })
|
await screen.findByRole ('link', { name: '説明' })
|
||||||
const marker = screen.getByText ('(廃止)')
|
expect (screen.getByRole ('link', { name: 'タググループ:一覧' }))
|
||||||
|
.toHaveAttribute ('href', `/wiki/${ encodeURIComponent (group.title) }`)
|
||||||
expect (link).toBeInTheDocument ()
|
await waitFor (() => {
|
||||||
expect (marker.closest ('a')).toBeNull ()
|
expect (errors.mock.calls.some (args => args.some (value =>
|
||||||
|
String (value).includes ('same key')))).toBe (false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ const WikiSearchPage: FC = () => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{results.map (page => (
|
{results.map (page => (
|
||||||
<tr key={page.id} className="even:bg-gray-100 dark:even:bg-gray-700">
|
<tr key={`${ page.resourceType }:${ page.id }`}
|
||||||
|
className="even:bg-gray-100 dark:even:bg-gray-700">
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
|
<PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}>
|
||||||
{page.title}
|
{page.title}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const buildUser = (overrides: Partial<User> = {}): User => ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
|
export const buildWikiPage = (overrides: Partial<WikiPage> = {}): WikiPage => ({
|
||||||
|
resourceType: 'tag_description',
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'テストWiki',
|
title: 'テストWiki',
|
||||||
deprecatedAt: null,
|
deprecatedAt: null,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
|
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
|
||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest'
|
||||||
import { parse } from 'yaml'
|
|
||||||
|
|
||||||
import source from '@/lib/help.ts?raw'
|
import source from '@/lib/help.ts?raw'
|
||||||
|
|
||||||
@@ -14,51 +13,113 @@ export const editionRows = [
|
|||||||
['guide', 'ja', '2026-10-02', 'ホーム', 'タグを検索する'],
|
['guide', 'ja', '2026-10-02', 'ホーム', 'タグを検索する'],
|
||||||
['guide', 'ja', '2026-10-02.2', 'ホーム第二版', '第二版本文'],
|
['guide', 'ja', '2026-10-02.2', 'ホーム第二版', '第二版本文'],
|
||||||
['guide', 'ja', '2026-10-02.3', 'ホーム第三版', '第三版本文'],
|
['guide', 'ja', '2026-10-02.3', 'ホーム第三版', '第三版本文'],
|
||||||
['guide', 'ja', '2026-10-02.10', 'ホーム第十版', '第十版本文'],
|
['guide', 'ja', '2026-10-02.10', 'ホーム第十版', 'タグを検索する 第十版本文'],
|
||||||
|
['guide', 'ja', '2026-10-02.11', '未公開ホーム', '未公開限定本文'],
|
||||||
['guide', 'en', '2026-10-02', 'Home', 'English body'],
|
['guide', 'en', '2026-10-02', 'Home', 'English body'],
|
||||||
['設定 手順', 'ja', '2026-10-03', '設定', 'タグを設定する']] as const
|
['設定 手順', 'ja', '2026-10-03', '設定', 'タグを設定する']] as const
|
||||||
|
|
||||||
const compiled = transpileModule (source.replaceAll ('import.meta', 'helpMeta'), {
|
const compiled = transpileModule (source, {
|
||||||
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
|
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
|
||||||
|
|
||||||
|
|
||||||
// Replace Vite's file-discovery boundary, executing the actual Help module.
|
// Supply generated virtual modules, executing the actual runtime Help module.
|
||||||
export const createHelpFixture = () => {
|
export const createHelpFixture = () => {
|
||||||
const articles: Record<string, string> = {
|
const articles: Record<string, Help.ArticleMeta> = {
|
||||||
'../../help/guide/article.yml':
|
guide: { identifier: 'guide', canonicalLanguage: 'ja', languages: {
|
||||||
'identifier: guide\nlanguages:\n ja:\n current_edition: 2026-10-02\n'
|
ja: { currentEdition: '2026-10-02.10', publishedEditions: [
|
||||||
+ ' en:\n current_edition: 2026-10-02\n',
|
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'] },
|
||||||
'../../help/設定 手順/article.yml':
|
en: { currentEdition: '2026-10-02', publishedEditions: ['2026-10-02'] } } },
|
||||||
'identifier: 設定 手順\nlanguages:\n ja:\n current_edition: 2026-10-03\n' }
|
'設定 手順': { identifier: '設定 手順', canonicalLanguage: 'ja', languages: {
|
||||||
|
ja: { currentEdition: '2026-10-03', publishedEditions: ['2026-10-03'] } } } }
|
||||||
|
const published = editionRows.filter (([id, language, edition]) =>
|
||||||
|
articles[id].languages[language].publishedEditions.includes (edition))
|
||||||
const raw = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [
|
const raw = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [
|
||||||
`../../help/${id}/editions/${language}/${edition}.mdx`,
|
`../../help/${id}/editions/${language}/${edition}.mdx`,
|
||||||
vi.fn (async () => `# ${title}\n\n${body}`)]))
|
vi.fn (async () => `# ${title}\n\n${body}`)]))
|
||||||
const moduleLoads = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [
|
const moduleLoads = Object.fromEntries (published.map (([id, language, edition, title, body]) => [
|
||||||
`../../help/${id}/editions/${language}/${edition}.mdx`,
|
`../../help/${id}/editions/${language}/${edition}.mdx`,
|
||||||
vi.fn (async (): Promise<{ default: () => ReactElement }> => ({
|
vi.fn (async (): Promise<{ default: () => ReactElement }> => ({
|
||||||
default: () => createElement ('div', null,
|
default: () => createElement ('div', null,
|
||||||
createElement ('h1', null, title), createElement ('p', null, body)) }))]))
|
createElement ('h1', null, title), createElement ('p', null, body)) }))]))
|
||||||
// Dynamic imports reuse a module namespace even when called more than once.
|
// Dynamic imports reuse a module namespace even when called more than once.
|
||||||
|
const caches: Record<string, { promise?: Promise<{ default: () => ReactElement }> }> = { }
|
||||||
const modules = Object.fromEntries (Object.entries (moduleLoads).map (([path, load]) => {
|
const modules = Object.fromEntries (Object.entries (moduleLoads).map (([path, load]) => {
|
||||||
const cache: { promise?: ReturnType<typeof load> } = { }
|
caches[path] = { }
|
||||||
return [path, vi.fn (() => cache.promise ??= load ())]
|
return [path, vi.fn (() => caches[path].promise ??= load ())]
|
||||||
}))
|
}))
|
||||||
const glob = (pattern: string, options?: { query?: string }) => {
|
const texts = Object.fromEntries (published.map (([id, language, edition, title, body]) => [
|
||||||
if (pattern.endsWith ('article.yml'))
|
`${ id }/${ language }/${ edition }`,
|
||||||
return articles
|
vi.fn (async () => ({ default: { title, body: `\n${ body }` } }))]))
|
||||||
if (pattern.endsWith ('*.mdx'))
|
const components = Object.fromEntries (published.map (([id, language, edition]) => [
|
||||||
return options?.query === '?raw' ? raw : modules
|
`${ id }/${ language }/${ edition }`,
|
||||||
throw new Error (`Unexpected Help glob: ${pattern}`)
|
modules[`../../help/${ id }/editions/${ language }/${ edition }.mdx`]]))
|
||||||
}
|
const sources = Object.fromEntries (published.map (([id, language, edition]) => [
|
||||||
|
`${ id }/${ language }/${ edition }`,
|
||||||
|
async () => ({ default: await raw[
|
||||||
|
`../../help/${ id }/editions/${ language }/${ edition }.mdx`] () })]))
|
||||||
|
const search: Record<string, Help.HelpSearchEntry[]> = { }
|
||||||
|
const changes: Record<string, Help.HelpChange[]> = { }
|
||||||
|
const titles: Record<string, Help.HelpTitleIndex> = { }
|
||||||
|
published.forEach (([id, language, edition, title, body]) => {
|
||||||
|
const editions = articles[id].languages[language].publishedEditions
|
||||||
|
const index = editions.indexOf (edition)
|
||||||
|
const searchRows = search[language] ??= []
|
||||||
|
if (articles[id].languages[language].currentEdition === edition)
|
||||||
|
searchRows.push ({ identifier: id, edition, title, body: `\n${ body }` })
|
||||||
|
const rows = changes[language] ??= []
|
||||||
|
rows.push ({ identifier: id, edition, title,
|
||||||
|
pred: editions[index - 1] ?? null,
|
||||||
|
change: index === 0 ? 'new' : 'update', date: edition.split ('.')[0] })
|
||||||
|
const lookup = (titles[language] ??= { })
|
||||||
|
const identifiers = lookup[title.trim ().normalize ('NFKC').toLowerCase ()] ??= []
|
||||||
|
if (!(identifiers.includes (id)))
|
||||||
|
identifiers.push (id)
|
||||||
|
})
|
||||||
|
Object.values (changes).forEach (rows => rows.sort ((a, b) => {
|
||||||
|
const [dateA, revisionA = '1'] = a.edition.split ('.')
|
||||||
|
const [dateB, revisionB = '1'] = b.edition.split ('.')
|
||||||
|
return dateB.localeCompare (dateA) || Number (revisionB) - Number (revisionA)
|
||||||
|
|| a.identifier.localeCompare (b.identifier)
|
||||||
|
}))
|
||||||
|
const assets = {
|
||||||
|
articles: { default: articles }, search: { default: search },
|
||||||
|
changes: { default: changes }, titles: { default: titles } }
|
||||||
|
const snapshot = JSON.stringify (assets)
|
||||||
|
const loaders = { components, sources, texts }
|
||||||
|
const loads = {
|
||||||
|
articles: vi.fn (() => assets.articles), search: vi.fn (() => assets.search),
|
||||||
|
changes: vi.fn (() => assets.changes), titles: vi.fn (() => assets.titles),
|
||||||
|
loaders: vi.fn (() => loaders) }
|
||||||
const api = { } as typeof Help
|
const api = { } as typeof Help
|
||||||
const requireFixture = (name: string) => {
|
const requireFixture = (name: string) => {
|
||||||
if (name === 'yaml')
|
const key = name.replace ('virtual:help/', '') as keyof typeof loads
|
||||||
return { parse }
|
if (Object.hasOwn (loads, key))
|
||||||
|
return loads[key] ()
|
||||||
throw new Error (`Unexpected Help dependency: ${name}`)
|
throw new Error (`Unexpected Help dependency: ${name}`)
|
||||||
}
|
}
|
||||||
new Function ('require', 'exports', 'helpMeta', compiled) (
|
new Function ('require', 'exports', compiled) (requireFixture, api)
|
||||||
requireFixture, api, { glob })
|
const reset = () => {
|
||||||
return { api, articles, raw, modules, moduleLoads }
|
Object.assign (assets, JSON.parse (snapshot))
|
||||||
|
editionRows.forEach (([id, language, edition, title, body]) => {
|
||||||
|
const path = `../../help/${ id }/editions/${ language }/${ edition }.mdx`
|
||||||
|
raw[path].mockReset ().mockResolvedValue (`# ${ title }\n\n${ body }`)
|
||||||
|
})
|
||||||
|
published.forEach (([id, language, edition, title, body]) => {
|
||||||
|
const path = `../../help/${ id }/editions/${ language }/${ edition }.mdx`
|
||||||
|
caches[path].promise = undefined
|
||||||
|
moduleLoads[path].mockClear ()
|
||||||
|
modules[path].mockReset ().mockImplementation (() =>
|
||||||
|
caches[path].promise ??= moduleLoads[path] ())
|
||||||
|
texts[`${ id }/${ language }/${ edition }`].mockReset ().mockResolvedValue ({
|
||||||
|
default: { title, body: `\n${ body }` } })
|
||||||
|
})
|
||||||
|
loads.articles.mockReset ().mockImplementation (() => assets.articles)
|
||||||
|
loads.search.mockReset ().mockImplementation (() => assets.search)
|
||||||
|
loads.changes.mockReset ().mockImplementation (() => assets.changes)
|
||||||
|
loads.titles.mockReset ().mockImplementation (() => assets.titles)
|
||||||
|
loads.loaders.mockReset ().mockImplementation (() => loaders)
|
||||||
|
}
|
||||||
|
return { api, assets, loads, raw, modules, moduleLoads, texts, reset }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ export type ViewFlagBehavior = typeof ViewFlagBehavior[keyof typeof ViewFlagBeha
|
|||||||
|
|
||||||
export type WikiPage = {
|
export type WikiPage = {
|
||||||
id: number
|
id: number
|
||||||
|
resourceType: 'tag_description' | 'tag_group'
|
||||||
title: string
|
title: string
|
||||||
deprecatedAt: string | null
|
deprecatedAt: string | null
|
||||||
createdUserId: number
|
createdUserId: number
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする