diff --git a/backend/app/controllers/materials_controller.rb b/backend/app/controllers/materials_controller.rb index f94f511..7c16a1a 100644 --- a/backend/app/controllers/materials_controller.rb +++ b/backend/app/controllers/materials_controller.rb @@ -70,7 +70,10 @@ class MaterialsController < ApplicationController .find_by(id: params[:id]) return head :not_found unless material - wiki_page_body = material.tag&.tag_name('ja')&.wiki_page&.current_revision&.body + wiki_page_body = + TagDescription + .find_by(tag_id: material.tag_id, language_code: locale.language_code) + &.body render json: MaterialRepr.base(material, host: request.base_url, language_code: locale.language_code).merge(wiki_page_body:) diff --git a/backend/app/controllers/nico_tags_controller.rb b/backend/app/controllers/nico_tags_controller.rb index 64f74ab..effb0e9 100644 --- a/backend/app/controllers/nico_tags_controller.rb +++ b/backend/app/controllers/nico_tags_controller.rb @@ -26,7 +26,7 @@ class NicoTagsController < ApplicationController ExternalTag .joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \ 'ON post_tag_max.external_tag_id = external_tags.id') - .includes(linked_tags: { tag_names: :wiki_page }) + .includes(linked_tags: [:tag_names, :tag_descriptions]) if name q = q.where(('external_tags.name LIKE ? ' + "OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"), diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 3843bb7..d08e466 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -53,7 +53,7 @@ class PostsController < ApplicationController .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) .preload(:external_tags, :uploaded_user, :parents, :children, post_tags: [:sections, { tag: [:deerjikists, :materials, - { tag_names: :wiki_page }] }]) + :tag_names, :tag_descriptions] }]) .with_attached_thumbnail q = q.where('posts.url LIKE ?', "%#{ url }%") if url @@ -111,7 +111,7 @@ class PostsController < ApplicationController filtered_posts(locale) .preload(:uploaded_user, :parents, :children, post_tags: [:sections, { tag: [:deerjikists, :materials, - { tag_names: :wiki_page }] }]) + :tag_names, :tag_descriptions] }]) .with_attached_thumbnail .order('RAND()') .first @@ -198,7 +198,7 @@ class PostsController < ApplicationController Post .includes(:uploaded_user, :parents, :children, post_tags: [:sections, { tag: [:deerjikists, :materials, - { tag_names: :wiki_page }] }]) + :tag_names, :tag_descriptions] }]) .with_attached_thumbnail .find_by(id: params[:id]) return head :not_found unless post diff --git a/backend/app/controllers/tag_versions_controller.rb b/backend/app/controllers/tag_versions_controller.rb index f45d042..9c05085 100644 --- a/backend/app/controllers/tag_versions_controller.rb +++ b/backend/app/controllers/tag_versions_controller.rb @@ -51,7 +51,7 @@ class TagVersionsController < ApplicationController tags_by_id = Tag - .includes(:tag_names, :materials, tag_names: :wiki_page) + .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions) .where(id: all_parent_tag_ids) .index_by(&:id) diff --git a/backend/app/controllers/tags_controller.rb b/backend/app/controllers/tags_controller.rb index 7919a24..d60d56d 100644 --- a/backend/app/controllers/tags_controller.rb +++ b/backend/app/controllers/tags_controller.rb @@ -175,7 +175,7 @@ class TagsController < ApplicationController tags_by_id = Tag .joins(:tag_names) - .includes(:tag_names, :materials, tag_names: :wiki_page) + .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions) .where(tag_names: { language_code: locale.language_code, primary_flg: true }, id: tag_ids) .index_by(&:id) @@ -238,7 +238,7 @@ class TagsController < ApplicationController base = Tag .joins(:tag_names) - .includes(:tag_names, :materials, tag_names: :wiki_page) + .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions) .where(tag_names: { language_code: locale.language_code, primary_flg: true }, deprecated_at: nil) base = base.where('tags.post_count > 0') if present_only @@ -282,7 +282,7 @@ class TagsController < ApplicationController tag = Tag .joins(:tag_names) - .includes(:tag_names, :materials, tag_names: :wiki_page) + .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions) .find_by(id: params[:id], tag_names: { language_code: locale.language_code, primary_flg: true }) return render json: TagRepr.base(tag, language_code: locale.language_code) if tag @@ -302,7 +302,7 @@ class TagsController < ApplicationController tag = Tag .joins(:tag_names) - .includes(:tag_names, :materials, tag_names: :wiki_page) + .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions) .find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) return render json: TagRepr.base(tag, language_code: locale.language_code) if tag @@ -320,7 +320,7 @@ class TagsController < ApplicationController tag = Tag.joins(:tag_names) - .includes(:tag_names, tag_names: :wiki_page) + .includes(:tag_names, :tag_descriptions, parents: :tag_descriptions) .find_by(id: params[:id], tag_names: { language_code: locale.language_code, primary_flg: true }) return head :not_found unless tag @@ -340,7 +340,7 @@ class TagsController < ApplicationController tag = Tag .joins(:tag_names) - .includes(:tag_names, tag_names: :wiki_page) + .includes(:tag_names, :tag_descriptions, parents: :tag_descriptions) .find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) return head :not_found unless tag @@ -359,7 +359,7 @@ class TagsController < ApplicationController tag = Tag .joins(:tag_names) - .includes(:tag_names, tag_names: :wiki_page) + .includes(:tag_names, :tag_descriptions, parents: :tag_descriptions) .find_by(id: params[:id], tag_names: { language_code: locale.language_code, primary_flg: true }) return head :not_found unless tag @@ -424,7 +424,7 @@ class TagsController < ApplicationController tag = Tag .joins(:tag_names) - .includes(:tag_names, :materials, tag_names: :wiki_page) + .includes(:tag_names, :materials, :tag_descriptions, parents: :tag_descriptions) .find_by(tag_names: { name:, language_code: locale.language_code, primary_flg: true }) return head :not_found unless tag diff --git a/backend/app/controllers/wiki_pages_controller.rb b/backend/app/controllers/wiki_pages_controller.rb index 929c550..abf2748 100644 --- a/backend/app/controllers/wiki_pages_controller.rb +++ b/backend/app/controllers/wiki_pages_controller.rb @@ -8,9 +8,9 @@ class WikiPagesController < ApplicationController descriptions = TagDescription.where(language_code: 'ja') .includes(tag: :tag_names) - pages = unmigrated_wiki_pages.includes(tag_name: :tag) - tag_groups = TagGroup.all + descriptions = descriptions.order(updated_at: :desc, id: :asc) + tag_groups = TagGroup.order(updated_at: :desc, id: :asc) if title.present? pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%" descriptions = @@ -19,12 +19,13 @@ class WikiPagesController < ApplicationController .where('tag_names.name LIKE ?', pattern).limit(20) tag_groups = tag_groups.where( 'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20) - pages = pages.where('tag_names.name LIKE ?', pattern).limit(20) end json = descriptions.map { |description| WikiPageRepr.tag_description(description) } json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) }) - json.concat(WikiPageRepr.base(pages)) + json.sort_by! do |row| + [-row['updated_at'].to_f, row.fetch('resource_type'), row.fetch('id')] + end json = json.first(20) if title.present? render json: end @@ -37,11 +38,7 @@ class WikiPagesController < ApplicationController tag_group = TagGroup.find_by(id: params[:id]) return render_tag_group tag_group if tag_group - page = WikiPage.joins(:tag_name) - .includes(tag_name: :tag) - .find_by(id: params[:id]) - - render_wiki_page_or_404 page + render_wiki_page_or_404 nil end def show_by_title @@ -58,20 +55,14 @@ class WikiPagesController < ApplicationController return render_wiki_page_by_tag_description tag_description if tag_description - page = - WikiPage - .joins(:tag_name) - .includes(tag_name: :tag) - .find_by(tag_name: { name: title }) - - render_wiki_page_or_404 page + render_wiki_page_or_404 nil end def exists return head :no_content if TagDescription.exists?(id: params[:id]) return head :no_content if TagGroup.exists?(id: params[:id]) - head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found + head :not_found end def exists_by_title @@ -82,8 +73,7 @@ class WikiPagesController < ApplicationController end return head :no_content if tag_description_by_title(title) - exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title }) - head exists ? :no_content : :not_found + head :not_found end def diff @@ -112,15 +102,7 @@ class WikiPagesController < ApplicationController 'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }", 'deprecated_at' => nil } else - page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id) - from_rev = from && page.wiki_revisions.find(from) - to_rev = to ? page.wiki_revisions.find(to) : page.current_revision - if ((from_rev && !from_rev.content?) || !to_rev&.content?) - return render_unprocessable_entity('差分を表示できない版です.') - end - from_body = from_rev&.body - to_body = to_rev.body - resource = WikiPageRepr.base(page) + return head :not_found end end @@ -160,26 +142,19 @@ class WikiPagesController < ApplicationController status: :created) end - page = if title.include?(':') - ApplicationRecord.transaction do - tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| - name.script_code = 'Jpan' - name.primary_flg = true - name.auto_generated = false + page = + if title.include?(':') + return render_unprocessable_entity field: :title + else + tag_name = TagName.find_by(language_code: 'ja', name: title) + return render_unprocessable_entity field: :title unless tag_name&.tag + + TagDescriptionOperation.create!( + tag: tag_name.tag, body:, created_by_user: current_user, message:) end - Wiki::Commit.create_legacy_content!( - tag_name:, body:, created_by_user: current_user, message:) - end - else - tag_name = TagName.find_by(language_code: 'ja', name: title) - return render_unprocessable_entity field: :title unless tag_name&.tag - - TagDescriptionOperation.create!( - tag: tag_name.tag, body:, created_by_user: current_user, message:) - end - - render json: WikiPageRepr.base(page), status: :created + description = TagDescription.find_by!(tag_id: page.tag_name.tag_id, language_code: 'ja') + render json: WikiPageRepr.tag_description(description), status: :created rescue ActiveRecord::RecordInvalid => e render_validation_error e.record rescue ActiveRecord::RecordNotUnique @@ -209,44 +184,34 @@ class WikiPagesController < ApplicationController return head :ok end - page = WikiPage.find_by(id: params[:id]) - if legacy_group_page?(page) + return render_unprocessable_entity field: :title if title.include?(':') + description = TagDescription.find_by(id: params[:id]) + unless description + return render_unprocessable_entity field: :title if group || WikiPage.exists?(id: params[:id]) + return head :not_found + end + tag = description.tag + tag_name = TagDescriptionOperation.compatibility_tag_name(tag) + if tag_name.name.include?(':') || (group && title != tag_name.name) return render_unprocessable_entity field: :title end - if group && !legacy_update_identified?(page, title, group) - return render_unprocessable_entity field: :title - end - return head :not_found unless page base_revision_id = params[:base_revision_id].presence ApplicationRecord.transaction do - page.lock! - - old_title = page.title - - tag = page.tag_name.tag - - if tag && title != old_title + tag.lock! + old_title = tag_name.name + if title != old_title TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) end - page.tag_name.update!(name: title) if title != old_title + tag_name.update!(name: title) if title != old_title message = params[:message].presence - description = TagDescription.find_by(id: page.id) - description_page = description && ( - tag&.id == description.tag_id || - (tag.nil? && !old_title.include?(':'))) - if description && !description_page - Wiki::Commit.legacy_content!(page:, body:, created_user: current_user, - message:, base_revision_id:) - else - Wiki::Commit.content!(page:, body:, created_user: current_user, - message:, base_revision_id:) - end + TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user, + message:, base_revision_id:) - if tag && title != old_title + if title != old_title tag.reload TagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user) end @@ -273,23 +238,7 @@ class WikiPagesController < ApplicationController return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id)) end - pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages - revisions = WikiRevision.joins(wiki_page: :tag_name) - .where(wiki_page_id: pages.select(:id)) - .includes(:created_user, wiki_page: { tag_name: :tag }) - .order(id: :desc).limit(200) - json = revisions.map { |rev| - { revision_id: rev.id, - pred: rev.base_revision_id, - succ: nil, - wiki_page: { id: rev.wiki_page_id, - title: rev.wiki_page.title, - deprecated_at: rev.wiki_page.deprecated_at }, - user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name }, - kind: rev.kind, - message: rev.message, - timestamp: rev.created_at } - } + json = [] unless id json.concat(tag_group_changes(TagGroupVersion.all)) json.concat(description_changes(TagDescription.where(language_code: 'ja'))) @@ -351,6 +300,7 @@ class WikiPagesController < ApplicationController updated_at = version.created_at render json: { 'id' => version.tag_group_id, + 'resource_type' => 'tag_group', 'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }", 'deprecated_at' => nil, 'body' => body, @@ -453,16 +403,6 @@ class WikiPagesController < ApplicationController .find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title }) end - def unmigrated_wiki_pages - descriptions = TagDescription.where(language_code: 'ja') - - WikiPage - .joins(:tag_name) - .where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%") - .where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)', - descriptions.select(:tag_id)) - end - def description_changes descriptions versions = TagDescriptionVersion.where(language_code: 'ja', tag_id: descriptions.select(:tag_id)) diff --git a/backend/app/models/external_tag.rb b/backend/app/models/external_tag.rb index dc81bef..a1f03a2 100644 --- a/backend/app/models/external_tag.rb +++ b/backend/app/models/external_tag.rb @@ -1,4 +1,6 @@ class ExternalTag < ApplicationRecord + before_create { SharedResourceIdAllocator.assign!(self) } + enum :platform, nico: 'nico' validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys } diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 7891029..173a52a 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -2,6 +2,8 @@ require 'set' class Tag < ApplicationRecord + before_create { SharedResourceIdAllocator.assign!(self) } + class NicoTagNormalisationError < ArgumentError ; end @@ -51,6 +53,7 @@ class Tag < ApplicationRecord has_many :tag_versions has_many :tag_names + has_many :tag_descriptions attribute :version_no, :integer, default: 1 @@ -96,7 +99,13 @@ class Tag < ApplicationRecord def deprecated? = deprecated_at? - def has_wiki = wiki_page.present? + def has_description? language_code: + if tag_descriptions.loaded? + tag_descriptions.any? { _1.language_code == language_code } + else + tag_descriptions.exists?(language_code:) + end + end def material_id = materials.first&.id diff --git a/backend/app/models/tag_description.rb b/backend/app/models/tag_description.rb index 2c5e226..f2f212d 100644 --- a/backend/app/models/tag_description.rb +++ b/backend/app/models/tag_description.rb @@ -1,3 +1,5 @@ class TagDescription < ApplicationRecord + before_create { SharedResourceIdAllocator.assign!(self) } + belongs_to :tag end diff --git a/backend/app/models/tag_group.rb b/backend/app/models/tag_group.rb index 3533641..19cf4ed 100644 --- a/backend/app/models/tag_group.rb +++ b/backend/app/models/tag_group.rb @@ -1,4 +1,6 @@ class TagGroup < ApplicationRecord + before_create { SharedResourceIdAllocator.assign!(self) } + belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :updated_by_user, class_name: 'User', optional: true diff --git a/backend/app/representations/tag_repr.rb b/backend/app/representations/tag_repr.rb index 9e3a42e..a8f61d1 100644 --- a/backend/app/representations/tag_repr.rb +++ b/backend/app/representations/tag_repr.rb @@ -3,21 +3,28 @@ module TagRepr BASE = { only: [:id, :category, :post_count, :created_at, :updated_at, :deprecated_at], - methods: [:has_wiki, :material_id, :has_deerjikists] }.freeze + methods: [:material_id, :has_deerjikists] }.freeze module_function def base tag, language_code: 'ja' - tag.as_json(BASE).merge(name: name_for(tag, language_code), + tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:), + name: name_for(tag, language_code), aliases: tag.snapshot_aliases(language_code), parents: tag.parents.map { inline(_1, language_code:) }) end def inline tag, language_code: 'ja' - tag.as_json(BASE).merge(name: name_for(tag, language_code), aliases: [], parents: []) + tag.as_json(BASE).merge('has_wiki' => tag.has_description?(language_code:), + name: name_for(tag, language_code), aliases: [], parents: []) end - def many(tags, language_code: 'ja') = tags.map { |tag| base(tag, language_code:) } + def many tags, language_code: 'ja' + records = tags.to_a + ActiveRecord::Associations::Preloader.new( + records:, associations: [:tag_descriptions, { parents: :tag_descriptions }]).call + records.map { |tag| base(tag, language_code:) } + end def name_for tag, language_code tag.name(language_code) || (tag.system? ? tag.name('ja') : nil) diff --git a/backend/app/representations/wiki_page_repr.rb b/backend/app/representations/wiki_page_repr.rb index 12fb3b6..e1b780e 100644 --- a/backend/app/representations/wiki_page_repr.rb +++ b/backend/app/representations/wiki_page_repr.rb @@ -13,6 +13,7 @@ module WikiPageRepr def tag_description description tag = description.tag { 'id' => description.id, + 'resource_type' => 'tag_description', 'tag_name_id' => tag.tag_name('ja')&.id, 'body' => description.body, 'created_user_id' => description.created_by_user_id, @@ -26,7 +27,7 @@ module WikiPageRepr end def tag_group tag_group - TagGroupRepr.tag_group(tag_group) + TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group') end def many wiki_pages diff --git a/backend/app/services/shared_resource_id_allocator.rb b/backend/app/services/shared_resource_id_allocator.rb new file mode 100644 index 0000000..03ad394 --- /dev/null +++ b/backend/app/services/shared_resource_id_allocator.rb @@ -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 diff --git a/backend/app/services/tag_description_operation.rb b/backend/app/services/tag_description_operation.rb index f3d9ebc..c0d0d11 100644 --- a/backend/app/services/tag_description_operation.rb +++ b/backend/app/services/tag_description_operation.rb @@ -8,9 +8,9 @@ class TagDescriptionOperation tag_id: tag.id, language_code: LANGUAGE_CODE) page = Wiki::CompatibilityWriter.create!( - tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:) + tag_name: compatibility_tag_name(tag), body:, created_by_user:, message:) revision = page.wiki_revisions.order(id: :desc).first - record_version!(tag:, revision:, body:, user: created_by_user, + record_version!(tag:, revision:, body: revision.body, user: created_by_user, description: nil) page end @@ -22,7 +22,20 @@ class TagDescriptionOperation tag.lock! description = TagDescription.lock.find_by!( tag_id: tag.id, language_code: LANGUAGE_CODE) - page = WikiPage.lock.find(description.id) + tag_name = compatibility_tag_name(tag) + page = WikiPage.lock.find_by(tag_name_id: tag_name.id) + unless page + versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: LANGUAGE_CODE) + if base_revision_id.present? && versions.maximum(:id) != base_revision_id.to_i + raise Wiki::Commit::Conflict, '競合が発生してゐます.' + end + page = Wiki::CompatibilityWriter.create!( + tag_name:, body:, created_by_user: updated_by_user, message:) + revision = page.current_revision + record_version!(tag:, revision:, body: revision.body, + user: updated_by_user, description:) + next revision + end revision = Wiki::CompatibilityWriter.update!( page:, body:, updated_by_user:, message:, base_revision_id:) @@ -34,6 +47,11 @@ class TagDescriptionOperation end end + def self.compatibility_tag_name tag + tag.tag_name(LANGUAGE_CODE) || TagName.find_by!(id: tag.tag_name_id, + language_code: LANGUAGE_CODE) + end + def self.record_version! tag:, revision:, body:, user:, description: version_no = description ? description.version_no + 1 : 1 TagDescriptionVersion.create!( @@ -44,7 +62,7 @@ class TagDescriptionOperation description.update!(body:, version_no:, updated_by_user_id: user.id) else TagDescription.create!( - id: revision.wiki_page_id, tag_id: tag.id, + tag_id: tag.id, language_code: LANGUAGE_CODE, version_no:, body:, created_by_user_id: user.id, updated_by_user_id: user.id) end diff --git a/backend/app/services/tag_group_operation.rb b/backend/app/services/tag_group_operation.rb index 905773b..985276e 100644 --- a/backend/app/services/tag_group_operation.rb +++ b/backend/app/services/tag_group_operation.rb @@ -349,7 +349,7 @@ class TagGroupOperation tag_name.primary_flg = true tag_name.auto_generated = false tag_name.save! - Wiki::Commit.create_legacy_content!(tag_name:, body:, created_by_user: actor) + Wiki::CompatibilityWriter.create!(tag_name:, body:, created_by_user: actor) end nil end diff --git a/backend/app/services/wiki/commit.rb b/backend/app/services/wiki/commit.rb index 1414307..d18bd63 100644 --- a/backend/app/services/wiki/commit.rb +++ b/backend/app/services/wiki/commit.rb @@ -8,12 +8,13 @@ module Wiki end def self.create_content! tag_name:, body:, created_by_user:, message: nil - if tag_name.tag && tag_name.language_code == 'ja' + if tag_name.tag && tag_name.language_code == 'ja' && !tag_name.name.include?(':') return TagDescriptionOperation.create!( tag: tag_name.tag, body:, created_by_user:, message:) end - create_legacy_content!(tag_name:, body:, created_by_user:, message:) + tag_name.errors.add(:base, :invalid) + raise ActiveRecord::RecordInvalid, tag_name end def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil @@ -42,14 +43,17 @@ module Wiki end def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil - description = TagDescription.find_by(id: page.id) - if description + description = TagDescription.find_by(tag_id: page.tag_name.tag_id, language_code: 'ja') + description ||= TagDescription.joins(:tag).find_by( + tags: { tag_name_id: page.tag_name_id }, language_code: 'ja') + if description && !page.title.include?(':') return TagDescriptionOperation.update!( tag: description.tag, body:, updated_by_user: created_user, message:, base_revision_id:) end - legacy_content!(page:, body:, created_user:, message:, base_revision_id:) + page.errors.add(:base, :invalid) + raise ActiveRecord::RecordInvalid, page end def self.legacy_content! page:, body:, created_user:, message: nil, diff --git a/backend/app/services/wiki/compatibility_writer.rb b/backend/app/services/wiki/compatibility_writer.rb index 2bb003c..c7bcaa1 100644 --- a/backend/app/services/wiki/compatibility_writer.rb +++ b/backend/app/services/wiki/compatibility_writer.rb @@ -1,8 +1,16 @@ module Wiki class CompatibilityWriter def self.create! tag_name:, body:, created_by_user:, message: nil - Commit.create_legacy_content!( - tag_name:, body:, created_by_user:, message:) + ApplicationRecord.transaction do + tag_name.lock! + page = WikiPage.lock.find_by(tag_name_id: tag_name.id) + if page + update!(page:, body:, updated_by_user: created_by_user, message:) + page + else + Commit.create_legacy_content!(tag_name:, body:, created_by_user:, message:) + end + end end def self.update! page:, body:, updated_by_user:, message: nil, @@ -23,7 +31,9 @@ module Wiki end end - return if Commit.normalise_body(page.body) == normalised + if Commit.normalise_body(page.body) == normalised && page.wiki_revisions.exists? + return + end Commit.legacy_content!( page:, body: normalised, created_user: updated_by_user, message:, diff --git a/backend/lib/tasks/migrate_help_pages.rake b/backend/lib/tasks/migrate_help_pages.rake new file mode 100644 index 0000000..c436091 --- /dev/null +++ b/backend/lib/tasks/migrate_help_pages.rake @@ -0,0 +1,51 @@ +namespace :help do + desc 'ヘルプ移行' + task migrate: :environment do + help_path = ENV.fetch('BTRC_HUB_HELP_PATH') + abort 'ヘルプ置場指定してね.' if help_path.blank? + + # ファイルがすでに存在する場合は通さなぃ. + abort 'ファイル消してね.' if Dir.glob("#{ help_path }/*").present? + + # 現状物理削除はなぃはず + pages = + WikiPage + .joins(:tag_name) + .where(tag_names: { tag_id: nil, language_code: 'ja' }) + .where.not("tag_names.name LIKE 'タググループ:%'") + + puts "移行対象: #{pages.count} 件" + abort '移行対象がありません.' if pages.empty? + + pages.find_each do |page| + puts page.body + puts page.title + + puts 'この identifier は何にする?' + identifier = $stdin.gets.chomp + Dir.mkdir "#{ help_path }/#{ identifier }" + Dir.mkdir "#{ help_path }/#{ identifier }/editions" + Dir.mkdir "#{ help_path }/#{ identifier }/editions/ja" + + last_file_name = '' + WikiVersion.where(wiki_page_id: page.id).order(:version_no).each do |ver| + last_file_name, last_file_name_no = last_file_name.split('.') + + file_name = ver.created_at.strftime('%Y-%m-%d') + if file_name == last_file_name + file_name += ".#{ (last_file_name_no || 1).to_i + 1 }" + end + + body = "# #{ ver.title.sub(/^ヘルプ:/, '') }\n\n#{ ver.body }" + File.write "#{ help_path }/#{ identifier }/editions/ja/#{ file_name }.mdx", body + + last_file_name = file_name + end + + article_meta = { 'identifier' => identifier, + 'canonical_language' => 'ja', + 'languages' => { 'ja' => { 'current_edition' => last_file_name } } } + File.write "#{ help_path }/#{ identifier }/article.yml", article_meta.to_yaml + end + end +end diff --git a/backend/spec/db/tag_description_migration_spec.rb b/backend/spec/db/tag_description_migration_spec.rb index 012bd29..c34c2a1 100644 --- a/backend/spec/db/tag_description_migration_spec.rb +++ b/backend/spec/db/tag_description_migration_spec.rb @@ -5,11 +5,11 @@ RSpec.describe 'Tag description migration identity' do it 'preserves every revision ID and repeated body in an independent sequence' do user = create_member_user! tag_name = create(:tag_name, name: 'description_migration') - page = Wiki::Commit.create_content!( + page = Wiki::Commit.create_legacy_content!( tag_name:, body: 'A', created_by_user: user) revisions = [page.current_revision] ['A', 'B', 'B', 'A'].each do |body| - revisions << Wiki::Commit.content!( + revisions << Wiki::Commit.legacy_content!( page:, body:, created_user: user, base_revision_id: revisions.last.id) end tag = create(:tag, primary_tag_name: tag_name) diff --git a/backend/spec/models/shared_tag_ids_spec.rb b/backend/spec/models/shared_tag_ids_spec.rb new file mode 100644 index 0000000..96ef653 --- /dev/null +++ b/backend/spec/models/shared_tag_ids_spec.rb @@ -0,0 +1,84 @@ +require 'rails_helper' + +RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do + include TagGroupRecords + let(:user) { create_member_user! } + + def create_resource resource + if resource == :tag + Tag.find_or_create_by_tag_name!(Locale.nipponese, 'shared_id_new', category: :general) + else + ExternalTag.create!(platform: :nico, name: 'shared_id_new') + end + end + + [:tag, :external_tag].each do |resource| + it "starts #{ resource } at 1 when both tables are empty" do + expect(Tag.count).to eq(0) + expect(ExternalTag.count).to eq(0) + expect(create_resource(resource).id).to eq(1) + end + + it "allocates #{ resource } above the other table's maximum" do + create(:tag, id: resource == :tag ? 1 : 20) + create(:external_tag, id: resource == :external_tag ? 1 : 20) + + expect(create_resource(resource).id).to eq(21) + end + + it "uses the current maximum after deleting earlier #{ resource } records" do + tag = create(:tag, id: 100) + TagName.where(tag_id: tag.id).update_all(tag_id: nil) + tag.destroy! + create(:external_tag, id: 200).destroy! + expect(Tag.unscoped.count).to eq(0) + expect(ExternalTag.unscoped.count).to eq(0) + expect(create_resource(resource).id).to eq(1) + end + + it "preserves legacy overlaps and gaps when creating #{ resource }" do + [1, 2, 5].each { create(:tag, id: _1) } + [1, 3, 8].each { create(:external_tag, id: _1) } + tags = Tag.order(:id).map(&:attributes) + external_tags = ExternalTag.order(:id).map(&:attributes) + + record = create_resource(resource) + + aggregate_failures do + expect(record.id).to eq(9) + expect(Tag.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(tags) + expect(ExternalTag.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(external_tags) + expect(Tag.exists?(id: 3)).to be(false) + expect(ExternalTag.exists?(id: 2)).to be(false) + end + end + + it "ignores the description and group ID space when creating #{ resource }" do + tag = create(:tag, id: 1) + create(:external_tag, id: 2) + TagDescription.create!(id: 100, tag:, language_code: 'ja', body: '', version_no: 1) + seed_group(name: 'separate_space', id: 200) + + expect(create_resource(resource).id).to eq(3) + end + end + + it 'preserves overlapping IDs during updates without rewriting the other resource' do + tag = create(:tag, id: 5) + external = create(:external_tag, id: 5) + tag.update!(category: :meta) + external.update!(name: 'updated_external') + + expect(tag.reload.id).to eq(5) + expect(external.reload.id).to eq(5) + expect(tag.category).to eq('meta') + expect(external.name).to eq('updated_external') + expect(Tag.count).to eq(1) + expect(ExternalTag.count).to eq(1) + end + + it 'allocates distinct IDs for successive creations across both tables' do + records = [:tag, :external_tag].map { create_resource(_1) } + expect(records.map(&:id)).to eq([1, 2]) + end +end diff --git a/backend/spec/models/tag_repr_spec.rb b/backend/spec/models/tag_repr_spec.rb new file mode 100644 index 0000000..2c3fd64 --- /dev/null +++ b/backend/spec/models/tag_repr_spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +RSpec.describe TagRepr do + let(:tag) { create(:tag) } + + def description_for record + TagDescription.create!(tag: record, language_code: 'ja', body: '', version_no: 1) + end + + it 'keeps has_wiki boolean for the selected language in base and inline' do + description_for(tag) + [:base, :inline].each do |method| + expect(described_class.public_send(method, tag).as_json.fetch('has_wiki')).to be(true) + expect(described_class.public_send(method, tag, language_code: 'en') + .as_json.fetch('has_wiki')).to be(false) + end + end + + it 'uses the same language for a tag and its parents' do + parent = create(:tag) + description_for(tag) + description_for(parent) + TagImplication.create!(tag:, parent_tag: parent) + ['ja', 'en'].each do |language_code| + row = described_class.base(tag, language_code:).as_json + expected = language_code == 'ja' + expect(row.fetch('has_wiki')).to be(expected) + expect(row.fetch('parents').sole.fetch('has_wiki')).to be(expected) + end + end + + it 'preloads existence checks for many tags and their parents without Wiki reads' do + parent = create(:tag) + description_for(parent) + records = Array.new(3) do + create(:tag).tap { TagImplication.create!(tag: _1, parent_tag: parent) } + end + description_for(records.first) + queries = [] + subscriber = ->(*args) { queries << args.last.fetch(:sql) } + rows = nil + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do + rows = described_class.many(records).as_json + end + expect(rows.map { _1.fetch('has_wiki') }).to eq([true, false, false]) + expect(rows.map { _1.fetch('parents').sole.fetch('has_wiki') }).to eq([true] * 3) + expect(queries.grep(/\bSELECT\b.*\btag_descriptions\b/im).size).to be <= 2 + expect(queries.grep(/\bwiki_(pages|revisions|versions)\b/i)).to be_empty + end +end diff --git a/backend/spec/models/tag_spec.rb b/backend/spec/models/tag_spec.rb index 1d08b42..0cc9d8c 100644 --- a/backend/spec/models/tag_spec.rb +++ b/backend/spec/models/tag_spec.rb @@ -1,6 +1,47 @@ require 'rails_helper' RSpec.describe Tag, type: :model do + describe '#has_description?' do + let(:tag) { create(:tag) } + + it 'checks the requested language and treats an empty description as present' do + TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1) + expect(tag.has_description?(language_code: 'ja')).to be(true) + expect(tag.has_description?(language_code: 'en')).to be(false) + end + + it 'returns false without a description' do + expect(tag.has_description?(language_code: 'ja')).to be(false) + end + + it 'does not use a Wiki page as evidence of a description' do + user = create_member_user! + Wiki::Commit.create_legacy_content!(tag_name: tag.tag_name('ja'), body: 'legacy', + created_by_user: user) + expect(tag.has_description?(language_code: 'ja')).to be(false) + end + + it 'reuses preloaded descriptions without reading Wiki or querying again' do + TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1) + loaded = Tag.includes(:tag_descriptions).find(tag.id) + queries = [] + subscriber = ->(*args) { queries << args.last.fetch(:sql) } + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do + expect(loaded.has_description?(language_code: 'ja')).to be(true) + expect(loaded.has_description?(language_code: 'en')).to be(false) + end + expect(queries).to be_empty + end + + it 'keeps language selection generic without enabling another Language record' do + tag.association(:tag_descriptions).target = [ + TagDescription.new(tag:, language_code: 'en', body: '', version_no: 1)] + tag.association(:tag_descriptions).loaded! + expect(tag.has_description?(language_code: 'en')).to be(true) + expect(tag.has_description?(language_code: 'ja')).to be(false) + end + end + describe 'external tag separation' do ['nico:reserved', 'NiCo:reserved'].each do |name| it "rejects the reserved prefix #{ name } for internal tags" do diff --git a/backend/spec/requests/materials_spec.rb b/backend/spec/requests/materials_spec.rb index 14b5d8a..b891faa 100644 --- a/backend/spec/requests/materials_spec.rb +++ b/backend/spec/requests/materials_spec.rb @@ -178,6 +178,7 @@ RSpec.describe 'Materials API', type: :request do end describe 'GET /materials/:id' do + include WikiRecords let!(:tag) do create(:tag, primary_name: 'material_show', category: :material) end @@ -205,6 +206,56 @@ RSpec.describe 'Materials API', type: :request do get '/materials/999999999' expect(response).to have_http_status(:not_found) end + + def get_material_without_legacy_reads + queries = [] + subscriber = lambda do |*args| + sql = args.last.fetch(:sql) + if sql.match?(/\bSELECT\b/i) && sql.match?(/\bwiki_(pages|revisions|versions)\b/i) + queries << sql + end + end + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do + get "/materials/#{ material.id }" + end + expect(response).to have_http_status(:ok) + expect(queries).to be_empty + end + + it 'reads the description even when the legacy revision has different content' do + page = seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'legacy body', + created_by_user: member_user) + TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') + .update!(body: 'description body') + get_material_without_legacy_reads + expect(json.fetch('wiki_page_body')).to eq('description body') + expect(page.current_revision.body).to eq('legacy body') + end + + it 'uses tag_id rather than description ID without a Wiki page or TagName' do + description = TagDescription.create!(id: tag.id + 1000, tag:, language_code: 'ja', + body: 'independent body', version_no: 1) + tag.tag_names.update_all(tag_id: nil) + get_material_without_legacy_reads + expect(description.id).not_to eq(tag.id) + expect(json.fetch('wiki_page_body')).to eq('independent body') + expect(json.fetch('tag').fetch('has_wiki')).to be(true) + end + + it 'returns nil when only legacy Wiki content exists' do + seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'legacy only', + created_by_user: member_user) + TagDescription.where(tag_id: tag.id).delete_all + get_material_without_legacy_reads + expect(json.fetch('wiki_page_body')).to be_nil + expect(json.fetch('tag').fetch('has_wiki')).to be(false) + end + + it 'returns nil for an untagged material' do + material.update!(tag: nil) + get_material_without_legacy_reads + expect(json.fetch('wiki_page_body')).to be_nil + end end describe 'POST /materials' do diff --git a/backend/spec/requests/tag_description_wiki_spec.rb b/backend/spec/requests/tag_description_wiki_spec.rb index 90e2bbb..02b1184 100644 --- a/backend/spec/requests/tag_description_wiki_spec.rb +++ b/backend/spec/requests/tag_description_wiki_spec.rb @@ -1,16 +1,17 @@ require 'rails_helper' RSpec.describe 'Tag description Wiki compatibility', type: :request do + include WikiRecords let!(:user) { create_member_user! } let!(:tag_name) { create(:tag_name, name: 'description_contract') } let!(:page) do - Wiki::Commit.create_content!( + seed_wiki_page( tag_name:, body: 'old-revision', created_by_user: user) end let!(:revisions) do first = page.current_revision - second = Wiki::Commit.content!(page:, body: 'old-second', created_user: user) - third = Wiki::Commit.content!(page:, body: 'old-third', created_user: user) + second = Wiki::Commit.legacy_content!(page:, body: 'old-second', created_user: user) + third = Wiki::Commit.legacy_content!(page:, body: 'old-third', created_user: user) [first, second, third] end let!(:tag) { create(:tag, primary_tag_name: tag_name) } @@ -242,8 +243,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do end describe 'mixed changes' do - it 'includes unmigrated history and ignores migrated legacy-only revisions' do - legacy_page = Wiki::Commit.create_content!( + it 'excludes unmigrated history and migrated legacy-only revisions' do + legacy_page = seed_wiki_page( tag_name: create(:tag_name, name: 'Help_mixed'), body: 'help', created_by_user: user) legacy_revision = legacy_page.current_revision @@ -256,7 +257,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id } expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id)) expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id) - expect(json).to include(include( + expect(json).not_to include(include( 'revision_id' => legacy_revision.id, 'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed'))) end diff --git a/backend/spec/requests/tag_description_wiki_write_spec.rb b/backend/spec/requests/tag_description_wiki_write_spec.rb index 798cffc..3b3af9d 100644 --- a/backend/spec/requests/tag_description_wiki_write_spec.rb +++ b/backend/spec/requests/tag_description_wiki_write_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' RSpec.describe 'Tag description Wiki dual writes', type: :request do + include WikiRecords let!(:user) { create_member_user! } let!(:tag_name) { create(:tag_name, name: 'description_write') } let(:tag) { create(:tag, primary_tag_name: tag_name) } @@ -31,7 +32,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do headers: headers end - it 'creates both current records and histories with a shared revision ID' do + it 'creates a description with matching Wiki content and histories' do expect { create_description } .to change(WikiPage, :count).by(1) .and change(WikiRevision, :count).by(1) @@ -39,17 +40,28 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do .and change(TagDescription, :count).by(1) .and change(TagDescriptionVersion, :count).by(1) expect(response).to have_http_status(:created) - - page = WikiPage.find(json.fetch('id')) description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') - version = TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja') - expect(description).to have_attributes(id: page.id, body: 'initial', version_no: 1) - expect(version).to have_attributes( - id: page.current_revision.id, body: 'initial', version_no: 1, - event_type: 'create', created_by_user_id: user.id) + expect(description).to have_attributes(body: 'initial', version_no: 1) + page = WikiPage.find_by!(tag_name:) expect(page.body).to eq(description.body) - expect(page.current_revision.body).to eq(version.body) - expect(page.wiki_versions.last.body).to eq(version.body) + expect(page.current_revision.body).to eq(description.body) + expect(page.wiki_versions.sole.body).to eq(description.body) + expect(json.fetch('id')).to eq(description.id) + expect(TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')) + .to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id) + end + + it 'reuses an existing compatibility page and synchronises its histories' do + page = Wiki::Commit.create_legacy_content!( + tag_name:, body: 'legacy', created_by_user: user) + expect { create_description } + .to change(WikiPage, :count).by(0) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + expect(response).to have_http_status(:created) + description = TagDescription.find_by!(tag_id: tag.id) + expect(page.reload.body).to eq(description.body) + expect(TagDescriptionVersion.find(page.current_revision.id).body).to eq(page.body) end ['missing_description', 'orphan_description'].each do |title| @@ -65,48 +77,19 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do end end - it 'creates a namespaced legacy Wiki resource without a Tag' do - expect do - post '/wiki', params: { title: 'Help:description_write', body: 'legacy' }, - headers: headers - end.to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - .and change(TagDescription, :count).by(0) - .and change(TagDescriptionVersion, :count).by(0) - - expect(response).to have_http_status(:created) - page = WikiPage.find(json.fetch('id')) - expect(page.tag_name.tag).to be_nil - expect(page.body).to eq('legacy') - expect(page.current_revision.body).to eq('legacy') - end - - it 'creates only legacy Wiki for a namespaced title with an existing Tag' do - namespaced_name = create(:tag_name, name: 'help:foo') - namespaced_tag = create(:tag, primary_tag_name: namespaced_name) - - expect do - post '/wiki', params: { title: namespaced_name.name, body: 'legacy' }, - headers: headers - end.to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - .and change(TagName, :count).by(0) - .and change(TagDescription, :count).by(0) - .and change(TagDescriptionVersion, :count).by(0) - - expect(response).to have_http_status(:created) - page = WikiPage.find(json.fetch('id')) - expect(page).to have_attributes(tag_name_id: namespaced_name.id, body: 'legacy') - expect(page.tag_name.tag).to eq(namespaced_tag) - expect(page.current_revision.body).to eq('legacy') - expect(page.wiki_versions.last.body).to eq('legacy') + [false, true].each do |linked| + it "rejects namespaced creation with Tag present: #{ linked }" do + name = create(:tag_name, name: 'Help:frozen') + create(:tag, primary_tag_name: name) if linked + before_state = stored_state + post '/wiki', params: { title: name.name, body: 'legacy' }, headers: headers + expect(response.status).to be_between(400, 499) + expect(stored_state).to eq(before_state) + end end [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model| - it "rolls back both systems when #{ model.name } persistence fails" do - tag_name + it "rolls back new description creation when #{ model.name } persistence fails" do before_state = stored_state aggregate_failures do fail_save(model) { create_description } @@ -116,10 +99,32 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do end end + it 'creates missing compatibility data without writing an ID-colliding Help page' do + description = TagDescription.create!( + id: 500, tag:, language_code: 'ja', body: 'before', version_no: 1) + help = WikiPage.create!(id: description.id, + tag_name: create(:tag_name, name: 'Help:unrelated'), + body: 'unrelated', created_user: user, updated_user: user) + Wiki::Commit.legacy_content!(page: help, body: help.body, created_user: user) + before_help = help.attributes + expect do + put "/wiki/#{ description.id }", params: { title: tag_name.name, body: 'after' }, + headers: headers + end.to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + expect(response).to have_http_status(:ok) + expect(description.reload).to have_attributes(id: 500, body: 'after') + expect(help.reload.attributes).to eq(before_help) + compatibility = WikiPage.find_by!(tag_name:) + expect(compatibility.body).to eq(description.body) + expect(compatibility.current_revision.body).to eq(description.body) + end + describe 'PUT /wiki/:id' do let!(:page) do tag - Wiki::Commit.create_content!( + seed_wiki_page( tag_name:, body: 'before', created_by_user: user) end let!(:first_revision) { page.current_revision } @@ -138,7 +143,8 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do expect(page.reload.version_no).to eq(1) expect(description.version_no).to eq(1) expect { update_description } - .to change(WikiRevision, :count).by(1) + .to change(WikiPage, :count).by(0) + .and change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(1) .and change(TagDescriptionVersion, :count).by(1) expect(response).to have_http_status(:ok) @@ -157,7 +163,8 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do tag_name.update_columns(tag_id: nil) expect { update_description } - .to change(WikiRevision, :count).by(1) + .to change(WikiPage, :count).by(0) + .and change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(1) .and change(TagDescriptionVersion, :count).by(1) @@ -177,10 +184,10 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do page_state = page.attributes description_state = description.attributes - update_description(body: 'before', title: 'Help:description_renamed') + update_description(body: 'before', title: 'description_renamed') expect(response).to have_http_status(:ok) - expect(tag_name.reload.name).to eq('Help:description_renamed') + expect(tag_name.reload.name).to eq('description_renamed') expect(WikiRevision.count).to eq(revision_count) expect(WikiVersion.count).to eq(wiki_version_count) expect(TagDescriptionVersion.count).to eq(description_version_count) diff --git a/backend/spec/requests/tag_descriptions_spec.rb b/backend/spec/requests/tag_descriptions_spec.rb index a0746e5..7901144 100644 --- a/backend/spec/requests/tag_descriptions_spec.rb +++ b/backend/spec/requests/tag_descriptions_spec.rb @@ -206,7 +206,13 @@ RSpec.describe 'Tag description native API', type: :request do context 'without an existing description' do before { tag } - it 'creates both current resources and histories with matching compatibility IDs' do + it 'creates a description with synchronised Wiki content and histories' do + unrelated = Wiki::Commit.create_legacy_content!( + tag_name: create(:tag_name, name: 'Help:unrelated_native_description'), + body: 'unrelated body', created_by_user: author) + unrelated_state = [unrelated.attributes, + unrelated.wiki_revisions.map(&:attributes), + unrelated.wiki_versions.map(&:attributes)] expect { native_write(body: 'created natively') } .to change(TagDescription, :count).by(1) .and change(TagDescriptionVersion, :count).by(1) @@ -215,7 +221,7 @@ RSpec.describe 'Tag description native API', type: :request do .and change(WikiVersion, :count).by(1) expect(response).to have_http_status(:created) description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') - page = WikiPage.find(description.id) + page = WikiPage.find_by!(tag_name: tag.tag_name('ja')) revision = page.current_revision version = TagDescriptionVersion.find(revision.id) expect(description).to have_attributes( @@ -227,6 +233,9 @@ RSpec.describe 'Tag description native API', type: :request do expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body) expect(revision.body).to eq(version.body) expect(page.wiki_versions.last.body).to eq(version.body) + unrelated.reload + expect([unrelated.attributes, unrelated.wiki_revisions.map(&:attributes), + unrelated.wiki_versions.map(&:attributes)]).to eq(unrelated_state) expect_current(description) get "/wiki/#{ description.id }" diff --git a/backend/spec/requests/tag_group_wiki_spec.rb b/backend/spec/requests/tag_group_wiki_spec.rb index 2104beb..b42805c 100644 --- a/backend/spec/requests/tag_group_wiki_spec.rb +++ b/backend/spec/requests/tag_group_wiki_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' RSpec.describe 'Tag group Wiki facade reads', type: :request do + include WikiRecords include TagGroupRecords let!(:user) { create_member_user! } @@ -69,34 +70,36 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end end - it 'combines Tag Groups, Help and Tag Descriptions in the unfiltered list' do - help = Wiki::Commit.create_legacy_content!( + it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do + help = seed_wiki_page( tag_name: create(:tag_name, name: 'Help:混在'), body: 'help', created_by_user: user) TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user) get '/wiki' expect(response).to have_http_status(:ok) expect(json.map { _1.fetch('title') }).to include( - 'タググループ:読取り契約', help.title, '新タグ') + 'タググループ:読取り契約', '新タグ') + expect(json.map { _1.fetch('title') }).not_to include(help.title) end - it 'keeps Help readable when its ID collides with a new group' do - help = Wiki::Commit.create_legacy_content!( + it 'excludes Help when its ID collides with a new group' do + help = seed_wiki_page( tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body', created_by_user: user) seed_group(name: '別 ID重複', id: help.id, description: 'group body') get '/wiki' expect(response).to have_http_status(:ok) - expect(json.map { _1.fetch('title') }).to include(help.title, 'タググループ:別 ID重複') + expect(json.map { _1.fetch('title') }).to include('タググループ:別 ID重複') + expect(json.map { _1.fetch('title') }).not_to include(help.title) get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }" - expect(response).to have_http_status(:ok) - expect(json.fetch('body')).to eq('help body') + expect(response).to have_http_status(:not_found) get '/wiki/changes' expect(response).to have_http_status(:ok) expect(json.map { _1.dig('wiki_page', 'title') }).to include( - help.title, 'タググループ:別 ID重複') + 'タググループ:別 ID重複') + expect(json.map { _1.dig('wiki_page', 'title') }).not_to include(help.title) end it 'finds groups by a partial prefix of their displayed title' do @@ -122,7 +125,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end it 'orders mixed resource history by timestamp rather than independent IDs' do - help = Wiki::Commit.create_legacy_content!( + help = seed_wiki_page( tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help', created_by_user: user) TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user) @@ -136,8 +139,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) } expect(timestamps).to eq(timestamps.sort.reverse) titles = json.map { _1.dig('wiki_page', 'title') } - expect(titles.index('新タグ')).to be < titles.index(help.title) - expect(titles.index(help.title)).to be < titles.index('タググループ:読取り契約') + expect(titles).not_to include(help.title) + expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約') end it 'renders nullable group editors without raising' do @@ -166,16 +169,16 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do 'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約')) end - it 'includes Help and group history but excludes legacy-only migrated revisions' do + it 'includes group history but excludes all legacy-only revisions' do extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user) - help = Wiki::Commit.create_legacy_content!( + help = seed_wiki_page( tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user) get '/wiki/changes' expect(response).to have_http_status(:ok) rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' } expect(rows.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id]) expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id) - expect(json).to include(include( + expect(json).not_to include(include( 'revision_id' => help.current_revision.id, 'wiki_page' => include('title' => help.title))) end @@ -214,7 +217,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do context 'when Help, description and group share the same numeric ID' do let(:colliding_help) do - Wiki::Commit.create_legacy_content!( + seed_wiki_page( tag_name: create(:tag_name, name: 'Help:三者ID衝突'), body: 'help', created_by_user: user) end @@ -239,13 +242,12 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do colliding_description_version end - it 'lists all three resources without deduplicating their numeric ID' do + it 'lists both new resources sharing a numeric ID without legacy Help' do get '/wiki' expect(response).to have_http_status(:ok) rows = json.select { _1.fetch('id') == colliding_help.id } expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly( - [colliding_help.id, colliding_help.title], [colliding_description.id, '新タグ'], [colliding_group_id, 'タググループ:三者ID衝突']) end @@ -259,7 +261,6 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do [row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')] end expect(histories).to contain_exactly( - [colliding_help.current_revision.id, colliding_help.title], [colliding_description_version.id, '新タグ'], [group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突']) end @@ -285,8 +286,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do end end - it 'resolves colliding group, Help and description IDs by title' do - help = Wiki::Commit.create_legacy_content!( + it 'resolves new resources by title while excluding colliding legacy Help' do + help = seed_wiki_page( tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user) seed_group(name: 'ID衝突', id: help.id, description: 'group') TagDescription.create!( @@ -294,11 +295,15 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do TagDescriptionVersion.create!( tag_id: new_tag.id, language_code: 'ja', version_no: 1, event_type: 'create', body: 'description', created_at: Time.current) - { 'タググループ:ID衝突' => 'group', 'Help:ID衝突' => 'help', '新タグ' => 'description' } + { 'タググループ:ID衝突' => 'group', '新タグ' => 'description' } .each do |title, body| get "/wiki/title/#{ CGI.escapeURIComponent(title) }" expect(response).to have_http_status(:ok) expect(json.fetch('body').strip).to eq(body) end + without_group_legacy_reads do + get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }" + end + expect(response).to have_http_status(:not_found) end end diff --git a/backend/spec/requests/tag_group_wiki_write_spec.rb b/backend/spec/requests/tag_group_wiki_write_spec.rb index 4c80a63..7498e9f 100644 --- a/backend/spec/requests/tag_group_wiki_write_spec.rb +++ b/backend/spec/requests/tag_group_wiki_write_spec.rb @@ -33,8 +33,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do end end - it 'creates the group, first snapshot and compatibility Wiki history' do - create_group + it 'creates the group, snapshot and matching compatibility histories' do + expect { create_group } + .to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) expect(response).to have_http_status(:created) group = group_rows('tag_groups', name: '新規').sole expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規') @@ -43,10 +46,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id) expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id) page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' }) - expect(page.body.strip).to eq('- 洗澡鹿') + expect(page.body).to eq('- 洗澡鹿') expect(page.current_revision.body).to eq(page.body) expect(page.wiki_versions.sole.body).to eq(page.body) - # New IDs are independently allocated; no global identity assertion here. end it 'keeps repeated placements but only one membership and one snapshot member' do @@ -293,8 +295,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do expect(group_state).to eq(before_state) end - (TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions']) - .each do |table| + tables = TagGroupRecords::GROUP_TABLES + tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions'] + tables.each do |table| it "rolls back all writes after a real #{ table } write fails" do expect_atomic_group_write(table) { write_group(operation, headers) } end diff --git a/backend/spec/requests/tag_group_write_integrity_spec.rb b/backend/spec/requests/tag_group_write_integrity_spec.rb index 2d6ce0e..98c2e5d 100644 --- a/backend/spec/requests/tag_group_write_integrity_spec.rb +++ b/backend/spec/requests/tag_group_write_integrity_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' RSpec.describe 'Tag group write integrity', type: :request do + include WikiRecords include TagGroupRecords include ActiveSupport::Testing::TimeHelpers @@ -24,7 +25,7 @@ RSpec.describe 'Tag group write integrity', type: :request do end def create_help body - Wiki::Commit.create_legacy_content!( + seed_wiki_page( tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user) end @@ -70,17 +71,16 @@ RSpec.describe 'Tag group write integrity', type: :request do expect(page.body).to eq('- 伊地知ニジカ') end - it 'updates Help with a colliding group ID without altering the group' do + it 'rejects a standalone Help update despite a colliding group ID' do page = create_help('Help body') id = seed_group(name: '別グループ', id: page.id, members:, placements:) group_before = TagGroup.find(id).attributes version_before = group_version(id, 1) - put_wiki(id, title: 'Help:改名', body: 'Help after') + expect_no_writes { put_wiki(id, title: 'Help:改名', body: 'Help after') } - expect(response).to have_http_status(:ok) - expect(page.reload.body).to eq('Help after') - expect(page.title).to eq('Help:改名') + expect(page.reload.body).to eq('Help body') + expect(page.title).to eq('Help:既存') expect(TagGroup.find(id).attributes).to eq(group_before) expect(group_version(id, 1)).to eq(version_before) end @@ -98,7 +98,8 @@ RSpec.describe 'Tag group write integrity', type: :request do it 'keeps a description update separate from a colliding group' do page = Wiki::Commit.create_content!( tag_name: tag.tag_name('ja'), body: 'description before', created_by_user: user) - id = seed_group(name: '更新', id: page.id, members:, placements:) + description = TagDescription.find_by!(tag_id: tag.id) + id = seed_group(name: '更新', id: description.id, members:, placements:) group_before = TagGroup.find(id).attributes put_wiki(id, title: '洗澡鹿', body: 'description after') @@ -115,18 +116,19 @@ RSpec.describe 'Tag group write integrity', type: :request do description = TagDescription.create!( id:, tag:, language_code: 'ja', version_no: 1, body: 'description') - put_wiki(id, title: 'Help:既存', body: 'Help after') + expect_no_writes { put_wiki(id, title: 'Help:既存', body: 'Help after') } - expect(response).to have_http_status(:ok) - expect(page.reload.body).to eq('Help after') + expect(page.reload.body).to eq('Help before') expect(description.reload.body).to eq('description') expect(TagGroup.find(id).version_no).to eq(1) - expect_no_writes { put_wiki(id, title: '洗澡鹿', body: 'description after') } + put_wiki(id, title: '洗澡鹿', body: 'description after') + expect(response).to have_http_status(:ok) + expect(description.reload.body).to eq('description after') put_wiki(id, title: 'タググループ:更新') expect(response).to have_http_status(:ok) expect(TagGroup.find(id).version_no).to eq(2) - expect(page.reload.body).to eq('Help after') - expect(description.reload.body).to eq('description') + expect(page.reload.body).to eq('Help before') + expect(description.reload.body).to eq('description after') end end @@ -136,14 +138,14 @@ RSpec.describe 'Tag group write integrity', type: :request do id = seed_group(name: '更新', id: help.id, members:, placements:) help_before = help.attributes - put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json + expect do + put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json + end.to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) expect(response).to have_http_status(:ok) - page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:改名' }) - expect(page.id).not_to eq(help.id) - expect(page.body).to eq('- 洗澡鹿') - expect(page.current_revision.body).to eq(page.body) - expect(page.wiki_versions.sole.body).to eq(page.body) + expect(TagGroup.find(id).name).to eq('改名') expect(help.reload.attributes).to eq(help_before) end diff --git a/backend/spec/requests/tag_groups_spec.rb b/backend/spec/requests/tag_groups_spec.rb index 33ba293..d910dfb 100644 --- a/backend/spec/requests/tag_groups_spec.rb +++ b/backend/spec/requests/tag_groups_spec.rb @@ -391,6 +391,17 @@ RSpec.describe 'Tag group native API', type: :request do end end + it 'creates native state and its compatibility histories' do + expect { native_write(:create) } + .to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + expect(response).to have_http_status(:created) + expect_group_snapshot( + json.fetch('id'), number: 1, members: snapshot_members, + placements:, description: '全体の説明') + end + [:create, :update].each do |operation| it "synchronises Wiki current state and histories on native #{ operation }" do native_write(operation) @@ -418,19 +429,18 @@ RSpec.describe 'Tag group native API', type: :request do end end - it 'uses AUTO_INCREMENT for a new group instead of supplying a compatibility ID' do - inserts = [] - subscriber = lambda do |*args| - sql = args.last.fetch(:sql) - inserts << sql if sql.match?(/\A\s*INSERT INTO `?tag_groups`?\s/i) - end - ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do - native_write(:create) - end + it 'assigns the next group ID from the existing maximum' do + child_id + expected_id = [TagGroup.maximum(:id).to_i, TagDescription.maximum(:id).to_i].max + 1 + native_write(:create) expect(response).to have_http_status(:created) - expect(inserts.size).to eq(1) - columns = inserts.sole[/\((.*?)\)/, 1].split(',').map { _1.strip.delete('`') } - expect(columns).not_to include('id') + expect(json.fetch('id')).to eq(expected_id) + end + + TagGroupRecords::GROUP_TABLES.each do |table| + it "rolls back native creation after #{ table } writes" do + expect_atomic_group_write(table) { native_write(:create) } + end end it 'updates a native group through Wiki and then reads structured current data' do diff --git a/backend/spec/requests/tag_resource_ids_spec.rb b/backend/spec/requests/tag_resource_ids_spec.rb new file mode 100644 index 0000000..7c324cc --- /dev/null +++ b/backend/spec/requests/tag_resource_ids_spec.rb @@ -0,0 +1,101 @@ +require 'rails_helper' + +RSpec.describe 'Tag resource ID allocation', type: :request do + include TagGroupRecords + include WikiRecords + + let!(:user) { create_member_user! } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + + def seed_description id + tag = create(:tag, primary_name: "description_id_#{ id }") + TagDescription.create!(id:, tag:, language_code: 'ja', body: 'existing', version_no: 1) + end + + def create_resource resource + if resource == :description + tag = create(:tag, primary_name: 'new_description') + post '/wiki', params: { title: tag.name('ja'), body: 'new description' }, headers: headers + else + post '/tag_groups', params: { + name: 'new_group', description: 'new group', members: [], placements: [] }, + headers:, as: :json + end + expect(response).to have_http_status(:created) + end + + [:description, :group].each do |resource| + it "keeps #{ resource } independent of the Tag and ExternalTag ID space" do + create(:tag, id: 100) + create(:external_tag, id: 200) + seed_description(1) + seed_group(name: 'shared_space', id: 2) + + create_resource(resource) + + expect(json.fetch('id')).to eq(3) + end + + it "uses the other resource's larger maximum when allocating #{ resource }" do + seed_description(resource == :description ? 1 : 20) + seed_group(name: 'other_space', id: resource == :group ? 1 : 20) + create_resource(resource) + expect(json.fetch('id')).to eq(21) + end + + it "starts #{ resource } at 1 even after previous allocated rows were removed" do + expect(TagDescription.count).to eq(0) + expect(TagGroup.count).to eq(0) + if resource == :description + seed_description(100).destroy! + else + id = seed_group(name: 'removed', id: 100) + TagGroup.find(id).destroy! + end + create_resource(resource) + expect(json.fetch('id')).to eq(1) + end + + it "allocates #{ resource } above both maxima, preserving legacy overlaps and gaps" do + [1, 2, 5].each { seed_description(_1) } + [1, 3, 8].each { seed_group(name: "group_#{ _1 }", id: _1) } + descriptions = TagDescription.order(:id).map(&:attributes) + groups = TagGroup.order(:id).map(&:attributes) + + create_resource(resource) + + expect(json.fetch('id')).to eq(9) + expect(TagDescription.where(id: [1, 2, 5]).order(:id).map(&:attributes)).to eq(descriptions) + expect(TagGroup.where(id: [1, 3, 8]).order(:id).map(&:attributes)).to eq(groups) + expect(TagDescription.exists?(id: 3)).to be(false) + expect(TagGroup.exists?(id: 2)).to be(false) + end + end + + it 'preserves group ID and unrelated rows on update' do + id = seed_group(name: 'existing', id: 5, description: 'before') + other_id = seed_group(name: 'other', id: 8, description: 'other') + other = TagGroup.find(other_id).attributes + put "/tag_groups/#{ id }", params: { + name: 'renamed', description: 'after', members: [], placements: [] }, headers:, as: :json + expect(response).to have_http_status(:ok) + expect(json.fetch('id')).to eq(id) + expect(TagGroup.find(id).description).to eq('after') + expect(TagGroup.find(other_id).attributes).to eq(other) + expect(TagGroup.count).to eq(2) + end + + it 'preserves description identity on a compatibility update' do + tag = create(:tag, primary_name: 'existing_description') + page = seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user) + description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja') + id = description.id + put "/wiki/#{ id }", params: { + title: tag.name('ja'), body: 'after', base_revision_id: page.current_revision.id }, + headers: headers + expect(response).to have_http_status(:ok) + expect(description.reload.id).to eq(id) + expect(description.body).to eq('after') + expect(TagDescription.count).to eq(1) + end +end diff --git a/backend/spec/requests/tag_wiki_history_integrity_spec.rb b/backend/spec/requests/tag_wiki_history_integrity_spec.rb index 356e7e0..665eee2 100644 --- a/backend/spec/requests/tag_wiki_history_integrity_spec.rb +++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' RSpec.describe 'Tag and wiki history integrity', type: :request do + include WikiRecords let(:member_user) { create(:user, role: 'member') } def stub_current_user user @@ -13,7 +14,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do end def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user - Wiki::Commit.create_content!( + seed_wiki_page( tag_name: primary_tag_name_for(tag, 'ja'), body:, created_by_user: user, diff --git a/backend/spec/requests/tags_spec.rb b/backend/spec/requests/tags_spec.rb index 0d0b75a..d30f5e4 100644 --- a/backend/spec/requests/tags_spec.rb +++ b/backend/spec/requests/tags_spec.rb @@ -38,6 +38,22 @@ RSpec.describe 'Tags API', type: :request do end describe 'GET /tags' do + it 'serialises descriptions and parents without loading legacy Wiki pages' do + TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1) + TagDescription.create!(tag: tag2, language_code: 'ja', body: '', version_no: 1) + TagImplication.create!(tag:, parent_tag: tag2) + queries = [] + subscriber = ->(*args) { queries << args.last.fetch(:sql) } + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do + get '/tags', params: { name: 'spec_tag' } + end + expect(response).to have_http_status(:ok) + row = response_tags.sole + expect(row.fetch('has_wiki')).to be(true) + expect(row.fetch('parents').sole.fetch('has_wiki')).to be(true) + expect(queries.grep(/\bSELECT\b.*\bwiki_(pages|revisions|versions)\b/im)).to be_empty + end + it 'includes legacy external JSON alongside an internal tag with the same id' do external = create(:external_tag, id: tag.id, name: 'spec_external', post_count: 3) diff --git a/backend/spec/requests/wiki_conflict_spec.rb b/backend/spec/requests/wiki_conflict_spec.rb index 39c5458..8aebaa6 100644 --- a/backend/spec/requests/wiki_conflict_spec.rb +++ b/backend/spec/requests/wiki_conflict_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' RSpec.describe 'Wiki conflict handling', type: :request do + include WikiRecords let!(:user) { create_member_user! } def auth_headers user @@ -9,8 +10,8 @@ RSpec.describe 'Wiki conflict handling', type: :request do it 'returns 409 when base_revision_id is stale' do page = - Wiki::Commit.create_content!( - tag_name: create(:tag_name, name: 'wiki_conflict_request'), + seed_wiki_page( + tag_name: create(:tag, primary_name: 'wiki_conflict_request').tag_name('ja'), body: 'first', created_by_user: user, message: 'init') @@ -25,12 +26,10 @@ RSpec.describe 'Wiki conflict handling', type: :request do base_revision_id: stale_id) put "/wiki/#{ page.id }", - params: { - title: 'wiki_conflict_request', - body: 'third', - message: 'stale', - base_revision_id: stale_id, - }, + params: { title: 'wiki_conflict_request', + body: 'third', + message: 'stale', + base_revision_id: stale_id }, headers: auth_headers(user) expect(response).to have_http_status(:conflict) diff --git a/backend/spec/requests/wiki_creation_freeze_spec.rb b/backend/spec/requests/wiki_creation_freeze_spec.rb new file mode 100644 index 0000000..96e4af4 --- /dev/null +++ b/backend/spec/requests/wiki_creation_freeze_spec.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +RSpec.describe 'Wiki creation freeze', type: :request do + include TagGroupRecords + include WikiRecords + + let!(:user) { create_member_user! } + let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } } + + it 'creates missing compatibility history when updating an independent group' do + id = seed_group(name: 'independent', description: 'before') + expect do + put "/tag_groups/#{ id }", params: { + name: 'independent', description: 'after', members: [], placements: [] }, + headers:, as: :json + end.to change(WikiPage, :count).by(1) + .and change(WikiRevision, :count).by(1) + .and change(WikiVersion, :count).by(1) + expect(response).to have_http_status(:ok) + expect(TagGroup.find(id).description).to eq('after') + page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:independent' }) + expect(page.body).to include('after') + expect(page.current_revision.body).to eq(page.body) + expect(page.wiki_versions.sole.body).to eq(page.body) + end + + context 'existing description write protection' do + let!(:tag) { create(:tag, primary_name: 'protected_description') } + let!(:page) do + seed_wiki_page(tag_name: tag.tag_name('ja'), body: 'before', created_by_user: user) + end + + [nil, :guest, :banned].each do |role| + it "rejects #{ role || 'anonymous' } without changing either history" do + actor = role && create(:user, role == :banned ? :member : role) + actor.update!(banned_at: Time.current) if role == :banned + request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { } + before_state = group_state + put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' }, + headers: request_headers + expect(response).to have_http_status(role ? :forbidden : :unauthorized) + expect(group_state).to eq(before_state) + end + end + + it 'rejects banned IPs without writes' do + IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current) + before_state = group_state + put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' }, headers: headers + expect(response).to have_http_status(:forbidden) + expect(group_state).to eq(before_state) + end + + ['', "\r\n\r\n"].each do |body| + it "rejects invalid description body #{ body.inspect } atomically" do + before_state = group_state + put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: }, headers: headers + expect(response).to have_http_status(:unprocessable_entity) + expect(group_state).to eq(before_state) + end + end + end +end diff --git a/backend/spec/requests/wiki_history_integrity_spec.rb b/backend/spec/requests/wiki_history_integrity_spec.rb index 50de843..60fda7f 100644 --- a/backend/spec/requests/wiki_history_integrity_spec.rb +++ b/backend/spec/requests/wiki_history_integrity_spec.rb @@ -2,6 +2,7 @@ require 'cgi' require 'rails_helper' RSpec.describe 'Wiki history integrity', type: :request do + include WikiRecords let!(:user) { create_member_user! } def auth_headers user @@ -9,49 +10,22 @@ RSpec.describe 'Wiki history integrity', type: :request do end def create_wiki_page title:, body: 'body', message: 'init', user: self.user - Wiki::Commit.create_content!( - tag_name: create(:tag_name, name: title), + seed_wiki_page( + tag_name: create(:tag, primary_name: title).tag_name('ja'), body:, created_by_user: user, message:) end describe 'POST /wiki' do - it 'creates wiki_page, wiki_revision, and wiki_version atomically' do - expect { - post '/wiki', - params: { - title: 'Help:wiki_history_create_atomic', - body: "a\nb\nc", - message: 'initial commit', - }, + it 'rejects frozen creation without partial page or history' do + expect do + post '/wiki', params: { title: 'Help:wiki_history_create_atomic', body: 'initial' }, headers: auth_headers(user) - } - .to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - - expect(response).to have_http_status(:created) - - page = WikiPage.find(json.fetch('id')) - revision = page.current_revision - version = page.wiki_versions.order(:version_no).last - - expect(page.title).to eq('Help:wiki_history_create_atomic') - expect(page.body).to eq("a\nb\nc") - - expect(revision).to be_content - expect(revision.message).to eq('initial commit') - expect(revision.lines_count).to eq(3) - - expect(version).to have_attributes( - version_no: 1, - event_type: 'create', - title: 'Help:wiki_history_create_atomic', - body: "a\nb\nc", - reason: 'initial commit', - created_by_user_id: user.id - ) + end.to change(WikiPage, :count).by(0) + .and change(WikiRevision, :count).by(0) + .and change(WikiVersion, :count).by(0) + expect(response.status).to be_between(400, 499) end it 'returns 422 and creates nothing when normalised body is blank' do @@ -71,21 +45,19 @@ RSpec.describe 'Wiki history integrity', type: :request do .where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist end - it 'returns 422 and creates no partial page when title already exists' do + it 'rejects frozen creation without modifying an existing page or history' do create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first') - expect { + expect do post '/wiki', - params: { - title: 'Help:wiki_history_duplicate_title', - body: 'second', - message: 'duplicate', - }, + params: { title: 'Help:wiki_history_duplicate_title', + body: 'second', message: 'duplicate' }, headers: auth_headers(user) - } - .not_to change(WikiPage, :count) + end.to change(WikiPage, :count).by(0) + .and change(WikiRevision, :count).by(0) + .and change(WikiVersion, :count).by(0) - expect(response).to have_http_status(:unprocessable_entity) + expect(response.status).to be_between(400, 499) expect(WikiPage.joins(:tag_name) .where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1) end diff --git a/backend/spec/requests/wiki_index_order_spec.rb b/backend/spec/requests/wiki_index_order_spec.rb new file mode 100644 index 0000000..c5ce7e9 --- /dev/null +++ b/backend/spec/requests/wiki_index_order_spec.rb @@ -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 diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb index 5a4eeae..377f851 100644 --- a/backend/spec/requests/wiki_spec.rb +++ b/backend/spec/requests/wiki_spec.rb @@ -4,6 +4,7 @@ require 'securerandom' RSpec.describe 'Wiki API', type: :request do + include WikiRecords def auth_headers(user) { 'X-Transfer-Code' => user.inheritance_code } end @@ -18,7 +19,7 @@ RSpec.describe 'Wiki API', type: :request do deprecated_at: Time.zone.local(2026, 6, 1)) end let!(:page) do - Wiki::Commit.create_content!( + seed_wiki_page( tag_name: tn, body: 'init', created_by_user: user, @@ -108,94 +109,14 @@ RSpec.describe 'Wiki API', type: :request do end end - context 'when success' do - it 'creates wiki_page and first content revision' do + ['Help:TestPage', 'ヘルプ:ホーム'].each do |title| + it "rejects frozen creation of #{ title } without history writes" do expect do - post endpoint, params: { title: 'Help:TestPage', body: "a\nb\nc", message: 'init' }, - headers: auth_headers(member) - end - .to change(WikiPage, :count).by(1) - .and change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) - - expect(response).to have_http_status(:created) - - page_id = json.fetch('id') - expect(json.fetch('title')).to eq('Help:TestPage') - - created_page = WikiPage.find(page_id) - version = created_page.wiki_versions.order(:version_no).last - - expect(version).to have_attributes( - version_no: 1, - event_type: 'create', - title: 'Help:TestPage', - body: "a\nb\nc", - created_by_user_id: member.id - ) - - rev = created_page.current_revision - expect(rev).to be_present - expect(rev).to be_content - expect(rev.message).to eq('init') - - expect(created_page.body).to eq("a\nb\nc") - - expect(rev.lines_count).to eq(3) - expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2]) - expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c']) - end - - it 'reuses existing WikiLine rows by sha256' do - # 先に同じ行を作っておく - WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current) - - post endpoint, - params: { title: 'Help:Reuse', body: "a\na" }, - headers: auth_headers(member) - - page = WikiPage.find(JSON.parse(response.body).fetch('id')) - rev = page.current_revision - expect(rev.lines_count).to eq(2) - - # "a" の WikiLine が増殖しない(1行のはず) - expect(WikiLine.where(body: 'a').count).to eq(1) - end - - it 'deduplicates duplicated new lines before upsert' do - duplicated = 'duplicated_line_for_wiki_line_upsert_spec' - - post endpoint, - params: { title: 'Help:DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" }, - headers: auth_headers(member) - - expect(response).to have_http_status(:created) - - page = WikiPage.find(json.fetch('id')) - rev = page.current_revision - - expect(rev.lines_count).to eq(2) - expect(WikiLine.where(body: duplicated).count).to eq(1) - expect(rev.wiki_revision_lines.count).to eq(2) - expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1) - end - - it 'normalises CRLF and strips trailing newlines' do - post endpoint, - params: { title: 'Help:NormalisedBody', - body: "a\r\nb\r\n\r\n", message: 'normalise' }, - headers: auth_headers(member) - - expect(response).to have_http_status(:created) - - page = WikiPage.find(json.fetch('id')) - rev = page.current_revision - version = page.wiki_versions.order(:version_no).last - - expect(page.body).to eq("a\nb") - expect(version.body).to eq("a\nb") - expect(rev.lines_count).to eq(2) - expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b']) + post endpoint, params: { title:, body: 'initial' }, headers: auth_headers(member) + end.to change(WikiPage, :count).by(0) + .and change(WikiRevision, :count).by(0) + .and change(WikiVersion, :count).by(0) + expect(response.status).to be_between(400, 499) end end end @@ -208,10 +129,10 @@ RSpec.describe 'Wiki API', type: :request do { 'X-Transfer-Code' => user.inheritance_code } end - let!(:test_tag_name) { create(:tag_name, name: 'TestPage') } + let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') } let!(:page) do - Wiki::Commit.create_content!( + seed_wiki_page( tag_name: test_tag_name, body: "a\nb", created_by_user: member, @@ -249,7 +170,8 @@ RSpec.describe 'Wiki API', type: :request do expect do put "/wiki/#{page.id}", - params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id }, + params: { title: 'TestPage', body: "x\ny", message: 'edit', + base_revision_id: current_id }, headers: auth_headers(member) end .to change(WikiRevision, :count).by(1) @@ -284,7 +206,7 @@ RSpec.describe 'Wiki API', type: :request do created_by_user: member) linked_page = - Wiki::Commit.create_content!( + seed_wiki_page( tag_name: linked_tag_name, body: 'before', created_by_user: member, @@ -358,13 +280,14 @@ RSpec.describe 'Wiki API', type: :request do describe 'GET /wiki/search' do before do - Wiki::Commit.create_content!( - tag_name: create(:tag_name, name: 'spec_wiki_title_2'), + search_tag = create(:tag, primary_name: 'spec_wiki_title_2') + seed_wiki_page( + tag_name: search_tag.tag_name('ja'), body: 'search body 2', created_by_user: user, message: 'init') - Wiki::Commit.create_content!( + seed_wiki_page( tag_name: create(:tag_name, name: 'unrelated_title'), body: 'unrelated body', created_by_user: user, @@ -516,7 +439,7 @@ RSpec.describe 'Wiki API', type: :request do it 'raises because redirect revisions are deprecated' do target_tag_name = create(:tag_name, name: 'redirect_deprecated_target') target = - Wiki::Commit.create_content!( + seed_wiki_page( tag_name: target_tag_name, body: 'target', created_by_user: user, @@ -539,7 +462,7 @@ RSpec.describe 'Wiki API', type: :request do linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general) linked_page = - Wiki::Commit.create_content!( + seed_wiki_page( tag_name: linked_tag_name, body: 'before', created_by_user: user, diff --git a/backend/spec/requests/wiki_title_collision_spec.rb b/backend/spec/requests/wiki_title_collision_spec.rb index a8df14b..d61122f 100644 --- a/backend/spec/requests/wiki_title_collision_spec.rb +++ b/backend/spec/requests/wiki_title_collision_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' RSpec.describe 'Wiki title collision', type: :request do + include WikiRecords let!(:user) { create_member_user! } def auth_headers user @@ -8,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do end def create_wiki_page title:, body: - Wiki::Commit.create_content!( + seed_wiki_page( tag_name: create(:tag_name, name: title), body:, created_by_user: user, @@ -43,20 +44,18 @@ RSpec.describe 'Wiki title collision', type: :request do expect(source.wiki_versions.count).to eq(source_version_count) end - it 'returns 422 when creating wiki with existing title' do + it 'rejects frozen creation with an existing title without adding history' do create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists') - expect { + expect do post '/wiki', - params: { - title: 'Help:wiki_collision_create', - body: 'new body', - message: 'duplicate create', - }, + params: { title: 'Help:wiki_collision_create', body: 'new body', + message: 'duplicate create' }, headers: auth_headers(user) - } - .not_to change(WikiPage, :count) + end.to change(WikiPage, :count).by(0) + .and change(WikiRevision, :count).by(0) + .and change(WikiVersion, :count).by(0) - expect(response).to have_http_status(:unprocessable_entity) + expect(response.status).to be_between(400, 499) end end diff --git a/backend/spec/requests/wiki_unmigrated_contract_spec.rb b/backend/spec/requests/wiki_unmigrated_contract_spec.rb index 9cd106b..e60c8e0 100644 --- a/backend/spec/requests/wiki_unmigrated_contract_spec.rb +++ b/backend/spec/requests/wiki_unmigrated_contract_spec.rb @@ -1,115 +1,78 @@ require 'rails_helper' RSpec.describe 'Unmigrated Wiki compatibility', type: :request do + include WikiRecords let!(:user) { create_member_user! } let!(:tag_name) { create(:tag_name, name: 'Help_contract') } let!(:page) do - Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: user) + seed_wiki_page(tag_name:, body: 'before', created_by_user: user) end let!(:first_revision) { page.current_revision } let!(:last_revision) do - Wiki::Commit.content!(page:, body: 'after', created_user: user, - message: 'legacy edit', base_revision_id: first_revision.id) + Wiki::Commit.legacy_content!(page:, body: 'after', created_user: user, + message: 'legacy edit', base_revision_id: first_revision.id) end before { expect(TagDescription.exists?(id: page.id)).to be(false) } - it 'keeps a Tag-linked legacy page in the legacy domain when its title changes' do + it 'rejects standalone updates even after a Tag is linked to the legacy page' do create(:tag, primary_tag_name: tag_name) + original = page.reload.attributes expect do put "/wiki/#{ page.id }", params: { title: 'legacy_tag_renamed', body: 'changed legacy body', message: 'legacy rename', base_revision_id: last_revision.id }, headers: { 'X-Transfer-Code' => user.inheritance_code } - end.to change(WikiRevision, :count).by(1) - .and change(WikiVersion, :count).by(1) + end.to change(WikiRevision, :count).by(0) + .and change(WikiVersion, :count).by(0) .and change(TagDescription, :count).by(0) .and change(TagDescriptionVersion, :count).by(0) - expect(response).to have_http_status(:ok) - expect(page.reload).to have_attributes(body: 'changed legacy body') - expect(page.title).to eq('legacy_tag_renamed') - expect(page.current_revision).to have_attributes(message: 'legacy rename') + expect(response).to have_http_status(:unprocessable_entity) + expect(page.reload.attributes).to eq(original) + expect(page.current_revision).to have_attributes(message: 'legacy edit') end - it 'shows legacy current and historical content by ID' do - get "/wiki/#{ page.id }" - expect(response).to have_http_status(:ok) - expect(json).to include('body' => 'after', 'revision_id' => last_revision.id, - 'pred' => first_revision.id, 'succ' => nil) - get "/wiki/#{ page.id }", params: { version: first_revision.id } - expect(response).to have_http_status(:ok) - expect(json).to include('body' => 'before', 'revision_id' => first_revision.id, - 'pred' => nil, 'succ' => last_revision.id) + include TagGroupRecords + + [:id, :title].each do |lookup| + [nil, :historical].each do |edition| + it "rejects legacy #{ lookup } #{ edition || 'current' } without legacy SELECTs" do + path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }" + params = edition ? { version: first_revision.id } : { } + without_group_legacy_reads { get path, params: } + expect(response).to have_http_status(:not_found) + end + end + + it "does not recognise legacy #{ lookup } existence" do + path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }" + without_group_legacy_reads { get "#{ path }/exists" } + expect(response).to have_http_status(:not_found) + end end - it 'shows legacy current and historical content by title' do - get "/wiki/title/#{ tag_name.name }" - expect(response).to have_http_status(:ok) - expect(json).to include('body' => 'after', 'revision_id' => last_revision.id) - get "/wiki/title/#{ tag_name.name }", params: { version: first_revision.id } - expect(response).to have_http_status(:ok) - expect(json).to include('body' => 'before', 'revision_id' => first_revision.id) - end - - it 'recognises legacy IDs' do - get "/wiki/#{ page.id }/exists" - expect(response).to have_http_status(:no_content) - end - - it 'recognises legacy titles' do - get "/wiki/title/#{ tag_name.name }/exists" - expect(response).to have_http_status(:no_content) - end - - it 'diffs legacy revisions with explicit and current target' do - [last_revision.id, nil].each do |to| - get "/wiki/#{ page.id }/diff", params: { from: first_revision.id, to: } + ['/wiki', '/wiki/search', '/wiki/changes'].each do |path| + it "excludes unmigrated content from #{ path } without legacy SELECTs" do + without_group_legacy_reads { get path } expect(response).to have_http_status(:ok) - expect(json).to include( - 'wiki_page_id' => page.id, 'title' => tag_name.name, - 'older_revision_id' => first_revision.id, 'newer_revision_id' => last_revision.id, - 'diff' => [{ 'type' => 'removed', 'content' => 'before' }, - { 'type' => 'added', 'content' => 'after' }]) + expect(json).to eq([]) end end - describe 'revision resource scope' do - let!(:other_page) do - Wiki::Commit.create_content!( - tag_name: create(:tag_name, name: 'Help_other_contract'), - body: 'other page history', created_by_user: user) - end - let(:other_revision) { other_page.current_revision } - - it 'returns 404 when show specifies another page revision' do - get "/wiki/#{ page.id }", params: { version: other_revision.id } - expect(response).to have_http_status(:not_found) - end - - it 'returns 404 when diff from belongs to another page' do - get "/wiki/#{ page.id }/diff", - params: { from: other_revision.id, to: last_revision.id } - expect(response).to have_http_status(:not_found) - end - - it 'returns 404 when diff to belongs to another page' do - get "/wiki/#{ page.id }/diff", - params: { from: first_revision.id, to: other_revision.id } - expect(response).to have_http_status(:not_found) - end - end - - it 'retains legacy history metadata and ordering' do - get '/wiki/changes', params: { id: page.id } + it 'returns empty scoped history without legacy SELECTs' do + without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } } expect(response).to have_http_status(:ok) - expect(json.map { |row| row.fetch('revision_id') }).to eq( - [last_revision.id, first_revision.id]) - expect(json.first).to include( - 'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content', - 'message' => 'legacy edit', 'timestamp' => last_revision.created_at.iso8601(3), - 'wiki_page' => { 'id' => page.id, 'title' => tag_name.name, 'deprecated_at' => nil }, - 'user' => { 'id' => user.id, 'name' => user.name }) + expect(json).to eq([]) + end + + [false, true].each do |explicit| + it "rejects legacy diff with #{ explicit ? 'explicit' : 'current' } target" do + params = { from: first_revision.id } + params[:to] = last_revision.id if explicit + without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: } + expect(response).to have_http_status(:not_found) + end end end diff --git a/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb b/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb new file mode 100644 index 0000000..40f9559 --- /dev/null +++ b/backend/spec/services/shared_resource_id_allocator_mysql_spec.rb @@ -0,0 +1,143 @@ +require 'rails_helper' +require 'timeout' + +RSpec.describe 'Shared resource IDs across MySQL connections', type: :model do + self.use_transactional_tests = false + + before do + expect(Rails.env.test?).to be(true) + expect(ApplicationRecord.connection.adapter_name).to match(/mysql/i) + expect(ApplicationRecord.connection_pool.size).to be >= 3 + @prefix = "shared_ids_#{ SecureRandom.hex(8) }" + @names = [0, 1].map { create(:tag_name, name: "#{ @prefix }_#{ _1 }") } + @tag = Tag.create!(tag_name_id: @names.first.id, category: :general) + @names.first.update!(tag_id: @tag.id) + @threads = [] + @release = Queue.new + end + + after do + @release << true if @release + begin + Array(@threads).each do |thread| + raise 'Shared ID worker did not finish' unless thread.join(15) + end + ensure + if @prefix + ExternalTag.where(name: "#{ @prefix }_external").delete_all + TagGroup.where(name: "#{ @prefix }_group").delete_all + end + TagDescription.where(tag_id: @tag.id).delete_all if @tag + if @names + ids = @names.map(&:id) + TagName.where(id: ids).update_all(tag_id: nil) + Tag.where(tag_name_id: ids).delete_all + TagName.where(id: ids).delete_all + end + end + end + + def await_queue queue + Timeout.timeout(15) { queue.pop } + end + + def worker &block + Thread.new do + Thread.current.report_on_exception = false + ApplicationRecord.connection_pool.with_connection(&block) + end.tap { @threads << _1 } + end + + def create_resource kind + case kind + when :tag + Tag.create!(tag_name_id: @names.last.id, category: :general) + when :external + ExternalTag.create!(platform: :nico, name: "#{ @prefix }_external") + when :description + TagDescription.create!(tag: @tag, language_code: 'ja', body: '', version_no: 1) + when :group + TagGroup.create!(name: "#{ @prefix }_group", version_no: 1) + end + end + + def hold_creation kind, rollback: false + ready = Queue.new + thread = worker do |connection| + id = nil + connection.transaction do + lock_name = nil + subscriber = lambda do |*args| + match = args.last.fetch(:sql).match(/GET_LOCK\('([^']+)'/) + lock_name = match[1] if match + end + ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do + id = create_resource(kind).id + end + ready << [id, connection.select_value('SELECT CONNECTION_ID()').to_i, lock_name] + await_queue(@release) + raise ActiveRecord::Rollback if rollback + end + id + end + [thread, await_queue(ready)] + end + + def result thread + raise 'Shared ID worker did not finish' unless thread.join(15) + thread.value + end + + { tags: [:external, :tag], wiki: [:description, :group] }.each do |space, kinds| + [false, true].each do |rollback| + it "serialises #{ space } inserts through #{ rollback ? 'rollback' : 'commit' }" do + tables = SharedResourceIdAllocator::SPACES.fetch(space.to_s) + maxima = tables.map do |table| + ApplicationRecord.connection.select_value("SELECT MAX(id) FROM #{ table }").to_i + end + first, (first_id, connection_id, lock_name) = hold_creation(kinds.first, rollback:) + expect(first_id).to eq(maxima.max + 1) + connection = ApplicationRecord.connection + quoted_lock = connection.quote(lock_name) + expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i) + .to eq(connection_id) + + started = Queue.new + second = worker do |other| + started << other.select_value('SELECT CONNECTION_ID()').to_i + create_resource(kinds.last).id + end + expect(await_queue(started)).not_to eq(connection_id) + @release << true + expect(result(first)).to eq(first_id) + expect(result(second)).to eq(rollback ? first_id : first_id + 1) + expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i).to eq(1) + end + end + end + + it 'allows the other ID space to commit while the first space remains locked' do + first, (_id, connection_id, lock_name) = hold_creation(:external) + second = worker { create_resource(:group).id } + expect(result(second)).to be_positive + connection = ApplicationRecord.connection + quoted_lock = connection.quote(lock_name) + expect(connection.select_value("SELECT IS_USED_LOCK(#{ quoted_lock })").to_i) + .to eq(connection_id) + @release << true + result(first) + end + + it 'raises on a real lock acquisition failure without inserting a record' do + first, (_id, _connection_id, lock_name) = hold_creation(:external, rollback: true) + stub_const('SharedResourceIdAllocator::LOCK_TIMEOUT', 0) + expect { create_resource(:external) }.to raise_error(ActiveRecord::LockWaitTimeout) + expect(ExternalTag.where(name: "#{ @prefix }_external")).not_to exist + @release << true + result(first) + connection = ApplicationRecord.connection + quoted_lock = connection.quote(lock_name) + expect(connection.select_value("SELECT IS_FREE_LOCK(#{ quoted_lock })").to_i) + .to eq(1) + end +end diff --git a/backend/spec/services/shared_resource_id_allocator_spec.rb b/backend/spec/services/shared_resource_id_allocator_spec.rb new file mode 100644 index 0000000..823fb4a --- /dev/null +++ b/backend/spec/services/shared_resource_id_allocator_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +RSpec.describe SharedResourceIdAllocator do + let(:record) { build(:external_tag) } + let(:connection) { double('connection') } + let(:transaction) { double('transaction', open?: true) } + let(:callbacks) { { } } + let(:sql) { [] } + + before do + config = double('config', database: 'test_shared_ids') + allow(connection).to receive_message_chain(:pool, :db_config).and_return(config) + allow(connection).to receive(:quote) { "'#{ _1 }'" } + allow(connection).to receive(:quote_table_name) { "`#{ _1 }`" } + allow(connection).to receive(:select_value) do |statement| + sql << statement + if statement.include?('GET_LOCK') || statement.include?('RELEASE_LOCK') + 1 + elsif statement.include?('`tags`') + 8 + else + 3 + end + end + allow(ExternalTag).to receive(:connection).and_return(connection) + allow(ExternalTag).to receive(:current_transaction).and_return(transaction) + allow(ExternalTag).to receive(:uncached).and_yield + [:after_commit, :after_rollback].each do |event| + allow(transaction).to receive(event) { |&callback| callbacks[event] = callback } + end + end + + [:after_commit, :after_rollback].each do |event| + it "holds the shared lock until #{ event }" do + described_class.assign!(record) + expect(record.id).to eq(9) + expect(sql.grep(/RELEASE_LOCK/)).to be_empty + expect(sql.grep(/FOR UPDATE/).size).to eq(2) + callbacks.fetch(event).call + expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) + end + end + + it 'does not silently proceed after an acquisition timeout' do + allow(connection).to receive(:select_value).and_return(0) + expect { described_class.assign!(record) }.to raise_error(ActiveRecord::LockWaitTimeout) + expect(record.id).to be_nil + expect(callbacks).to be_empty + end + + it 'releases a successfully acquired lock when allocation fails and rolls back' do + allow(connection).to receive(:select_value).with(/FOR UPDATE/).and_raise('query failed') + expect { described_class.assign!(record) }.to raise_error(RuntimeError, 'query failed') + callbacks.fetch(:after_rollback).call + expect(sql.grep(/RELEASE_LOCK/).size).to eq(1) + end + + it 'preserves explicitly supplied historical IDs' do + record.id = 5 + described_class.assign!(record) + expect(record.id).to eq(5) + expect(sql.grep(/FOR UPDATE/)).to be_empty + callbacks.fetch(:after_commit).call + end + + it 'uses the same named lock within each space and different locks between spaces' do + locks = [Tag, ExternalTag, TagDescription, TagGroup].map do |model| + allow(model).to receive(:connection).and_return(connection) + allow(model).to receive(:current_transaction).and_return(transaction) + allow(model).to receive(:uncached).and_yield + record = model.new(id: 50) + described_class.assign!(record) + statement = sql.grep(/GET_LOCK/).last + callbacks.fetch(:after_commit).call + statement + end + expect(locks[0]).to eq(locks[1]) + expect(locks[2]).to eq(locks[3]) + expect(locks[0]).not_to eq(locks[2]) + end + + it 'reports lock release failures' do + described_class.assign!(record) + allow(connection).to receive(:select_value).with(/RELEASE_LOCK/).and_return(nil) + expect { callbacks.fetch(:after_commit).call } + .to raise_error(ActiveRecord::ActiveRecordError, /lock release failed/) + end +end diff --git a/backend/spec/services/wiki/commit_integrity_spec.rb b/backend/spec/services/wiki/commit_integrity_spec.rb index 42c42b7..e58a60c 100644 --- a/backend/spec/services/wiki/commit_integrity_spec.rb +++ b/backend/spec/services/wiki/commit_integrity_spec.rb @@ -2,41 +2,42 @@ require 'digest' require 'rails_helper' RSpec.describe Wiki::Commit do + include WikiRecords let(:user) { create_member_user! } def create_page title:, body: 'initial body' - described_class.create_content!( - tag_name: create(:tag_name, name: title), + seed_wiki_page( + tag_name: create(:tag, primary_name: title).tag_name('ja'), body:, created_by_user: user, message: 'init') end describe '.create_content!' do - it 'creates page, revision, and version with normalised body' do - expect { - described_class.create_content!( - tag_name: create(:tag_name, name: 'commit_integrity_create'), - body: "a\r\nb\r\n\r\n", - created_by_user: user, - message: 'init') - } - .to change(WikiPage, :count).by(1) + it 'rejects standalone Help creation without adding page or history' do + name = create(:tag_name, name: 'Help:frozen_service') + counts = [WikiPage.count, WikiRevision.count, WikiVersion.count] + expect do + described_class.create_content!(tag_name: name, body: 'initial', created_by_user: user) + end.to raise_error(ActiveRecord::RecordInvalid) + expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts) + end + + it 'keeps the internal compatibility writer available' do + name = create(:tag_name, name: 'タググループ:compatibility_service') + page = nil + expect do + page = described_class.create_legacy_content!( + tag_name: name, body: 'initial', created_by_user: user) + end.to change(WikiPage, :count).by(1) .and change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(1) - - page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' }) - revision = page.current_revision - version = page.wiki_versions.order(:version_no).last - - expect(page.body).to eq("a\nb") - expect(revision.lines_count).to eq(2) - expect(version.body).to eq("a\nb") - expect(version.reason).to eq('init') + expect(page.current_revision.body).to eq('initial') + expect(page.wiki_versions.sole.body).to eq('initial') end it 'rejects body that becomes blank after normalisation' do - tag_name = create(:tag_name, name: 'commit_integrity_blank') + tag_name = create(:tag, primary_name: 'commit_integrity_blank').tag_name('ja') expect { described_class.create_content!( @@ -52,6 +53,17 @@ RSpec.describe Wiki::Commit do end describe '.content!' do + it 'rejects standalone Help updates without modifying histories' do + page = seed_wiki_page(tag_name: create(:tag_name, name: 'Help:standalone'), + body: 'before', created_by_user: user) + counts = [WikiRevision.count, WikiVersion.count] + expect do + described_class.content!(page:, body: 'after', created_user: user) + end.to raise_error(ActiveRecord::RecordInvalid) + expect(page.reload.body).to eq('before') + expect([WikiRevision.count, WikiVersion.count]).to eq(counts) + end + it 'updates page body, revision, and version' do page = create_page(title: 'commit_integrity_update', body: 'before') current_id = page.current_revision.id @@ -80,7 +92,7 @@ RSpec.describe Wiki::Commit do tag = create(:tag, primary_tag_name: tag_name, category: :general) page = - described_class.create_content!( + seed_wiki_page( tag_name:, body: 'before', created_by_user: user, diff --git a/backend/spec/services/wiki/commit_spec.rb b/backend/spec/services/wiki/commit_spec.rb index 122ac58..6acd297 100644 --- a/backend/spec/services/wiki/commit_spec.rb +++ b/backend/spec/services/wiki/commit_spec.rb @@ -1,12 +1,13 @@ require 'rails_helper' RSpec.describe Wiki::Commit do + include WikiRecords let(:user) { create_member_user! } def create_page(title: 'commit_spec_page', body: 'initial body') - tag_name = create(:tag_name, name: title) + tag_name = create(:tag, primary_name: title).tag_name('ja') - Wiki::Commit.create_content!( + seed_wiki_page( tag_name:, body:, created_by_user: user, @@ -84,7 +85,7 @@ RSpec.describe Wiki::Commit do tag = create(:tag, primary_tag_name: tag_name, category: :general) page = - described_class.create_content!( + seed_wiki_page( tag_name:, body: 'before', created_by_user: user, @@ -111,7 +112,7 @@ RSpec.describe Wiki::Commit do tag = create(:tag, primary_tag_name: tag_name, category: :general) page = - described_class.create_content!( + seed_wiki_page( tag_name:, body: 'before', created_by_user: user, diff --git a/backend/spec/services/wiki_version_recorder_spec.rb b/backend/spec/services/wiki_version_recorder_spec.rb index c8c4dfa..6c1457c 100644 --- a/backend/spec/services/wiki_version_recorder_spec.rb +++ b/backend/spec/services/wiki_version_recorder_spec.rb @@ -1,10 +1,11 @@ require 'rails_helper' RSpec.describe WikiVersionRecorder do + include WikiRecords let(:user) { create_member_user! } def create_page title:, body: 'body' - Wiki::Commit.create_content!( + seed_wiki_page( tag_name: create(:tag_name, name: title), body:, created_by_user: user, diff --git a/backend/spec/support/wiki_records.rb b/backend/spec/support/wiki_records.rb new file mode 100644 index 0000000..f321aba --- /dev/null +++ b/backend/spec/support/wiki_records.rb @@ -0,0 +1,20 @@ +# Historical records must remain seedable after application creation is frozen. +module WikiRecords + def seed_wiki_page(tag_name:, body:, created_by_user:, message: nil) + page = WikiPage.create!(tag_name:, body:, + created_user: created_by_user, updated_user: created_by_user) + revision = Wiki::Commit.legacy_content!( + page:, body:, created_user: created_by_user, message:) + if tag_name.tag && tag_name.language_code == 'ja' + TagDescription.create!( + id: page.id, tag: tag_name.tag, language_code: 'ja', body: page.reload.body, + version_no: 1, created_by_user_id: created_by_user.id, + updated_by_user_id: created_by_user.id) + TagDescriptionVersion.create!( + id: revision.id, tag_id: tag_name.tag_id, language_code: 'ja', + version_no: 1, event_type: 'create', body: page.body, + created_by_user_id: created_by_user.id, created_at: revision.created_at) + end + page + end +end diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..86addbc 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +/help diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 799f788..7516be9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,6 +38,7 @@ "remark-gfm": "^4.0.1", "tailwind-merge": "^3.3.0", "unist-util-visit-parents": "^6.0.1", + "yaml": "^2.9.1", "zustand": "^5.0.8" }, "devDependencies": { @@ -2060,9 +2061,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2077,9 +2075,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2094,9 +2089,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2111,9 +2103,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2128,9 +2117,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2145,9 +2131,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2162,9 +2145,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2179,9 +2159,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2196,9 +2173,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2213,9 +2187,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2230,9 +2201,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2247,9 +2215,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2264,9 +2229,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9114,10 +9076,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "dev": true, + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/frontend/package.json b/frontend/package.json index 2d5d533..65b24e5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,7 @@ "remark-gfm": "^4.0.1", "tailwind-merge": "^3.3.0", "unist-util-visit-parents": "^6.0.1", + "yaml": "^2.9.1", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/frontend/scripts/help-assets.test.ts b/frontend/scripts/help-assets.test.ts new file mode 100644 index 0000000..1e49d2f --- /dev/null +++ b/frontend/scripts/help-assets.test.ts @@ -0,0 +1,112 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { generateHelpAssets } from './help-assets' + +import type { ArticleMeta, HelpChange, HelpSearchEntry, HelpTitleIndex } from '@/lib/help' + +vi.hoisted (() => { + vi.stubGlobal ('Uint8Array', Object.getPrototypeOf (new TextEncoder ().encode ('')).constructor) +}) +afterAll (() => vi.unstubAllGlobals ()) + + +const readAsset = (modules: Map, name: string): T => { + const source = modules.get (`virtual:help/${ name }`)! + const argument = source.slice ('export default JSON.parse('.length, -1) + return JSON.parse (JSON.parse (argument)) +} + + +describe ('Build-time Help assets', () => { + let root: string + + beforeEach (async () => { + root = await mkdtemp (path.join (tmpdir (), 'btrc-help-assets-')) + }) + + afterEach (async () => { + await rm (root, { recursive: true, force: true }) + }) + + const writeArticle = async ( + identifier: string, + editions: Record, + current = '2026-10-02.10', + ) => { + const articleRoot = path.join (root, identifier) + const editionRoot = path.join (articleRoot, 'editions', 'ja') + await mkdir (editionRoot, { recursive: true }) + await writeFile (path.join (articleRoot, 'article.yml'), [ + `identifier: ${ identifier }`, 'canonical_language: ja', 'title: stale YAML title', + 'languages:', ' ja:', ` current_edition: ${ current }`].join ('\n')) + await Promise.all (Object.entries (editions).map (([edition, source]) => + writeFile (path.join (editionRoot, `${ edition }.mdx`), source))) + } + + it ('generates current search and ordered published history from each edition H1', async () => { + await writeArticle ('guide', { + '2026-10-01': '# 旧ホーム\n\n過去本文', + '2026-10-02': '# ホーム\n\n最初の同日版', + '2026-10-02.2': '# ホーム\n\n第二版', + '2026-10-02.3': '# ホーム\n\n第三版', + '2026-10-02.10': '\uFEFF# 最新ホーム\r\n\r\n現在本文\r\n# 別見出し', + '2026-10-02.11': '# 未公開\n\n検索に含めない' }) + const { modules } = await generateHelpAssets (root) + const articles = readAsset> (modules, 'articles') + expect (articles.guide.languages.ja).toEqual ({ + currentEdition: '2026-10-02.10', publishedEditions: [ + '2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'] }) + expect (readAsset> (modules, 'search').ja).toEqual ([{ + identifier: 'guide', edition: '2026-10-02.10', title: '最新ホーム', + body: '\r\n現在本文\r\n# 別見出し' }]) + const changes = readAsset> (modules, 'changes').ja + expect (changes.map (row => [row.edition, row.title, row.pred, row.change, row.date])) + .toEqual ([ + ['2026-10-02.10', '最新ホーム', '2026-10-02.3', 'update', '2026-10-02'], + ['2026-10-02.3', 'ホーム', '2026-10-02.2', 'update', '2026-10-02'], + ['2026-10-02.2', 'ホーム', '2026-10-02', 'update', '2026-10-02'], + ['2026-10-02', 'ホーム', '2026-10-01', 'update', '2026-10-02'], + ['2026-10-01', '旧ホーム', null, 'new', '2026-10-01']]) + const titles = readAsset> (modules, 'titles').ja + expect (titles).toEqual ({ '旧ホーム': ['guide'], 'ホーム': ['guide'], + '最新ホーム': ['guide'] }) + expect (modules.get ('virtual:help/loaders')).not.toContain ('2026-10-02.11') + expect (modules.get ('virtual:help/loaders')).toContain ('2026-10-02.10.mdx?raw') + expect (modules.get ('virtual:help/search')).not.toContain ('import(') + }) + + it ('retains ambiguous title identifiers and normalises width and case', async () => { + await writeArticle ('alpha', { '2026-10-02': '# HOME\n本文' }, '2026-10-02') + await writeArticle ('beta', { '2026-10-02': '# Home\n本文' }, '2026-10-02') + const { modules } = await generateHelpAssets (root) + expect (readAsset> (modules, 'titles').ja.home) + .toEqual (['alpha', 'beta']) + expect (readAsset> (modules, 'changes').ja + .map (row => row.identifier)).toEqual (['alpha', 'beta']) + }) + + it ('rejects missing current files during generation', async () => { + await writeArticle ('guide', { '2026-10-02': '# ホーム' }) + await expect (generateHelpAssets (root)).rejects.toThrow ('Missing current Help edition') + }) + + it.each (['本文\n# 遅い見出し', '# \n本文']) ( + 'rejects missing leading H1 at build time: %s', async source => { + await writeArticle ('guide', { '2026-10-02.10': source }) + await expect (generateHelpAssets (root)).rejects.toThrow ('Missing leading Help H1') + }) + + it ('rejects absent canonical metadata instead of generating incomplete assets', async () => { + await writeArticle ('guide', { '2026-10-02.10': '# ホーム' }) + await writeFile (path.join (root, 'guide', 'article.yml'), + 'identifier: guide\nlanguages:\n ja:\n current_edition: 2026-10-02.10') + await expect (generateHelpAssets (root)).rejects.toThrow ('Invalid Help metadata') + }) + + it ('rejects an empty Help checkout', async () => { + await expect (generateHelpAssets (root)).rejects.toThrow ('No Help articles found') + }) +}) diff --git a/frontend/scripts/help-assets.ts b/frontend/scripts/help-assets.ts new file mode 100644 index 0000000..157d0aa --- /dev/null +++ b/frontend/scripts/help-assets.ts @@ -0,0 +1,192 @@ +import { readFile, readdir } from 'node:fs/promises' +import path from 'node:path' +import { normalizePath as normalisePath } from 'vite' +import { parse as parseYAML } from 'yaml' + +import type { Plugin } from 'vite' + +type Language = { currentEdition: string; publishedEditions: string[] } +type Article = { + identifier: string + canonicalLanguage: string + languages: Record } +type Text = { title: string; body: string } +type SearchEntry = Text & { identifier: string; edition: string } +type Change = { + identifier: string + edition: string + title: string + pred: string | null + change: 'new' | 'update' + date: string } +type RawArticle = { + identifier: string + canonical_language: string + languages: Record } + +const prefix = 'virtual:help/' + + +const compareEditions = (a: string, b: string): number => { + const [dateA, revisionA = '1'] = a.split ('.') + const [dateB, revisionB = '1'] = b.split ('.') + return dateA.localeCompare (dateB) || Number (revisionA) - Number (revisionB) +} + + +const validateEdition = (edition: string): void => { + const [date, revision = '1'] = edition.split ('.') + if (!(/^\d{4}-\d{2}-\d{2}(?:\.[1-9]\d*)?$/.test (edition)) + || new Date (date).toISOString ().slice (0, 10) !== date + || !(Number.isSafeInteger (Number (revision)))) + throw new Error (`Invalid Help edition: ${ edition }`) +} + + +export const generateHelpAssets = async ( + root: string, +): Promise<{ modules: Map; files: string[] }> => { + const modules = new Map () + const files: string[] = [root] + const articles: Record = Object.create (null) + const search: Record = Object.create (null) + const changes: Record = Object.create (null) + const titles: Record> = Object.create (null) + const components: string[] = [] + const sources: string[] = [] + const texts: string[] = [] + const directories = (await readdir (root, { withFileTypes: true })) + .filter (entry => entry.isDirectory () && !(entry.name.startsWith ('.'))) + .sort ((a, b) => a.name.localeCompare (b.name)) + + for (const directory of directories) + { + const articlePath = path.join (root, directory.name, 'article.yml') + files.push (articlePath) + const raw = parseYAML (await readFile (articlePath, 'utf8')) as RawArticle | null + if (raw == null || raw.identifier !== directory.name + || typeof raw.canonical_language !== 'string' + || raw.languages == null || typeof raw.languages !== 'object' + || !(Object.hasOwn (raw.languages, raw.canonical_language))) + throw new Error (`Invalid Help metadata: ${ articlePath }`) + + const article: Article = { + identifier: raw.identifier, + canonicalLanguage: raw.canonical_language, + languages: Object.create (null) } + articles[raw.identifier] = article + + for (const [language, metadata] of Object.entries (raw.languages)) + { + if (!(/^[a-zA-Z0-9-]+$/.test (language)) + || typeof metadata?.current_edition !== 'string') + throw new Error (`Invalid Help language: ${ articlePath }/${ language }`) + + const currentEdition = metadata.current_edition + validateEdition (currentEdition) + const editionRoot = path.join (root, directory.name, 'editions', language) + files.push (editionRoot) + const editions = (await readdir (editionRoot)).filter (name => name.endsWith ('.mdx')) + .map (name => name.slice (0, -4)) + editions.forEach (validateEdition) + if (!(editions.includes (currentEdition))) + throw new Error (`Missing current Help edition: ${ editionRoot }/${ currentEdition }`) + + const publishedEditions = editions + .filter (edition => compareEditions (edition, currentEdition) <= 0) + .sort (compareEditions) + article.languages[language] = { currentEdition, publishedEditions } + search[language] ??= [] + changes[language] ??= [] + titles[language] ??= Object.create (null) + + for (const [index, edition] of publishedEditions.entries ()) + { + const sourcePath = path.join (editionRoot, `${ edition }.mdx`) + files.push (sourcePath) + const mdx = (await readFile (sourcePath, 'utf8')).replace (/^\uFEFF/, '') + const heading = mdx.match (/^# +([^\r\n]+)(?:\r?\n|$)/) + if (heading == null || !(heading[1].trim ())) + throw new Error (`Missing leading Help H1: ${ sourcePath }`) + + const text = { title: heading[1].trim (), body: mdx.slice (heading[0].length) } + const key = JSON.stringify (`${ raw.identifier }/${ language }/${ edition }`) + const textId = `${ prefix }text/${ raw.identifier }/${ language }/${ edition }` + const componentId = JSON.stringify (normalisePath (sourcePath)) + const sourceId = JSON.stringify (normalisePath (sourcePath) + '?raw') + modules.set (textId, `export default ${ JSON.stringify (text) }`) + texts.push (`${ key }: () => import(${ JSON.stringify (textId) })`) + components.push (`${ key }: () => import(${ componentId })`) + sources.push (`${ key }: () => import(${ sourceId })`) + + if (edition === currentEdition) + search[language].push ({ identifier: raw.identifier, edition, ...text }) + changes[language].push ({ identifier: raw.identifier, edition, title: text.title, + pred: publishedEditions[index - 1] ?? null, + change: index === 0 ? 'new' : 'update', date: edition.split ('.')[0] }) + const title = text.title.trim ().normalize ('NFKC').toLowerCase () + const identifiers = titles[language][title] ??= [] + if (!(identifiers.includes (raw.identifier))) + identifiers.push (raw.identifier) + } + } + } + + if (Object.keys (articles).length === 0) + throw new Error (`No Help articles found: ${ root }`) + + Object.values (changes).forEach (rows => rows.sort ((a, b) => + compareEditions (b.edition, a.edition) || a.identifier.localeCompare (b.identifier))) + for (const [name, data] of Object.entries ({ articles, search, changes, titles })) + { + modules.set (`${ prefix }${ name }`, + `export default JSON.parse(${ JSON.stringify (JSON.stringify (data)) })`) + } + modules.set (`${ prefix }loaders`, [ + `export const components = { ${ components.join (',\n') } };`, + `export const sources = { ${ sources.join (',\n') } };`, + `export const texts = { ${ texts.join (',\n') } };`].join ('\n')) + return { modules, files } +} + + +export const helpAssets = (): Plugin => { + const state: { root: string; data?: ReturnType } = { root: '' } + const data = () => state.data ??= generateHelpAssets (state.root) + + return { + name: 'help-assets', + configResolved (config) { + state.root = path.resolve (config.root, 'help') + }, + async buildStart () { + state.data = generateHelpAssets (state.root) + const generated = await data () + generated.files.forEach (file => this.addWatchFile (file)) + }, + resolveId (id) { + return id.startsWith (prefix) ? `\0${ id }` : null + }, + async load (id) { + if (!(id.startsWith (`\0${ prefix }`))) + return null + const source = (await data ()).modules.get (id.slice (1)) + if (source == null) + throw new Error (`Unknown Help asset: ${ id.slice (1) }`) + return source + }, + configureServer (server) { + server.watcher.add (state.root) + server.watcher.on ('all', (_event, file) => { + if (!(file.startsWith (state.root + path.sep)) || !(/\.(mdx|yml)$/.test (file))) + return + state.data = undefined + for (const module of server.moduleGraph.idToModuleMap.values ()) + { + if (module.id?.startsWith (`\0${ prefix }`)) + server.moduleGraph.invalidateModule (module) + } + server.ws.send ({ type: 'full-reload' }) + }) + } } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c6dae14..2367731 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,7 +15,6 @@ import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' import { applyClientAnimationMode, applyClientAppearance, - type ClientAnimationMode, fetchUserThemeSlots, fetchUserSettings, getClientThemeMode, @@ -25,7 +24,6 @@ import { applyClientAnimationMode, seedClientThemeMode } from '@/lib/settings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' - import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' @@ -34,12 +32,15 @@ import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage' import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage' import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage' -// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage' import MorePage from '@/pages/MorePage' import GekanatorPage from '@/pages/GekanatorPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NotFound from '@/pages/NotFound' import TOSPage from '@/pages/TOSPage.mdx' +import HelpDetailPage from '@/pages/help/HelpDetailPage' +import HelpChangesPage from '@/pages/help/HelpChangesPage' +import HelpDiffPage from '@/pages/help/HelpDiffPage' +import HelpListPage from '@/pages/help/HelpListPage' import PostDetailPage from '@/pages/posts/PostDetailPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostNewPage from '@/pages/posts/PostNewPage' @@ -60,90 +61,62 @@ import WikiSearchPage from '@/pages/wiki/WikiSearchPage' import type { Dispatch, FC, SetStateAction } from 'react' +import type { ClientAnimationMode } from '@/lib/settings' import type { User } from '@/types' const RouteTransitionWrapper = ({ animationMode, user, setUser }: { - animationMode: ClientAnimationMode - user: User | null - setUser: Dispatch> }) => { + animationMode: ClientAnimationMode + user: User | null + setUser: Dispatch> }) => { const location = useLocation () + const routes = ( + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }> + }/> + }/> + }/> + }/> + }/> + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + ) + if (animationMode === 'off') - { - return ( - - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }> - }/> - }/> - }/> - }/> - }/> - - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - ) - } + return routes return ( - - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }> - }/> - }/> - }/> - }/> - }/> - - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - + {routes} ) } @@ -166,9 +139,9 @@ const RoutedApp: FC = () => { () => ( animationMode === 'off' ? { duration: 0 } - : animationMode === 'reduced' - ? { duration: .08, ease: 'linear' as const } - : { duration: .2, ease: 'easeOut' as const } + : (animationMode === 'reduced' + ? { duration: .08, ease: 'linear' as const } + : { duration: .2, ease: 'easeOut' as const }) ), [animationMode], ) @@ -285,10 +258,12 @@ const RoutedApp: FC = () => { ) } + const router = createBrowserRouter ([{ path: '*', element: }]) + const App: FC = () => ( <> @@ -296,4 +271,5 @@ const App: FC = () => ( ) + export default App diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 755651a..5980ebc 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -20,6 +20,7 @@ import type { FC, MouseEvent } from 'react' import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types' type Props = { user: User | null } +type NavDensity = 'auto' | 'comfortable' | 'compact' export const menuOutline = ( @@ -40,6 +41,9 @@ export const menuOutline = ( const materialFlg = /^\/materials\/\d+/.test (pathName) + const helpVsbl = /\/help\/((?!changes)[^\/?]+)/.test (pathName) + const helpIdentifier = pathName.split ('/')[2] ?? '' + return [ { name: '広場', to: '/posts', subMenu: [ { name: '一覧', to: '/posts' }, @@ -71,7 +75,7 @@ export const menuOutline = ( visible: materialFlg && Boolean (material?.tag) }, { name: '履歴', to: `/materials/changes?material_id=${ material?.id }`, visible: materialFlg }] }, - { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ + { name: 'Wiki', to: '/wiki', subMenu: [ { name: '検索', to: '/wiki' }, { name: '新規', to: '/wiki/new', visible: editable }, { name: '全体履歴', to: '/wiki/changes' }, @@ -82,6 +86,13 @@ export const menuOutline = ( { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg && editable }] }, + { name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [ + { name: '検索', to: '/help' }, + { name: '全体履歴', to: '/help/changes' }, + { component: , visible: helpVsbl }, + { name: '履歴', + to: `/help/changes?identifier=${ helpIdentifier }`, + visible: helpVsbl }] }, { name: 'おたのしみ', visible: false, subMenu: [ { name: '上映会 (β)', to: '/theatres/1' }, { name: 'グカネータ (β)', to: '/gekanator' }] }, @@ -104,6 +115,8 @@ const TopNav: FC = ({ user }) => { const dirRef = useRef<(-1) | 1> (1) const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([]) const navRef = useRef (null) + const layoutRef = useRef (null) + const barRef = useRef (null) const measure = (idx: number) => { const nav = navRef.current @@ -128,6 +141,8 @@ const TopNav: FC = ({ user }) => { width: 0, visible: false }) const [menuOpen, setMenuOpen] = useState (false) + const [desktop, setDesktop] = useState (false) + const [density, setDensity] = useState ('auto') const [moreVsbl, setMoreVsbl] = useState (false) const [openItemIdx, setOpenItemIdx] = useState (-1) const [wikiId, setWikiId] = useState (WikiIdBus.get ()) @@ -194,15 +209,81 @@ const TopNav: FC = ({ user }) => { const dir = dirRef.current useLayoutEffect (() => { - const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx)) - const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx)) + const layout = layoutRef.current + const bar = barRef.current + const primary = bar?.querySelector ('.top-nav-primary') + if (layout == null || bar == null || primary == null) + return - addEventListener ('resize', onResize) - return () => { - cancelAnimationFrame (raf) - removeEventListener ('resize', onResize) + const userLink = bar.querySelector ('.top-nav-user-desktop') + let raf = 0 + let active = true + + const update = () => { + if (!(window.matchMedia ('(min-width: 768px)').matches)) + { + setDesktop (false) + setDensity ('auto') + return + } + + // Measure the same desktop elements while the mobile layout is active. + layout.setAttribute ('data-measuring', '') + const previousDensity = layout.getAttribute ('data-density') ?? 'auto' + const fittingDensity = (() => { + try + { + for (const candidate of ['auto', 'comfortable', 'compact'] as const) + { + layout.setAttribute ('data-density', candidate) + const style = getComputedStyle (bar) + const required = primary.getBoundingClientRect ().width + + (userLink?.getBoundingClientRect ().width ?? 0) + + parseFloat (style.paddingLeft) + parseFloat (style.paddingRight) + + (userLink == null ? 0 : parseFloat (style.columnGap)) + if (required <= bar.clientWidth) + return candidate + } + return null + } + finally + { + layout.setAttribute ('data-density', previousDensity) + layout.removeAttribute ('data-measuring') + } + }) () + setDensity (fittingDensity ?? 'compact') + setDesktop (fittingDensity != null) + if (fittingDensity != null) + measure (moreVsbl ? -1 : activeIdx) } - }) + + const schedule = () => { + if (!(active)) + return + cancelAnimationFrame (raf) + raf = requestAnimationFrame (update) + } + + const observer = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule) + observer?.observe (bar) + observer?.observe (primary) + if (userLink != null) + observer?.observe (userLink) + update () + addEventListener ('resize', schedule) + document.fonts?.addEventListener ('loadingdone', schedule) + void document.fonts?.ready.then (schedule) + + return () => { + active = false + cancelAnimationFrame (raf) + observer?.disconnect () + removeEventListener ('resize', schedule) + document.fonts?.removeEventListener ('loadingdone', schedule) + } + }, [activeIdx, moreVsbl, desktop, density, user?.name]) useEffect (() => { const unsubscribe = WikiIdBus.subscribe (setWikiId) @@ -215,19 +296,22 @@ const TopNav: FC = ({ user }) => { }, [activeIdx, location]) return ( - <> -