コミットを比較

..
9 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 4164c3c3f7 #424 2026-10-12 03:45:11 +09:00
みてるぞ 4c891604b5 #424 2026-10-12 02:49:24 +09:00
みてるぞ 88e691165a #424 2026-10-12 02:37:56 +09:00
みてるぞ 18415d7f55 Merge remote-tracking branch 'origin/main' into feature/424 2026-10-11 03:58:13 +09:00
みてるぞ 534a66f90d タグ・グループを Wiki から分離 (#425) (#429)
Reviewed-on: #429
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-10-11 03:39:47 +09:00
みてるぞ a219cc350b #424 一時コミット 2026-10-08 02:24:39 +09:00
みてるぞ 592932b5a5 ニコニコ同期の環境変数修正 (#426) (#427)
Reviewed-on: #427
2026-10-07 04:26:04 +09:00
みてるぞ 4a6ace3322 タグ説明移行 (#422) (#423)
Reviewed-on: #423
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-10-05 01:22:54 +09:00
みてるぞ e6ab941774 多言語化の基盤作り (#55) (#421)
Reviewed-on: #421
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-10-03 02:00:09 +09:00
75個のファイルの変更、6983行の追加、524行の削除
+57
ファイルの表示
@@ -0,0 +1,57 @@
class TagDescriptionsController < ApplicationController
def show
description = TagDescription.find_by(tag_id: params[:id], language_code: 'ja')
return head :not_found unless description
render json: representation(description)
end
def show_by_title
tag_name = TagName.find_by(language_code: 'ja', name: params[:name].to_s.strip)
description = tag_name&.tag && TagDescription.find_by(tag: tag_name.tag, language_code: 'ja')
return head :not_found unless description
render json: representation(description)
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
body = params[:body].to_s
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag = Tag.find_by(id: params[:id])
return head :not_found unless tag
description = TagDescription.find_by(tag:, language_code: 'ja')
if description
TagDescriptionOperation.update!(tag:, body:, updated_by_user: current_user)
description.reload
render json: representation(description)
else
TagDescriptionOperation.create!(tag:, body:, created_by_user: current_user)
description = TagDescription.find_by!(tag:, language_code: 'ja')
render json: representation(description), status: :created
end
rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record
rescue ActiveRecord::RecordNotUnique
render_record_not_unique
end
private
def representation description
description.as_json(only: [
:tag_id,
:language_code,
:body,
:version_no,
:created_by_user_id,
:updated_by_user_id,
:created_at,
:updated_at])
end
end
+39
ファイルの表示
@@ -0,0 +1,39 @@
class TagGroupsController < ApplicationController
def index
groups = TagGroup.includes(:created_by_user, :updated_by_user).order(:id)
render json: TagGroupRepr.many(groups.to_a)
end
def show
group = TagGroup.includes(:created_by_user, :updated_by_user).find_by(id: params[:id])
return head :not_found unless group
render json: TagGroupRepr.one(group)
end
def create
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
group = TagGroupOperation.create!(**write_attributes, actor: current_user)
render json: TagGroupRepr.one(group), status: :created
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
group = TagGroup.find_by(id: params[:id])
return head :not_found unless group
TagGroupOperation.update!(group:, **write_attributes, actor: current_user)
render json: TagGroupRepr.one(group.reload)
end
private
def write_attributes
{ name: params[:name], description: params[:description],
members: params[:members], placements: params[:placements] }
end
end
+3 -20
ファイルの表示
@@ -464,7 +464,6 @@ class TagsController < ApplicationController
old_name = tag.name(locale.language_code) old_name = tag.name(locale.language_code)
name_changed = name != old_name name_changed = name != old_name
wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed
if tag.deprecated? == deprecated if tag.deprecated? == deprecated
tag.update!(category:) tag.update!(category:)
@@ -485,9 +484,7 @@ class TagsController < ApplicationController
tag, tag,
event_type: :update, event_type: :update,
created_by_user: current_user, created_by_user: current_user,
language_code: locale.language_code, language_code: locale.language_code)
name_changed:,
wiki_page:)
end end
render json: TagRepr.base(tag.reload, language_code: locale.language_code) render json: TagRepr.base(tag.reload, language_code: locale.language_code)
@@ -518,7 +515,6 @@ class TagsController < ApplicationController
old_name = tag.name(locale.language_code) old_name = tag.name(locale.language_code)
name_changed = name.present? && name != old_name name_changed = name.present? && name != old_name
wiki_page = tag.tag_name(locale.language_code)&.wiki_page if name_changed
rename_tag_name!(locale, tag, name) if name_changed rename_tag_name!(locale, tag, name) if name_changed
tag.update!(category:) if category.present? tag.update!(category:) if category.present?
@@ -532,9 +528,7 @@ class TagsController < ApplicationController
tag, tag,
event_type: :update, event_type: :update,
created_by_user: current_user, created_by_user: current_user,
language_code: locale.language_code, language_code: locale.language_code)
name_changed:,
wiki_page:)
end end
render json: TagRepr.base(tag.reload, language_code: locale.language_code) render json: TagRepr.base(tag.reload, language_code: locale.language_code)
@@ -722,19 +716,8 @@ class TagsController < ApplicationController
language_code:)) language_code:))
end end
def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja', def record_tag_version! tag, event_type:, created_by_user:, language_code: 'ja'
name_changed: false, wiki_page: nil
TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:) TagVersionRecorder.record!(tag:, event_type:, created_by_user:, language_code:)
return unless name_changed
wiki_page ||= tag.tag_name('ja')&.wiki_page
return unless wiki_page&.wiki_versions&.exists?
WikiVersionRecorder.record!(
page: wiki_page,
event_type: :update,
created_by_user:)
end end
def validate_tag_rename locale, tag, name def validate_tag_rename locale, tag, name
+313 -81
ファイルの表示
@@ -2,47 +2,74 @@ class WikiPagesController < ApplicationController
rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict rescue_from Wiki::Commit::Conflict, with: :render_wiki_conflict
def index def index
# 互換性のための化石リソース
title = params[:title].to_s.strip title = params[:title].to_s.strip
if title.blank? descriptions =
return render json: WikiPageRepr.base( TagDescription.where(language_code: 'ja')
WikiPage.joins(:tag_name).includes(tag_name: :tag)) .includes(tag: :tag_names)
tag_groups = TagGroup.all
if title.present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(title) }%"
descriptions =
descriptions.joins(tag: :tag_names)
.where(tag_names: { language_code: 'ja', primary_flg: true })
.where('tag_names.name LIKE ?', pattern).limit(20)
tag_groups = tag_groups.where(
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
end end
q = WikiPage.joins(:tag_name).includes(tag_name: :tag) json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
.where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
render json: WikiPageRepr.base(q.limit(20)) json = json.first(20) if title.present?
render json:
end end
def show def show
page = WikiPage.joins(:tag_name) tag_description = TagDescription.find_by(id: params[:id])
.includes(tag_name: :tag)
.find_by(id: params[:id]) return render_wiki_page_by_tag_description tag_description if tag_description
render_wiki_page_or_404 page
tag_group = TagGroup.find_by(id: params[:id])
return render_tag_group tag_group if tag_group
render_wiki_page_or_404 nil
end end
def show_by_title def show_by_title
title = params[:title].to_s.strip title = params[:title].to_s.strip
page = WikiPage.joins(:tag_name)
.includes(tag_name: :tag) if title.start_with?(TagGroupRepr::PREFIX)
.find_by(tag_name: { name: title }) name = title.delete_prefix(TagGroupRepr::PREFIX)
render_wiki_page_or_404 page tag_group = TagGroup.find_by(name:)
return render_tag_group tag_group if tag_group
return head :not_found
end
tag_description = tag_description_by_title(title)
return render_wiki_page_by_tag_description tag_description if tag_description
render_wiki_page_or_404 nil
end end
def exists def exists
if WikiPage.exists?(id: params[:id]) return head :no_content if TagDescription.exists?(id: params[:id])
head :no_content return head :no_content if TagGroup.exists?(id: params[:id])
else
head :not_found head :not_found
end
end end
def exists_by_title def exists_by_title
title = params[:title].to_s.strip title = params[:title].to_s.strip
if WikiPage.joins(:tag_name).exists?(tag_names: { name: title }) if title.start_with?(TagGroupRepr::PREFIX)
head :no_content exists = TagGroup.exists?(name: title.delete_prefix(TagGroupRepr::PREFIX))
else return head exists ? :no_content : :not_found
head :not_found
end end
return head :no_content if tag_description_by_title(title)
head :not_found
end end
def diff def diff
@@ -52,35 +79,41 @@ class WikiPagesController < ApplicationController
from = params[:from].presence from = params[:from].presence
to = params[:to].presence to = params[:to].presence
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id) description = TagDescription.find_by(id:)
if description
versions = description_versions(description)
from_rev = from && versions.find(from)
to_rev = to ? versions.find(to) : versions.find_by!(version_no: description.version_no)
resource = WikiPageRepr.tag_description(description)
else
tag_group = TagGroup.find_by(id:)
if tag_group
from_rev = from && group_version(tag_group, from)
to_rev = to ? group_version(tag_group, to) : current_group_version(tag_group)
return head :not_found if (from && !from_rev) || !to_rev
from_rev = from && page.wiki_revisions.find(from) from_body = from_rev && render_group_version(from_rev)
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision to_body = render_group_version(to_rev)
if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?)) resource = { 'id' => tag_group.id,
return render_unprocessable_entity('差分を表示できない版です.') 'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
'deprecated_at' => nil }
else
raise 'お前やりませんねぇスギ!'
end
end end
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines) from_body ||= from_rev&.body
diff_json = diffs.map { |change| to_body ||= to_rev.body
case change.action diff_json = wiki_diff(from_body, to_body)
when ?=
{ type: 'context', content: change.old_element }
when ?!
[{ type: 'removed', content: change.old_element },
{ type: 'added', content: change.new_element }]
when ?+
{ type: 'added', content: change.new_element }
when ?-
{ type: 'removed', content: change.old_element }
end
}.flatten.compact
render json: { wiki_page_id: page.id, render json: { wiki_page_id: resource.fetch('id'),
title: page.title, title: resource.fetch('title'),
deprecated_at: page.deprecated_at, deprecated_at: resource.fetch('deprecated_at'),
older_revision_id: from_rev&.id, older_revision_id: from_rev&.id,
newer_revision_id: to_rev.id, newer_revision_id: to_rev.id,
diff: diff_json } diff: diff_json }
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
render_unprocessable_entity(error.message)
end end
def create def create
@@ -94,24 +127,35 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name| if title.start_with?(TagGroupRepr::PREFIX)
name.script_code = 'Jpan' name = title.delete_prefix(TagGroupRepr::PREFIX)
name.primary_flg = true parsed = TagGroupMarkdownParser.parse(body:, group: nil)
name.auto_generated = false group = TagGroupOperation.create!(name:, **parsed, actor: current_user,
require_compatibility: true)
version = current_group_version(group)
return render_group_representation(TagGroupRepr.one(group), version,
body: parsed.fetch(:compatibility_body),
status: :created)
end end
page = page =
Wiki::Commit.create_content!( if title.include?(':')
tag_name:, raise 'お前やりませんねぇスギ!'
body:, else
created_by_user: current_user, tag_name = TagName.find_by(language_code: 'ja', name: title)
message:) 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 render json: WikiPageRepr.base(page), status: :created
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record render_validation_error e.record
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
render_record_not_unique render_record_not_unique
rescue TagGroupMarkdownParser::InvalidDocument => error
render_unprocessable_entity(error.message)
end end
def update def update
@@ -124,7 +168,26 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank? return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
page = WikiPage.find(params[:id]) group = TagGroup.find_by(id: params[:id])
if title.start_with?(TagGroupRepr::PREFIX)
return head :not_found unless group
name = title.delete_prefix(TagGroupRepr::PREFIX)
parsed = TagGroupMarkdownParser.parse(body:, group:)
TagGroupOperation.update!(group:, name:, **parsed, actor: current_user,
require_compatibility: true)
return head :ok
end
page = WikiPage.find_by(id: params[:id])
if legacy_group_page?(page)
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 base_revision_id = params[:base_revision_id].presence
ApplicationRecord.transaction do ApplicationRecord.transaction do
@@ -132,7 +195,7 @@ class WikiPagesController < ApplicationController
old_title = page.title old_title = page.title
tag = Tag.find_by(tag_name_id: page.tag_name_id) tag = page.tag_name.tag
if tag && title != old_title if tag && title != old_title
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
@@ -141,11 +204,17 @@ class WikiPagesController < ApplicationController
page.tag_name.update!(name: title) if title != old_title page.tag_name.update!(name: title) if title != old_title
message = params[:message].presence message = params[:message].presence
Wiki::Commit.content!(page:, description = TagDescription.find_by(id: page.id)
body:, description_page = description && (
created_user: current_user, tag&.id == description.tag_id ||
message:, (tag.nil? && !old_title.include?(':')))
base_revision_id:) 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
if tag && title != old_title if tag && title != old_title
tag.reload tag.reload
@@ -154,35 +223,144 @@ class WikiPagesController < ApplicationController
end end
head :ok head :ok
end rescue ActiveRecord::RecordInvalid => e
render_validation_error e.record
def search rescue ActiveRecord::RecordNotUnique
index render_record_not_unique
rescue TagGroupMarkdownParser::InvalidDocument => error
render_unprocessable_entity(error.message)
end end
def changes def changes
id = params[:id].presence id = params[:id].presence
q = WikiRevision.joins(wiki_page: :tag_name) description = id && TagDescription.find_by(id:)
.includes(:created_user, wiki_page: { tag_name: :tag }) if description
.order(id: :desc) return render json: description_changes(TagDescription.where(id: description.id))
q = q.where(wiki_page_id: id) if id end
render json: q.limit(200).map { |rev| tag_group = id && TagGroup.find_by(id:)
{ revision_id: rev.id, if tag_group
pred: rev.base_revision_id, return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
succ: nil, end
wiki_page: { id: rev.wiki_page_id,
title: rev.wiki_page.title, json = []
deprecated_at: rev.wiki_page.deprecated_at }, unless id
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name }, json.concat(tag_group_changes(TagGroupVersion.all))
kind: rev.kind, json.concat(description_changes(TagDescription.where(language_code: 'ja')))
message: rev.message, json = json.each_with_index.sort_by do |row, index|
timestamp: rev.created_at } [-row.fetch(:timestamp).to_f, index]
}.compact end.map(&:first)
end
render json: json.first(200)
end end
private private
def legacy_group_page? page
return false unless page
return true if page.title.start_with?(TagGroupRepr::PREFIX)
prefix = ApplicationRecord.sanitize_sql_like(TagGroupRepr::PREFIX)
page.wiki_versions.where('title LIKE ?', "#{ prefix }%").exists?
end
def legacy_update_identified? page, title, group
return false unless page
return true if page.title == title
return false if title == group.name
namespace = page.title.split(':', 2)
return true if namespace.size == 2 && title.start_with?("#{ namespace.first }:")
base = params[:base_revision_id].presence
base && page.wiki_revisions.exists?(id: base)
end
def render_tag_group group
version = if params[:version].present?
group_version(group, params[:version])
else
current_group_version(group)
end
return head :not_found unless version
representation = if params[:version].present?
TagGroupRepr.historical(version)
else
TagGroupRepr.one(group)
end
render_group_representation(representation, version)
end
def render_group_version version
representation = TagGroupRepr.historical(version)
TagGroupMarkdownRenderer.render(representation)
end
def render_group_representation representation, version, body: nil, status: :ok
body ||= TagGroupMarkdownRenderer.render(representation)
versions = TagGroupVersion.where(tag_group_id: version.tag_group_id)
pred = versions.find_by(version_no: version.version_no - 1)&.id
succ = versions.find_by(version_no: version.version_no + 1)&.id
updated_at = version.created_at
render json: {
'id' => version.tag_group_id,
'title' => "#{ TagGroupRepr::PREFIX }#{ representation.fetch('name') }",
'deprecated_at' => nil,
'body' => body,
'revision_id' => version.id,
'pred' => pred,
'succ' => succ,
'updated_at' => updated_at }, status:
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
render_unprocessable_entity(error.message)
end
def current_group_version group
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
end
def group_version group, version_id
TagGroupVersion.find_by(tag_group_id: group.id, id: version_id)
end
def wiki_diff from_body, to_body
diffs = Diff::LCS.sdiff(from_body.to_s.lines, to_body.to_s.lines)
diffs.map { |change|
case change.action
when ?=
{ type: 'context', content: change.old_element }
when ?!
[{ type: 'removed', content: change.old_element },
{ type: 'added', content: change.new_element }]
when ?+
{ type: 'added', content: change.new_element }
when ?-
{ type: 'removed', content: change.old_element }
end
}.flatten.compact
end
def tag_group_changes versions
rows = versions.includes(:created_by_user).order(id: :desc).limit(200).to_a
groups = TagGroup.where(id: rows.map(&:tag_group_id).uniq).index_by(&:id)
predecessors = TagGroupVersion.where(tag_group_id: groups.keys,
version_no: rows.map { _1.version_no - 1 })
.pluck(:tag_group_id, :version_no, :id)
.to_h { |group_id, number, version_id| [[group_id, number], version_id] }
rows.map do |version|
group = groups.fetch(version.tag_group_id)
user = version.created_by_user
{ revision_id: version.id,
pred: predecessors[[group.id, version.version_no - 1]],
succ: nil,
wiki_page: { id: group.id, title: "#{ TagGroupRepr::PREFIX }#{ version.name }",
deprecated_at: nil },
user: user && { id: user.id, name: user.name },
kind: 'content', message: nil, timestamp: version.created_at }
end
end
def render_wiki_page_or_404 page def render_wiki_page_or_404 page
return head :not_found unless page return head :not_found unless page
@@ -204,6 +382,60 @@ class WikiPagesController < ApplicationController
render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:) render json: WikiPageRepr.base(page).merge(body:, revision_id:, pred:, succ:, updated_at:)
end end
def render_wiki_page_by_tag_description description
versions = description_versions(description)
historical = params[:version].present?
rev = historical ? versions.find_by(id: params[:version]) :
versions.find_by(version_no: description.version_no)
return head :not_found unless rev
pred = versions.find_by(version_no: rev.version_no - 1)&.id
succ = versions.find_by(version_no: rev.version_no + 1)&.id
body = historical ? rev.body : description.body
updated_at = historical ? rev.created_at : description.updated_at
render json: WikiPageRepr.tag_description(description).merge(
body:, revision_id: rev.id, pred:, succ:, updated_at:)
end
def description_versions description
TagDescriptionVersion.where(tag_id: description.tag_id,
language_code: description.language_code)
end
def tag_description_by_title title
TagDescription.joins(tag: :tag_names)
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
end
def description_changes descriptions
versions = TagDescriptionVersion.where(language_code: 'ja',
tag_id: descriptions.select(:tag_id))
.order(id: :desc).limit(200).to_a
return [] if versions.empty?
by_tag = descriptions.where(tag_id: versions.map(&:tag_id))
.includes(tag: :tag_names).index_by(&:tag_id)
users = User.where(id: versions.map(&:created_by_user_id)).index_by(&:id)
predecessors = TagDescriptionVersion.where(
language_code: 'ja', tag_id: by_tag.keys,
version_no: versions.map { |version| version.version_no - 1 })
.pluck(:tag_id, :version_no, :id)
.to_h { |tag_id, version_no, id| [[tag_id, version_no], id] }
versions.map do |version|
description = by_tag.fetch(version.tag_id)
user = users[version.created_by_user_id]
{ revision_id: version.id,
pred: predecessors[[version.tag_id, version.version_no - 1]],
succ: nil,
wiki_page: { id: description.id, title: description.tag.name('ja'),
deprecated_at: description.tag.deprecated_at },
user: user && { id: user.id, name: user.name },
kind: 'content',
message: nil,
timestamp: version.created_at }
end
end
def find_revision page def find_revision page
if params[:version].present? if params[:version].present?
page.wiki_revisions.find_by(id: params[:version]) page.wiki_revisions.find_by(id: params[:version])
+3
ファイルの表示
@@ -0,0 +1,3 @@
class TagDescription < ApplicationRecord
belongs_to :tag
end
+3
ファイルの表示
@@ -0,0 +1,3 @@
class TagDescriptionVersion < ApplicationRecord
;
end
+16
ファイルの表示
@@ -0,0 +1,16 @@
class TagGroup < ApplicationRecord
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
has_many :tag_group_tags, dependent: :delete_all
has_many :tags, through: :tag_group_tags
has_many :tag_group_groups, dependent: :delete_all
has_many :child_tag_groups, through: :tag_group_groups
has_many :placements, -> { order(:position) },
class_name: 'TagGroupPlacement', dependent: :delete_all
has_many :versions, -> { order(:version_no) },
class_name: 'TagGroupVersion', dependent: :delete_all
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
validates :version_no, numericality: { only_integer: true, greater_than: 0 }
end
+7
ファイルの表示
@@ -0,0 +1,7 @@
class TagGroupGroup < ApplicationRecord
self.primary_key = [:tag_group_id, :child_tag_group_id]
belongs_to :tag_group
belongs_to :child_tag_group, class_name: 'TagGroup'
belongs_to :created_by_user, class_name: 'User', optional: true
end
+9
ファイルの表示
@@ -0,0 +1,9 @@
class TagGroupPlacement < ApplicationRecord
self.primary_key = [:tag_group_id, :position]
belongs_to :tag_group
belongs_to :tag, optional: true
belongs_to :child_tag_group, class_name: 'TagGroup', optional: true
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
end
+7
ファイルの表示
@@ -0,0 +1,7 @@
class TagGroupTag < ApplicationRecord
self.primary_key = [:tag_group_id, :tag_id]
belongs_to :tag_group
belongs_to :tag
belongs_to :created_by_user, class_name: 'User', optional: true
end
+7
ファイルの表示
@@ -0,0 +1,7 @@
class TagGroupVersion < ApplicationRecord
include VersionRecord
belongs_to :tag_group
scope :ordered, -> { order(:version_no) }
end
+7 -4
ファイルの表示
@@ -48,8 +48,11 @@ class WikiPage < ApplicationRecord
page page
end end
def pred_revision_id(revision_id) = def pred_revision_id revision_id
wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id) wiki_revisions.where('id < ?', revision_id).order(id: :desc).limit(1).pick(:id)
def succ_revision_id(revision_id) = end
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
def succ_revision_id revision_id
wiki_revisions.where('id > ?', revision_id).order(id: :asc).limit(1).pick(:id)
end
end end
+194
ファイルの表示
@@ -0,0 +1,194 @@
module TagGroupRepr
PREFIX = 'タググループ:'
module_function
def many groups
ids = groups.map(&:id)
return [] if ids.empty?
tag_members = TagGroupTag.where(tag_group_id: ids)
.group_by(&:tag_group_id)
group_members = TagGroupGroup.where(tag_group_id: ids)
.group_by(&:tag_group_id)
placements = TagGroupPlacement.where(tag_group_id: ids).order(:position)
.group_by(&:tag_group_id)
target_ids = tag_members.values.flatten.map(&:tag_id) +
group_members.values.flatten.map(&:child_tag_group_id) +
placements.values.flatten.filter_map(&:tag_id)
child_ids = group_members.values.flatten.map(&:child_tag_group_id) +
placements.values.flatten.filter_map(&:child_tag_group_id)
tags = Tag.select(:id, :category).where(id: target_ids.uniq).index_by(&:id)
names = TagName.where(tag_id: target_ids.uniq, language_code: 'ja', primary_flg: true)
.pluck(:tag_id, :name).to_h
child_groups = TagGroup.where(id: child_ids.uniq).index_by(&:id)
groups.map do |group|
group_json(group,
tag_members.fetch(group.id, []),
group_members.fetch(group.id, []),
placements.fetch(group.id, []), tags, names, child_groups)
end
end
def one group
many([group]).first
end
def tag_group wiki_page
{ 'id' => wiki_page.id,
'title' => "#{ PREFIX }#{ wiki_page.name }",
'deprecated_at' => nil,
'created_at' => wiki_page.created_at,
'updated_at' => wiki_page.updated_at }
end
def current_version group
TagGroupVersion.find_by(tag_group_id: group.id, version_no: group.version_no)
end
def historical version
members = Array(version.members)
placements = Array(version.placements)
tag_references = members.filter_map { |member| member['tag_id'] } +
collect_ids(placements, 'tag_id')
group_references = members.filter_map { |member| member['tag_group_id'] } +
collect_ids(placements, 'tag_group_id')
tag_versions = historical_tag_versions(tag_references)
group_versions = historical_group_versions(group_references)
tag_version_numbers = members.filter_map do |member|
next unless member['tag_id']
[member.fetch('tag_id'), member.fetch('version_no')]
end.to_h
group_version_numbers = members.filter_map do |member|
next unless member['tag_group_id']
[member.fetch('tag_group_id'), member.fetch('version_no')]
end.to_h
historical_members = members.map do |member|
historical_member(member, tag_versions, group_versions)
end
historical_placements = placements.map do |node|
historical_placement(node, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
end
{ 'id' => version.tag_group_id,
'name' => version.name,
'description' => version.description,
'version_no' => version.version_no,
'members' => historical_members,
'placements' => historical_placements,
'created_at' => version.created_at,
'updated_at' => version.created_at,
'created_by_user' => user_json(version.created_by_user),
'updated_by_user' => user_json(version.created_by_user) }
end
def group_json group, tag_members, group_members, rows, tags, names, child_groups
tree = placement_tree(rows, tags, names, child_groups)
members = tag_members.sort_by(&:tag_id).map do |member|
{ 'tag' => tag_json(tags.fetch(member.tag_id), names.fetch(member.tag_id)) }
end
members.concat(group_members.sort_by(&:child_tag_group_id).map do |member|
{ 'tag_group' => group_json_reference(child_groups.fetch(member.child_tag_group_id)) }
end)
{ 'id' => group.id,
'name' => group.name,
'description' => group.description,
'version_no' => group.version_no,
'members' => members,
'placements' => tree,
'created_at' => group.created_at,
'updated_at' => group.updated_at,
'created_by_user' => user_json(group.created_by_user),
'updated_by_user' => user_json(group.updated_by_user) }
end
def user_json user
UserRepr.base(user) if user
end
def placement_tree rows, tags, names, child_groups
by_parent = rows.group_by(&:parent_position)
build = lambda do |parent|
by_parent.fetch(parent, []).sort_by(&:position).map do |row|
node = if row.tag_id
{ 'tag' => tag_json(tags.fetch(row.tag_id), names.fetch(row.tag_id)) }
elsif row.child_tag_group_id
{ 'tag_group' => group_json_reference(child_groups.fetch(row.child_tag_group_id)) }
else
{ 'section_name' => row.section_name, 'description' => row.description }
end
node.merge('children' => build.call(row.position))
end
end
build.call(nil)
end
def tag_json tag, name
{ 'id' => tag.id, 'name' => name, 'category' => tag.category }
end
def group_json_reference group
{ 'id' => group.id, 'name' => group.name }
end
def collect_ids nodes, key
nodes.flat_map do |node|
[node[key], *collect_ids(Array(node['children']), key)].compact
end
end
def historical_tag_versions ids
TagVersion.where(tag_id: ids.uniq).to_a.index_by do |version|
[version.tag_id, version.version_no]
end
end
def historical_group_versions ids
TagGroupVersion.where(tag_group_id: ids.uniq).to_a.index_by do |version|
[version.tag_group_id, version.version_no]
end
end
def historical_member member, tag_versions, group_versions
if member['tag_id']
id = member.fetch('tag_id')
version = tag_versions.fetch([id, member.fetch('version_no')])
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
'category' => version.category } }
else
id = member.fetch('tag_group_id')
version = group_versions.fetch([id, member.fetch('version_no')])
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
end
end
def historical_placement(node, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
result = if node['tag_id']
id = node.fetch('tag_id')
version_no = tag_version_numbers.fetch(id)
version = tag_versions.fetch([id, version_no])
{ 'tag' => { 'id' => id, 'name' => japanese_version_name(version),
'category' => version.category } }
elsif node['tag_group_id']
id = node.fetch('tag_group_id')
version_no = group_version_numbers.fetch(id)
version = group_versions.fetch([id, version_no])
{ 'tag_group' => { 'id' => id, 'name' => version.name } }
else
{ 'section_name' => node.fetch('section_name'), 'description' => node['description'] }
end
result.merge('children' => Array(node['children']).map do |child|
historical_placement(child, tag_versions, group_versions,
tag_version_numbers, group_version_numbers)
end)
end
def japanese_version_name version
version.names.fetch('ja').fetch('name')
end
end
+19
ファイルの表示
@@ -10,6 +10,25 @@ module WikiPageRepr
wiki_page.as_json(BASE) wiki_page.as_json(BASE)
end end
def tag_description description
tag = description.tag
{ 'id' => description.id,
'tag_name_id' => tag.tag_name('ja')&.id,
'body' => description.body,
'created_user_id' => description.created_by_user_id,
'updated_user_id' => description.updated_by_user_id,
'created_at' => description.created_at,
'updated_at' => description.updated_at,
'discarded_at' => nil,
'version_no' => description.version_no,
'title' => tag.name('ja'),
'deprecated_at' => tag.deprecated_at }
end
def tag_group tag_group
TagGroupRepr.tag_group(tag_group)
end
def many wiki_pages def many wiki_pages
wiki_pages.map { |p| base(p) } wiki_pages.map { |p| base(p) }
end end
+54
ファイルの表示
@@ -0,0 +1,54 @@
class TagDescriptionOperation
LANGUAGE_CODE = 'ja'
def self.create!(tag:, body:, created_by_user:, message: nil)
ApplicationRecord.transaction do
tag.lock!
raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
tag_id: tag.id, language_code: LANGUAGE_CODE)
page = Wiki::CompatibilityWriter.create!(
tag_name: tag.tag_name(LANGUAGE_CODE), body:, created_by_user:, message:)
revision = page.wiki_revisions.order(id: :desc).first
record_version!(tag:, revision:, body:, user: created_by_user,
description: nil)
page
end
end
def self.update!(tag:, body:, updated_by_user:, message: nil,
base_revision_id: nil)
ApplicationRecord.transaction do
tag.lock!
description = TagDescription.lock.find_by!(
tag_id: tag.id, language_code: LANGUAGE_CODE)
page = WikiPage.lock.find(description.id)
revision = Wiki::CompatibilityWriter.update!(
page:, body:, updated_by_user:, message:,
base_revision_id:)
next unless revision
record_version!(tag:, revision:, body: revision.body,
user: updated_by_user, description:)
revision
end
end
def self.record_version! tag:, revision:, body:, user:, description:
version_no = description ? description.version_no + 1 : 1
TagDescriptionVersion.create!(
id: revision.id, tag_id: tag.id, language_code: LANGUAGE_CODE,
version_no:, event_type: description ? 'update' : 'create', body:,
created_by_user_id: user.id, created_at: revision.created_at)
if description
description.update!(body:, version_no:, updated_by_user_id: user.id)
else
TagDescription.create!(
id: revision.wiki_page_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
end
private_class_method :record_version!
end
+177
ファイルの表示
@@ -0,0 +1,177 @@
require 'kramdown'
class TagGroupMarkdownParser
PREFIX = TagGroupRepr::PREFIX
class InvalidDocument < StandardError
;
end
def self.parse(body:, group: nil)
new(body:, group:).parse
end
def initialize(body:, group:)
@body = body.to_s.gsub(/\r\n?/, "\n")
@group = group
end
def parse
parse_body = @body.gsub(/([^\n])\n([\x23]{1,6}[ \t]+\S)/, "\\1\n\n\\2")
root = Kramdown::Document.new(parse_body, input: 'GFM', auto_ids: false).root
placements = []
description_parts = []
headings = []
root.children.each do |node|
case node.type
when :blank
next
when :header
level = node.options.fetch(:level)
name = inline_text(node).strip
invalid!('Section 名は必須です.') if name.blank?
headings.pop while headings.any? && headings.last.fetch(:level) >= level
parent = headings.last&.fetch(:node)
section = { 'section_name' => name, 'description' => nil,
'children' => [] }
(parent ? parent.fetch('children') : placements) << section
headings << { level:, node: section }
when :p
text = inline_markdown(node).strip
next if text.empty?
if headings.any?
section = headings.last.fetch(:node)
section['description'] = [section['description'], text].compact.join("\n\n")
else
description_parts << text
end
when :ul
invalid!('未対応のリスト項目があります.') if node.attr.any?
target = headings.any? ? headings.last.fetch(:node).fetch('children') : placements
target.concat(node.children.map { parse_list_item(_1) })
else
invalid!("未対応の Markdown 構文 #{ node.type.inspect } です.")
end
end
description = description_parts.presence&.join("\n\n")
{ description:, placements:, members: build_members(placements),
compatibility_body: @body.sub(/\n+\z/, '') }
rescue Kramdown::Error => error
invalid!("Markdown を解析できません:#{ error.message }")
end
private
def parse_list_item(item)
invalid!('リスト項目ではありません.') unless item.type == :li
invalid!('未対応のリスト項目があります.') if item.attr.any?
nested, own = item.children.partition { |child| child.type == :ul }
invalid!('順序付きリストは使用できません.') if own.any? { _1.type == :ol }
own.reject! { _1.type == :blank }
invalid!('未対応のリスト項目があります.') unless own.all? do |child|
[:p, :text].include?(child.type)
end
invalid!('リスト項目は一つの段落で指定してください.') if own.size != 1
label = own.map { inline_text(_1) }.join(' ').strip
invalid!('空のリスト項目は使用できません.') if label.blank?
node = if label.start_with?(PREFIX)
group_name = label.delete_prefix(PREFIX).strip
child_group = TagGroup.find_by(name: group_name)
invalid!("Tag Group #{ group_name.inspect } を解決できません.") unless child_group
invalid!('自分自身を含めることはできません.') if child_group.id == @group&.id
{ 'tag_group_id' => child_group.id }
elsif label.end_with?(':', ':')
section_name = label.sub(/[::]\z/, '').strip
invalid!('Section 名は必須です.') if section_name.blank?
{ 'section_name' => section_name, 'description' => nil }
else
{ 'tag_id' => resolve_tag(label) }
end
node['children'] = nested.flat_map do |list|
invalid!('未対応の入れ子リストです.') unless list.type == :ul
list.children.map { parse_list_item(_1) }
end
node
end
def resolve_tag(name)
names = TagName.where(language_code: 'ja', name:).to_a
tag_ids = names.filter_map(&:tag_id).uniq
invalid!("Tag #{ name.inspect } を解決できません.") if tag_ids.empty?
invalid!("Tag 名 #{ name.inspect } が曖昧です.") if tag_ids.many?
tag_ids.first
end
def build_members(nodes)
references = []
visit = lambda do |items|
items.each do |node|
references << node.slice('tag_id', 'tag_group_id') if
node.key?('tag_id') || node.key?('tag_group_id')
visit.call(node.fetch('children'))
end
end
visit.call(nodes)
references.uniq
end
def inline_text(node)
if node.attr.any?
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
end
case node.type
when :text
node.value.to_s
when :p, :header
node.children.map { inline_text(_1) }.join
else
invalid!("未対応のインライン構文 #{ node.type.inspect } です.")
end
end
def inline_markdown(node)
case node.type
when :text
node.value.to_s
when :codespan
text = node.value.to_s
longest = text.scan(/`+/).map(&:length).max || 0
delimiter = '`' * (longest + 1)
padded = text.start_with?('`', ' ') || text.end_with?('`', ' ')
content = padded ? " #{ text } " : text
"#{ delimiter }#{ content }#{ delimiter }"
when :softbreak
"\n"
when :br
" \n"
when :p
node.children.map { inline_markdown(_1) }.join
when :a
href = node.attr.fetch('href')
label = node.children.map { inline_markdown(_1) }.join
title = node.attr['title']&.gsub('"', '\\"')
suffix = title ? " \"#{ title }\"" : ''
"[#{ label }](#{ href }#{ suffix })"
when :em
"*#{ node.children.map { inline_markdown(_1) }.join }*"
when :strong
"**#{ node.children.map { inline_markdown(_1) }.join }**"
else
invalid!("未対応の説明文構文 #{ node.type.inspect } です.")
end
end
def invalid! message
raise InvalidDocument, message
end
end
+118
ファイルの表示
@@ -0,0 +1,118 @@
class TagGroupMarkdownRenderer
class UnsupportedStructure < StandardError
;
end
def self.render group
new(group).render
end
def initialize group
@group = group
end
def render
placements = @group.fetch('placements')
validate_membership!(placements)
blocks = []
if @group['description'].present?
blocks << clean_text(@group['description'])
end
sections = placements.select { _1.key?('section_name') }
if sections.any? && sections.size != placements.size
raise UnsupportedStructure,
'最上位の見出し Section と Placement の混在は Markdown にできません.'
end
if placements.all? { _1.key?('section_name') }
blocks.concat(placements.map { render_heading(_1, 2) })
else
blocks << render_list(placements, 0)
end
blocks.compact.reject(&:empty?).join("\n").sub(/\n+\z/, '')
end
private
def validate_membership! placements
placed = placement_members(placements).uniq.sort
members = @group.fetch('members').map { |member|
if member['tag']
['tag', member.fetch('tag').fetch('id')]
else
['tag_group', member.fetch('tag_group').fetch('id')]
end
}.uniq.sort
missing = members - placed
if missing.any?
raise UnsupportedStructure, '掲載位置のない membership は Markdown にできません.'
end
end
def placement_members nodes
nodes.flat_map do |node|
own = if node['tag']
[['tag', node.fetch('tag').fetch('id')]]
elsif node['tag_group']
[['tag_group', node.fetch('tag_group').fetch('id')]]
else
[]
end
own + placement_members(node.fetch('children'))
end
end
def render_heading section, level
header = "#{ '#' * level } #{ clean_text(section.fetch('section_name')) }"
blocks = [header]
blocks << clean_text(section['description']) if section['description'].present?
children = section.fetch('children')
section_indexes = children.each_index.select { |index| children[index].key?('section_name') }
if section_indexes.any?
first_section = section_indexes.first
if children[first_section..].any? { !_1.key?('section_name') }
raise UnsupportedStructure,
'見出し Section と兄弟 Placement の交互配置は Markdown にできません.'
end
list_nodes = children[0...first_section]
blocks << render_list(list_nodes, 0) if list_nodes.any?
blocks.concat(children[first_section..].map { render_heading(_1, level + 1) })
elsif children.any?
blocks << render_list(children, 0)
end
blocks.join("\n")
end
def render_list nodes, depth
nodes.map do |node|
indent = ' ' * depth
if node.key?('section_name')
raise UnsupportedStructure,
'リスト内 Section の説明文は Markdown にできません.' if node['description'].present?
children = node.fetch('children')
head = "#{ indent }- #{ clean_text(node.fetch('section_name')) }:"
tail = render_list(children, depth + 1) if children.any?
[head, tail].compact.join("\n")
else
label = if node['tag']
node.fetch('tag').fetch('name')
else
"#{ TagGroupRepr::PREFIX }#{ node.fetch('tag_group').fetch('name') }"
end
raise UnsupportedStructure, '名称のない Placement は Markdown にできません.' if label.blank?
head = "#{ indent }- #{ clean_text(label) }"
children = node.fetch('children')
tail = render_list(children, depth + 1) if children.any?
[head, tail].compact.join("\n")
end
end.join("\n")
end
def clean_text value
value.to_s.gsub(/\r\n?/, "\n").lines.map(&:rstrip).join("\n").strip
end
end
+410
ファイルの表示
@@ -0,0 +1,410 @@
class TagGroupOperation
MAX_PLACEMENT_DEPTH = 5
PREFIX = TagGroupRepr::PREFIX
def self.create!(name:, description:, members:, placements:, actor:,
require_compatibility: false, compatibility_body: nil)
group, sync_issue = ApplicationRecord.transaction do
group = TagGroup.new(version_no: 1, created_by_user: actor,
updated_by_user: actor)
attributes = prepare!(group:, name:, description:, members:, placements:)
group.assign_attributes(name: attributes.fetch(:name),
description: attributes.fetch(:description))
group.save!
persist_structure!(group:, attributes:, actor:)
persist_version!(group:, attributes:, actor:, event_type: 'create')
issue = sync_compatibility!(group:, actor:, old_name: nil,
require_compatibility:, compatibility_body:)
[group, issue]
end
report_sync_issue(group, actor, sync_issue)
group
end
def self.update!(group:, name:, description:, members:, placements:, actor:,
require_compatibility: false, compatibility_body: nil)
group, sync_issue = ApplicationRecord.transaction do
group.lock!
old_name = group.name
attributes = prepare!(group:, name:, description:, members:, placements:)
next [group, nil] if unchanged?(group, attributes)
group.update!(name: attributes.fetch(:name),
description: attributes.fetch(:description),
version_no: group.version_no + 1,
updated_by_user: actor)
persist_structure!(group:, attributes:, actor:)
persist_version!(group:, attributes:, actor:, event_type: 'update')
issue = sync_compatibility!(group:, actor:, old_name:,
require_compatibility:, compatibility_body:)
[group, issue]
end
report_sync_issue(group, actor, sync_issue)
group
end
def self.prepare!(group:, name:, description:, members:, placements:)
record = group
normalized_name = name.to_s.strip
invalid!(record, :name, 'グループ名は必須です.') if normalized_name.blank?
if normalized_name.length > 255
invalid!(record, :name, 'グループ名は 255 文字以内で入力してください.')
end
invalid!(record, :description, '説明文は文字列で入力してください.') unless
description.nil? || description.is_a?(String)
record.name = normalized_name
record.description = description
unless record.valid?
raise ActiveRecord::RecordInvalid, record
end
member_rows = normalize_members(record, members)
placement_tree = normalize_placements(record, placements)
validate_references!(record, member_rows, placement_tree)
validate_containment!(record, member_rows)
{ name: normalized_name, description:, members: member_rows,
placements: placement_tree }
end
private_class_method :prepare!
def self.unchanged?(group, attributes)
unless (group.name_in_database == attributes.fetch(:name) &&
group.description_in_database == attributes.fetch(:description))
return false
end
members = TagGroupTag.where(tag_group_id: group.id).pluck(:tag_id)
.map { { 'tag_id' => _1 } }
members.concat(TagGroupGroup.where(tag_group_id: group.id)
.pluck(:child_tag_group_id).map { { 'tag_group_id' => _1 } })
requested_members = attributes.fetch(:members).map do |member|
member.except('version_no')
end
return false unless members.to_set == requested_members.to_set
rows = TagGroupPlacement.where(tag_group_id: group.id).order(:position)
.group_by(&:parent_position)
build = lambda do |parent_position|
rows.fetch(parent_position, []).map do |row|
node = if row.tag_id
{ 'tag_id' => row.tag_id }
elsif row.child_tag_group_id
{ 'tag_group_id' => row.child_tag_group_id }
else
{ 'section_name' => row.section_name, 'description' => row.description }
end
node.merge('children' => build.call(row.position))
end
end
build.call(nil) == comparable_placements(attributes.fetch(:placements))
end
private_class_method :unchanged?
def self.comparable_placements(nodes)
nodes.map do |node|
result = node.dup
result['description'] = node['description'] if node.key?('section_name')
result['children'] = comparable_placements(node.fetch('children'))
result
end
end
private_class_method :comparable_placements
def self.normalize_members(record, members)
invalid!(record, :members, 'members は配列で指定してください.') unless members.is_a?(Array)
result = members.map do |member|
member = stringify_keys(member)
unless member.is_a?(Hash) && member.keys.one? &&
(member.key?('tag_id') || member.key?('tag_group_id'))
invalid!(record, :members, '各 member は tag_id または tag_group_id を一つ指定してください.')
end
key = member.keys.first
id = integer_id(record, member.fetch(key), :members)
{ key => id }
end
invalid!(record, :members, 'membership に重複があります.') unless result.uniq == result
result
end
private_class_method :normalize_members
def self.normalize_placements(record, placements)
invalid!(record, :placements, 'placements は配列で指定してください.') unless
placements.is_a?(Array)
normalize_placement_nodes(record, placements, 1)
end
private_class_method :normalize_placements
def self.normalize_placement_nodes(record, nodes, depth)
if depth > MAX_PLACEMENT_DEPTH && nodes.any?
invalid!(record, :placements, '配置の深さは 5 階層までです.')
end
nodes.map do |raw_node|
node = stringify_keys(raw_node)
unless node.is_a?(Hash) && node.key?('children')
invalid!(record, :placements, '各 Placement に children 配列が必要です.')
end
children = node.delete('children')
unless children.is_a?(Array)
invalid!(record, :placements, 'children は配列で指定してください.')
end
kinds = ['tag_id', 'tag_group_id', 'section_name'].select { node.key?(_1) }
invalid!(record, :placements, 'Placement の種類を一つ指定してください.') unless
kinds.one?
kind = kinds.first
allowed = [kind, 'description']
invalid!(record, :placements, '不明な Placement 項目があります.') unless
(node.keys - allowed).empty?
case kind
when 'tag_id', 'tag_group_id'
node[kind] = integer_id(record, node.fetch(kind), :placements)
if node['description'].present?
invalid!(record, :placements, 'Tag / Tag Group Placement に説明文は指定できません.')
end
node.delete('description')
when 'section_name'
section_name = node.fetch(kind)
unless section_name.is_a?(String) && section_name.strip.present? &&
section_name.length <= 255
invalid!(record, :placements, 'Section 名は 1〜255 文字で入力してください.')
end
node[kind] = section_name.strip
unless node['description'].nil? || node['description'].is_a?(String)
invalid!(record, :placements, 'Section の説明文は文字列で入力してください.')
end
end
node['children'] = normalize_placement_nodes(record, children, depth + 1)
node
end
end
private_class_method :normalize_placement_nodes
def self.validate_references!(record, members, placements)
membership = members.to_set
placed = placement_references(placements).uniq
missing = placed - membership.to_a
invalid!(record, :placements, 'Placement の対象は membership に含めてください.') if
missing.any?
tag_ids = members.filter_map { _1['tag_id'] }
group_ids = members.filter_map { _1['tag_group_id'] }
tags = Tag.where(id: tag_ids).index_by(&:id)
groups = TagGroup.where(id: group_ids).index_by(&:id)
invalid!(record, :members, '存在しない Tag が含まれています.') if
tags.size != tag_ids.uniq.size
invalid!(record, :members, '存在しない Tag Group が含まれています.') if
groups.size != group_ids.uniq.size
tag_versions = TagVersion.where(tag_id: tag_ids).pluck(:tag_id, :version_no).to_set
group_versions = TagGroupVersion.where(tag_group_id: group_ids)
.pluck(:tag_group_id, :version_no).to_set
tag_names = TagName.where(tag_id: tag_ids, language_code: 'ja', primary_flg: true)
.pluck(:tag_id).uniq
members.each do |member|
if member.key?('tag_id')
id = member.fetch('tag_id')
invalid!(record, :members, "Tag ##{ id } に日本語の正規名がありません.") unless
tag_names.include?(id)
version_no = tags.fetch(id).version_no
unless tag_versions.include?([id, version_no])
invalid!(record, :members, "Tag ##{ id } の Version がありません.")
end
member['version_no'] = version_no
else
id = member.fetch('tag_group_id')
version_no = groups.fetch(id).version_no
unless group_versions.include?([id, version_no])
invalid!(record, :members, "Tag Group ##{ id } の Version がありません.")
end
member['version_no'] = version_no
end
end
end
private_class_method :validate_references!
def self.validate_containment!(record, members)
target_id = record.id
return unless target_id
children = members.filter_map { _1['tag_group_id'] }
edges = TagGroupGroup.pluck(:tag_group_id, :child_tag_group_id)
.group_by(&:first).transform_values { _1.map(&:last) }
edges[target_id] = children
visiting = Set.new
visited = Set.new
visit = lambda do |group_id|
invalid!(record, :members, 'Tag Group の循環包含はできません.') if
group_id == target_id && visiting.include?(group_id)
return if visited.include?(group_id)
visiting.add(group_id)
edges.fetch(group_id, []).each do |child_id|
invalid!(record, :members, 'Tag Group の循環包含はできません.') if
child_id == target_id
visit.call(child_id)
end
visiting.delete(group_id)
visited.add(group_id)
end
children.each { visit.call(_1) }
end
private_class_method :validate_containment!
def self.persist_structure!(group:, attributes:, actor:)
tag_members = TagGroupTag.where(tag_group_id: group.id).index_by(&:tag_id)
group_members = TagGroupGroup.where(tag_group_id: group.id).index_by(&:child_tag_group_id)
attributes.fetch(:members).each do |member|
if member.key?('tag_id')
next if tag_members.delete(member.fetch('tag_id'))
TagGroupTag.create!(tag_group_id: group.id, tag_id: member.fetch('tag_id'),
created_by_user: actor)
else
next if group_members.delete(member.fetch('tag_group_id'))
TagGroupGroup.create!(tag_group_id: group.id,
child_tag_group_id: member.fetch('tag_group_id'),
created_by_user: actor)
end
end
existing = TagGroupPlacement.where(tag_group_id: group.id).index_by(&:position)
persist_placements!(group:, nodes: attributes.fetch(:placements), actor:, existing:)
existing.values.sort_by(&:position).reverse_each(&:delete)
tag_members.each_value(&:delete)
group_members.each_value(&:delete)
end
private_class_method :persist_structure!
def self.persist_placements!(group:, nodes:, actor:, existing:, parent_position: nil,
next_position: [0])
nodes.each do |node|
next_position[0] += 1
position = next_position[0]
row = existing.delete(position) || TagGroupPlacement.new(
tag_group_id: group.id, position:, created_by_user: actor)
row.assign_attributes(
parent_position:,
section_name: node['section_name'], description: node['description'],
tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'])
if row.new_record? || row.changed?
row.updated_by_user = actor
row.save!
end
persist_placements!(group:, nodes: node.fetch('children'), actor:, existing:,
parent_position: position, next_position:)
end
end
private_class_method :persist_placements!
def self.persist_version!(group:, attributes:, actor:, event_type:)
TagGroupVersion.create!(tag_group: group, version_no: group.version_no,
event_type:, name: group.name,
description: group.description,
members: attributes.fetch(:members),
placements: attributes.fetch(:placements),
created_by_user: actor)
end
private_class_method :persist_version!
def self.sync_compatibility!(group:, actor:, old_name:,
require_compatibility:, compatibility_body:)
representation = TagGroupRepr.one(group)
body = begin
TagGroupMarkdownRenderer.render(representation)
rescue TagGroupMarkdownRenderer::UnsupportedStructure => error
if compatibility_body.present?
compatibility_body
elsif require_compatibility
invalid!(group, :base, error.message)
else
return error.message
end
end
invalid!(group, :base, '本文は必須です.') if body.blank?
title = "#{ PREFIX }#{ group.name }"
old_title = old_name && "#{ PREFIX }#{ old_name }"
page = if old_title
WikiPage.joins(:tag_name).find_by(tag_names: { name: old_title })
else
nil
end
page ||= WikiPage.joins(:tag_name).find_by(tag_names: { name: title })
if page
tag_name = page.tag_name
tag_name.update!(name: title) if tag_name.name != title
Wiki::Commit.legacy_content!(page:, body:, created_user: actor, message: nil,
base_revision_id: nil)
else
tag_name = TagName.find_or_initialize_by(language_code: 'ja', name: title)
tag_name.script_code = 'Jpan'
tag_name.primary_flg = true
tag_name.auto_generated = false
tag_name.save!
Wiki::Commit.create_legacy_content!(tag_name:, body:, created_by_user: actor)
end
nil
end
private_class_method :sync_compatibility!
def self.report_sync_issue(group, actor, reason)
return unless reason
payload = { status: 'unsupported_structure', tag_group_id: group.id,
version_no: group.version_no, user_id: actor&.id, reason: }
Rails.logger.warn("tag_group_wiki_sync_skipped #{ payload.to_json }")
end
private_class_method :report_sync_issue
def self.placement_references(nodes)
nodes.flat_map do |node|
own = if node.key?('tag_id')
[{ 'tag_id' => node.fetch('tag_id') }]
elsif node.key?('tag_group_id')
[{ 'tag_group_id' => node.fetch('tag_group_id') }]
else
[]
end
own + placement_references(node.fetch('children'))
end
end
private_class_method :placement_references
def self.stringify_keys(value)
case value
when ActionController::Parameters
value.to_unsafe_h.deep_stringify_keys
when Hash
value.deep_stringify_keys
else
value
end
end
private_class_method :stringify_keys
def self.integer_id(record, value, attribute)
integer = if value.is_a?(Integer)
value
elsif value.is_a?(String) && value.match?(/\A[0-9]+\z/)
value.to_i
end
invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer
invalid!(record, attribute, 'ID は正の整数で指定してください.') unless integer.positive?
integer
end
private_class_method :integer_id
def self.invalid!(record, attribute, message)
record.errors.add(attribute, message)
raise ActiveRecord::RecordInvalid, record
end
private_class_method :invalid!
end
+23 -4
ファイルの表示
@@ -8,6 +8,15 @@ module Wiki
end end
def self.create_content! tag_name:, body:, created_by_user:, message: nil def self.create_content! tag_name:, body:, created_by_user:, message: nil
if tag_name.tag && tag_name.language_code == 'ja'
return TagDescriptionOperation.create!(
tag: tag_name.tag, body:, created_by_user:, message:)
end
create_legacy_content!(tag_name:, body:, created_by_user:, message:)
end
def self.create_legacy_content! tag_name:, body:, created_by_user:, message: nil
normalised = normalise_body(body) normalised = normalise_body(body)
page = WikiPage.new(tag_name:, page = WikiPage.new(tag_name:,
@@ -33,6 +42,18 @@ module Wiki
end end
def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil def self.content! page:, body:, created_user:, message: nil, base_revision_id: nil
description = TagDescription.find_by(id: page.id)
if description
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:)
end
def self.legacy_content! page:, body:, created_user:, message: nil,
base_revision_id: nil
new(page:, created_user:).content!(body:, message:, base_revision_id:) new(page:, created_user:).content!(body:, message:, base_revision_id:)
end end
@@ -57,9 +78,6 @@ module Wiki
line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) } line_shas = lines.map { |line| Digest::SHA256.hexdigest(line) }
tree_sha = Digest::SHA256.hexdigest(line_shas.join(',')) tree_sha = Digest::SHA256.hexdigest(line_shas.join(','))
line_id_by_sha = upsert_lines!(lines, line_shas)
line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) }
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
@page.lock! @page.lock!
@@ -72,6 +90,8 @@ module Wiki
end end
end end
line_id_by_sha = upsert_lines!(lines, line_shas)
line_ids = line_shas.map { |sha| line_id_by_sha.fetch(sha) }
@page.update!(body: normalised) @page.update!(body: normalised)
WikiVersionRecorder.record!( WikiVersionRecorder.record!(
@@ -94,7 +114,6 @@ module Wiki
{ wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos } { wiki_revision_id: rev.id, wiki_line_id: line_id, position: pos }
end end
WikiRevisionLine.insert_all!(rows) if rows.any? WikiRevisionLine.insert_all!(rows) if rows.any?
rev rev
end end
end end
+33
ファイルの表示
@@ -0,0 +1,33 @@
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:)
end
def self.update! page:, body:, updated_by_user:, message: nil,
base_revision_id: nil
normalised = Commit.normalise_body(body)
if normalised.blank?
return Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:,
base_revision_id:)
end
if base_revision_id.present?
current_id = page.wiki_revisions.maximum(:id)
if current_id && current_id != base_revision_id.to_i
raise Commit::Conflict,
"競合が発生してゐます" +
"(現在の Id.:#{ current_id },ベース Id.:#{ base_revision_id })."
end
end
return if Commit.normalise_body(page.body) == normalised
Commit.legacy_content!(
page:, body: normalised, created_user: updated_by_user, message:,
base_revision_id:)
end
end
end
+7 -1
ファイルの表示
@@ -1,4 +1,6 @@
Rails.application.routes.draw do Rails.application.routes.draw do
resources :tag_groups, only: [:index, :show, :create, :update]
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update] resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
scope 'tags/:parent_id/children', controller: :tag_children do scope 'tags/:parent_id/children', controller: :tag_children do
@@ -14,6 +16,7 @@ Rails.application.routes.draw do
scope :name do scope :name do
get ':name/deerjikists', action: :deerjikists_by_name get ':name/deerjikists', action: :deerjikists_by_name
get ':name/description', to: 'tag_descriptions#show_by_title'
get ':name/materials', action: :materials_by_name get ':name/materials', action: :materials_by_name
get ':name', action: :show_by_name get ':name', action: :show_by_name
end end
@@ -25,6 +28,9 @@ Rails.application.routes.draw do
get :deerjikists get :deerjikists
put :deerjikists, action: :update_deerjikists put :deerjikists, action: :update_deerjikists
get :description, to: 'tag_descriptions#show'
put :description, to: 'tag_descriptions#update'
end end
end end
@@ -35,7 +41,7 @@ Rails.application.routes.draw do
resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do resources :wiki_pages, path: 'wiki', only: [:index, :show, :create, :update] do
collection do collection do
get :search get :search, action: :index
get :changes get :changes
scope :title do scope :title do
+63
ファイルの表示
@@ -0,0 +1,63 @@
class CreateTagDescriptions < ActiveRecord::Migration[8.0]
def change
create_table :tag_description_versions do |t|
t.references :tag, null: false, foreign_key: false
t.string :language_code, limit: 16, null: false
t.integer :version_no, null: false
t.string :event_type, null: false
t.text :body, null: false
t.string :source_language_code, limit: 16
t.integer :source_version_no
t.references :created_by_user
t.datetime :created_at, null: false
t.index [:tag_id, :language_code, :version_no], unique: true
t.foreign_key :tag_description_versions,
column: [:tag_id, :source_language_code, :source_version_no],
primary_key: [:tag_id, :language_code, :version_no]
t.check_constraint 'version_no > 0',
name: 'chk_tag_description_versions_version_no_positive'
t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')",
name: 'tag_description_versions_event_type_valid'
t.check_constraint ('(source_language_code IS NULL AND source_version_no IS NULL) OR ' +
'(source_language_code IS NOT NULL AND source_version_no IS NOT NULL)'),
name: 'chk_tag_description_versions_source_pair'
t.check_constraint 'source_language_code IS NULL OR source_language_code <> language_code',
name: 'chk_tag_description_versions_source_language_code_different'
end
create_table :tag_descriptions do |t|
t.references :tag, null: false, foreign_key: true
t.string :language_code, limit: 16, null: false, index: true
t.integer :version_no, null: false
t.text :body, null: false
t.string :source_language_code, limit: 16
t.integer :source_version_no
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.foreign_key :languages, column: :language_code, primary_key: :code
t.foreign_key :tag_description_versions,
column: [:tag_id, :source_language_code, :source_version_no],
primary_key: [:tag_id, :language_code, :version_no]
t.index [:tag_id, :language_code], unique: true
t.index [:source_language_code, :source_version_no]
t.check_constraint 'version_no > 0',
name: 'chk_tag_descriptions_version_no_positive'
t.check_constraint ('(source_language_code IS NULL AND source_version_no IS NULL) OR ' +
'(source_language_code IS NOT NULL AND source_version_no IS NOT NULL)'),
name: 'chk_tag_descriptions_source_pair'
t.check_constraint 'source_language_code IS NULL OR source_language_code <> language_code',
name: 'chk_tag_descriptions_source_language_code_different'
end
end
end
+104
ファイルの表示
@@ -0,0 +1,104 @@
class MigrateTagDescriptionsFromWikiPages < ActiveRecord::Migration[8.0]
class WikiPage < ActiveRecord::Base
self.table_name = 'wiki_pages'
end
class WikiRevision < ActiveRecord::Base
self.table_name = 'wiki_revisions'
end
class WikiRevisionLine < ActiveRecord::Base
self.table_name = 'wiki_revision_lines'
end
class TagName < ActiveRecord::Base
self.table_name = 'tag_names'
end
class TagDescription < ActiveRecord::Base
self.table_name = 'tag_descriptions'
end
class TagDescriptionVersion < ActiveRecord::Base
self.table_name = 'tag_description_versions'
end
def up
WikiPage.find_each do |page|
tag_name = TagName.find_by(id: page.tag_name_id, language_code: 'ja')
next unless tag_name&.tag_id
revisions = WikiRevision.where(wiki_page_id: page.id).order(:id).to_a
revisions.each_with_index do |revision, index|
version_no = index + 1
tag_id = tag_name[:tag_id]
unless tag_id
raise (
'tag_id is nil: ' +
"wiki_page_id=#{ page.id }, " +
"tag_name_id=#{ tag_name.id }, " +
"tag_name=#{ tag_name.attributes.inspect}")
end
revision_id = revision.id
say (
"wiki_page_id=#{ page.id }, " +
"tag_name_id=#{ tag_name.id }, " +
"tag_id=#{ tag_id }, " +
"revision_id=#{ revision_id }")
version = TagDescriptionVersion.new(
id: revision_id,
tag_id:,
language_code: 'ja',
version_no:,
event_type: version_no == 1 ? 'create' : 'update',
body: revision_body(revision_id),
source_language_code: nil,
source_version_no: nil,
created_by_user_id: revision.created_user_id,
created_at: revision.created_at)
say version.attributes.inspect
version.save!
end
next if page.discarded_at.present?
next if revisions.empty?
TagDescription.create!(
id: page.id,
tag_id: tag_name.tag_id,
language_code: 'ja',
version_no: revisions.length,
body: page.body,
source_language_code: nil,
source_version_no: nil,
created_by_user_id: page.created_user_id,
updated_by_user_id: page.updated_user_id,
created_at: page.created_at,
updated_at: page.updated_at)
end
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
private
def revision_body revision_id
WikiRevisionLine
.joins('INNER JOIN wiki_lines ' +
'ON wiki_lines.id = wiki_revision_lines.wiki_line_id')
.where(wiki_revision_id: revision_id)
.order(:position)
.pluck('wiki_lines.body')
.join("\n")
end
end
+53
ファイルの表示
@@ -0,0 +1,53 @@
class RepairWikiUpdatedAt < ActiveRecord::Migration[8.0]
class WikiPage < ActiveRecord::Base
self.table_name = 'wiki_pages'
end
class WikiVersion < ActiveRecord::Base
self.table_name = 'wiki_versions'
end
class TagDescription < ActiveRecord::Base
self.table_name = 'tag_descriptions'
end
class TagDescriptionVersion < ActiveRecord::Base
self.table_name = 'tag_description_versions'
end
def up
say_with_time 'Repairing wiki_pages.updated_at from wiki_versions' do
WikiPage.find_each do |page|
version =
WikiVersion
.where(wiki_page_id: page.id)
.order(version_no: :desc)
.first
next unless version
page.update_columns(updated_at: version.created_at)
end
end
say_with_time 'Repairing tag_descriptions.updated_at from tag_description_versions' do
TagDescription.find_each do |description|
version =
TagDescriptionVersion
.where(
tag_id: description.tag_id,
language_code: description.language_code)
.order(version_no: :desc)
.first
next unless version
description.update_columns(updated_at: version.created_at)
end
end
end
def down
;
end
end
+149
ファイルの表示
@@ -0,0 +1,149 @@
class CreateTagGroups < ActiveRecord::Migration[8.0]
def change
# 翻訳系は上流の Translation リソースが未完のため保留
create_table :tag_groups do |t|
t.integer :version_no, null: false
t.string :name, limit: 255, null: false
t.text :description
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.check_constraint 'version_no > 0', name: 'chk_tag_groups_version_no_positive'
t.index :name, unique: true
end
create_table :tag_group_tags, primary_key: [:tag_group_id, :tag_id] do |t|
t.references :tag_group, null: false, foreign_key: true, index: false
t.references :tag, null: false, foreign_key: true
t.references :created_by_user, foreign_key: { to_table: :users }
t.datetime :created_at, null: false
end
create_table :tag_group_groups, primary_key: [:tag_group_id, :child_tag_group_id] do |t|
t.references :tag_group, null: false, foreign_key: true, index: false
t.references :child_tag_group,
null: false, foreign_key: { to_table: :tag_groups }
t.references :created_by_user, foreign_key: { to_table: :users }
t.datetime :created_at, null: false
t.check_constraint 'tag_group_id <> child_tag_group_id',
name: 'chk_tag_group_groups_tag_group_id_not_child_tag_group_id'
end
create_table :tag_group_placements, primary_key: [:tag_group_id, :position] do |t|
t.references :tag_group, null: false, foreign_key: true, index: false
t.integer :position, null: false
t.integer :parent_position
t.string :section_name, limit: 255
t.text :description
t.references :tag
t.references :child_tag_group
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.foreign_key :tag_group_placements,
column: [:tag_group_id, :parent_position],
primary_key: [:tag_group_id, :position]
t.foreign_key :tag_group_tags,
column: [:tag_group_id, :tag_id],
primary_key: [:tag_group_id, :tag_id]
t.foreign_key :tag_group_groups,
column: [:tag_group_id, :child_tag_group_id],
primary_key: [:tag_group_id, :child_tag_group_id]
t.check_constraint 'position > parent_position',
name: 'chk_tag_group_placements_position_gt_parent_position'
t.check_constraint ('((section_name IS NOT NULL AND tag_id IS NULL AND child_tag_group_id IS NULL) OR ' +
'(section_name IS NULL AND tag_id IS NOT NULL AND child_tag_group_id IS NULL) OR ' +
'(section_name IS NULL AND tag_id IS NULL AND child_tag_group_id IS NOT NULL)) AND ' +
'(section_name IS NOT NULL OR description IS NULL)'),
name: 'chk_tag_group_placements_references_exclusive'
end
create_table :tag_group_versions do |t|
t.references :tag_group, null: false
t.integer :version_no, null: false
t.string :event_type, null: false
t.string :name, limit: 255, null: false
t.text :description
t.json :members, null: false
t.json :placements, null: false
t.references :created_by_user
t.datetime :created_at, null: false
t.check_constraint 'version_no > 0',
name: 'chk_tag_group_versions_version_no_positive'
t.index [:tag_group_id, :version_no], unique: true
add_members_constraint!(t)
add_placements_constraint!(t)
end
end
private
def add_members_constraint! t
schema = { type: 'array',
uniqueItems: true,
items: member_schema }
quoted_schema = connection.quote(JSON.generate(schema))
t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, members)",
name: 'chk_tag_group_versions_members_schema'
end
def member_schema
{ oneOf: [tag_member_schema, group_member_schema] }
end
def tag_member_schema
{ type: 'object',
required: ['tag_id', 'version_no'],
properties: { tag_id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 } },
additionalProperties: false }
end
def group_member_schema
{ type: 'object',
required: ['tag_group_id', 'version_no'],
properties: { tag_group_id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 } },
additionalProperties: false }
end
def add_placements_constraint! t
schema = { type: 'array', items: placement_schema(5) }
quoted_schema = connection.quote(JSON.generate(schema))
t.check_constraint "JSON_SCHEMA_VALID(#{ quoted_schema }, placements)",
name: 'chk_tag_group_versions_placements_schema'
end
def placement_schema rest
{ type: 'object',
required: ['children'],
properties: { section_name: { type: 'string' },
description: { type: ['string', 'null'] },
tag_id: { type: 'integer', minimum: 1 },
tag_group_id: { type: 'integer', minimum: 1 },
children: if rest > 1
{ type: 'array', items: placement_schema(rest - 1) }
else
{ type: 'array', maxItems: 0 }
end },
oneOf: [{ required: ['section_name'] },
{ required: ['tag_id'] },
{ required: ['tag_group_id'] }],
dependencies: { description: ['section_name'] },
additionalProperties: false }
end
end
+518
ファイルの表示
@@ -0,0 +1,518 @@
class MigrateTagGroupsFromWikiPages < ActiveRecord::Migration[8.0]
PREFIX = 'タググループ:'
def up
require 'json'
require 'time'
require 'kramdown'
require 'kramdown-parser-gfm'
assert_empty_destination!
pages = connection.select_all(<<~SQL).to_a
SELECT
wp.id
, wp.created_user_id
, wp.updated_user_id
, wp.created_at
, wp.updated_at
, wp.discarded_at
, tn.name
FROM
wiki_pages wp
INNER JOIN
tag_names tn
ON
tn.id = wp.tag_name_id
WHERE
tn.language_code = 'ja'
AND tn.name LIKE #{ quote("#{ PREFIX }%") }
ORDER BY
wp.id
SQL
return if pages.empty?
pages.each do |page|
if page['discarded_at']
say "Wiki ##{ page.fetch('id') }: 破棄済みですが,Revision の移行を継続します."
end
end
tag_ids = load_tag_ids
page_ids = pages.map { |page| page.fetch('id').to_i }
revisions = connection.select_all(<<~SQL).to_a
SELECT
wr.id
, wr.wiki_page_id
, wr.kind
, wr.redirect_page_id
, wr.created_user_id
, wr.created_at
FROM
wiki_revisions wr
WHERE
wr.wiki_page_id IN (#{ page_ids.join(', ') })
ORDER BY
wr.wiki_page_id
, wr.id
SQL
revisions_by_page = revisions.group_by { |row| row.fetch('wiki_page_id').to_i }
pages = pages.select do |page|
page_id = page.fetch('id').to_i
if revisions_by_page.fetch(page_id, []).empty?
# tag_groups.version_no > 0 のため,Revision 0 件の Wiki は作成しない.
say "Wiki ##{ page_id }: Revision が 0 件のため移行対象から除外します."
false
else
true
end
end
return if pages.empty?
group_ids = pages.to_h { |page| [page.fetch('name'), page.fetch('id').to_i] }
revision_history = pages.to_h do |page|
page_id = page.fetch('id').to_i
history = revisions_by_page.fetch(page_id).each_with_index.map do |revision, index|
{ 'id' => revision.fetch('id').to_i,
'version_no' => index + 1,
'created_at' => revision.fetch('created_at') }
end
[page_id, history]
end
tag_history = connection.select_all(<<~SQL).to_a.group_by { |row| row.fetch('tag_id').to_i }
SELECT
tag_id
, version_no
, created_at
FROM
tag_versions
ORDER BY
tag_id
, created_at
, version_no
SQL
prepared = { }
pages.each do |page|
page_id = page.fetch('id').to_i
history = revisions_by_page.fetch(page_id)
prepared[page_id] = history.each_with_index.map do |revision, index|
context = "Wiki ##{ page_id } / Revision ##{ revision.fetch('id') }"
body = revision_body(revision.fetch('id').to_i)
if revision.fetch('kind').to_i == 1
destination = revision['redirect_page_id']
description = "旧 Wiki のリダイレクト先: Wiki ##{ destination }"
placements = []
members = []
say "#{ context }: リダイレクトを説明文として移行します."
else
begin
description, placements = parse_wiki(body, tag_ids, group_ids, context)
validate_tree!(placements, context)
members = build_members(placements, revision.fetch('created_at'),
tag_history, revision_history, context)
rescue StandardError => error
# 解析できない版も捨てず,当時の Markdown を説明文として保存する.
say "#{ context }: 構造化を保留し,本文を保存します (#{ error.message })."
description = body
placements = []
members = []
end
end
{ revision:, version_no: index + 1, description:, members:, placements:, body: }
end
end
# 自己包含は禁止されてゐるため,当該版は原文の保存を優先する.
prepared.each do |group_id, versions|
current = versions.last
next unless current.fetch(:members).any? { |member| member['tag_group_id'] == group_id }
say "Tag Group ##{ group_id }: 直接の自己参照を検出したため,現行構造化を保留します."
current[:description] = current.fetch(:body)
current[:members] = []
current[:placements] = []
end
begin
validate_current_groups!(prepared)
rescue StandardError => error
say "Tag Group の包含関係に問題がありますが,移行を継続します (#{ error.message })."
end
# 移行先の書込みはすべて検証の後.DDL は含めない.
connection.transaction do
pages.each do |page|
page_id = page.fetch('id').to_i
current = prepared.fetch(page_id).last
insert_row('tag_groups', {
id: page_id,
# page.version_no ではなく,この Wiki に紐付く Revision の総数.
version_no: revisions_by_page.fetch(page_id).size,
name: page.fetch('name').delete_prefix(PREFIX),
description: current.fetch(:description),
created_by_user_id: page.fetch('created_user_id'),
updated_by_user_id: page.fetch('updated_user_id'),
created_at: page.fetch('created_at'),
updated_at: page.fetch('updated_at') })
end
pages.each do |page|
page_id = page.fetch('id').to_i
name = page.fetch('name').delete_prefix(PREFIX)
prepared.fetch(page_id).each do |item|
revision = item.fetch(:revision)
insert_row('tag_group_versions', {
id: revision.fetch('id'),
tag_group_id: page_id,
version_no: item.fetch(:version_no),
event_type: item.fetch(:version_no) == 1 ? 'create' : 'update',
name:,
description: item.fetch(:description),
members: JSON.generate(item.fetch(:members)),
placements: JSON.generate(item.fetch(:placements)),
created_by_user_id: revision.fetch('created_user_id'),
created_at: revision.fetch('created_at') })
end
end
pages.each do |page|
page_id = page.fetch('id').to_i
current = prepared.fetch(page_id).last
revision = current.fetch(:revision)
current.fetch(:members).each do |member|
if member.key?('tag_id')
insert_row('tag_group_tags', {
tag_group_id: page_id,
tag_id: member.fetch('tag_id'),
created_by_user_id: revision.fetch('created_user_id'),
created_at: revision.fetch('created_at') })
else
insert_row('tag_group_groups', {
tag_group_id: page_id,
child_tag_group_id: member.fetch('tag_group_id'),
created_by_user_id: revision.fetch('created_user_id'),
created_at: revision.fetch('created_at') })
end
end
end
pages.each do |page|
page_id = page.fetch('id').to_i
current = prepared.fetch(page_id).last
revision = current.fetch(:revision)
position = 0
append = -> nodes, parent_position do
nodes.each do |node|
position += 1
own_position = position
insert_row('tag_group_placements', {
tag_group_id: page_id,
position: own_position,
parent_position:,
section_name: node['section_name'],
description: node['description'],
tag_id: node['tag_id'],
child_tag_group_id: node['tag_group_id'],
created_by_user_id: revision.fetch('created_user_id'),
updated_by_user_id: revision.fetch('created_user_id'),
created_at: revision.fetch('created_at'),
updated_at: revision.fetch('created_at') })
append.(node.fetch('children'), own_position)
end
end
append.(current.fetch(:placements), nil)
end
end
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
private
def assert_empty_destination!
['tag_groups', 'tag_group_versions', 'tag_group_tags',
'tag_group_groups', 'tag_group_placements'].each do |table|
count = connection.select_value("SELECT COUNT(*) FROM #{ table }").to_i
raise "#{ table } が空ではありません." if count.positive?
end
end
# 解決は tag_names.name -> tag_names.tag_id のみ.
# tags.tag_name_id は読まない.
def load_tag_ids
rows = connection.select_all(<<~SQL).to_a
SELECT
name
, tag_id
FROM
tag_names
WHERE
language_code = 'ja'
AND tag_id IS NOT NULL
SQL
rows.each_with_object({ }) do |row, by_name|
name = row.fetch('name')
id = row.fetch('tag_id').to_i
by_name[name] ||= []
by_name[name] << id unless by_name[name].include?(id)
end
end
def revision_body revision_id
connection.select_rows(<<~SQL).flatten.join("\n")
SELECT
wl.body
FROM
wiki_revision_lines wrl
INNER JOIN wiki_lines wl ON wl.id = wrl.wiki_line_id
WHERE
wrl.wiki_revision_id = #{ Integer(revision_id) }
ORDER BY
wrl.position
SQL
end
def parse_wiki markdown, tag_ids, group_ids, context
root = Kramdown::Document.new(markdown, input: 'GFM').root
placements = []
description_parts = []
headings = []
root.children.each_with_index do |node, index|
case node.type
when :blank
next
when :header
level = node.options.fetch(:level)
name = inline_text(node).strip
raise "#{ context }: 名前のない見出しがあります." if name.empty?
headings.pop while headings.any? && headings.last.first >= level
parent = headings.last&.last
section = { 'section_name' => name, 'description' => nil, 'children' => [] }
(parent ? parent.fetch('children') : placements) << section
headings << [level, section]
when :p
text = inline_markdown(node).strip
next if text.empty?
if headings.any?
heading = headings.last.last
heading['description'] = [heading['description'], text].compact.join("\n\n")
else
description_parts << text
end
when :ul, :ol
if headings.any? && see_also_heading?(headings.last.last.fetch('section_name'))
heading = headings.last.last
raw = original_block(markdown, root.children, index)
heading['description'] = [heading['description'], raw].compact.join("\n\n")
else
target = headings.any? ? headings.last.last.fetch('children') : placements
target.concat(node.children.map { |item| parse_list_item(item, tag_ids, group_ids, context) })
end
else
raise "#{ context }: 未対応の Markdown 構文 #{ node.type.inspect }"
end
end
[description_parts.empty? ? nil : description_parts.join("\n\n"), placements]
end
def see_also_heading? name
['see also', '関連項目'].include?(name.strip.downcase.sub(/[::]\z/, ''))
end
def original_block markdown, siblings, index
current = siblings.fetch(index)
start_line = current.options[:location]
raise 'Markdown block の位置が不明です.' unless start_line
following = siblings[(index + 1)..].find do |node|
node.options[:location] && node.options[:location] > start_line
end
end_line = following ? following.options.fetch(:location) - 1 : markdown.lines.length
markdown.lines[(start_line - 1)...end_line].join.strip
end
def parse_list_item item, tag_ids, group_ids, context
raise "#{ context }: リスト項目ではありません." if item.type != :li
nested, own = item.children.partition { |child| [:ul, :ol].include?(child.type) }
own.reject! { |child| child.type == :blank }
unless own.all? { |child| [:p, :text, :a, :codespan].include?(child.type) }
raise "#{ context }: 未対応のリスト項目があります."
end
label = own.map { |child| inline_text(child) }.join(' ').strip
raise "#{ context }: 空のリスト項目があります." if label.empty?
node =
if group_ids.key?(label)
{ 'tag_group_id' => group_ids.fetch(label) }
elsif tag_ids.key?(label)
ids = tag_ids.fetch(label)
unless ids.one?
raise "#{ context }: タグ名 #{ label.inspect } が複数の ID を指します:#{ ids.inspect }"
end
{ 'tag_id' => ids.first }
elsif label.end_with?(':', ':')
name = label.sub(/[::]\z/, '')
raise "#{ context }: 空の区分名があります." if name.empty?
if see_also_heading?(name) && nested.any?
raise "#{ context }: リスト内の See also は説明文への保管処理が未実装です."
end
{ 'section_name' => name, 'description' => nil }
else
raise "#{ context }: 解決不能なリスト項目 #{ label.inspect }"
end
node['children'] = nested.flat_map do |list|
list.children.map { |child| parse_list_item(child, tag_ids, group_ids, context) }
end
node
end
def inline_text node
case node.type
when :text, :codespan
node.value.to_s
when :softbreak, :br
' '
when :a, :p, :header, :em, :strong, :span, :li
node.children.map { |child| inline_text(child) }.join
else
raise "未対応のインライン構文 #{ node.type.inspect }"
end
end
def inline_markdown node
case node.type
when :text
node.value.to_s
when :codespan
"`#{ node.value }`"
when :softbreak
"\n"
when :br
" \n"
when :p
node.children.map { |child| inline_markdown(child) }.join
when :a
href = node.attr.fetch('href')
"[#{ node.children.map { |child| inline_markdown(child) }.join }](#{ href })"
when :em
"*#{ node.children.map { |child| inline_markdown(child) }.join }*"
when :strong
"**#{ node.children.map { |child| inline_markdown(child) }.join }**"
else
raise "未対応のインライン構文 #{ node.type.inspect }"
end
end
def validate_tree! nodes, context, depth = 1
raise "#{ context }: 配置の深さが 5 を超えてゐます." if depth > 5 && nodes.any?
nodes.each do |node|
name = node['section_name']
if name && name.length > 255
raise "#{ context }: 区分名が 255 文字を超えてゐます."
end
validate_tree!(node.fetch('children'), context, depth + 1)
end
end
def build_members nodes, date, tag_history, group_history, context
ids_of_tags = []
ids_of_groups = []
visit = -> items do
items.each do |node|
ids_of_tags << node['tag_id'] if node.key?('tag_id')
ids_of_groups << node['tag_group_id'] if node.key?('tag_group_id')
visit.(node.fetch('children'))
end
end
visit.(nodes)
tags = ids_of_tags.uniq.map do |id|
{ 'tag_id' => id,
'version_no' => version_at(tag_history[id], date, context, "Tag ##{ id }") }
end
groups = ids_of_groups.uniq.map do |id|
{ 'tag_group_id' => id,
'version_no' => version_at(group_history[id], date, context, "TagGroup ##{ id }") }
end
tags + groups
end
def version_at history, date, context, target
raise "#{ context }: #{ target } の履歴が存在しません." unless history&.any?
timestamp = Time.parse(date.to_s)
candidates = history.select do |row|
Time.parse(row.fetch('created_at').to_s) <= timestamp
end
if candidates.empty?
raise "#{ context }: #{ target } は #{ date } 時点で存在しません."
end
candidates.max_by { |row|
[Time.parse(row.fetch('created_at').to_s), row.fetch('version_no').to_i]
}.fetch('version_no').to_i
end
def validate_current_groups! prepared
edges = { }
prepared.each do |group_id, versions|
edges[group_id] = versions.last.fetch(:members)
.filter_map { |member| member['tag_group_id'] }
end
visiting = { }
visited = { }
visit = -> group_id do
raise "Tag Group の循環参照を検出しました:#{ group_id }" if visiting[group_id]
return if visited[group_id]
visiting[group_id] = true
edges.fetch(group_id, []).each { |child_id| visit.(child_id) }
visiting.delete(group_id)
visited[group_id] = true
end
edges.keys.each { |group_id| visit.(group_id) }
end
def quote(value) = connection.quote(value)
def insert_row table, values
columns = values.keys.join(', ')
payload = values.values.map { |value| quote(value) }.join(', ')
execute "INSERT INTO #{ table } (#{ columns }) VALUES (#{ payload })"
end
end
生成ファイル
+138 -17
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do ActiveRecord::Schema[8.0].define(version: 2026_10_08_010000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -440,6 +440,123 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do
t.index ["user_id"], name: "index_settings_on_user_id", unique: true t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end end
create_table "tag_description_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.string "language_code", limit: 16, null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.text "body", null: false
t.string "source_language_code", limit: 16
t.integer "source_version_no"
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_description_versions_on_created_by_user_id"
t.index ["tag_id", "language_code", "version_no"], name: "idx_on_tag_id_language_code_version_no_ced77e57e6", unique: true
t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_4f13094dbc"
t.index ["tag_id"], name: "index_tag_description_versions_on_tag_id"
t.check_constraint "((`source_language_code` is null) and (`source_version_no` is null)) or ((`source_language_code` is not null) and (`source_version_no` is not null))", name: "chk_tag_description_versions_source_pair"
t.check_constraint "(`source_language_code` is null) or (`source_language_code` <> `language_code`)", name: "chk_tag_description_versions_source_language_code_different"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "tag_description_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "chk_tag_description_versions_version_no_positive"
end
create_table "tag_descriptions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.string "language_code", limit: 16, null: false
t.integer "version_no", null: false
t.text "body", null: false
t.string "source_language_code", limit: 16
t.integer "source_version_no"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_tag_descriptions_on_created_by_user_id"
t.index ["language_code"], name: "index_tag_descriptions_on_language_code"
t.index ["source_language_code", "source_version_no"], name: "idx_on_source_language_code_source_version_no_00117fe02a"
t.index ["tag_id", "language_code"], name: "index_tag_descriptions_on_tag_id_and_language_code", unique: true
t.index ["tag_id", "source_language_code", "source_version_no"], name: "fk_rails_d1be0a7696"
t.index ["tag_id"], name: "index_tag_descriptions_on_tag_id"
t.index ["updated_by_user_id"], name: "index_tag_descriptions_on_updated_by_user_id"
t.check_constraint "((`source_language_code` is null) and (`source_version_no` is null)) or ((`source_language_code` is not null) and (`source_version_no` is not null))", name: "chk_tag_descriptions_source_pair"
t.check_constraint "(`source_language_code` is null) or (`source_language_code` <> `language_code`)", name: "chk_tag_descriptions_source_language_code_different"
t.check_constraint "`version_no` > 0", name: "chk_tag_descriptions_version_no_positive"
end
create_table "tag_group_groups", primary_key: ["tag_group_id", "child_tag_group_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_group_id", null: false
t.bigint "child_tag_group_id", null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["child_tag_group_id"], name: "index_tag_group_groups_on_child_tag_group_id"
t.index ["created_by_user_id"], name: "index_tag_group_groups_on_created_by_user_id"
t.check_constraint "`tag_group_id` <> `child_tag_group_id`", name: "chk_tag_group_groups_tag_group_id_not_child_tag_group_id"
end
create_table "tag_group_placements", primary_key: ["tag_group_id", "position"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_group_id", null: false
t.integer "position", null: false
t.integer "parent_position"
t.string "section_name"
t.text "description"
t.bigint "tag_id"
t.bigint "child_tag_group_id"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["child_tag_group_id"], name: "index_tag_group_placements_on_child_tag_group_id"
t.index ["created_by_user_id"], name: "index_tag_group_placements_on_created_by_user_id"
t.index ["tag_group_id", "child_tag_group_id"], name: "fk_rails_7342b54ced"
t.index ["tag_group_id", "parent_position"], name: "fk_rails_3af6ee71ca"
t.index ["tag_group_id", "tag_id"], name: "fk_rails_365dc60759"
t.index ["tag_id"], name: "index_tag_group_placements_on_tag_id"
t.index ["updated_by_user_id"], name: "index_tag_group_placements_on_updated_by_user_id"
t.check_constraint "(((`section_name` is not null) and (`tag_id` is null) and (`child_tag_group_id` is null)) or ((`section_name` is null) and (`tag_id` is not null) and (`child_tag_group_id` is null)) or ((`section_name` is null) and (`tag_id` is null) and (`child_tag_group_id` is not null))) and ((`section_name` is not null) or (`description` is null))", name: "chk_tag_group_placements_references_exclusive"
t.check_constraint "`position` > `parent_position`", name: "chk_tag_group_placements_position_gt_parent_position"
end
create_table "tag_group_tags", primary_key: ["tag_group_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_group_id", null: false
t.bigint "tag_id", null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_group_tags_on_created_by_user_id"
t.index ["tag_id"], name: "index_tag_group_tags_on_tag_id"
end
create_table "tag_group_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_group_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "name", null: false
t.text "description"
t.json "members", null: false
t.json "placements", null: false
t.bigint "created_by_user_id"
t.datetime "created_at", null: false
t.index ["created_by_user_id"], name: "index_tag_group_versions_on_created_by_user_id"
t.index ["tag_group_id", "version_no"], name: "index_tag_group_versions_on_tag_group_id_and_version_no", unique: true
t.index ["tag_group_id"], name: "index_tag_group_versions_on_tag_group_id"
t.check_constraint "`version_no` > 0", name: "chk_tag_group_versions_version_no_positive"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"required\":[\"children\"],\"properties\":{\"section_name\":{\"type\":\"string\"},\"description\":{\"type\":[\"string\",\"null\"]},\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"children\":{\"type\":\"array\",\"maxItems\":0}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}},\"oneOf\":[{\"required\":[\"section_name\"]},{\"required\":[\"tag_id\"]},{\"required\":[\"tag_group_id\"]}],\"dependencies\":{\"description\":[\"section_name\"]},\"additionalProperties\":false}}',`placements`)", name: "chk_tag_group_versions_placements_schema"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"uniqueItems\":true,\"items\":{\"oneOf\":[{\"type\":\"object\",\"required\":[\"tag_id\",\"version_no\"],\"properties\":{\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1}},\"additionalProperties\":false},{\"type\":\"object\",\"required\":[\"tag_group_id\",\"version_no\"],\"properties\":{\"tag_group_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1}},\"additionalProperties\":false}]}}',`members`)", name: "chk_tag_group_versions_members_schema"
end
create_table "tag_groups", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.integer "version_no", null: false
t.string "name", null: false
t.text "description"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_tag_groups_on_created_by_user_id"
t.index ["name"], name: "index_tag_groups_on_name", unique: true
t.index ["updated_by_user_id"], name: "index_tag_groups_on_updated_by_user_id"
t.check_constraint "`version_no` > 0", name: "chk_tag_groups_version_no_positive"
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.bigint "parent_tag_id", null: false t.bigint "parent_tag_id", null: false
@@ -656,19 +773,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do
t.index ["banned_at"], name: "index_users_on_banned_at" t.index ["banned_at"], name: "index_users_on_banned_at"
end end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false t.string "sha256", limit: 64, null: false
t.text "body", null: false t.text "body", null: false
@@ -685,7 +789,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false
t.integer "version_no", null: false t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
@@ -788,6 +891,26 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do
add_foreign_key "post_versions", "users", column: "created_by_user_id" add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users" add_foreign_key "settings", "users"
add_foreign_key "tag_description_versions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
add_foreign_key "tag_descriptions", "languages", column: "language_code", primary_key: "code"
add_foreign_key "tag_descriptions", "tag_description_versions", column: ["tag_id", "source_language_code", "source_version_no"], primary_key: ["tag_id", "language_code", "version_no"]
add_foreign_key "tag_descriptions", "tags"
add_foreign_key "tag_descriptions", "users", column: "created_by_user_id"
add_foreign_key "tag_descriptions", "users", column: "updated_by_user_id"
add_foreign_key "tag_group_groups", "tag_groups"
add_foreign_key "tag_group_groups", "tag_groups", column: "child_tag_group_id"
add_foreign_key "tag_group_groups", "users", column: "created_by_user_id"
add_foreign_key "tag_group_placements", "tag_group_groups", column: ["tag_group_id", "child_tag_group_id"], primary_key: ["tag_group_id", "child_tag_group_id"]
add_foreign_key "tag_group_placements", "tag_group_placements", column: ["tag_group_id", "parent_position"], primary_key: ["tag_group_id", "position"]
add_foreign_key "tag_group_placements", "tag_group_tags", column: ["tag_group_id", "tag_id"], primary_key: ["tag_group_id", "tag_id"]
add_foreign_key "tag_group_placements", "tag_groups"
add_foreign_key "tag_group_placements", "users", column: "created_by_user_id"
add_foreign_key "tag_group_placements", "users", column: "updated_by_user_id"
add_foreign_key "tag_group_tags", "tag_groups"
add_foreign_key "tag_group_tags", "tags"
add_foreign_key "tag_group_tags", "users", column: "created_by_user_id"
add_foreign_key "tag_groups", "users", column: "created_by_user_id"
add_foreign_key "tag_groups", "users", column: "updated_by_user_id"
add_foreign_key "tag_implications", "tags" add_foreign_key "tag_implications", "tags"
add_foreign_key "tag_implications", "tags", column: "parent_tag_id" add_foreign_key "tag_implications", "tags", column: "parent_tag_id"
add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code" add_foreign_key "tag_names", "languages", column: "language_code", primary_key: "code"
@@ -822,8 +945,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_22_040000) do
add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users" add_foreign_key "user_post_views", "users"
add_foreign_key "user_theme_slots", "users" add_foreign_key "user_theme_slots", "users"
add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
add_foreign_key "wiki_assets", "wiki_pages"
add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "tag_names"
add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "created_user_id"
add_foreign_key "wiki_pages", "users", column: "updated_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id"
+3 -3
ファイルの表示
@@ -3,8 +3,8 @@ namespace :nico do
task export: :environment do task export: :environment do
require 'open3' require 'open3'
mysql_user = ENV.fetch('MYSQL_USER') mysql_user = ENV.fetch('NIZIKA_NICO_MYSQL_USER')
mysql_pass = ENV.fetch('MYSQL_PASS') mysql_pass = ENV.fetch('NIZIKA_NICO_MYSQL_PASS')
nizika_nico_path = ENV.fetch('NIZIKA_NICO_PATH') nizika_nico_path = ENV.fetch('NIZIKA_NICO_PATH')
videos = Post.where('url LIKE ?', '%nicovideo.jp/watch/%').pluck(:url).filter_map { videos = Post.where('url LIKE ?', '%nicovideo.jp/watch/%').pluck(:url).filter_map {
@@ -14,7 +14,7 @@ namespace :nico do
next if videos.empty? next if videos.empty?
_, stderr, status = Open3.capture3( _, stderr, status = Open3.capture3(
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass }, { 'NIZIKA_NICO_MYSQL_USER' => mysql_user, 'NIZIKA_NICO_MYSQL_PASS' => mysql_pass },
'python3', '-m', 'tracked_videos.put_bulk_upsert', *videos, 'python3', '-m', 'tracked_videos.put_bulk_upsert', *videos,
chdir: nizika_nico_path) chdir: nizika_nico_path)
+51
ファイルの表示
@@ -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
+3 -3
ファイルの表示
@@ -54,11 +54,11 @@ namespace :nico do
.find_each(&:destroy!) .find_each(&:destroy!)
end end
mysql_user = ENV['MYSQL_USER'] mysql_user = ENV['NIZIKA_NICO_MYSQL_USER']
mysql_pass = ENV['MYSQL_PASS'] mysql_pass = ENV['NIZIKA_NICO_MYSQL_PASS']
nizika_nico_path = ENV['NIZIKA_NICO_PATH'] nizika_nico_path = ENV['NIZIKA_NICO_PATH']
stdout, stderr, status = Open3.capture3( stdout, stderr, status = Open3.capture3(
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass }, { 'NIZIKA_NICO_MYSQL_USER' => mysql_user, 'NIZIKA_NICO_MYSQL_PASS' => mysql_pass },
'python3', "#{ nizika_nico_path }/get_videos.py") 'python3', "#{ nizika_nico_path }/get_videos.py")
unless status.success? unless status.success?
+34
ファイルの表示
@@ -0,0 +1,34 @@
require 'rails_helper'
require_relative '../../db/migrate/20261003010000_migrate_tag_descriptions_from_wiki_pages'
RSpec.describe 'Tag description migration identity' do
it 'preserves every revision ID and repeated body in an independent sequence' do
user = create_member_user!
tag_name = create(:tag_name, name: 'description_migration')
page = Wiki::Commit.create_content!(
tag_name:, body: 'A', created_by_user: user)
revisions = [page.current_revision]
['A', 'B', 'B', 'A'].each do |body|
revisions << Wiki::Commit.content!(
page:, body:, created_user: user, base_revision_id: revisions.last.id)
end
tag = create(:tag, primary_tag_name: tag_name)
legacy_state = [page.reload.attributes, revisions.map(&:attributes)]
expect(page.wiki_versions.count).to eq(3)
migration = MigrateTagDescriptionsFromWikiPages.new
migration.suppress_messages { migration.up }
versions = TagDescriptionVersion.where(tag_id: tag.id, language_code: 'ja')
.order(:version_no)
expect(versions.pluck(:id)).to eq(revisions.map(&:id))
expect(versions.pluck(:version_no)).to eq([1, 2, 3, 4, 5])
expect(versions.pluck(:body)).to eq(['A', 'A', 'B', 'B', 'A'])
expect(versions.pluck(:created_by_user_id)).to eq([user.id] * 5)
expect(versions.pluck(:created_at)).to eq(revisions.map(&:created_at))
expect(TagDescription.find(page.id)).to have_attributes(
tag_id: tag.id, language_code: 'ja', version_no: 5, body: 'A')
expect([page.reload.attributes, revisions.map { |revision| revision.reload.attributes }])
.to eq(legacy_state)
end
end
+110
ファイルの表示
@@ -0,0 +1,110 @@
require 'rails_helper'
RSpec.describe 'Tag group persistence constraints' do
include TagGroupRecords
let!(:user) { create_member_user! }
let!(:tag) { group_tag('制約確認') }
let!(:group_id) do
seed_group(
name: '制約確認',
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }])
end
def placement attributes
insert_group_row('tag_group_placements', {
tag_group_id: group_id, position: 1,
created_at: Time.current, updated_at: Time.current }.merge(attributes))
end
def reject_statement
before_state = group_state
expect { yield }.to raise_error(ActiveRecord::StatementInvalid)
expect(group_state).to eq(before_state)
end
it 'allows membership without placement' do
expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1)
expect(group_rows('tag_group_placements', tag_group_id: group_id)).to be_empty
end
it 'allows repeated Tag placements with unique membership' do
placement(tag_id: tag.id)
placement(tag_id: tag.id, position: 2)
expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1)
expect(group_rows('tag_group_placements', tag_group_id: group_id).size).to eq(2)
end
it 'rejects duplicate Tag membership' do
reject_statement do
insert_group_row('tag_group_tags', {
tag_group_id: group_id, tag_id: tag.id, created_at: Time.current })
end
end
it 'rejects duplicate child membership' do
child_id = seed_group(name: '子')
attributes = { tag_group_id: group_id, child_tag_group_id: child_id,
created_at: Time.current }
insert_group_row('tag_group_groups', attributes)
reject_statement { insert_group_row('tag_group_groups', attributes) }
end
it 'rejects direct self membership' do
reject_statement do
insert_group_row('tag_group_groups', {
tag_group_id: group_id, child_tag_group_id: group_id, created_at: Time.current })
end
end
it 'rejects a placement without a kind' do
reject_statement { placement({ }) }
end
it 'rejects simultaneous Section and Tag kinds' do
reject_statement { placement(section_name: '区分', tag_id: tag.id) }
end
it 'rejects a description attached to a Tag placement' do
reject_statement { placement(tag_id: tag.id, description: '不正') }
end
it 'allows an optional description on a Section' do
placement(section_name: '区分', description: '説明')
expect(group_rows('tag_group_placements', tag_group_id: group_id).sole)
.to include('section_name' => '区分', 'description' => '説明')
end
it 'rejects a Tag placement absent from membership' do
other = group_tag('非メンバー')
reject_statement { placement(tag_id: other.id) }
end
it 'rejects a missing parent placement' do
reject_statement { placement(section_name: '子', parent_position: 9) }
end
it 'rejects a child ordered before its parent' do
placement(section_name: '親', position: 2)
reject_statement { placement(section_name: '子', position: 1, parent_position: 2) }
end
it 'rejects duplicate group names' do
reject_statement { seed_group(name: '制約確認') }
end
it 'rejects a missing mandatory group name' do
reject_statement { seed_group(name: nil) }
end
it 'rejects a six-level snapshot' do
nodes = []
6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] }
reject_statement do
insert_group_row('tag_group_versions', {
tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認',
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json,
placements: nodes.to_json, created_at: Time.current })
end
end
end
+119
ファイルの表示
@@ -0,0 +1,119 @@
require 'rails_helper'
RSpec.describe 'Tag group migration identity and structure' do
include TagGroupRecords
let!(:user) { create_member_user! }
before { I18n.locale = :ja }
it 'preserves page and revision IDs, timestamps and repeated historical structures' do
tag = group_tag('洗澡鹿')
body = "説明文\n\n- 洗澡鹿\n- 洗澡鹿"
page = legacy_group('伊地知ニジカ亜種', body)
revisions = [page.current_revision]
[body, '# 空の区分', body].each do |text|
revisions << Wiki::Commit.legacy_content!(page:, body: text, created_user: user)
end
before_state = group_state.except(*TagGroupRecords::GROUP_TABLES)
migrate_groups
versions = group_rows('tag_group_versions', tag_group_id: page.id)
.sort_by { _1.fetch('version_no') }
expect(versions.map { _1.fetch('id') }).to eq(revisions.map(&:id))
expect(versions.map { _1.fetch('version_no') }).to eq([1, 2, 3, 4])
expect(versions.map { _1.fetch('created_at') }).to eq(revisions.map(&:created_at))
expect(versions.map { _1.fetch('created_by_user_id') }).to eq([user.id] * 4)
expect(versions.map { _1.fetch('event_type') }).to eq(['create', 'update', 'update', 'update'])
expect(group_version(page.id, 1).fetch('placements')).to eq(
group_version(page.id, 2).fetch('placements'))
expect(group_version(page.id, 3).fetch('placements')).to eq([
{ 'section_name' => '空の区分', 'description' => nil, 'children' => [] }])
expect_group_snapshot(
page.id, number: 4, description: '説明文',
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }],
placements: [{ 'tag_id' => tag.id, 'children' => [] }] * 2)
expect(group_rows('tag_groups', id: page.id).sole).to include(
'created_at' => page.created_at, 'updated_at' => page.reload.updated_at)
expect(group_state.except(*TagGroupRecords::GROUP_TABLES)).to eq(before_state)
end
it 'migrates nested list Sections and child groups as different placement kinds' do
series = group_tag('ぼざろクリーチャーシリーズ')
deer = group_tag('洗澡鹿')
nijika = group_tag('伊地知ニジカ')
child = legacy_group('伊地知ニジカ亜種', <<~MARKDOWN)
- ぼざろクリーチャーシリーズ
- 伊地知ニジカ亜種:
- 洗澡鹿
MARKDOWN
parent = legacy_group('ぼざろクリーチャーシリーズのキャラクター', <<~MARKDOWN)
- ぼざろクリーチャーシリーズ
- 伊地知ニジカ
- タググループ:伊地知ニジカ亜種
MARKDOWN
migrate_groups
expect_group_snapshot(
child.id, number: 1,
members: [series, deer].map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
placements: [{ 'tag_id' => series.id, 'children' => [
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
'children' => [{ 'tag_id' => deer.id, 'children' => [] }] }] }])
expect_group_snapshot(
parent.id, number: 1,
members: [
{ 'tag_id' => series.id, 'version_no' => series.version_no },
{ 'tag_id' => nijika.id, 'version_no' => nijika.version_no },
{ 'tag_group_id' => child.id, 'version_no' => 1 }],
placements: [{ 'tag_id' => series.id, 'children' => [
{ 'tag_id' => nijika.id, 'children' => [
{ 'tag_group_id' => child.id, 'children' => [] }] }] }])
end
it 'preserves heading order, group paragraphs and Section descriptions' do
names = ['タグ希望', 'キャラクター名希望', '楽曲情報希望', 'bot操作', 'ニジラー情報不詳']
tags = names.map { group_tag(_1) }
markdown = <<~MARKDOWN
全体の説明
# 要求
要求の説明
- タグ希望
- キャラクター名希望
- 楽曲情報希望
# 情報
- bot操作
- ニジラー情報不詳
MARKDOWN
page = legacy_group('メタタグ', markdown)
expect(page.current_revision.body).to eq(markdown.chomp)
migrate_groups
leaves = tags.map { { 'tag_id' => _1.id, 'children' => [] } }
leaves.first['children'] = leaves[1..2]
expect_group_snapshot(
page.id, number: 1, description: '全体の説明',
members: tags.map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
placements: [
{ 'section_name' => '要求', 'description' => '要求の説明',
'children' => [leaves.first] },
{ 'section_name' => '情報', 'description' => nil,
'children' => leaves[3..4] }])
end
it 'leaves Help in the legacy domain' do
page = Wiki::Commit.create_legacy_content!(
tag_name: create(:tag_name, name: 'Help:移行対象外'),
body: 'Help 本文', created_by_user: user)
before_state = group_state
migrate_groups
expect(group_state).to eq(before_state)
expect(page.reload.body).to eq('Help 本文')
end
end
+277
ファイルの表示
@@ -0,0 +1,277 @@
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
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)
[first, second, third]
end
let!(:tag) { create(:tag, primary_tag_name: tag_name) }
let!(:versions) do
revisions.each_with_index.map do |revision, index|
TagDescriptionVersion.create!(
id: revision.id, tag_id: tag.id, language_code: 'ja',
version_no: index + 1, event_type: index.zero? ? 'create' : 'update',
body: index == 2 ? 'new-version' : 'A',
created_by_user_id: user.id, created_at: revision.created_at)
end
end
let!(:description) do
TagDescription.create!(
id: page.id, tag:, language_code: 'ja', version_no: 3, body: 'new',
created_by_user_id: user.id, updated_by_user_id: user.id)
end
before { page.update_columns(body: 'old') }
# Observe real SELECTs, including cached queries, only during the request.
def without_legacy_reads
queries = []
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') { yield }
expect(queries).to be_empty
end
describe 'show' do
it 'uses current content and revision identity without legacy reads' do
without_legacy_reads { get "/wiki/#{ description.id }" }
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'new', 'revision_id' => versions.last.id,
'pred' => versions.second.id, 'succ' => nil)
end
[0, 1, 2].each do |index|
it "navigates displayed version #{ index + 1 }, preserving repeated bodies" do
without_legacy_reads do
get "/wiki/#{ description.id }", params: { version: versions[index].id }
end
expect(response).to have_http_status(:ok)
expect(json).to include(
'body' => versions[index].body,
'revision_id' => versions[index].id,
'pred' => index.zero? ? nil : versions[index - 1].id,
'succ' => index == 2 ? nil : versions[index + 1].id)
end
end
it 'does not fall back to a revision absent from new history' do
versions.first.destroy!
without_legacy_reads do
get "/wiki/#{ description.id }", params: { version: revisions.first.id }
end
expect(response).to have_http_status(:not_found)
end
it 'does not fall back when the new current revision is missing' do
versions.last.destroy!
without_legacy_reads { get "/wiki/#{ description.id }" }
expect(response).to have_http_status(:not_found)
end
end
describe 'revision resource scope' do
let!(:other_tag) { create(:tag, primary_name: 'other_description_contract') }
let!(:other_version) do
TagDescriptionVersion.create!(
tag_id: other_tag.id, language_code: 'ja', version_no: 1,
event_type: 'create', body: 'other tag history',
created_by_user_id: user.id, created_at: Time.current)
end
let!(:other_description) do
TagDescription.create!(
tag: other_tag, language_code: 'ja', version_no: 1, body: 'other tag history',
created_by_user_id: user.id, updated_by_user_id: user.id)
end
it 'returns 404 when show specifies another tag description version' do
get "/wiki/#{ description.id }", params: { version: other_version.id }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include(other_version.body)
end
it 'returns 404 when diff from belongs to another tag description' do
get "/wiki/#{ description.id }/diff",
params: { from: other_version.id, to: versions.last.id }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include(other_version.body)
end
it 'returns 404 when diff to belongs to another tag description' do
get "/wiki/#{ description.id }/diff",
params: { from: versions.first.id, to: other_version.id }
expect(response).to have_http_status(:not_found)
expect(response.body).not_to include(other_version.body)
end
end
describe 'show_by_title' do
it 'resolves TagName and reads current content from new tables' do
without_legacy_reads { get "/wiki/title/#{ tag_name.name }" }
expect(response).to have_http_status(:ok)
expect(json).to include('id' => description.id, 'body' => 'new',
'revision_id' => versions.last.id)
end
it 'uses historical content and navigation from the new history' do
get "/wiki/title/#{ tag_name.name }", params: { version: versions.second.id }
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'A', 'revision_id' => versions.second.id,
'pred' => versions.first.id, 'succ' => versions.last.id)
end
it 'ignores changes to same-title legacy content and revision metadata' do
get "/wiki/title/#{ tag_name.name }"
original = json
page.update_columns(body: 'changed legacy body')
revisions.last.update_columns(message: 'legacy message', created_at: 1.day.ago)
revisions.last.wiki_lines.update_all(body: 'changed legacy revision')
get "/wiki/title/#{ tag_name.name }"
expect(json).to eq(original)
expect(json.fetch('body')).to eq('new')
end
end
describe 'exists' do
it 'recognises an ID without reading legacy tables' do
without_legacy_reads { get "/wiki/#{ description.id }/exists" }
expect(response).to have_http_status(:no_content)
end
it 'recognises a title without reading legacy tables' do
without_legacy_reads { get "/wiki/title/#{ tag_name.name }/exists" }
expect(response).to have_http_status(:no_content)
end
context 'without a legacy page' do
before do
WikiVersion.where(wiki_page_id: page.id).delete_all
page.destroy!
end
it 'recognises the description ID alone' do
get "/wiki/#{ description.id }/exists"
expect(response).to have_http_status(:no_content)
end
it 'recognises the TagName alone' do
get "/wiki/title/#{ tag_name.name }/exists"
expect(response).to have_http_status(:no_content)
end
it 'shows the description by title' do
get "/wiki/title/#{ tag_name.name }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'new', 'revision_id' => versions.last.id)
end
end
end
describe 'diff' do
[false, true].each do |current|
it "uses new bodies with #{ current ? 'implicit current' : 'explicit to' }" do
params = { from: versions.first.id }
params[:to] = versions.last.id unless current
get "/wiki/#{ description.id }/diff", params: params
expect(response).to have_http_status(:ok)
expect(json).to include(
'wiki_page_id' => description.id,
'older_revision_id' => versions.first.id,
'newer_revision_id' => versions.last.id,
'diff' => [{ 'type' => 'removed', 'content' => 'A' },
{ 'type' => 'added', 'content' => 'new-version' }])
end
end
it 'does not query legacy tables for diff' do
without_legacy_reads do
get "/wiki/#{ description.id }/diff",
params: { from: versions.first.id, to: versions.last.id }
end
expect(response).to have_http_status(:ok)
end
it 'keeps distinct revision IDs for identical consecutive bodies' do
get "/wiki/#{ description.id }/diff",
params: { from: versions.first.id, to: versions.second.id }
expect(response).to have_http_status(:ok)
expect(json).to include(
'older_revision_id' => versions.first.id,
'newer_revision_id' => versions.second.id,
'diff' => [{ 'type' => 'context', 'content' => 'A' }])
end
end
describe 'changes' do
it 'lists every new version including consecutive identical bodies' do
without_legacy_reads { get '/wiki/changes', params: { id: description.id } }
expect(response).to have_http_status(:ok)
expect(json.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
expect(json.first.keys).to match_array(
['revision_id', 'pred', 'succ', 'wiki_page', 'user', 'kind', 'message', 'timestamp'])
expect(json.first).to include(
'pred' => versions.second.id, 'succ' => nil, 'kind' => 'content',
'timestamp' => versions.last.created_at.iso8601(3),
'user' => { 'id' => user.id, 'name' => user.name })
expect(json.first.fetch('wiki_page')).to include('id' => description.id,
'title' => tag_name.name)
end
it 'ignores legacy-only revisions and metadata changes' do
get '/wiki/changes', params: { id: description.id }
original = json
revisions.first.update_columns(message: 'legacy changed', created_at: 1.day.ago)
WikiRevision.create!(
wiki_page: page, created_user: user, kind: :content,
tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0)
get '/wiki/changes', params: { id: description.id }
expect(json).to eq(original)
expect(json.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
end
end
describe 'mixed changes' do
it 'excludes unmigrated history and migrated legacy-only revisions' do
legacy_page = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help_mixed'),
body: 'help', created_by_user: user)
legacy_revision = legacy_page.current_revision
extra = WikiRevision.create!(
wiki_page: page, created_user: user, kind: :content,
tree_sha256: Digest::SHA256.hexdigest('legacy only'), lines_count: 0)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id }
expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id)
expect(json).not_to include(include(
'revision_id' => legacy_revision.id,
'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed')))
end
end
describe 'index' do
[nil, 'description_contract'].each do |title|
it "returns new content once in #{ title ? 'filtered' : 'unfiltered' } index" do
get '/wiki', params: { title: }
expect(response).to have_http_status(:ok)
rows = json.select { |row| row.fetch('id') == description.id }
expect(rows.size).to eq(1)
expect(rows.first).to include('body' => 'new', 'title' => tag_name.name)
end
end
end
end
+241
ファイルの表示
@@ -0,0 +1,241 @@
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) }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
def stored_state
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion,
TagName, TagVersion].map do |model|
model.order(:id).map(&:attributes)
end
end
# Fail after a real INSERT/UPDATE, exercising rollback rather than a mock write.
def fail_save model
failure = Class.new(StandardError)
callback = ->(_record) { raise failure, 'injected persistence failure' }
model.set_callback(:save, :after, callback)
expect { yield }.to raise_error(failure)
ensure
model.skip_callback(:save, :after, callback)
end
describe 'POST /wiki' do
before { tag }
def create_description
post '/wiki', params: { title: tag_name.name, body: 'initial', message: 'create' },
headers: headers
end
it 'creates a description without implicitly creating Wiki records' do
expect { create_description }
.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
.and change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:created)
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
expect(description).to have_attributes(body: 'initial', version_no: 1)
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
['missing_description', 'orphan_description'].each do |title|
it "rejects the bare title #{ title } without writing any resource" do
create(:tag_name, name: title) if title == 'orphan_description'
before_state = stored_state
post '/wiki', params: { title:, body: 'initial' }, headers: headers
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(stored_state).to eq(before_state)
end
end
[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
[TagDescription, TagDescriptionVersion].each do |model|
it "rolls back new description creation when #{ model.name } persistence fails" do
before_state = stored_state
aggregate_failures do
fail_save(model) { create_description }
expect(stored_state).to eq(before_state)
end
end
end
end
describe 'PUT /wiki/:id' do
let!(:page) do
tag
seed_wiki_page(
tag_name:, body: 'before', created_by_user: user)
end
let!(:first_revision) { page.current_revision }
let!(:description) do
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
end
def update_description body: 'after', title: tag_name.name, base: first_revision.id
put "/wiki/#{ page.id }",
params: { title:, body:, message: 'edit', base_revision_id: base },
headers: headers
end
it 'increments the independent description sequence and shares revision identity' do
expect(page.reload.version_no).to eq(1)
expect(description.version_no).to eq(1)
expect { update_description }
.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok)
revision = page.reload.current_revision
version = TagDescriptionVersion.find(revision.id)
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
expect(version).to have_attributes(
body: 'after', version_no: 2, event_type: 'update', created_by_user_id: user.id)
expect(page).to have_attributes(body: 'after', version_no: 2)
expect(revision.body).to eq(version.body)
expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body)
end
it 'uses description identity even when its Wiki TagName no longer owns the Tag' do
tag_name.update_columns(tag_id: nil)
expect { update_description }
.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(description.reload).to have_attributes(body: 'after', version_no: 2)
expect(TagDescriptionVersion.find(page.reload.current_revision.id))
.to have_attributes(tag_id: tag.id, body: 'after', version_no: 2)
end
it 'does not add body history for a title-only update' do
revision_count = WikiRevision.count
wiki_version_count = WikiVersion.count
description_version_count = TagDescriptionVersion.count
wiki_version_no = page.reload.version_no
description_version_no = description.version_no
current_revision_id = page.current_revision.id
page_state = page.attributes
description_state = description.attributes
update_description(body: 'before', title: 'Help:description_renamed')
expect(response).to have_http_status(:ok)
expect(tag_name.reload.name).to eq('Help:description_renamed')
expect(WikiRevision.count).to eq(revision_count)
expect(WikiVersion.count).to eq(wiki_version_count)
expect(TagDescriptionVersion.count).to eq(description_version_count)
expect(page.reload).to have_attributes(body: 'before', version_no: wiki_version_no)
expect(page.current_revision.id).to eq(current_revision_id)
expect(page.reload.attributes).to eq(page_state)
expect(description.reload).to have_attributes(
body: 'before', version_no: description_version_no)
expect(description.reload.attributes).to eq(description_state)
end
it 'does not add body history for a TagName rename through tags' do
revision_count = WikiRevision.count
wiki_version_count = WikiVersion.count
description_version_count = TagDescriptionVersion.count
page_version_no = page.reload.version_no
current_revision_id = page.current_revision.id
patch "/tags/#{ tag.id }",
params: { name: 'description_tag_renamed' },
headers: headers
expect(response).to have_http_status(:ok)
expect(tag_name.reload.name).to eq('description_tag_renamed')
expect(WikiRevision.count).to eq(revision_count)
expect(WikiVersion.count).to eq(wiki_version_count)
expect(TagDescriptionVersion.count).to eq(description_version_count)
expect(page.reload.version_no).to eq(page_version_no)
expect(page.current_revision.id).to eq(current_revision_id)
expect(description.reload).to have_attributes(
body: 'before', version_no: 1)
end
it 'keeps both systems and the title unchanged on a stale revision conflict' do
update_description(body: 'current body', base: first_revision.id)
expect(response).to have_http_status(:ok)
stale_revision_id = first_revision.id
before_state = stored_state
update_description(
body: 'conflicting body',
title: 'conflicting_rename',
base: stale_revision_id)
expect(response).to have_http_status(:conflict)
expect(stored_state).to eq(before_state)
expect(tag_name.reload.name).not_to eq('conflicting_rename')
end
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
it "rolls back both systems when #{ model.name } persistence fails" do
before_state = stored_state
aggregate_failures do
fail_save(model) do
update_description(body: 'rollback body', title: 'rollback_rename')
end
expect(stored_state).to eq(before_state)
end
end
end
[['identical body', 'before'],
['normalised-equivalent body', "before\n\n"]].each do |label, body|
it "does not write histories for #{ label }" do
before_state = stored_state
page_state = page.reload.attributes
description_state = description.reload.attributes
current_revision_id = page.current_revision.id
wiki_lines = WikiLine.order(:id).map(&:attributes)
revision_lines = WikiRevisionLine.order(:id).map(&:attributes)
page_version_no = page.version_no
description_version_no = description.version_no
update_description(body:)
expect(response).to have_http_status(:ok)
expect(WikiRevision.count).to eq(before_state[1].length)
expect(WikiVersion.count).to eq(before_state[2].length)
expect(TagDescriptionVersion.count).to eq(before_state[4].length)
expect(page.reload.version_no).to eq(page_version_no)
expect(description.reload.version_no).to eq(description_version_no)
expect(page.current_revision.id).to eq(current_revision_id)
expect(page.reload.attributes).to eq(page_state)
expect(description.reload.attributes).to eq(description_state)
expect(WikiLine.order(:id).map(&:attributes)).to eq(wiki_lines)
expect(WikiRevisionLine.order(:id).map(&:attributes)).to eq(revision_lines)
end
end
end
end
+359
ファイルの表示
@@ -0,0 +1,359 @@
require 'rails_helper'
RSpec.describe 'Tag description native API', type: :request do
let!(:author) { create_member_user! }
let(:editor) { create(:user, :member, name: 'description editor') }
let(:tag_name) { create(:tag_name, name: 'native_description') }
let(:tag) { create(:tag, primary_tag_name: tag_name) }
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } }
let(:endpoint) { "/tags/#{ tag.id }/description" }
def stored_state
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion,
WikiLine, WikiRevisionLine, TagName, TagVersion].map do |model|
model.order(:id).map(&:attributes)
end
end
def fail_save model
failure = Class.new(StandardError)
callback = ->(_record) { raise failure, 'injected persistence failure' }
model.set_callback(:save, :after, callback)
expect { yield }.to raise_error(failure)
ensure
model.skip_callback(:save, :after, callback)
end
def 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') { yield }
expect(queries).to be_empty
end
def native_get path, action: 'show'
get path
# A missing route must not make the resource-not-found examples pass.
expect(request.path_parameters).to include(controller: 'tag_descriptions', action:)
end
def native_write body:, request_headers: headers
put endpoint, params: { body: }, headers: request_headers
expect(request.path_parameters).to include(
controller: 'tag_descriptions', action: 'update')
end
def expect_current description
expect(json).to include(
'tag_id' => description.tag_id,
'language_code' => 'ja', 'body' => description.body,
'version_no' => description.version_no,
'created_by_user_id' => description.created_by_user_id,
'updated_by_user_id' => description.updated_by_user_id,
'created_at' => description.created_at.iso8601(3),
'updated_at' => description.updated_at.iso8601(3))
expect(json.keys).not_to include('title', 'name', 'wiki_page', 'tag_name_id')
end
describe 'routing', type: :routing do
it 'routes GET by Tag ID to show' do
expect(get: '/tags/123/description').to route_to(
controller: 'tag_descriptions', action: 'show', id: '123')
end
it 'routes GET by TagName to show_by_title' do
expect(get: '/tags/name/native_description/description').to route_to(
controller: 'tag_descriptions', action: 'show_by_title', name: 'native_description')
end
it 'does not route POST by Tag ID' do
expect(post: '/tags/123/description').not_to be_routable
end
it 'routes PUT by Tag ID to update' do
expect(put: '/tags/123/description').to route_to(
controller: 'tag_descriptions', action: 'update', id: '123')
end
end
describe 'native reads' do
let!(:description) do
TagDescription.create!(
id: tag.id + 1_000_000, tag:, language_code: 'ja', body: 'native current',
version_no: 1, created_by_user_id: author.id, updated_by_user_id: editor.id)
end
let!(:version) do
TagDescriptionVersion.create!(
tag_id: tag.id, language_code: 'ja', body: 'native version', version_no: 1,
event_type: 'create', created_by_user_id: author.id, created_at: Time.current)
end
let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" }
it 'reads the current resource by Tag ID without a WikiPage' do
expect(description.id).not_to eq(tag.id)
expect(WikiPage.exists?(id: description.id)).to be(false)
without_legacy_reads { native_get(endpoint) }
expect(response).to have_http_status(:ok)
expect_current(description)
end
it 'does not interpret a compatibility handle as a Tag ID' do
other_tag = create(:tag, id: description.id, primary_name: 'handle_collision')
other = TagDescription.create!(
tag: other_tag, language_code: 'ja', body: 'other current', version_no: 1,
created_by_user_id: author.id, updated_by_user_id: author.id)
without_legacy_reads { native_get("/tags/#{ other_tag.id }/description") }
expect(response).to have_http_status(:ok)
expect_current(other)
end
context 'with another language for the same Tag' do
before do
# Seed only the reference row; V1 does not enable an English locale.
Language.connection.execute(<<~SQL)
INSERT IGNORE INTO languages (code, name, created_at)
VALUES ('en', 'English', CURRENT_TIMESTAMP)
SQL
end
it 'selects the Japanese resource' do
TagDescription.create!(
tag:, language_code: 'en', body: 'English current', version_no: 4,
created_by_user_id: author.id, updated_by_user_id: author.id)
without_legacy_reads { native_get(endpoint) }
expect(response).to have_http_status(:ok)
expect_current(description)
end
it 'returns 404 if only the English description exists' do
description.update!(language_code: 'en')
without_legacy_reads { native_get(endpoint) }
expect(response).to have_http_status(:not_found)
end
end
it 'resolves the primary Japanese TagName without a WikiPage' do
without_legacy_reads { native_get(name_endpoint, action: 'show_by_title') }
expect(response).to have_http_status(:ok)
expect_current(description)
end
it 'resolves an alias through the existing TagName canonicalisation semantics' do
alias_name = create(:tag_name, :alias, tag:, name: 'native_alias')
expect(TagName.canonicalise(Locale.nipponese, [alias_name.name])).to eq([tag_name.name])
without_legacy_reads do
native_get("/tags/name/#{ alias_name.name }/description", action: 'show_by_title')
end
expect(response).to have_http_status(:ok)
expect_current(description)
end
[:id, :name].each do |lookup|
it "ignores divergent legacy body and history for #{ lookup } lookup" do
page = WikiPage.create!(
id: description.id, tag_name:, body: 'old current',
created_user: author, updated_user: author)
revision = WikiRevision.create!(
wiki_page: page, created_user: author, kind: :content,
lines_count: 1, tree_sha256: Digest::SHA256.hexdigest('old revision'))
line = WikiLine.create!(body: 'old revision', sha256: revision.tree_sha256)
WikiRevisionLine.create!(wiki_revision: revision, wiki_line: line, position: 0)
WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: author)
path = lookup == :id ? endpoint : name_endpoint
action = lookup == :id ? 'show' : 'show_by_title'
without_legacy_reads { native_get(path, action:) }
expect(response).to have_http_status(:ok)
expect_current(description)
original = json
page.update_columns(body: 'changed old current')
line.update_columns(body: 'changed old revision')
revision.update_columns(message: 'changed old history', created_at: 1.day.ago)
without_legacy_reads { native_get(path, action:) }
expect(response).to have_http_status(:ok)
expect(json).to eq(original)
end
end
end
describe 'missing native resources' do
it 'returns 404 for a Tag without a description' do
without_legacy_reads { native_get(endpoint) }
expect(response).to have_http_status(:not_found)
end
it 'returns 404 for a primary name without a description' do
tag
without_legacy_reads do
native_get("/tags/name/#{ tag_name.name }/description", action: 'show_by_title')
end
expect(response).to have_http_status(:not_found)
end
it 'returns 404 for a missing Tag' do
missing_id = Tag.maximum(:id).to_i + 1
without_legacy_reads { native_get("/tags/#{ missing_id }/description") }
expect(response).to have_http_status(:not_found)
end
end
describe 'PUT /tags/:id/description' do
context 'without an existing description' do
before { tag }
it 'creates both current resources and histories with matching compatibility IDs' do
expect { native_write(body: 'created natively') }
.to change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
.and change(WikiPage, :count).by(1)
.and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created)
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
page = WikiPage.find(description.id)
revision = page.current_revision
version = TagDescriptionVersion.find(revision.id)
expect(description).to have_attributes(
body: 'created natively', version_no: 1,
created_by_user_id: editor.id, updated_by_user_id: editor.id)
expect(version).to have_attributes(
tag_id: tag.id, language_code: 'ja', body: description.body,
version_no: 1, event_type: 'create', created_by_user_id: editor.id)
expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body)
expect(revision.body).to eq(version.body)
expect(page.wiki_versions.last.body).to eq(version.body)
expect_current(description)
get "/wiki/#{ description.id }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => description.body, 'revision_id' => version.id)
end
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
it "rolls back native create when #{ model.name } persistence fails" do
headers
before_state = stored_state
aggregate_failures do
fail_save(model) { native_write(body: 'rollback create') }
expect(stored_state).to eq(before_state)
end
end
end
end
context 'with an existing description' do
let!(:page) do
tag
Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author)
end
let!(:description) do
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
end
it 'writes matching histories when the body changes' do
expect(page.reload.version_no).to eq(1)
expect(description.version_no).to eq(1)
expect { native_write(body: 'after') }
.to change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok)
expect(description.reload).to have_attributes(
body: 'after', version_no: 2,
created_by_user_id: author.id, updated_by_user_id: editor.id)
expect(page.reload).to have_attributes(body: 'after', version_no: 2)
revision = page.current_revision
version = TagDescriptionVersion.find(revision.id)
expect(version).to have_attributes(
tag_id: tag.id, language_code: 'ja', version_no: 2, event_type: 'update',
body: 'after', created_by_user_id: editor.id)
expect(revision.body).to eq(version.body)
expect(page.wiki_versions.order(:version_no).last.body).to eq(version.body)
get "/wiki/#{ description.id }"
expect(response).to have_http_status(:ok)
expect(json).to include('body' => 'after', 'revision_id' => version.id)
end
['before', "before\n\n"].each do |body|
it 'does not write histories when the normalised body is unchanged' do
before_state = stored_state
page_state = page.reload.attributes
description_state = description.reload.attributes
current_revision_id = page.current_revision.id
native_write(body:)
expect(response).to have_http_status(:ok)
expect(stored_state).to eq(before_state)
expect(page.reload.attributes).to eq(page_state)
expect(description.reload.attributes).to eq(description_state)
expect(page.current_revision.id).to eq(current_revision_id)
end
end
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
it "rolls back native update when #{ model.name } persistence fails" do
headers
before_state = stored_state
aggregate_failures do
fail_save(model) { native_write(body: 'rollback update') }
expect(stored_state).to eq(before_state)
end
end
end
end
end
describe 'write access and validation' do
before { tag }
it 'requires authentication without changing either system' do
before_state = stored_state
native_write(body: 'unauthorised', request_headers: { })
expect(response).to have_http_status(:unauthorized)
expect(stored_state).to eq(before_state)
end
it 'rejects a guest without changing either system' do
guest = create(:user, :guest)
before_state = stored_state
native_write(body: 'forbidden',
request_headers: { 'X-Transfer-Code' => guest.inheritance_code })
expect(response).to have_http_status(:forbidden)
expect(stored_state).to eq(before_state)
end
it 'rejects a banned member without changing either system' do
banned = create(:user, :member, :banned)
before_state = stored_state
native_write(body: 'forbidden',
request_headers: { 'X-Transfer-Code' => banned.inheritance_code })
expect(response).to have_http_status(:forbidden)
expect(stored_state).to eq(before_state)
end
it 'rejects blank body using the existing validation error format' do
headers
before_state = stored_state
native_write(body: '')
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error',
'errors' => include('body' => a_kind_of(Array)))
expect(stored_state).to eq(before_state)
end
it 'returns 404 when PUT targets a missing Tag' do
missing_id = Tag.maximum(:id).to_i + 1
put "/tags/#{ missing_id }/description",
params: { body: 'missing' },
headers: headers
expect(response).to have_http_status(:not_found)
end
end
end
+139
ファイルの表示
@@ -0,0 +1,139 @@
require 'rails_helper'
RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :request do
include TagGroupRecords
let!(:user) { create_member_user! }
let!(:series) { group_tag('ぼざろクリーチャーシリーズ') }
let!(:deer) { group_tag('洗澡鹿') }
let!(:nizika) { group_tag('伊地知ニジカ') }
let!(:wanted) { group_tag('タグ希望') }
let!(:character) { group_tag('キャラクター名希望') }
let!(:music) { group_tag('楽曲情報希望') }
let!(:bot) { group_tag('bot操作') }
let!(:unknown) { group_tag('ニジラー情報不詳') }
let!(:child_id) { seed_group(name: '伊地知ニジカ亜種', description: '子の説明') }
before { I18n.locale = :ja }
def tag_node tag, children = []
{ 'tag_id' => tag.id, 'children' => children }
end
def tag_member tag
{ 'tag_id' => tag.id, 'version_no' => tag.version_no }
end
def check_round_trip placements:, members:, markdown:, description: nil
id = seed_group(name: '変換元', placements:, members:, description:)
without_group_legacy_reads { get group_title_path('変換元') }
expect(response).to have_http_status(:ok)
expect(json).to include('id' => id)
body = json.fetch('body')
expect(body).not_to include("\r")
expect(body.lines.map(&:chomp)).to all(satisfy { |line| line == line.rstrip })
# A trailing LF or a blank line between blocks is semantically irrelevant.
expect(body.lines.map(&:chomp).reject(&:empty?)).to eq(
markdown.lines.map(&:chomp).reject(&:empty?))
post '/wiki', params: { title: 'タググループ:再解析', body: },
headers: { 'X-Transfer-Code' => user.inheritance_code }
expect(response).to have_http_status(:created)
parsed = group_rows('tag_groups', name: '再解析').sole
expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:)
end
it 'renders a list Section under a Tag with two-space indentation' do
check_round_trip(
members: [series, deer].map { tag_member(_1) },
placements: [tag_node(series, [
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
'children' => [tag_node(deer)] }])],
markdown: <<~MARKDOWN)
- ぼざろクリーチャーシリーズ
- 伊地知ニジカ亜種:
- 洗澡鹿
MARKDOWN
end
it 'renders a child Tag Group distinctly from its parent Tags' do
check_round_trip(
members: [tag_member(series), tag_member(nizika),
{ 'tag_group_id' => child_id, 'version_no' => 1 }],
placements: [tag_node(series, [tag_node(nizika, [
{ 'tag_group_id' => child_id, 'children' => [] }])])],
markdown: <<~MARKDOWN)
- ぼざろクリーチャーシリーズ
- 伊地知ニジカ
- タググループ:伊地知ニジカ亜種
MARKDOWN
end
it 'renders top-level heading Sections, descriptions and ordered nested Tags' do
check_round_trip(
description: '全体の説明',
members: [wanted, character, music, bot, unknown].map { tag_member(_1) },
placements: [
{ 'section_name' => '要求', 'description' => '要求の説明',
'children' => [tag_node(wanted, [tag_node(character), tag_node(music)])] },
{ 'section_name' => '情報', 'description' => nil,
'children' => [tag_node(bot), tag_node(unknown)] }],
markdown: <<~MARKDOWN)
全体の説明
## 要求
要求の説明
- タグ希望
- キャラクター名希望
- 楽曲情報希望
## 情報
- bot操作
- ニジラー情報不詳
MARKDOWN
end
it 'renders nested heading Sections and all repeated Tag placements' do
check_round_trip(
members: [tag_member(deer)],
placements: [
{ 'section_name' => '親', 'description' => '親の説明', 'children' => [
tag_node(deer),
{ 'section_name' => '子', 'description' => '子の説明',
'children' => [tag_node(deer)] }] }],
markdown: <<~MARKDOWN)
## 親
親の説明
- 洗澡鹿
### 子
子の説明
- 洗澡鹿
MARKDOWN
end
it 'rejects mixed top-level heading Sections and Tags without losing structure' do
id = seed_group(
name: '混在構造', members: [tag_member(series)],
placements: [
{ 'section_name' => '区分', 'description' => nil, 'children' => [] },
tag_node(series)])
get group_title_path('混在構造')
expect(response).to have_http_status(:unprocessable_entity)
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
end
it 'rejects membership without a placement instead of omitting it' do
id = seed_group(name: '未配置構造', members: [tag_member(series)])
get group_title_path('未配置構造')
expect(response).to have_http_status(:unprocessable_entity)
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
end
end
+309
ファイルの表示
@@ -0,0 +1,309 @@
require 'rails_helper'
RSpec.describe 'Tag group Wiki facade reads', type: :request do
include WikiRecords
include TagGroupRecords
let!(:user) { create_member_user! }
let!(:old_tag) { group_tag('旧タグ') }
let!(:new_tag) { group_tag('新タグ') }
let!(:page) { legacy_group('読取り契約', '- 旧タグ') }
let!(:first_revision) { page.current_revision }
let!(:last_revision) do
Wiki::Commit.legacy_content!(page:, body: '- 新タグ', created_user: user)
end
before do
I18n.locale = :ja
migrate_groups
page.update_columns(body: 'legacy stale current')
page.wiki_revisions.each do |revision|
revision.wiki_lines.update_all(body: 'legacy stale revision')
end
page.wiki_versions.update_all(body: 'legacy stale version')
end
[:id, :title].each do |lookup|
context "with #{ lookup } lookup" do
let(:path) { lookup == :id ? "/wiki/#{ page.id }" : group_title_path('読取り契約') }
it 'reads current structure without consulting migrated Wiki tables' do
without_group_legacy_reads { get path }
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => page.id, 'title' => 'タググループ:読取り契約',
'revision_id' => last_revision.id, 'pred' => first_revision.id, 'succ' => nil)
expect(json.fetch('body').strip).to eq('- 新タグ')
end
it 'reads the old snapshot rather than current membership or placement' do
without_group_legacy_reads { get path, params: { version: first_revision.id } }
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq('- 旧タグ')
expect(json).to include(
'revision_id' => first_revision.id, 'pred' => nil, 'succ' => last_revision.id,
'updated_at' => first_revision.created_at.iso8601(3))
end
it 'checks existence without consulting migrated Wiki tables' do
without_group_legacy_reads { get "#{ path }/exists" }
expect(response).to have_http_status(:no_content)
end
it 'does not fall back to legacy history when a group version is missing' do
group_connection.execute(
"DELETE FROM tag_group_versions WHERE id = #{ first_revision.id }")
without_group_legacy_reads { get path, params: { version: first_revision.id } }
expect(response).to have_http_status(:not_found)
end
end
end
['/wiki', '/wiki/search'].each do |path|
it "lists migrated groups once with current titles at #{ path }" do
get path, params: { title: 'タググループ:読取り' }
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('title') == 'タググループ:読取り契約' }
expect(rows.size).to eq(1)
expect(rows.sole).to include('id' => page.id)
expect(response.body).not_to include('legacy stale')
end
end
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(
'タググループ:読取り契約', '新タグ')
expect(json.map { _1.fetch('title') }).not_to include(help.title)
end
it 'excludes Help when its ID collides with a new group' do
help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body',
created_by_user: user)
seed_group(name: '別 ID重複', id: help.id, description: 'group body')
get '/wiki'
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include('タググループ:別 ID重複')
expect(json.map { _1.fetch('title') }).not_to include(help.title)
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
expect(response).to have_http_status(:not_found)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
expect(json.map { _1.dig('wiki_page', 'title') }).to include(
'タググループ:別 ID重複')
expect(json.map { _1.dig('wiki_page', 'title') }).not_to include(help.title)
end
it 'finds groups by a partial prefix of their displayed title' do
get '/wiki', params: { title: 'タググル' }
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include('タググループ:読取り契約')
expect(json.find { _1.fetch('title') == 'タググループ:読取り契約' })
.to include('updated_at' => be_present)
end
it 'does not restore a stale namespaced Wiki page after a group rename' do
old_page = legacy_group('旧名称', '- 旧本文')
seed_group(name: '新名称', id: old_page.id, description: '正本')
get group_title_path('旧名称')
expect(response).to have_http_status(:not_found)
get "#{ group_title_path('旧名称') }/exists"
expect(response).to have_http_status(:not_found)
get group_title_path('新名称')
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq('正本')
end
it 'orders mixed resource history by timestamp rather than independent IDs' do
help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help',
created_by_user: user)
TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user)
TagGroupVersion.update_all(created_at: 3.days.ago)
WikiRevision.where(wiki_page_id: help.id).update_all(created_at: 2.days.ago)
TagDescriptionVersion.update_all(created_at: 1.day.ago)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
expect(timestamps).to eq(timestamps.sort.reverse)
titles = json.map { _1.dig('wiki_page', 'title') }
expect(titles).not_to include(help.title)
expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約')
end
it 'renders nullable group editors without raising' do
group_connection.execute(
"UPDATE tag_groups SET created_by_user_id = NULL, updated_by_user_id = NULL " +
"WHERE id = #{ page.id }")
group_connection.execute(
"UPDATE tag_group_versions SET created_by_user_id = NULL " +
"WHERE tag_group_id = #{ page.id }")
get "/tag_groups/#{ page.id }"
expect(response).to have_http_status(:ok)
expect(json).to include('created_by_user' => nil, 'updated_by_user' => nil)
get "/wiki/title/#{ CGI.escapeURIComponent('タググループ:読取り契約') }"
expect(response).to have_http_status(:ok)
end
it 'reads group version IDs, editor and timestamps in scoped changes' do
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
expect(json.first).to include(
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
'timestamp' => last_revision.created_at.iso8601(3),
'user' => { 'id' => user.id, 'name' => user.name },
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
end
it 'includes group history but excludes all legacy-only revisions' do
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' }
expect(rows.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
expect(json).not_to include(include(
'revision_id' => help.current_revision.id,
'wiki_page' => include('title' => help.title)))
end
[false, true].each do |explicit|
it "diffs structured snapshots with #{ explicit ? 'explicit' : 'current' } target" do
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(:ok)
expect(json).to include(
'wiki_page_id' => page.id, 'older_revision_id' => first_revision.id,
'newer_revision_id' => last_revision.id)
differences = json.fetch('diff').map { [_1.fetch('type'), _1.fetch('content').strip] }
expect(differences).to include(['removed', '- 旧タグ'], ['added', '- 新タグ'])
end
end
it 'does not use a version belonging to another group' do
other_id = seed_group(name: '別グループ', description: '別本文')
other_version = group_version(other_id, 1)
get group_title_path('読取り契約'), params: { version: other_version.fetch('id') }
expect(response).to have_http_status(:not_found)
get "/wiki/#{ page.id }/diff",
params: { from: other_version.fetch('id'), to: last_revision.id }
expect(response).to have_http_status(:not_found)
end
it 'resolves a group without any compatibility Wiki page' do
id = seed_group(name: '独立グループ', description: '独立した説明')
without_group_legacy_reads { get group_title_path('独立グループ') }
expect(response).to have_http_status(:ok)
expect(json).to include('id' => id, 'title' => 'タググループ:独立グループ')
expect(json.fetch('body').strip).to eq('独立した説明')
end
context 'when Help, description and group share the same numeric ID' do
let(:colliding_help) do
seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:三者ID衝突'),
body: 'help', created_by_user: user)
end
let(:colliding_group_id) do
seed_group(name: '三者ID衝突', id: colliding_help.id, description: 'group')
end
let(:colliding_description) do
TagDescription.create!(
id: colliding_help.id, tag: new_tag, language_code: 'ja',
body: 'description', version_no: 1)
end
let(:colliding_description_version) do
TagDescriptionVersion.create!(
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
event_type: 'create', body: 'description', created_by_user_id: user.id,
created_at: Time.current)
end
before do
colliding_group_id
colliding_description
colliding_description_version
end
it 'lists both new resources sharing a numeric ID without legacy Help' do
get '/wiki'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('id') == colliding_help.id }
expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly(
[colliding_description.id, '新タグ'],
[colliding_group_id, 'タググループ:三者ID衝突'])
end
it 'lists each resource history without deduplicating the shared resource ID' do
get '/wiki/changes'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('wiki_page').fetch('id') == colliding_help.id }
histories = rows.map do |row|
[row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')]
end
expect(histories).to contain_exactly(
[colliding_description_version.id, '新タグ'],
[group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突'])
end
it 'still excludes legacy copies of an actually migrated group and its history' do
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
get '/wiki'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('id') == page.id }
expect(rows.map { _1.fetch('title') }).to eq(['タググループ:読取り契約'])
get '/wiki/changes'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('wiki_page').fetch('id') == page.id }
expect(rows.map { _1.fetch('revision_id') })
.to contain_exactly(first_revision.id, last_revision.id)
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
expect(rows.map { _1.fetch('wiki_page').fetch('title') })
.to all(eq('タググループ:読取り契約'))
end
end
it 'resolves new resources by title while excluding colliding legacy Help' do
help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
seed_group(name: 'ID衝突', id: help.id, description: 'group')
TagDescription.create!(
id: help.id, tag: new_tag, language_code: 'ja', body: 'description', version_no: 1)
TagDescriptionVersion.create!(
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
event_type: 'create', body: 'description', created_at: Time.current)
{ 'タググループ:ID衝突' => 'group', '新タグ' => 'description' }
.each do |title, body|
get "/wiki/title/#{ CGI.escapeURIComponent(title) }"
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq(body)
end
without_group_legacy_reads do
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
end
expect(response).to have_http_status(:not_found)
end
end
+303
ファイルの表示
@@ -0,0 +1,303 @@
require 'rails_helper'
RSpec.describe 'Tag group Wiki facade writes', type: :request do
include TagGroupRecords
include ActiveSupport::Testing::TimeHelpers
let!(:user) { create_member_user! }
let!(:tag) { group_tag('洗澡鹿') }
let!(:other_tag) { group_tag('伊地知ニジカ') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
before { I18n.locale = :ja }
def create_group title: 'タググループ:新規', body: '- 洗澡鹿', request_headers: headers
post '/wiki', params: { title:, body: }, headers: request_headers
end
def expect_rejected_without_writes
before_state = group_state
yield
aggregate_failures do
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
expect(response.media_type).to eq('application/json')
expect(response.body).not_to be_empty
next if response.body.empty?
expect(json).to include(
'type' => 'validation_error', 'errors' => a_kind_of(Hash),
'base_errors' => a_kind_of(Array))
end
end
it 'creates the group and first snapshot without new Wiki records' do
expect { create_group }
.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:created)
group = group_rows('tag_groups', name: '新規').sole
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
expect_group_snapshot(group.fetch('id'), number: 1, members:, placements:)
version = group_version(group.fetch('id'), 1)
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
end
it 'keeps repeated placements but only one membership and one snapshot member' do
create_group(body: "- 洗澡鹿\n# 再掲\n- 洗澡鹿")
expect(response).to have_http_status(:created)
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
expect_group_snapshot(
id, number: 1, members:,
placements: placements + [
{ 'section_name' => '再掲', 'description' => nil, 'children' => placements }])
end
it 'resolves a Japanese alias through tag_names.tag_id' do
create(:tag_name, :alias, tag:, name: '鹿の別名')
create_group(body: '- 鹿の別名')
expect(response).to have_http_status(:created)
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
expect_group_snapshot(id, number: 1, members:, placements:)
get group_title_path('新規')
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq('- 洗澡鹿')
end
{
'unknown Tag' => '- 解決不能',
'unknown Tag Group' => '- タググループ:存在しない',
'empty Section' => '- :',
'unsupported placement syntax' => "```\n洗澡鹿\n```",
'six levels' => "- 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n" +
" - 洗澡鹿\n - 洗澡鹿",
'blank body' => '',
'normalised blank body' => "\r\n\r\n" }.each do |label, body|
it "rejects #{ label } without fallback or partial create" do
expect_rejected_without_writes { create_group(body:) }
end
end
['', 'タググループ:', "タググループ:#{ '長' * 256 }"].each do |title|
it "rejects an invalid required title of length #{ title.length }" do
expect_rejected_without_writes { create_group(title:) }
end
end
it 'rejects a duplicate group name even without a matching legacy Wiki' do
seed_group(name: '新規', description: '既存')
expect_rejected_without_writes { create_group }
end
it 'accepts the existing five-level placement depth boundary' do
body = (0...5).map { "#{ ' ' * _1 }- 洗澡鹿" }.join("\n")
create_group(body:)
expect(response).to have_http_status(:created)
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
tree = []
5.times { tree = [{ 'tag_id' => tag.id, 'children' => tree }] }
expect_group_snapshot(id, number: 1, members:, placements: tree)
end
context 'updating a migrated group' do
let!(:page) { legacy_group('更新', '- 洗澡鹿') }
let!(:old_revision) { page.current_revision }
before { migrate_groups }
def update_group body: '- 伊地知ニジカ', title: 'タググループ:更新', request_headers: headers
put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers
end
[1, 3].each do |repetitions|
it "keeps identical Markdown unchanged after #{ repetitions } Wiki PUTs" do
body = page.body
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id: page.id, page:) do
repetitions.times do
update_group(body:)
expect(response).to have_http_status(:ok)
end
end
end
end
end
it 'keeps identical Markdown unchanged with a stale base_revision_id' do
update_group
expect(response).to have_http_status(:ok)
body = page.reload.body
expect(page.current_revision.id).not_to eq(old_revision.id)
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id: page.id, page:) do
put "/wiki/#{ page.id }",
params: { title: page.title, body:, base_revision_id: old_revision.id },
headers: headers
end
end
end
it 'keeps the original editor on identical Markdown from another member' do
editor = create(:user, :member)
body = page.body
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id: page.id, page:) do
update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
end
end
end
it 'does not add history for Wiki content already changed through Native' do
changed_members = [{ 'tag_id' => other_tag.id }]
changed_placements = [{ 'tag_id' => other_tag.id, 'children' => [] }]
put "/tag_groups/#{ page.id }",
params: { name: '更新', description: nil,
members: changed_members, placements: changed_placements },
headers:, as: :json
expect(response).to have_http_status(:ok)
expect_group_snapshot(
page.id, number: 2,
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
placements: changed_placements)
body = page.reload.body
expect(body).to eq('- 伊地知ニジカ')
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id: page.id, page:) { update_group(body:) }
end
end
it 'replaces current membership and placement, retaining the old snapshot' do
old = group_version(page.id, 1)
update_group
expect(response).to have_http_status(:ok)
expect_group_snapshot(
page.id, number: 2,
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
placements: [{ 'tag_id' => other_tag.id, 'children' => [] }])
expect(group_version(page.id, 1)).to eq(old)
expect(group_version(page.id, 2)).to include(
'event_type' => 'update', 'created_by_user_id' => user.id)
expect(page.reload.body.strip).to eq('- 伊地知ニジカ')
expect(page.wiki_revisions.count).to eq(2)
expect(page.wiki_versions.count).to eq(2)
expect(page.current_revision.body).to eq(page.body)
expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body)
end
it 'accepts the current base_revision_id used by the existing frontend' do
put "/wiki/#{ page.id }",
params: { title: page.title, body: '- 伊地知ニジカ',
base_revision_id: old_revision.id }, headers: headers
expect(response).to have_http_status(:ok)
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(2)
end
it 'ignores a stale base_revision_id without rejecting the update' do
update_group
expect(response).to have_http_status(:ok)
put "/wiki/#{ page.id }",
params: { title: page.title, body: '- 洗澡鹿',
base_revision_id: old_revision.id }, headers: headers
expect(response).to have_http_status(:ok)
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(3)
expect(page.reload.body.strip).to eq('- 洗澡鹿')
end
it 'records the editor and update time' do
editor = create(:user, :member, name: '編輯者')
travel_to(Time.current + 1.hour) do
update_group(request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
expect(response).to have_http_status(:ok)
current = group_rows('tag_groups', id: page.id).sole
version = group_version(page.id, 2)
expect(current).to include('updated_by_user_id' => editor.id)
expect(version).to include('created_by_user_id' => editor.id)
expect(current.fetch('updated_at')).to be_within(1.second).of(Time.current)
expect(version.fetch('created_at')).to be_within(1.second).of(Time.current)
end
end
['- 不明なタグ', '- タググループ:不明', '', '- :'].each do |body|
it "rejects invalid update #{ body.inspect } atomically" do
expect_rejected_without_writes { update_group(body:) }
end
end
it 'rejects direct self inclusion' do
expect_rejected_without_writes { update_group(body: '- タググループ:更新') }
end
it 'rejects indirect cyclic membership' do
seed_group(
name: '子', members: [{ 'tag_group_id' => page.id, 'version_no' => 1 }],
placements: [{ 'tag_group_id' => page.id, 'children' => [] }])
expect_rejected_without_writes { update_group(body: '- タググループ:子') }
end
it 'rejects duplicate rename without retaining a partial title or version' do
seed_group(name: '既存', description: '既存')
expect_rejected_without_writes { update_group(title: 'タググループ:既存') }
end
end
[:create, :update].each do |operation|
context "#{ operation } access and atomicity" do
let!(:page) do
next unless operation == :update
legacy = legacy_group('更新', '- 洗澡鹿')
seed_group(name: '更新', id: legacy.id, members:, placements:)
legacy
end
let!(:child_id) { seed_group(name: '子グループ', description: '子') }
def write_group operation, request_headers
body = "- 伊地知ニジカ\n- タググループ:子グループ"
if operation == :create
create_group(body:, request_headers:)
else
put "/wiki/#{ page.id }",
params: { title: 'タググループ:更新', body: }, headers: request_headers
end
end
[nil, :guest, :banned].each do |role|
it "rejects #{ role || 'anonymous' } without writes" do
actor = case role
when :guest then create(:user, :guest)
when :banned then create(:user, :member, :banned)
end
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
before_state = group_state
write_group(operation, request_headers)
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
expect(group_state).to eq(before_state)
end
end
it 'rejects a banned IP even for a member' do
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
before_state = group_state
write_group(operation, headers)
expect(response).to have_http_status(:forbidden)
expect(group_state).to eq(before_state)
end
tables = TagGroupRecords::GROUP_TABLES
tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions'] if operation == :update
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
end
end
end
end
+330
ファイルの表示
@@ -0,0 +1,330 @@
require 'rails_helper'
RSpec.describe 'Tag group write integrity', type: :request do
include WikiRecords
include TagGroupRecords
include ActiveSupport::Testing::TimeHelpers
let!(:user) { create_member_user! }
let!(:tag) { group_tag('洗澡鹿') }
let!(:other_tag) { group_tag('伊地知ニジカ') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
before { I18n.locale = :ja }
def native_payload name: '更新', nodes: placements, member_rows: members
{ name:, description: nil, members: member_rows.map do |member|
member.except('version_no')
end, placements: nodes }
end
def put_wiki id, title:, body: '- 伊地知ニジカ', base: nil
put "/wiki/#{ id }", params: { title:, body:, base_revision_id: base }, headers: headers
end
def create_help body
seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user)
end
def expect_no_writes
before_state = group_state
yield
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
context 'resource identity' do
it 'rejects removing the prefix from a migrated group atomically' do
page = legacy_group('更新', '- 洗澡鹿')
seed_group(name: '更新', id: page.id, members:, placements:)
expect_no_writes do
put_wiki(page.id, title: '更新', base: page.current_revision.id)
end
end
it 'rejects an unprefixed update to an independent group' do
id = seed_group(name: '独立', members:, placements:)
expect_no_writes { put_wiki(id, title: '独立') }
end
it 'does not revive legacy writes when the compatibility title is stale' do
page = legacy_group('更新', '- 洗澡鹿')
seed_group(name: '更新', id: page.id, members:, placements:)
page.tag_name.update!(name: '古い別名')
expect_no_writes { put_wiki(page.id, title: '古い別名') }
end
it 'renames the group and its compatibility page through the operation' do
page = legacy_group('更新', '- 洗澡鹿')
seed_group(name: '更新', id: page.id, members:, placements:)
put_wiki(page.id, title: 'タググループ:改名')
expect(response).to have_http_status(:ok)
expect(TagGroup.find(page.id)).to have_attributes(name: '改名', version_no: 2)
expect(page.reload.title).to eq('タググループ:改名')
expect(page.body).to eq('- 伊地知ニジカ')
end
it 'updates Help with a colliding group ID without altering the group' 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(response).to have_http_status(:ok)
expect(page.reload.body).to eq('Help after')
expect(page.title).to eq('Help:改名')
expect(TagGroup.find(id).attributes).to eq(group_before)
expect(group_version(id, 1)).to eq(version_before)
end
it 'rejects an ambiguous unprefixed rename when Help and group IDs collide' do
page = create_help('Help body')
seed_group(name: '更新', id: page.id, members:, placements:)
expect_no_writes { put_wiki(page.id, title: '更新') }
expect_no_writes do
put_wiki(page.id, title: '更新', base: page.current_revision.id)
end
end
it 'keeps a description update separate from a colliding group' do
page = 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:)
group_before = TagGroup.find(id).attributes
put_wiki(id, title: '洗澡鹿', body: 'description after')
expect(response).to have_http_status(:ok)
expect(TagDescription.find(id).body).to eq('description after')
expect(page.reload.body).to eq('description after')
expect(TagGroup.find(id).attributes).to eq(group_before)
end
it 'does not route a colliding Help write into an unrelated description' do
page = create_help('Help before')
id = seed_group(name: '更新', id: page.id, members:, placements:)
description = TagDescription.create!(
id:, tag:, language_code: 'ja', version_no: 1, body: 'description')
put_wiki(id, title: 'Help:既存', body: 'Help after')
expect(response).to have_http_status(:ok)
expect(page.reload.body).to eq('Help after')
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: 'タググループ:更新')
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')
end
end
context 'compatibility synchronisation' do
it 'synchronises a native rename without modifying an ID-colliding Help page' do
help = create_help('Help before')
id = seed_group(name: '更新', id: help.id, members:, placements:)
help_before = help.attributes
expect do
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
end.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).name).to eq('改名')
expect(help.reload.attributes).to eq(help_before)
end
['wiki_lines', 'wiki_revision_lines'].each do |table|
it "rolls back native and compatibility state on #{ table } failure" do
expect_atomic_group_write(table) do
post '/tag_groups', params: native_payload, headers:, as: :json
end
end
end
it 'rolls back a renderer failure instead of treating it as unsupported structure' do
allow(TagGroupMarkdownRenderer).to receive(:render).and_raise('renderer failure')
before_state = group_state
expect do
post '/tag_groups', params: native_payload, headers:, as: :json
end.to raise_error(RuntimeError, 'renderer failure')
expect(group_state).to eq(before_state)
end
it 'preserves unplaced membership and reports the skipped sync with its version' do
page = legacy_group('更新', '- 洗澡鹿')
id = seed_group(name: '更新', id: page.id, members:, placements:)
wiki_before = group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5))
warnings = []
allow(Rails.logger).to receive(:warn) { warnings << _1 }
put "/tag_groups/#{ id }",
params: native_payload(member_rows: members + [{ 'tag_id' => other_tag.id }]),
headers:, as: :json
expect(response).to have_http_status(:ok)
expect(json.fetch('members').map { _1.fetch('tag').fetch('id') })
.to contain_exactly(tag.id, other_tag.id)
expect(group_version(id, 2).fetch('members')).to include(
{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no })
expect(group_state.slice(*TagGroupRecords::WRITE_TABLES.drop(5))).to eq(wiki_before)
payload = JSON.parse(warnings.grep(/tag_group_wiki_sync_skipped/).sole.split(' ', 2).last)
expect(payload).to include(
'status' => 'unsupported_structure', 'tag_group_id' => id,
'version_no' => 2, 'user_id' => user.id,
'reason' => '掲載位置のない membership は Markdown にできません.')
end
it 'preserves a list Section description and records why no Wiki was created' do
nodes = [{ 'tag_id' => tag.id, 'children' => [
{ 'section_name' => '区分', 'description' => '構造化の説明', 'children' => [] }] }]
warnings = []
allow(Rails.logger).to receive(:warn) { warnings << _1 }
before_count = WikiPage.count
post '/tag_groups', params: native_payload(nodes:), headers:, as: :json
expect(response).to have_http_status(:created)
id = json.fetch('id')
expect(group_version(id, 1).fetch('placements')).to eq(nodes)
expect(json.fetch('placements').sole.fetch('children').sole)
.to include('description' => '構造化の説明')
expect(WikiPage.count).to eq(before_count)
expect(warnings.grep(/tag_group_wiki_sync_skipped/).sole)
.to include('リスト内 Section の説明文は Markdown にできません.')
end
end
context 'identifier Markdown' do
['[洗澡鹿](https://example.com)', '*洗澡鹿*', '**洗澡鹿**', '`洗澡鹿`',
'![洗澡鹿](https://example.com/image.png)',
'[タググループ:子](https://example.com)', '*区分*:',
'[区分](https://example.com):'].each do |label|
[:create, :update].each do |operation|
it "rejects decorated #{ label } on #{ operation } without writes" do
seed_group(name: '子', description: '子')
id = seed_group(name: '更新', members:, placements:)
expect_no_writes do
if operation == :create
post '/wiki', params: { title: 'タググループ:新規', body: "- #{ label }" },
headers: headers
else
put_wiki(id, title: 'タググループ:更新', body: "- #{ label }")
end
end
end
end
end
['# [区分](https://example.com)', '# *区分*', '# `区分`', '# 区分 {#custom}',
'# ![区分](https://example.com/image.png)'].each do |heading|
it "rejects a decorated heading #{ heading } without writes" do
expect_no_writes do
post '/wiki', params: { title: 'タググループ:新規', body: "#{ heading }\n- 洗澡鹿" },
headers: headers
end
end
end
it 'preserves allowed markup in group and heading descriptions' do
description = '[参照](https://example.com "リンクの説明") と *強調* と ``a`b``'
body = "#{ description }\n\n# 区分\n#{ description }\n\n- 洗澡鹿"
post '/wiki', params: { title: 'タググループ:説明構文', body: }, headers: headers
expect(response).to have_http_status(:created)
id = json.fetch('id')
version = group_version(id, 1)
expect(version.fetch('description')).to eq(description)
expect(version.fetch('placements').sole.fetch('description')).to eq(description)
end
end
context 'structure audit information' do
let!(:child_id) { seed_group(name: '子', description: '子') }
let(:members) do
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
end
let(:placements) do
[{ 'tag_id' => tag.id, 'children' => [
{ 'tag_group_id' => child_id, 'children' => [] }] }]
end
let!(:id) { seed_group(name: '更新', members:, placements:) }
let!(:editor) { create(:user, :member) }
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } }
it 'keeps all membership and placement audit fields on a name-only edit' do
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
travel_to(1.hour.from_now) do
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
end
expect(response).to have_http_status(:ok)
expect(group_state.slice(*before_rows.keys)).to eq(before_rows)
expect(group_version(id, 2)).to include('name' => '改名', 'members' => members)
end
it 'keeps membership and placement audit fields on a description-only edit' do
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
travel_to(1.hour.from_now) do
put "/tag_groups/#{ id }",
params: native_payload.merge(description: '説明を追加'), headers:, as: :json
end
expect(response).to have_http_status(:ok)
expect(group_state.slice(*before_rows.keys)).to eq(before_rows)
expect(group_version(id, 2)).to include('description' => '説明を追加', 'members' => members)
end
it 'rolls back membership removal and placement edits on a late sync failure' do
new_members = [{ 'tag_id' => tag.id }]
new_nodes = [{ 'tag_id' => tag.id, 'children' => [] }]
expect_atomic_group_write('wiki_versions') do
put "/tag_groups/#{ id }",
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
end
end
it 'changes only added or removed membership and keeps placement creation audit' do
tag_row = TagGroupTag.find([id, tag.id]).attributes
placement_row = TagGroupPlacement.find([id, 1]).attributes
new_members = [{ 'tag_id' => tag.id }, { 'tag_id' => other_tag.id }]
new_nodes = [{ 'tag_id' => tag.id, 'children' => [
{ 'tag_id' => other_tag.id, 'children' => [] }] }]
child_row = TagGroupPlacement.find([id, 2]).attributes
travel_to(1.hour.from_now) do
put "/tag_groups/#{ id }",
params: native_payload(nodes: new_nodes, member_rows: new_members), headers:, as: :json
end
expect(response).to have_http_status(:ok)
expect(TagGroupTag.find([id, tag.id]).attributes).to eq(tag_row)
expect(TagGroupGroup.exists?(tag_group_id: id, child_tag_group_id: child_id)).to be(false)
expect(TagGroupTag.find([id, other_tag.id]).created_by_user_id).to eq(editor.id)
expect(TagGroupPlacement.find([id, 1]).attributes).to eq(placement_row)
changed = TagGroupPlacement.find([id, 2])
expect(changed).to have_attributes(
tag_id: other_tag.id, child_tag_group_id: nil, updated_by_user_id: editor.id,
created_at: child_row.fetch('created_at'),
created_by_user_id: child_row.fetch('created_by_user_id'))
expect(changed.updated_at).to be > child_row.fetch('updated_at')
expect(group_version(id, 1).fetch('members')).to eq(members)
end
end
end
+467
ファイルの表示
@@ -0,0 +1,467 @@
require 'rails_helper'
RSpec.describe 'Tag group native API', type: :request do
include TagGroupRecords
include ActiveSupport::Testing::TimeHelpers
let!(:user) { create_member_user! }
let!(:group_id) { seed_group(name: '専用API', description: '説明') }
before { I18n.locale = :ja }
def expect_native_group value
expect(value).to include(
'id' => group_id, 'name' => '専用API', 'description' => '説明',
'version_no' => 1, 'members' => [], 'placements' => [],
'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user))
current = group_rows('tag_groups', id: group_id).sole
expect(value).to include(
'created_at' => current.fetch('created_at').iso8601(3),
'updated_at' => current.fetch('updated_at').iso8601(3))
end
it 'serves the collection as JSON without reading Wiki tables' do
without_group_legacy_reads { get '/tag_groups' }
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq('application/json')
expect(json).to be_a(Array)
expect_native_group(json.find { _1.fetch('id') == group_id })
end
it 'serves an independent group as JSON without reading Wiki tables' do
without_group_legacy_reads { get "/tag_groups/#{ group_id }" }
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq('application/json')
expect_native_group(json)
end
it 'provides the confirmed PUT update route' do
route = Rails.application.routes.recognize_path("/tag_groups/#{ group_id }", method: :put)
expect(route).to include(action: 'update', id: group_id.to_s)
end
[:create, :update].each do |operation|
[nil, :guest, :banned].each do |role|
it "rejects #{ role || 'anonymous' } native #{ operation } without writes" do
actor = case role
when :guest then create(:user, :guest)
when :banned then create(:user, :member, :banned)
end
headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
before_state = group_state
if operation == :create
post '/tag_groups', params: { }, headers:, as: :json
else
put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json
end
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
expect(group_state).to eq(before_state)
end
end
it "rejects a banned IP for native #{ operation }" do
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
headers = { 'X-Transfer-Code' => user.inheritance_code }
before_state = group_state
if operation == :create
post '/tag_groups', params: { }, headers:, as: :json
else
put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json
end
expect(response).to have_http_status(:forbidden)
expect(group_state).to eq(before_state)
end
end
context 'structured membership and placements' do
let!(:tag) { group_tag('掲載タグ') }
let!(:unplaced) { group_tag('未掲載タグ') }
let!(:child_id) { seed_group(name: '子', description: '子の説明') }
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
let(:members) do
[{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }]
end
let(:snapshot_members) do
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
{ 'tag_group_id' => child_id, 'version_no' => 1 },
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }]
end
let(:placements) do
[{ 'section_name' => '区分', 'description' => '区分の説明', 'children' => [
{ 'tag_id' => tag.id, 'children' => [
{ 'tag_group_id' => child_id, 'children' => [] }] },
{ 'tag_id' => tag.id, 'children' => [] }] }]
end
let(:payload) { { name: '構造化', description: '全体の説明', members:, placements: } }
def native_write operation, attributes = payload
if operation == :create
post '/tag_groups', params: attributes, headers:, as: :json
else
put "/tag_groups/#{ group_id }", params: attributes, headers:, as: :json
end
end
def expect_structured_response
tag_repr = { 'id' => tag.id, 'name' => '掲載タグ', 'category' => tag.category }
unplaced_repr = { 'id' => unplaced.id, 'name' => '未掲載タグ',
'category' => unplaced.category }
expect(json.fetch('members')).to contain_exactly(
include('tag' => include(tag_repr)),
include('tag' => include(unplaced_repr)),
include('tag_group' => include('id' => child_id, 'name' => '子')))
expect(json.fetch('placements')).to match([
include('section_name' => '区分', 'description' => '区分の説明', 'children' => [
include('tag' => include(tag_repr), 'children' => [
include('tag_group' => include('id' => child_id, 'name' => '子'),
'children' => [])]),
include('tag' => include(tag_repr), 'children' => [])])])
end
[:create, :update].each do |operation|
it "#{ operation }s structured data and keeps unplaced membership" do
previous = group_version(group_id, 1)
native_write(operation)
expect(response).to have_http_status(operation == :create ? :created : :ok)
id = operation == :create ? json.fetch('id') : group_id
number = operation == :create ? 1 : 2
expect_group_snapshot(
id, number:, members: snapshot_members, placements:, description: '全体の説明')
expect(group_version(group_id, 1)).to eq(previous)
get "/tag_groups/#{ id }"
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => id, 'name' => '構造化', 'description' => '全体の説明', 'version_no' => number,
'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user))
expect_structured_response
end
{
'blank name' => { name: '' },
'missing name' => { name: nil },
'oversized name' => { name: '長' * 256 },
'duplicate name' => { name: '子' },
'placement without a kind' => { placements: [{ children: [] }] },
'empty Section name' => { placements: [{ section_name: '', children: [] }] } }
.each do |label, invalid|
it "rejects #{ label } on #{ operation } without partial writes" do
before_state = group_state
native_write(operation, payload.merge(invalid))
aggregate_failures do
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
end
end
it "rejects multiple placement kinds on #{ operation }" do
before_state = group_state
invalid = [{ tag_id: tag.id, section_name: '不正', children: [] }]
native_write(operation, payload.merge(placements: invalid))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it "rejects a missing referenced Tag on #{ operation }" do
missing = Tag.maximum(:id).to_i + 1
before_state = group_state
native_write(operation, payload.merge(
members: [{ tag_id: missing }], placements: [{ tag_id: missing, children: [] }]))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it "rejects a missing referenced group on #{ operation }" do
missing = group_rows('tag_groups').map { _1.fetch('id') }.max + 1
before_state = group_state
native_write(operation, payload.merge(
members: [{ tag_group_id: missing }],
placements: [{ tag_group_id: missing, children: [] }]))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it "rejects six descendant placement levels on #{ operation }" do
nodes = []
6.times { nodes = [{ tag_id: tag.id, children: nodes }] }
before_state = group_state
native_write(operation, payload.merge(placements: nodes))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
end
it 'preserves membership-only data in structured reads without inventing placements' do
id = seed_group(name: '未掲載のみ', members: [snapshot_members.last])
without_group_legacy_reads { get "/tag_groups/#{ id }" }
expect(response).to have_http_status(:ok)
expect(json.fetch('placements')).to eq([])
expect(json.fetch('members')).to match([
include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))])
end
it 'rejects indirect containment cycles' do
parent = seed_group(
name: '親', members: [{ 'tag_group_id' => group_id, 'version_no' => 1 }])
before_state = group_state
native_write(:update, payload.merge(
members: [{ tag_group_id: parent }], placements: []))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it 'rejects direct containment even without a corresponding placement' do
before_state = group_state
native_write(:update, payload.merge(
members: [{ tag_group_id: group_id }], placements: []))
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
it 'preserves list Section descriptions in native structured data' do
nodes = [{ tag_id: tag.id, children: [
{ section_name: 'リスト内区分', description: '消してはいけない説明', children: [] }] }]
native_write(:update, payload.merge(placements: nodes))
expect(response).to have_http_status(:ok)
expect(group_version(group_id, 2).fetch('placements')).to eq(
JSON.parse(nodes.to_json))
get "/tag_groups/#{ group_id }"
expect(response).to have_http_status(:ok)
section = json.fetch('placements').sole.fetch('children').sole
expect(section).to include(
'section_name' => 'リスト内区分', 'description' => '消してはいけない説明')
end
context 'compatibility writes for representable structures' do
let(:group_id) do
page = legacy_group('専用API', '説明')
seed_group(name: '専用API', id: page.id, description: '説明')
end
let(:members) { [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }] }
let(:snapshot_members) do
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
end
it 'treats identical native content with unplaced membership as a no-op' do
unplaced_payload = payload.merge(
name: '専用API', members: members + [{ 'tag_id' => unplaced.id }])
native_write(:update, unplaced_payload)
expect(response).to have_http_status(:ok)
expect_group_snapshot(
group_id, number: 2, description: '全体の説明', placements:,
members: snapshot_members + [
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }])
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:専用API' })
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:, page:) do
native_write(:update, unplaced_payload)
end
end
end
context 'no-op updates against synchronised state' do
let(:synchronised_payload) do
{ name: '構造化', description: '全体の説明',
members: [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }], placements: }
end
let(:additional_tag) { group_tag('追加対象タグ') }
let(:compatibility_page) do
WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' })
end
before do
native_write(:update, synchronised_payload)
expect(response).to have_http_status(:ok)
expect(compatibility_page.body).to include('- 掲載タグ', '- タググループ:子')
end
[1, 3].each do |repetitions|
it "keeps identical native content unchanged after #{ repetitions } PUTs" do
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:, page: compatibility_page) do
repetitions.times do
native_write(:update, synchronised_payload)
expect(response).to have_http_status(:ok)
end
end
end
end
end
it 'treats membership order alone as a no-op' do
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:, page: compatibility_page) do
native_write(:update, synchronised_payload.merge(
members: synchronised_payload.fetch(:members).reverse))
end
end
end
it 'keeps the original editor when another member submits identical content' do
editor = create(:user, :member)
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:, page: compatibility_page) do
put "/tag_groups/#{ group_id }", params: synchronised_payload,
headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json
end
end
end
it 'does not add history for Native content already changed through Wiki' do
put "/wiki/#{ group_id }",
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
expect(response).to have_http_status(:ok)
expect(compatibility_page.reload.body).to eq('- 未掲載タグ')
same_content = {
name: '構造化', description: nil, members: [{ 'tag_id' => unplaced.id }],
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }] }
expect_group_snapshot(
group_id, number: 3, description: nil,
members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }],
placements: same_content.fetch(:placements))
travel_to(1.hour.from_now) do
expect_noop_group_update(group_id:, page: compatibility_page) do
native_write(:update, same_content)
end
end
end
['name', 'description', 'membership addition', 'membership removal',
'Section name', 'Section description', 'placement order', 'placement hierarchy']
.each do |change|
it "records a real #{ change } change in both histories" do
changed = synchronised_payload.deep_dup
section = changed.fetch(:placements).first
children = section.fetch('children')
case change
when 'name' then changed[:name] = '実際の改名'
when 'description' then changed[:description] = '実際の説明変更'
when 'membership addition'
expect(changed.fetch(:members)).not_to include('tag_id' => additional_tag.id)
changed[:members] << { 'tag_id' => additional_tag.id }
children << { 'tag_id' => additional_tag.id, 'children' => [] }
when 'membership removal'
changed[:members].reject! { _1.key?('tag_group_id') }
children.first['children'] = []
when 'Section name' then section['section_name'] = '別区分'
when 'Section description' then section['description'] = '別の区分説明'
when 'placement order' then children.reverse!
when 'placement hierarchy'
children << children.first.fetch('children').pop
end
old_versions = group_rows('tag_group_versions', tag_group_id: group_id)
group = TagGroup.find(group_id)
number = group.version_no
page = compatibility_page
old_revision_id = page.current_revision.id
old_wiki_version_id = page.wiki_versions.order(version_no: :desc).first.id
revision_count = page.wiki_revisions.count
version_count = page.wiki_versions.count
travel_to(1.hour.from_now) { native_write(:update, changed) }
expect(response).to have_http_status(:ok)
expected_members = changed.fetch(:members).map do |member|
target = if member.key?('tag_id')
Tag.find(member.fetch('tag_id'))
else
TagGroup.find(member.fetch('tag_group_id'))
end
member.merge('version_no' => target.version_no)
end
expect_group_snapshot(
group_id, number: number + 1, members: expected_members,
placements: changed.fetch(:placements), description: changed.fetch(:description))
expect(group.reload.name).to eq(changed.fetch(:name))
versions = group_rows('tag_group_versions', tag_group_id: group_id)
expect(versions.size).to eq(old_versions.size + 1)
expect(versions.select { _1.fetch('version_no') <= number }).to eq(old_versions)
expect(page.wiki_revisions.count).to eq(revision_count + 1)
expect(page.wiki_versions.count).to eq(version_count + 1)
expect(page.reload.current_revision.id).not_to eq(old_revision_id)
expect(page.wiki_versions.order(version_no: :desc).first.id)
.not_to eq(old_wiki_version_id)
get group_title_path(changed.fetch(:name))
expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to eq(page.body)
end
end
end
it 'creates native state without adding compatibility Wiki records' do
expect { native_write(:create) }
.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:created)
expect_group_snapshot(
json.fetch('id'), number: 1, members: snapshot_members,
placements:, description: '全体の説明')
end
[:update].each do |operation|
it "synchronises Wiki current state and histories on native #{ operation }" do
native_write(operation)
expect(response).to have_http_status(operation == :create ? :created : :ok)
id = operation == :create ? json.fetch('id') : group_id
number = operation == :create ? 1 : 2
expect_group_snapshot(
id, number:, members: snapshot_members, placements:, description: '全体の説明')
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' })
expect(page.body).to include('全体の説明', '# 区分', '- 掲載タグ', '- タググループ:子')
expect(page.current_revision.body).to eq(page.body)
expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body)
expect(page.wiki_revisions.count).to eq(number)
expect(page.wiki_versions.count).to eq(number)
get group_title_path('構造化')
expect(response).to have_http_status(:ok)
expect(json.fetch('body')).to eq(page.body)
end
(TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
.each do |table|
it "rolls back native #{ operation } after #{ table } writes" do
expect_atomic_group_write(table) { native_write(operation) }
end
end
end
it 'assigns the next group ID from the existing maximum' do
child_id
expected_id = (TagGroup.maximum(:id) || 0) + 1
native_write(:create)
expect(response).to have_http_status(:created)
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
native_write(:create)
expect(response).to have_http_status(:created)
id = json.fetch('id')
previous = group_version(id, 1)
put "/wiki/#{ id }",
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
expect(response).to have_http_status(:ok)
expect_group_snapshot(
id, number: 2,
members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }],
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }])
expect(group_version(id, 1)).to eq(previous)
get "/tag_groups/#{ id }"
expect(response).to have_http_status(:ok)
expect(json).to include('version_no' => 2)
expect(json.fetch('members')).to match([
include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))])
end
end
end
end
+91
ファイルの表示
@@ -0,0 +1,91 @@
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 "ignores 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(2)
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 } from its own maximum, preserving gaps and existing rows" 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)
expected_id = resource == :description ? 6 : 9
expect(json.fetch('id')).to eq(expected_id)
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
+10 -19
ファイルの表示
@@ -1,6 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Tag and wiki history integrity', type: :request do RSpec.describe 'Tag and wiki history integrity', type: :request do
include WikiRecords
let(:member_user) { create(:user, role: 'member') } let(:member_user) { create(:user, role: 'member') }
def stub_current_user user def stub_current_user user
@@ -13,7 +14,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
end end
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user 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'), tag_name: primary_tag_name_for(tag, 'ja'),
body:, body:,
created_by_user: user, created_by_user: user,
@@ -25,9 +26,10 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
end end
describe 'PATCH /tags/:id' do describe 'PATCH /tags/:id' do
it 'records wiki_version when tag name changes and tag has wiki' do it 'renames the current Wiki resource without adding body history' do
tag = create_tag!(name: 'patch_tag_wiki_before') tag = create_tag!(name: 'patch_tag_wiki_before')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect { expect {
patch "/tags/#{ tag.id }", params: { patch "/tags/#{ tag.id }", params: {
@@ -35,23 +37,17 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1) .and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
tag.reload tag.reload
wiki_page.reload wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last
expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after') expect(primary_tag_name_for(tag, 'ja').name).to eq('patch_tag_wiki_after')
expect(wiki_page.title).to eq('patch_tag_wiki_after') expect(wiki_page.title).to eq('patch_tag_wiki_after')
expect(version).to have_attributes( expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
event_type: 'update',
title: 'patch_tag_wiki_after',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end end
it 'does not record wiki_version when only category changes' do it 'does not record wiki_version when only category changes' do
@@ -79,9 +75,10 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
end end
describe 'PUT /tags/:id' do describe 'PUT /tags/:id' do
it 'records wiki_version when tag name changes and tag has wiki' do it 'renames the current Wiki resource without adding body history' do
tag = create_tag!(name: 'put_tag_wiki_before') tag = create_tag!(name: 'put_tag_wiki_before')
wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before') wiki_page = create_wiki_for_tag!(tag:, body: 'wiki body before')
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect { expect {
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
@@ -93,23 +90,17 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1) .and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
tag.reload tag.reload
wiki_page.reload wiki_page.reload
version = wiki_page.wiki_versions.order(:version_no).last
expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after') expect(primary_tag_name_for(tag, 'ja').name).to eq('put_tag_wiki_after')
expect(wiki_page.title).to eq('put_tag_wiki_after') expect(wiki_page.title).to eq('put_tag_wiki_after')
expect(version).to have_attributes( expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
event_type: 'update',
title: 'put_tag_wiki_after',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end end
it 'does not record wiki_version when only category changes' do it 'does not record wiki_version when only category changes' do
+22 -35
ファイルの表示
@@ -727,7 +727,7 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.reload.category).to eq('general') expect(tag.reload.category).to eq('general')
end end
it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do it 'PATCH の名称変更で Wiki の本文履歴を追加しない' do
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'), tag_name: primary_tag_name_for(tag, 'ja'),
@@ -735,24 +735,20 @@ RSpec.describe 'Tags API', type: :request do
created_by_user: member_user, created_by_user: member_user,
message: 'init') message: 'init')
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect { expect {
patch "/tags/#{ tag.id }", params: { patch "/tags/#{ tag.id }", params: {
name: 'patch_wiki_renamed_tag', name: 'patch_wiki_renamed_tag',
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1) .and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
version = wiki_page.reload.wiki_versions.order(:version_no).last expect(wiki_page.reload.title).to eq('patch_wiki_renamed_tag')
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
expect(version).to have_attributes(
event_type: 'update',
title: 'patch_wiki_renamed_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end end
it 'tag の category だけを変更しても wiki version は作成しない' do it 'tag の category だけを変更しても wiki version は作成しない' do
@@ -1380,7 +1376,7 @@ RSpec.describe 'Tags API', type: :request do
expect(system_tag.category).to eq(old_category) expect(system_tag.category).to eq(old_category)
end end
it 'wiki を持つ tag を更新すると wiki version も作成する' do it 'Wiki を持つ Tag の名称変更で本文履歴を追加しない' do
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'), tag_name: primary_tag_name_for(tag, 'ja'),
@@ -1395,6 +1391,8 @@ RSpec.describe 'Tags API', type: :request do
message: 'init' message: 'init'
) )
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect { expect {
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
name: 'put_wiki_version_tag', name: 'put_wiki_version_tag',
@@ -1405,18 +1403,12 @@ RSpec.describe 'Tags API', type: :request do
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1) .and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
version = wiki_page.reload.wiki_versions.order(:version_no).last expect(wiki_page.reload.title).to eq('put_wiki_version_tag')
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
expect(version).to have_attributes(
event_type: 'update',
title: 'put_wiki_version_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end end
it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do it 'wiki を持つ tag を旧 alias へ戻しても wiki を新しい正本名へ移す' do
@@ -1426,6 +1418,7 @@ RSpec.describe 'Tags API', type: :request do
body: 'wiki body before', body: 'wiki body before',
created_by_user: member_user, created_by_user: member_user,
message: 'init') message: 'init')
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect { expect {
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
@@ -1445,7 +1438,7 @@ RSpec.describe 'Tags API', type: :request do
} }
} }
.to change(TagVersion, :count).by(3) .to change(TagVersion, :count).by(3)
.and change(WikiVersion, :count).by(2) .and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -1457,10 +1450,8 @@ RSpec.describe 'Tags API', type: :request do
expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b')) expect(TagName.find_by!(language_code: 'ja', name: 'put_wiki_roundtrip_b'))
.to have_attributes(tag_id: tag.id, primary_flg: false) .to have_attributes(tag_id: tag.id, primary_flg: false)
versions = wiki_page.wiki_versions.order(:version_no).last(2) expect(wiki_page.title).to eq('spec_tag')
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
expect(versions.map(&:event_type)).to eq(['update', 'update'])
expect(versions.map(&:title)).to eq(['put_wiki_roundtrip_b', 'spec_tag'])
end end
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
@@ -1570,7 +1561,7 @@ RSpec.describe 'Tags API', type: :request do
expect(TagImplication.where(tag:, parent_tag: child)).not_to exist expect(TagImplication.where(tag:, parent_tag: child)).not_to exist
end end
it 'tag の name を変更すると対応する wiki version を作成する' do it 'Tag の名称変更で Wiki の本文履歴を追加しない' do
wiki_page = wiki_page =
Wiki::Commit.create_content!( Wiki::Commit.create_content!(
tag_name: primary_tag_name_for(tag, 'ja'), tag_name: primary_tag_name_for(tag, 'ja'),
@@ -1578,6 +1569,8 @@ RSpec.describe 'Tags API', type: :request do
created_by_user: member_user, created_by_user: member_user,
message: 'init') message: 'init')
wiki_history = wiki_page.wiki_versions.map(&:attributes)
expect { expect {
put "/tags/#{ tag.id }", params: { put "/tags/#{ tag.id }", params: {
name: 'put_wiki_renamed_tag', name: 'put_wiki_renamed_tag',
@@ -1588,18 +1581,12 @@ RSpec.describe 'Tags API', type: :request do
} }
} }
.to change(TagVersion, :count).by(2) .to change(TagVersion, :count).by(2)
.and change(WikiVersion, :count).by(1) .and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
version = wiki_page.reload.wiki_versions.order(:version_no).last expect(wiki_page.reload.title).to eq('put_wiki_renamed_tag')
expect(wiki_page.wiki_versions.reload.map(&:attributes)).to eq(wiki_history)
expect(version).to have_attributes(
event_type: 'update',
title: 'put_wiki_renamed_tag',
body: 'wiki body before',
created_by_user_id: member_user.id
)
end end
end end
end end
+2 -1
ファイルの表示
@@ -1,6 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Wiki conflict handling', type: :request do RSpec.describe 'Wiki conflict handling', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
def auth_headers user def auth_headers user
@@ -9,7 +10,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do
it 'returns 409 when base_revision_id is stale' do it 'returns 409 when base_revision_id is stale' do
page = page =
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: create(:tag_name, name: 'wiki_conflict_request'), tag_name: create(:tag_name, name: 'wiki_conflict_request'),
body: 'first', body: 'first',
created_by_user: user, created_by_user: user,
+59
ファイルの表示
@@ -0,0 +1,59 @@
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 'updates an independent group without implicitly creating a legacy page' do
id = seed_group(name: 'independent', description: 'before')
expect do
put "/tag_groups/#{ id }", params: {
name: 'independent', description: 'after', members: [], placements: [] },
headers:, as: :json
end.to change(WikiPage, :count).by(0)
.and change(WikiRevision, :count).by(0)
.and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok)
expect(TagGroup.find(id).description).to eq('after')
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
+23 -49
ファイルの表示
@@ -2,6 +2,7 @@ require 'cgi'
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Wiki history integrity', type: :request do RSpec.describe 'Wiki history integrity', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
def auth_headers user def auth_headers user
@@ -9,7 +10,7 @@ RSpec.describe 'Wiki history integrity', type: :request do
end end
def create_wiki_page title:, body: 'body', message: 'init', user: self.user def create_wiki_page title:, body: 'body', message: 'init', user: self.user
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: create(:tag_name, name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
@@ -17,48 +18,21 @@ RSpec.describe 'Wiki history integrity', type: :request do
end end
describe 'POST /wiki' do describe 'POST /wiki' do
it 'creates wiki_page, wiki_revision, and wiki_version atomically' do it 'rejects frozen creation without partial page or history' do
expect { expect do
post '/wiki', post '/wiki', params: { title: 'Help:wiki_history_create_atomic', body: 'initial' },
params: {
title: 'wiki_history_create_atomic',
body: "a\nb\nc",
message: 'initial commit',
},
headers: auth_headers(user) headers: auth_headers(user)
} end.to change(WikiPage, :count).by(0)
.to change(WikiPage, :count).by(1) .and change(WikiRevision, :count).by(0)
.and change(WikiRevision, :count).by(1) .and change(WikiVersion, :count).by(0)
.and change(WikiVersion, :count).by(1) expect(response.status).to be_between(400, 499)
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('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: 'wiki_history_create_atomic',
body: "a\nb\nc",
reason: 'initial commit',
created_by_user_id: user.id
)
end end
it 'returns 422 and creates nothing when normalised body is blank' do it 'returns 422 and creates nothing when normalised body is blank' do
expect { expect {
post '/wiki', post '/wiki',
params: { params: {
title: 'wiki_history_blank_body', title: 'Help:wiki_history_blank_body',
body: "\r\n\r\n", body: "\r\n\r\n",
message: 'blank', message: 'blank',
}, },
@@ -67,25 +41,25 @@ RSpec.describe 'Wiki history integrity', type: :request do
.not_to change(WikiPage, :count) .not_to change(WikiPage, :count)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:unprocessable_entity)
expect(WikiPage.joins(:tag_name).where(tag_names: { name: 'wiki_history_blank_body' })).not_to exist expect(WikiPage.joins(:tag_name)
.where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist
end 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: 'wiki_history_duplicate_title', body: 'first') create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first')
expect { expect do
post '/wiki', post '/wiki',
params: { params: { title: 'Help:wiki_history_duplicate_title',
title: 'wiki_history_duplicate_title', body: 'second', message: 'duplicate' },
body: 'second',
message: 'duplicate',
},
headers: auth_headers(user) headers: auth_headers(user)
} end.to change(WikiPage, :count).by(0)
.not_to change(WikiPage, :count) .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: 'wiki_history_duplicate_title' }).count).to eq(1) expect(WikiPage.joins(:tag_name)
.where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1)
end end
end end
+27 -103
ファイルの表示
@@ -4,6 +4,7 @@ require 'securerandom'
RSpec.describe 'Wiki API', type: :request do RSpec.describe 'Wiki API', type: :request do
include WikiRecords
def auth_headers(user) def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code } { 'X-Transfer-Code' => user.inheritance_code }
end end
@@ -11,19 +12,19 @@ RSpec.describe 'Wiki API', type: :request do
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
let!(:tn) { create(:tag_name, name: 'spec_wiki_title') } let!(:tn) { create(:tag_name, name: 'spec_wiki_title') }
let!(:page) do
Wiki::Commit.create_content!(
tag_name: tn,
body: 'init',
created_by_user: user,
message: 'init')
end
let!(:tag) do let!(:tag) do
create(:tag, create(:tag,
primary_tag_name: tn, primary_tag_name: tn,
category: :general, category: :general,
deprecated_at: Time.zone.local(2026, 6, 1)) deprecated_at: Time.zone.local(2026, 6, 1))
end end
let!(:page) do
seed_wiki_page(
tag_name: tn,
body: 'init',
created_by_user: user,
message: 'init')
end
describe 'GET /wiki' do describe 'GET /wiki' do
it 'returns wiki pages with title' do it 'returns wiki pages with title' do
@@ -108,93 +109,14 @@ RSpec.describe 'Wiki API', type: :request do
end end
end end
context 'when success' do ['Help:TestPage', 'ヘルプ:ホーム'].each do |title|
it 'creates wiki_page and first content revision' do it "rejects frozen creation of #{ title } without history writes" do
expect do expect do
post endpoint, params: { title: 'TestPage', body: "a\nb\nc", message: 'init' }, post endpoint, params: { title:, body: 'initial' }, headers: auth_headers(member)
headers: auth_headers(member) end.to change(WikiPage, :count).by(0)
end .and change(WikiRevision, :count).by(0)
.to change(WikiPage, :count).by(1) .and change(WikiVersion, :count).by(0)
.and change(WikiRevision, :count).by(1) expect(response.status).to be_between(400, 499)
.and change(WikiVersion, :count).by(1)
expect(response).to have_http_status(:created)
page_id = json.fetch('id')
expect(json.fetch('title')).to eq('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: '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: '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: '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: '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'])
end end
end end
end end
@@ -210,7 +132,7 @@ RSpec.describe 'Wiki API', type: :request do
let!(:test_tag_name) { create(:tag_name, name: 'TestPage') } let!(:test_tag_name) { create(:tag_name, name: 'TestPage') }
let!(:page) do let!(:page) do
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: test_tag_name, tag_name: test_tag_name,
body: "a\nb", body: "a\nb",
created_by_user: member, created_by_user: member,
@@ -248,7 +170,8 @@ RSpec.describe 'Wiki API', type: :request do
expect do expect do
put "/wiki/#{page.id}", 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) headers: auth_headers(member)
end end
.to change(WikiRevision, :count).by(1) .to change(WikiRevision, :count).by(1)
@@ -283,7 +206,7 @@ RSpec.describe 'Wiki API', type: :request do
created_by_user: member) created_by_user: member)
linked_page = linked_page =
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: linked_tag_name, tag_name: linked_tag_name,
body: 'before', body: 'before',
created_by_user: member, created_by_user: member,
@@ -357,13 +280,14 @@ RSpec.describe 'Wiki API', type: :request do
describe 'GET /wiki/search' do describe 'GET /wiki/search' do
before do before do
Wiki::Commit.create_content!( search_tag = create(:tag, primary_name: 'spec_wiki_title_2')
tag_name: create(:tag_name, name: 'spec_wiki_title_2'), seed_wiki_page(
tag_name: search_tag.tag_name('ja'),
body: 'search body 2', body: 'search body 2',
created_by_user: user, created_by_user: user,
message: 'init') message: 'init')
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: create(:tag_name, name: 'unrelated_title'), tag_name: create(:tag_name, name: 'unrelated_title'),
body: 'unrelated body', body: 'unrelated body',
created_by_user: user, created_by_user: user,
@@ -413,10 +337,10 @@ RSpec.describe 'Wiki API', type: :request do
top = json.first top = json.first
expect(top).to include( expect(top).to include(
'revision_id' => rev2.id, 'revision_id' => rev2.id,
'pred' => rev2.base_revision_id, 'pred' => rev1.id,
'succ' => nil, 'succ' => nil,
'kind' => 'content', 'kind' => 'content',
'message' => 'r2' 'message' => nil
) )
expect(top['wiki_page']).to include( expect(top['wiki_page']).to include(
'id' => page.id, 'id' => page.id,
@@ -515,7 +439,7 @@ RSpec.describe 'Wiki API', type: :request do
it 'raises because redirect revisions are deprecated' do it 'raises because redirect revisions are deprecated' do
target_tag_name = create(:tag_name, name: 'redirect_deprecated_target') target_tag_name = create(:tag_name, name: 'redirect_deprecated_target')
target = target =
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: target_tag_name, tag_name: target_tag_name,
body: 'target', body: 'target',
created_by_user: user, created_by_user: user,
@@ -538,7 +462,7 @@ RSpec.describe 'Wiki API', type: :request do
linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general) linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general)
linked_page = linked_page =
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: linked_tag_name, tag_name: linked_tag_name,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
+11 -12
ファイルの表示
@@ -1,6 +1,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Wiki title collision', type: :request do RSpec.describe 'Wiki title collision', type: :request do
include WikiRecords
let!(:user) { create_member_user! } let!(:user) { create_member_user! }
def auth_headers user def auth_headers user
@@ -8,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do
end end
def create_wiki_page title:, body: def create_wiki_page title:, body:
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: create(:tag_name, name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, 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) expect(source.wiki_versions.count).to eq(source_version_count)
end 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: 'wiki_collision_create', body: 'already exists') create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists')
expect { expect do
post '/wiki', post '/wiki',
params: { params: { title: 'Help:wiki_collision_create', body: 'new body',
title: 'wiki_collision_create', message: 'duplicate create' },
body: 'new body',
message: 'duplicate create',
},
headers: auth_headers(user) headers: auth_headers(user)
} end.to change(WikiPage, :count).by(0)
.not_to change(WikiPage, :count) .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
end end
+78
ファイルの表示
@@ -0,0 +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
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)
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
create(:tag, primary_tag_name: tag_name)
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)
.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')
end
include TagGroupRecords
[:id, :title].each do |lookup|
[nil, :historical].each do |edition|
it "rejects legacy #{ lookup } #{ edition || 'current' } without legacy SELECTs" do
path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }"
params = edition ? { version: first_revision.id } : { }
without_group_legacy_reads { get path, params: }
expect(response).to have_http_status(:not_found)
end
end
it "does not recognise legacy #{ lookup } existence" do
path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }"
without_group_legacy_reads { get "#{ path }/exists" }
expect(response).to have_http_status(:not_found)
end
end
['/wiki', '/wiki/search', '/wiki/changes'].each do |path|
it "excludes unmigrated content from #{ path } without legacy SELECTs" do
without_group_legacy_reads { get path }
expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
end
it 'returns empty scoped history without legacy SELECTs' do
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
expect(response).to have_http_status(:ok)
expect(json).to eq([])
end
[false, true].each do |explicit|
it "rejects legacy diff with #{ explicit ? 'explicit' : 'current' } target" do
params = { from: first_revision.id }
params[:to] = last_revision.id if explicit
without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: }
expect(response).to have_http_status(:not_found)
end
end
end
+15 -22
ファイルの表示
@@ -2,10 +2,11 @@ require 'digest'
require 'rails_helper' require 'rails_helper'
RSpec.describe Wiki::Commit do RSpec.describe Wiki::Commit do
include WikiRecords
let(:user) { create_member_user! } let(:user) { create_member_user! }
def create_page title:, body: 'initial body' def create_page title:, body: 'initial body'
described_class.create_content!( seed_wiki_page(
tag_name: create(:tag_name, name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
@@ -13,26 +14,18 @@ RSpec.describe Wiki::Commit do
end end
describe '.create_content!' do describe '.create_content!' do
it 'creates page, revision, and version with normalised body' do [:create_content!, :create_legacy_content!].each do |operation|
expect { it "freezes #{ operation } without adding page or history" do
described_class.create_content!( name = create(:tag_name, name: 'Help:frozen_service')
tag_name: create(:tag_name, name: 'commit_integrity_create'), counts = [WikiPage.count, WikiRevision.count, WikiVersion.count]
body: "a\r\nb\r\n\r\n", begin
created_by_user: user, described_class.public_send(operation, tag_name: name, body: 'initial',
message: 'init') created_by_user: user)
} rescue ActiveRecord::RecordInvalid
.to change(WikiPage, :count).by(1) # A validation rejection is allowed; persistence is not.
.and change(WikiRevision, :count).by(1) end
.and change(WikiVersion, :count).by(1) expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts)
end
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')
end end
it 'rejects body that becomes blank after normalisation' do it 'rejects body that becomes blank after normalisation' do
@@ -80,7 +73,7 @@ RSpec.describe Wiki::Commit do
tag = create(:tag, primary_tag_name: tag_name, category: :general) tag = create(:tag, primary_tag_name: tag_name, category: :general)
page = page =
described_class.create_content!( seed_wiki_page(
tag_name:, tag_name:,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
+4 -3
ファイルの表示
@@ -1,12 +1,13 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Wiki::Commit do RSpec.describe Wiki::Commit do
include WikiRecords
let(:user) { create_member_user! } let(:user) { create_member_user! }
def create_page(title: 'commit_spec_page', body: 'initial body') def create_page(title: 'commit_spec_page', body: 'initial body')
tag_name = create(:tag_name, name: title) tag_name = create(:tag_name, name: title)
Wiki::Commit.create_content!( seed_wiki_page(
tag_name:, tag_name:,
body:, body:,
created_by_user: user, created_by_user: user,
@@ -84,7 +85,7 @@ RSpec.describe Wiki::Commit do
tag = create(:tag, primary_tag_name: tag_name, category: :general) tag = create(:tag, primary_tag_name: tag_name, category: :general)
page = page =
described_class.create_content!( seed_wiki_page(
tag_name:, tag_name:,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
@@ -111,7 +112,7 @@ RSpec.describe Wiki::Commit do
tag = create(:tag, primary_tag_name: tag_name, category: :general) tag = create(:tag, primary_tag_name: tag_name, category: :general)
page = page =
described_class.create_content!( seed_wiki_page(
tag_name:, tag_name:,
body: 'before', body: 'before',
created_by_user: user, created_by_user: user,
+2 -1
ファイルの表示
@@ -1,10 +1,11 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe WikiVersionRecorder do RSpec.describe WikiVersionRecorder do
include WikiRecords
let(:user) { create_member_user! } let(:user) { create_member_user! }
def create_page title:, body: 'body' def create_page title:, body: 'body'
Wiki::Commit.create_content!( seed_wiki_page(
tag_name: create(:tag_name, name: title), tag_name: create(:tag_name, name: title),
body:, body:,
created_by_user: user, created_by_user: user,
+220
ファイルの表示
@@ -0,0 +1,220 @@
require 'cgi'
# SQL fixtures use the migration contract without inventing application models.
# Include explicitly; these helpers must not affect unrelated examples.
module TagGroupRecords
GROUP_TABLES = [
'tag_groups', 'tag_group_tags', 'tag_group_groups',
'tag_group_placements', 'tag_group_versions'].freeze
WRITE_TABLES = GROUP_TABLES + [
'wiki_pages', 'wiki_revisions', 'wiki_versions', 'wiki_lines',
'wiki_revision_lines', 'tag_names', 'tag_description_versions',
'tag_descriptions']
def group_connection = ApplicationRecord.connection
def group_rows table, conditions = { }
clauses = conditions.map do |column, value|
"#{ group_connection.quote_column_name(column) } = #{ group_connection.quote(value) }"
end
sql = "SELECT * FROM #{ group_connection.quote_table_name(table) }"
sql += " WHERE #{ clauses.join(' AND ') }" if clauses.any?
group_connection.select_all(sql).to_a.sort_by { |row| row.values.map(&:to_s) }
end
def insert_group_row table, attributes
columns = attributes.keys.map { group_connection.quote_column_name(_1) }.join(', ')
values = attributes.values.map { group_connection.quote(_1) }.join(', ')
group_connection.insert(
"INSERT INTO #{ group_connection.quote_table_name(table) } (#{ columns }) " +
"VALUES (#{ values })")
end
def group_state
WRITE_TABLES.to_h { |table| [table, group_rows(table)] }
end
def expect_noop_group_update group_id:, page:
before_state = group_state
group = TagGroup.find(group_id)
version_id = TagGroupVersion.find_by!(
tag_group_id: group_id, version_no: group.version_no).id
revision_id = page.reload.current_revision.id
wiki_version_id = page.wiki_versions.order(version_no: :desc).first.id
writes = []
tables = Regexp.union(WRITE_TABLES)
subscriber = lambda do |*args|
sql = args.last.fetch(:sql)
if sql.match?(/\A\s*(?:INSERT INTO|UPDATE|DELETE FROM)\s+`?(?:#{ tables })`?\s/i)
writes << sql
end
end
aggregate_failures 'no-op current state, history and SQL writes' do
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
expect(response).to have_http_status(:ok)
after_state = group_state
before_state.each do |table, rows|
expect(after_state.fetch(table)).to eq(rows), "No-op changed #{ table }"
end
expect(TagGroupVersion.find_by!(
tag_group_id: group_id, version_no: group.reload.version_no).id).to eq(version_id)
expect(page.reload.current_revision.id).to eq(revision_id)
expect(page.wiki_versions.order(version_no: :desc).first.id).to eq(wiki_version_id)
expect(writes).to be_empty
end
end
def expect_atomic_group_write table
before_state = group_state
failure = Class.new(StandardError)
injected = false
subscriber = lambda do |*args|
sql = args.last.fetch(:sql)
next unless sql.match?(/\A\s*(?:INSERT INTO|UPDATE)\s+`?#{ table }`?\s/i)
injected = true
raise failure, 'injected after SQL write'
end
begin
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
rescue failure
# Inspect the database after the operation's transaction unwinds.
end
aggregate_failures do
expect(injected).to be(true), "No INSERT/UPDATE reached #{ table }"
expect(group_state).to eq(before_state)
end
end
def group_version group_id, number
row = group_rows('tag_group_versions', tag_group_id: group_id, version_no: number).sole
row.merge('members' => JSON.parse(row.fetch('members')),
'placements' => JSON.parse(row.fetch('placements')))
end
def group_tag name
tag = create(:tag, primary_name: name)
TagVersioning.ensure_snapshot!(tag, created_by_user: user)
tag.reload
end
def legacy_group name, body
# Historical fixtures preserve blank lines independently of today's writer.
body = body.sub(/\n+\z/, '')
page = WikiPage.create!(
tag_name: create(:tag_name, name: "タググループ:#{ name }"),
body:, created_user: user, updated_user: user)
WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: user)
lines = body.split("\n")
shas = lines.map { Digest::SHA256.hexdigest(_1) }
revision = WikiRevision.create!(
wiki_page: page, kind: :content, created_user: user, lines_count: lines.size,
tree_sha256: Digest::SHA256.hexdigest(shas.join(',')))
lines.each_with_index do |line, position|
sha = shas.fetch(position)
line_id = WikiLine.find_by(sha256: sha)&.id || insert_group_row('wiki_lines', {
body: line, sha256: sha, created_at: Time.current, updated_at: Time.current })
WikiRevisionLine.create!(wiki_revision: revision, wiki_line_id: line_id, position:)
end
page
end
def seed_group name:, placements: [], members: [], description: nil, id: nil
now = Time.current.change(usec: 0)
attributes = {
name:, description:, version_no: 1,
created_by_user_id: user.id, updated_by_user_id: user.id,
created_at: now, updated_at: now }
attributes[:id] = id if id
group_id = insert_group_row('tag_groups', attributes)
insert_group_row('tag_group_versions', {
tag_group_id: group_id, version_no: 1, event_type: 'create',
name:, description:, members: members.to_json, placements: placements.to_json,
created_by_user_id: user.id, created_at: now })
members.each do |member|
tag_member = member.key?('tag_id')
table = tag_member ? 'tag_group_tags' : 'tag_group_groups'
key = tag_member ? :tag_id : :child_tag_group_id
value = member.fetch(tag_member ? 'tag_id' : 'tag_group_id')
insert_group_row(table, {
tag_group_id: group_id, key => value,
created_by_user_id: user.id, created_at: now })
end
position = 0
append = lambda do |nodes, parent|
nodes.each do |node|
position += 1
own_position = position
insert_group_row('tag_group_placements', {
tag_group_id: group_id, position:, parent_position: parent,
section_name: node['section_name'], description: node['description'],
tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'],
created_by_user_id: user.id, updated_by_user_id: user.id,
created_at: now, updated_at: now })
append.call(node.fetch('children'), own_position)
end
end
append.call(placements, nil)
group_id
end
def without_group_legacy_reads
queries = []
subscriber = lambda do |*args|
sql = args.last.fetch(:sql)
if (sql.match?(/\bSELECT\b/i) &&
sql.match?(/\bwiki_(pages|revisions|versions|lines|revision_lines)\b/i))
queries << sql
end
end
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
expect(queries).to be_empty
end
def group_title_path name
"/wiki/title/#{ CGI.escapeURIComponent("タググループ:#{ name }") }"
end
def migrate_groups
require_relative '../../db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages'
migration = MigrateTagGroupsFromWikiPages.new
@group_migration_messages = []
allow(migration).to receive(:say) do |message, *_args|
@group_migration_messages << message if message.start_with?('Wiki #', 'Tag Group')
end
migration.suppress_messages { migration.up }
end
def expect_group_snapshot group_id, number:, placements:, members:, description: nil
current = group_rows('tag_groups', id: group_id).sole
version = group_version(group_id, number)
expect(current).to include('version_no' => number, 'description' => description),
@group_migration_messages&.join("\n")
expect(version).to include(
'name' => current.fetch('name'), 'description' => description,
'version_no' => number, 'placements' => placements)
expect(version.fetch('members')).to match_array(members)
expect(group_rows('tag_group_tags', tag_group_id: group_id).map { _1.fetch('tag_id') })
.to match_array(members.filter_map { _1['tag_id'] })
children = group_rows('tag_group_groups', tag_group_id: group_id)
expect(children.map { _1.fetch('child_tag_group_id') })
.to match_array(members.filter_map { _1['tag_group_id'] })
rows = group_rows('tag_group_placements', tag_group_id: group_id)
tree = lambda do |parent|
rows.select { _1['parent_position'] == parent }.sort_by { _1.fetch('position') }
.map do |row|
node = if row['tag_id']
{ 'tag_id' => row.fetch('tag_id') }
elsif row['child_tag_group_id']
{ 'tag_group_id' => row.fetch('child_tag_group_id') }
else
{ 'section_name' => row.fetch('section_name'), 'description' => row['description'] }
end
node.merge('children' => tree.call(row.fetch('position')))
end
end
expect(tree.call(nil)).to eq(placements)
end
end
+20
ファイルの表示
@@ -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
+4 -4
ファイルの表示
@@ -18,8 +18,8 @@ RSpec.describe 'nico:export' do
before do before do
task.reenable task.reenable
allow(ENV).to receive(:fetch).with('MYSQL_USER').and_return('mysql-user') allow(ENV).to receive(:fetch).with('NIZIKA_NICO_MYSQL_USER').and_return('mysql-user')
allow(ENV).to receive(:fetch).with('MYSQL_PASS').and_return('mysql-pass') allow(ENV).to receive(:fetch).with('NIZIKA_NICO_MYSQL_PASS').and_return('mysql-pass')
allow(ENV).to receive(:fetch).with('NIZIKA_NICO_PATH').and_return('/srv/nizika-nico') allow(ENV).to receive(:fetch).with('NIZIKA_NICO_PATH').and_return('/srv/nizika-nico')
end end
@@ -33,8 +33,8 @@ RSpec.describe 'nico:export' do
expect(Open3).to receive(:capture3) do |env, *args, **kwargs| expect(Open3).to receive(:capture3) do |env, *args, **kwargs|
expect(env).to eq( expect(env).to eq(
{ {
'MYSQL_USER' => 'mysql-user', 'NIZIKA_NICO_MYSQL_USER' => 'mysql-user',
'MYSQL_PASS' => 'mysql-pass', 'NIZIKA_NICO_MYSQL_PASS' => 'mysql-pass',
}, },
) )
+2
ファイルの表示
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/help
生成ファイル
+4 -43
ファイルの表示
@@ -38,6 +38,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"unist-util-visit-parents": "^6.0.1", "unist-util-visit-parents": "^6.0.1",
"yaml": "^2.9.1",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
@@ -2060,9 +2061,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2077,9 +2075,6 @@
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2094,9 +2089,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2111,9 +2103,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2128,9 +2117,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2145,9 +2131,6 @@
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2162,9 +2145,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2179,9 +2159,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2196,9 +2173,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2213,9 +2187,6 @@
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2230,9 +2201,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2247,9 +2215,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2264,9 +2229,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -9114,10 +9076,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.3", "version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
+1
ファイルの表示
@@ -42,6 +42,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"unist-util-visit-parents": "^6.0.1", "unist-util-visit-parents": "^6.0.1",
"yaml": "^2.9.1",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
+52 -80
ファイルの表示
@@ -15,7 +15,6 @@ import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import { applyClientAnimationMode, import { applyClientAnimationMode,
applyClientAppearance, applyClientAppearance,
type ClientAnimationMode,
fetchUserThemeSlots, fetchUserThemeSlots,
fetchUserSettings, fetchUserSettings,
getClientThemeMode, getClientThemeMode,
@@ -25,7 +24,6 @@ import { applyClientAnimationMode,
seedClientThemeMode } from '@/lib/settings' seedClientThemeMode } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard' import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialBasePage from '@/pages/materials/MaterialBasePage'
@@ -34,12 +32,13 @@ import MaterialHistoryPage from '@/pages/materials/MaterialHistoryPage'
import MaterialListPage from '@/pages/materials/MaterialListPage' import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage' import MaterialNewPage from '@/pages/materials/MaterialNewPage'
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage' import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import MorePage from '@/pages/MorePage' import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage' import GekanatorPage from '@/pages/GekanatorPage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx' import TOSPage from '@/pages/TOSPage.mdx'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import HelpListPage from '@/pages/help/HelpListPage'
import PostDetailPage from '@/pages/posts/PostDetailPage' import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostNewPage from '@/pages/posts/PostNewPage' import PostNewPage from '@/pages/posts/PostNewPage'
@@ -60,90 +59,60 @@ import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
import type { Dispatch, FC, SetStateAction } from 'react' import type { Dispatch, FC, SetStateAction } from 'react'
import type { ClientAnimationMode } from '@/lib/settings'
import type { User } from '@/types' import type { User } from '@/types'
const RouteTransitionWrapper = ({ animationMode, user, setUser }: { const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
animationMode: ClientAnimationMode animationMode: ClientAnimationMode
user: User | null user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => { setUser: Dispatch<SetStateAction<User | null>> }) => {
const location = useLocation () const location = useLocation ()
const routes = (
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/help" element={<HelpListPage/>}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>)
if (animationMode === 'off') if (animationMode === 'off')
{ return routes
return (
<Routes location={location}>
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>)
}
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<Routes location={location}> {routes}
<Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags" element={<TagListPage/>}/>
<Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/>
<Route path="changes" element={<MaterialHistoryPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/>
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/>
</Route>
<Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/tos" element={<TOSPage/>}/>
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
</AnimatePresence>) </AnimatePresence>)
} }
@@ -166,9 +135,9 @@ const RoutedApp: FC = () => {
() => ( () => (
animationMode === 'off' animationMode === 'off'
? { duration: 0 } ? { duration: 0 }
: animationMode === 'reduced' : (animationMode === 'reduced'
? { duration: .08, ease: 'linear' as const } ? { duration: .08, ease: 'linear' as const }
: { duration: .2, ease: 'easeOut' as const } : { duration: .2, ease: 'easeOut' as const })
), ),
[animationMode], [animationMode],
) )
@@ -285,10 +254,12 @@ const RoutedApp: FC = () => {
</DialogueProvider>) </DialogueProvider>)
} }
const router = createBrowserRouter ([{ const router = createBrowserRouter ([{
path: '*', path: '*',
element: <RoutedApp/> }]) element: <RoutedApp/> }])
const App: FC = () => ( const App: FC = () => (
<> <>
<RouteBlockerOverlay/> <RouteBlockerOverlay/>
@@ -296,4 +267,5 @@ const App: FC = () => (
<RouterProvider router={router}/> <RouterProvider router={router}/>
</>) </>)
export default App export default App
+3
ファイルの表示
@@ -82,6 +82,9 @@ export const menuOutline = (
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
visible: wikiPageFlg && editable }] }, visible: wikiPageFlg && editable }] },
{ name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [
{ name: '検索', to: '/help' },
{ name: '全体履歴', to: '/help/changes' }] },
{ name: 'おたのしみ', visible: false, subMenu: [ { name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' }, { name: '上映会 (β)', to: '/theatres/1' },
{ name: 'グカネータ (β)', to: '/gekanator' }] }, { name: 'グカネータ (β)', to: '/gekanator' }] },
+56
ファイルの表示
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { createHelpFixture } from '@/test/helpFixtures'
describe ('Help file catalogue', () => {
it ('uses current_edition rather than the newest file, without YAML titles', async () => {
const { api } = createHelpFixture ()
expect (api.getHelpArticle ('guide')?.languages.ja.currentEdition).toBe ('2026-10-02')
expect (await api.getHelpSearchEntries ('ja')).toEqual ([
{ identifier: 'guide', edition: '2026-10-02',
title: 'ホーム', body: '\nタグを検索する' },
{ identifier: '設定 手順', edition: '2026-10-03',
title: '設定', body: '\nタグを設定する' }])
})
it ('orders same-day editions numerically and separates languages', () => {
const { api } = createHelpFixture ()
expect (api.getHelpEditions ('guide', 'ja')).toEqual ([
'2026-10-01', '2026-10-02', '2026-10-02.2', '2026-10-02.3', '2026-10-02.10'])
expect (api.getHelpEditions ('guide', 'en')).toEqual (['2026-10-02'])
})
it ('derives each edition title from its first H1', async () => {
const { api, raw } = createHelpFixture ()
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-01'))
.toEqual ({ title: '旧ホーム', body: '\n過去限定本文' })
raw['../../help/guide/editions/ja/2026-10-02.mdx']
.mockResolvedValue ('\uFEFF# 改題\r\n\r\n本文\r\n# 別見出し')
expect (await api.getHelpEditionText ('guide', 'ja', '2026-10-02'))
.toEqual ({ title: '改題', body: '\r\n本文\r\n# 別見出し' })
})
it ('returns missing resources without substituting another edition', async () => {
const { api } = createHelpFixture ()
expect (api.getHelpArticle ('absent')).toBeNull ()
expect (api.getHelpEditions ('absent', 'ja')).toEqual ([])
expect (await api.getHelpEdition ('guide', 'ja', 'missing')).toBeNull ()
expect (await api.getHelpEditionText ('guide', 'fr', '2026-10-02')).toBeNull ()
})
it ('rejects a missing current file instead of silently hiding an article', async () => {
const { api, raw } = createHelpFixture ()
delete raw['../../help/guide/editions/ja/2026-10-02.mdx']
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ()
})
it ('reports raw loading failures and missing leading H1', async () => {
const { api, raw } = createHelpFixture ()
const loader = raw['../../help/guide/editions/ja/2026-10-02.mdx']
loader.mockRejectedValueOnce (new Error ('fixture failure'))
await expect (api.getHelpSearchEntries ('ja')).rejects.toThrow ('fixture failure')
loader.mockResolvedValueOnce ('本文\n# 遅い見出し')
await expect (api.getHelpEditionText ('guide', 'ja', '2026-10-02')).rejects.toThrow ()
})
})
+132
ファイルの表示
@@ -0,0 +1,132 @@
import { parse as parseYAML } from 'yaml'
import type { ComponentType } from 'react'
type ArticleLanguage = { currentEdition: string }
type ArticleMeta = {
identifier: string
languages: Record<string, ArticleLanguage> }
type MDXModule = { default: ComponentType }
export type HelpEditionText = {
title: string
body: string }
export type HelpSearchEntry = HelpEditionText & {
identifier: string
edition: string }
const articleFiles = import.meta.glob<string> ('../../help/*/article.yml',
{ query: '?raw',
import: 'default',
eager: true })
const editionFiles = import.meta.glob<MDXModule> ('../../help/*/editions/*/*.mdx')
const rawEditionFiles = import.meta.glob<string> ('../../help/*/editions/*/*.mdx',
{ query: '?raw',
import: 'default' })
const cameliseArticle = (
value: { identifier: string
languages: Record<string, { ['current_edition']: string }> },
): ArticleMeta => (
{ identifier: value.identifier,
languages: Object.fromEntries (
Object.entries (value.languages).map (([language, data]) => [
language, { currentEdition: data['current_edition'] }])) })
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)
}
export const getHelpArticle = (identifier: string): ArticleMeta | null => {
const path = `../../help/${ identifier }/article.yml`
const source = articleFiles[path]
if (!(source))
return null
return cameliseArticle (parseYAML (source))
}
export const getHelpEditions = (
identifier: string,
language: string,
): string[] => {
const prefix = `../../help/${ identifier }/editions/${ language }/`
return (Object.keys (editionFiles)
.filter (path => path.startsWith (prefix))
.map (path => path.slice (prefix.length, -'.mdx'.length))
.sort (compareEditions))
}
export const getHelpEdition = async (
identifier: string,
language: string,
edition: string,
): Promise<ComponentType | null> => {
const path = `../../help/${ identifier }/editions/${ language }/${ edition }.mdx`
const loader = editionFiles[path]
if (!(loader))
return null
return (await loader ()).default
}
export const getHelpEditionText = async (
identifier: string,
language: string,
edition: string,
): Promise<HelpEditionText | null> => {
const path = `../../help/${ identifier }/editions/${ language }/${ edition }.mdx`
const loader = rawEditionFiles[path]
if (!(loader))
return null
const mdx = (await loader ()).replace (/^\uFEFF/, '')
const heading = mdx.match (/^# +([^\r\n]+)(?:\r?\n|$)/)
if (!(heading))
throw new Error (`Help MDX の先頭に H1 がありません: ${ path }`)
return { title: heading[1].trim (),
body: mdx.slice (heading[0].length) }
}
export const getHelpSearchEntries = async (
language: string,
): Promise<HelpSearchEntry[]> => {
const entries = await Promise.all (
Object.values (articleFiles).map (async source => {
const article = cameliseArticle (parseYAML (source))
const edition = article.languages[language]?.currentEdition
if (!(edition))
return null
const content = await getHelpEditionText (article.identifier, language, edition)
if (!(content))
throw new Error (`Help edition が見つかりません: ${ article.identifier }/${ edition }`)
return { identifier: article.identifier, edition, ...content }
}))
return entries.filter ((entry): entry is HelpSearchEntry => entry != null)
}
+94
ファイルの表示
@@ -0,0 +1,94 @@
import { QueryClient } from '@tanstack/react-query'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PrefetchLink from '@/components/PrefetchLink'
import * as prefetchers from '@/lib/prefetchers'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import { helpFixture } from '@/test/helpFixtures'
import { renderWithProviders } from '@/test/render'
const settings = vi.hoisted (() => ({ linkPreload: 'intent' }))
vi.mock ('@/lib/useClientBehaviourSettings', () => ({
useClientBehaviourSettings: () => settings }))
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
const Location = () => {
const location = useLocation ()
return <output data-testid="location">{location.pathname}</output>
}
describe ('Help prefetch integration', () => {
beforeEach (() => {
settings.linkPreload = 'intent'
vi.clearAllMocks ()
})
it.each (['/help', '/help/guide', '/help/changes',
'/help/%E8%A8%AD%E5%AE%9A%20%E6%89%8B%E9%A0%86?edition=2026-10-03']) (
'registers a unique prefetcher for %s', path => {
const url = new URL (path, window.location.origin)
expect (prefetchers.routePrefetchers.filter (entry => entry.test (url))).toHaveLength (1)
})
it ('loads and reuses the same MDX module for repeated prefetch and display', async () => {
const client = new QueryClient ()
const loader = helpFixture.modules['../../help/guide/editions/ja/2026-10-02.mdx']
await Promise.all ([
prefetchers.prefetchForURL (client, '/help/guide'),
prefetchers.prefetchForURL (client, '/help/guide')])
expect (loader).toHaveBeenCalledTimes (1)
renderWithProviders (
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
{ route: '/help/guide' })
await screen.findByRole ('heading', { name: 'ホーム' })
expect (loader).toHaveBeenCalledTimes (1)
})
it ('prefetches the requested identifier and edition independently', async () => {
const client = new QueryClient ()
await prefetchers.prefetchForURL (client, '/help/guide?edition=2026-10-02.3')
await prefetchers.prefetchForURL (client, `/help/${encodeURIComponent ('設定 手順')}`)
expect (helpFixture.modules['../../help/guide/editions/ja/2026-10-02.3.mdx'])
.toHaveBeenCalledTimes (1)
expect (helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx'])
.toHaveBeenCalledTimes (1)
expect (helpFixture.modules['../../help/guide/editions/en/2026-10-02.mdx'])
.not.toHaveBeenCalled ()
})
it ('loads history editions without treating changes as an article', async () => {
const article = vi.spyOn (helpFixture.api, 'getHelpArticle')
await prefetchers.prefetchForURL (new QueryClient (), '/help/changes')
expect (article).not.toHaveBeenCalledWith ('changes')
expect (helpFixture.raw['../../help/guide/editions/ja/2026-10-01.mdx'])
.toHaveBeenCalled ()
})
it.each (['intent', 'click', 'off']) ('respects %s intent settings and still prefetches clicks',
async mode => {
settings.linkPreload = mode
const run = vi.spyOn (prefetchers, 'prefetchForURL').mockResolvedValue ()
renderWithProviders (<><Location/><PrefetchLink to="/help/guide">Help</PrefetchLink></>)
const link = screen.getByRole ('link', { name: 'Help' })
fireEvent.mouseEnter (link)
fireEvent.touchStart (link)
expect (run).toHaveBeenCalledTimes (mode === 'intent' ? 2 : 0)
fireEvent.click (link)
await waitFor (() => expect (screen.getByTestId ('location'))
.toHaveTextContent ('/help/guide'))
expect (run).toHaveBeenCalledTimes (mode === 'intent' ? 3 : 1)
})
it ('continues navigation after prefetch failure', async () => {
vi.spyOn (console, 'error').mockImplementation (() => {})
vi.spyOn (prefetchers, 'prefetchForURL').mockRejectedValue (new Error ('offline'))
renderWithProviders (<><Location/><PrefetchLink to="/help/guide">Help</PrefetchLink></>)
fireEvent.click (screen.getByRole ('link', { name: 'Help' }))
await waitFor (() => expect (screen.getByTestId ('location'))
.toHaveTextContent ('/help/guide'))
})
})
+24 -17
ファイルの表示
@@ -2,9 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki' import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki'
const api = vi.hoisted (() => ({ const api = vi.hoisted (() => ({ apiGet: vi.fn () }))
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api) vi.mock ('@/lib/api', () => api)
@@ -21,28 +19,37 @@ describe ('wiki API functions', () => {
await fetchWikiPage ('1', { version: '3' }) await fetchWikiPage ('1', { version: '3' })
expect (api.apiGet).toHaveBeenNthCalledWith ( expect (api.apiGet).toHaveBeenNthCalledWith (
1, 1,
'/wiki', '/wiki',
{ params: { title: '虹' } }, { params: { title: '虹' } })
)
expect (api.apiGet).toHaveBeenNthCalledWith ( expect (api.apiGet).toHaveBeenNthCalledWith (
2, 2,
'/wiki/1', '/wiki/1',
{ params: { version: '3' } }, { params: { version: '3' } })
)
}) })
it ('encodes title path segments and returns null on misses', async () => { it ('encodes title path segments and returns null on misses', async () => {
api.apiGet.mockResolvedValueOnce ({ id: 2 }) api.apiGet.mockResolvedValueOnce ({ id: 2 })
api.apiGet.mockRejectedValueOnce (new Error ('missing')) api.apiGet.mockRejectedValueOnce ({ isAxiosError: true, response: { status: 404 } })
await fetchWikiPageByTitle ('a/b c', { version: undefined }) await fetchWikiPageByTitle ('a/b c', { version: undefined })
await expect (fetchWikiPageByTitle ('missing', {})).resolves.toBeNull () await expect (fetchWikiPageByTitle ('missing', { })).resolves.toBeNull ()
expect (api.apiGet).toHaveBeenNthCalledWith ( expect (api.apiGet).toHaveBeenNthCalledWith (
1, 1,
'/wiki/title/a%2Fb%20c', '/wiki/title/a%2Fb%20c',
{ params: { version: undefined } }, { params: { version: undefined } })
) })
it.each ([403, 500]) ('preserves HTTP %s errors', async status => {
const error = { isAxiosError: true, response: { status } }
api.apiGet.mockRejectedValueOnce (error)
await expect (fetchWikiPageByTitle ('ホーム', { })).rejects.toBe (error)
})
it ('preserves network failures for callers', async () => {
const error = new Error ('network failure')
api.apiGet.mockRejectedValueOnce (error)
await expect (fetchWikiPageByTitle ('ホーム', { })).rejects.toBe (error)
}) })
}) })
+77
ファイルの表示
@@ -0,0 +1,77 @@
import { fireEvent, screen, within } from '@testing-library/react'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { Route, Routes } from 'react-router-dom'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import { compileMdx, transformMdx } from '@/test/compileMdx'
import { helpFixture } from '@/test/helpFixtures'
import { renderWithProviders } from '@/test/render'
const prefetch = vi.hoisted (() => vi.fn (async () => {}))
vi.hoisted (() => {
vi.stubGlobal ('Uint8Array', Object.getPrototypeOf (new TextEncoder ().encode ('')).constructor)
})
afterAll (() => vi.unstubAllGlobals ())
vi.mock ('@/lib/prefetchers', () => ({ prefetchForURL: prefetch }))
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
const tableSource = [
'# 表のある記事', '', '| 項目 | 説明 |', '| --- | --- |',
'| [ヘルプ](/help/guide) | **強調** |', '| 通常 | 本文 |'].join ('\n')
describe ('Help MDX with the application compiler', () => {
it ('compiles GFM table structure, links and emphasis', async () => {
const Content = await compileMdx (tableSource)
renderWithProviders (<Content/>)
const table = screen.getByRole ('table')
expect (table.querySelector ('thead')).not.toBeNull ()
expect (table.querySelector ('tbody')).not.toBeNull ()
expect (within (table).getAllByRole ('columnheader')).toHaveLength (2)
expect (within (table).getAllByRole ('cell')).toHaveLength (4)
expect (within (table).getByRole ('link', { name: 'ヘルプ' }))
.toHaveAttribute ('href', '/help/guide')
expect (table.querySelector ('strong')).toHaveTextContent ('強調')
})
it ('retains headings, paragraphs and lists', async () => {
const Content = await compileMdx ('# 見出し\n\n本文\n\n- 第一\n- 第二')
renderWithProviders (<Content/>)
expect (screen.getByRole ('heading', { level: 1 })).toHaveTextContent ('見出し')
expect (screen.getByText ('本文').tagName).toBe ('P')
expect (screen.getAllByRole ('listitem')).toHaveLength (2)
})
it ('keeps tables inside the Help dark-mode typography container', async () => {
const Content = await compileMdx (tableSource)
vi.spyOn (helpFixture.api, 'getHelpEdition').mockResolvedValue (Content)
renderWithProviders (
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
{ route: '/help/guide' })
const table = await screen.findByRole ('table')
expect (table.closest ('article')).toHaveClass ('prose', 'dark:prose-invert')
})
it ('contains wide tables in a horizontal scroll area', async () => {
const Content = await compileMdx (tableSource.replace ('通常', 'W'.repeat (200)))
vi.spyOn (helpFixture.api, 'getHelpEdition').mockResolvedValue (Content)
renderWithProviders (
<Routes><Route path="/help/:identifier" element={<HelpDetailPage/>}/></Routes>,
{ route: '/help/guide' })
const table = await screen.findByRole ('table')
expect (table.closest ('.overflow-x-auto, .overflow-x-scroll')).not.toBeNull ()
})
it.each (['.mdx?raw', '.mdx?url']) ('preserves the %s transform bypass', async suffix => {
expect (await transformMdx (tableSource, suffix)).toBeNull ()
})
it ('uses the common PrefetchLink interaction for internal MDX links', async () => {
prefetch.mockClear ()
const Content = await compileMdx ('[内部リンク](/help/guide)')
renderWithProviders (<Content/>)
fireEvent.mouseEnter (screen.getByRole ('link', { name: '内部リンク' }))
expect (prefetch).toHaveBeenCalledWith (expect.anything (),
new URL ('/help/guide', window.location.origin).href)
})
})
+41
ファイルの表示
@@ -0,0 +1,41 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { describe, expect, it, vi } from 'vitest'
import { apiGet } from '@/lib/api'
import App from '@/App'
vi.hoisted (() => {
window.history.replaceState (null, '', '/help/changes')
})
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn (), apiPost: async () => ({}),
isApiError: () => false }))
vi.mock ('@/components/TopNav', () => ({ default: () => null }))
vi.mock ('@/components/DevModeWatermark', () => ({ default: () => null }))
vi.mock ('@/lib/useClientBehaviourSettings', () => ({
useClientBehaviourSettings: () => ({ animation: 'off', linkPreload: 'off' }) }))
describe ('Help global edition history', () => {
it ('routes /help/changes to published editions in date and numeric order', async () => {
const client = new QueryClient ({ defaultOptions: { queries: { retry: false } } })
const { container } = render (
<QueryClientProvider client={client}>
<HelmetProvider><App/></HelmetProvider>
</QueryClientProvider>)
const links = await screen.findAllByRole ('link')
const editions = links.map (link => link.getAttribute ('href'))
.filter (href => href?.startsWith ('/help/') && href.includes ('edition='))
expect (editions).toEqual ([
`/help/${encodeURIComponent ('設定 手順')}?edition=2026-10-03`,
'/help/guide?edition=2026-10-02.10', '/help/guide?edition=2026-10-02.3',
'/help/guide?edition=2026-10-02.2', '/help/guide?edition=2026-10-02',
'/help/guide?edition=2026-10-01'])
expect (container).toHaveTextContent ('旧ホーム')
expect (apiGet).not.toHaveBeenCalled ()
expect (window.location.pathname).toBe ('/help/changes')
})
})
+108
ファイルの表示
@@ -0,0 +1,108 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import { helpFixture } from '@/test/helpFixtures'
import { renderWithProviders } from '@/test/render'
import type { ReactElement } from 'react'
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
const Navigation = () => {
const navigate = useNavigate ()
const location = useLocation ()
return (
<>
<output data-testid="location">{location.pathname}{location.search}</output>
<button onClick={() => navigate ('/help/設定%20手順')}>switch article</button>
<button onClick={() => navigate ('/help/guide?edition=2026-10-02.3')}>
switch edition
</button>
</>)
}
const renderDetail = (route = '/help/guide') => renderWithProviders (
<>
<Navigation/>
<Routes>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
</Routes>
</>, { route })
describe ('Help details', () => {
it ('displays current_edition H1 and body even when newer files exist', async () => {
renderDetail ()
expect (await screen.findByRole ('heading', { name: 'ホーム' })).toBeInTheDocument ()
expect (screen.getByText ('タグを検索する')).toBeInTheDocument ()
expect (screen.queryByText ('第十版本文')).not.toBeInTheDocument ()
await waitFor (() => expect (document.title).toContain ('ホーム ヘルプ'))
})
it ('uses the historical H1 in both page and document titles', async () => {
renderDetail ('/help/guide?edition=2026-10-01')
expect (await screen.findByRole ('heading', { name: '旧ホーム' })).toBeInTheDocument ()
expect (screen.getByText ('過去限定本文')).toBeInTheDocument ()
await waitFor (() => expect (document.title).toContain ('旧ホーム ヘルプ'))
expect (screen.getByText ('(最古)')).toBeInTheDocument ()
expect (screen.getByRole ('link', { name: '新 >' }))
.toHaveAttribute ('href', '/help/guide?edition=2026-10-02')
})
it ('navigates .2, .3 and .10 in numeric edition order', async () => {
renderDetail ('/help/guide?edition=2026-10-02.3')
await screen.findByRole ('heading', { name: 'ホーム第三版' })
expect (screen.getByRole ('link', { name: '< 古' }))
.toHaveAttribute ('href', '/help/guide?edition=2026-10-02.2')
const next = screen.getByRole ('link', { name: '新 >' })
expect (next).toHaveAttribute ('href', '/help/guide?edition=2026-10-02.10')
fireEvent.click (next)
await screen.findByRole ('heading', { name: 'ホーム第十版' })
expect (screen.getByText ('(最新)')).toBeInTheDocument ()
expect (screen.queryByText ('第三版本文')).not.toBeInTheDocument ()
})
it.each (['/help/missing', '/help/guide?edition=missing']) (
'shows NotFound without redirecting %s', async route => {
renderDetail (route)
expect (await screen.findByText ('404')).toBeInTheDocument ()
expect (screen.getByTestId ('location')).toHaveTextContent (route)
})
it ('hides the old body and title immediately while another article loads', async () => {
let finish!: (value: { default: () => ReactElement }) => void
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
finish = resolve
})
helpFixture.modules['../../help/設定 手順/editions/ja/2026-10-03.mdx']
.mockReturnValueOnce (pending)
renderDetail ()
await screen.findByRole ('heading', { name: 'ホーム' })
fireEvent.click (screen.getByRole ('button', { name: 'switch article' }))
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
await waitFor (() => expect (document.title).not.toContain ('ホーム'))
await act (async () => finish ({ default: () => <h1>設定</h1> }))
await screen.findByRole ('heading', { name: '設定' })
await waitFor (() => expect (document.title).toContain ('設定 ヘルプ'))
})
it ('ignores a late response for the previous edition', async () => {
let finish!: (value: { default: () => ReactElement }) => void
const pending = new Promise<{ default: () => ReactElement }> (resolve => {
finish = resolve
})
helpFixture.modules['../../help/guide/editions/ja/2026-10-02.mdx']
.mockReturnValueOnce (pending)
renderDetail ()
fireEvent.click (screen.getByRole ('button', { name: 'switch edition' }))
await screen.findByRole ('heading', { name: 'ホーム第三版' })
await act (async () => finish ({ default: () => <h1>遅延した旧本文</h1> }))
expect (screen.queryByText ('遅延した旧本文')).not.toBeInTheDocument ()
expect (screen.getByRole ('heading', { name: 'ホーム第三版' })).toBeInTheDocument ()
await waitFor (() => expect (document.title).toContain ('ホーム第三版 ヘルプ'))
})
})
+126
ファイルの表示
@@ -0,0 +1,126 @@
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { getHelpArticle, getHelpEdition, getHelpEditionText, getHelpEditions } from '@/lib/help'
import NotFound from '@/pages/NotFound'
import type { FC, ComponentType } from 'react'
const HelpDetailPage: FC = () => {
const { identifier = '' } = useParams ()
const location = useLocation ()
// Locale 対応が入るまでは ja 固定
const language = 'ja'
const article = useMemo (
() => getHelpArticle (identifier),
[identifier])
const query = new URLSearchParams (location.search)
const requestedEdition = query.get ('edition')
const languageMeta = article?.languages[language]
const edition = requestedEdition ?? languageMeta?.currentEdition
const editions = useMemo (() => getHelpEditions (identifier, language),
[identifier, language])
const index = edition ? editions.indexOf (edition) : -1
const pred = index > 0 ? editions[index - 1] : undefined
const succ =
index >= 0 && index < editions.length - 1
? editions[index + 1]
: undefined
const [loaded, setLoaded] = useState<{ identifier: string
language: string
edition: string
title: string
content: ComponentType | null } | null> (null)
useEffect (() => {
if (!(edition))
return
let active = true
void (async () => {
const component = await getHelpEdition (identifier, language, edition)
const text = await getHelpEditionText (identifier, language, edition)
if (active)
{
setLoaded ({ identifier,
language,
edition,
title: text?.title ?? '',
content: component })
}
}) ()
return () => {
active = false
}
}, [identifier, language, edition])
if (!(article)
|| !(languageMeta)
|| !(edition)
|| !(editions.includes (edition)))
return <NotFound/>
const current =
(loaded?.identifier === identifier
&& loaded?.language === language
&& loaded?.edition === edition)
? loaded
: null
if (current && (!(current.content) || !(current.title)))
return <NotFound/>
const Content = current?.content
return (
<MainArea>
<Helmet>
<title>{current?.title || identifier} ヘルプ | {SITE_TITLE}</title>
</Helmet>
{requestedEdition && (
<div className="text-sm flex gap-3 items-center justify-center
border border-gray-700 rounded px-2 py-1 mb-4">
{pred
? (
<PrefetchLink
to={`/help/${ encodeURIComponent (identifier) }?edition=${ pred }`}>
&lt; 古
</PrefetchLink>)
: '(最古)'}
<span>{edition}</span>
{succ
? (
<PrefetchLink
to={`/help/${ encodeURIComponent (identifier) }?edition=${ succ }`}>
新 &gt;
</PrefetchLink>)
: '(最新)'}
</div>)}
<article className="prose dark:prose-invert mx-auto p-4">
{Content ? <Content/> : <div>Loading...</div>}
</article>
</MainArea>)
}
export default HelpDetailPage
+52
ファイルの表示
@@ -0,0 +1,52 @@
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import HelpListPage from '@/pages/help/HelpListPage'
import { helpFixture } from '@/test/helpFixtures'
import { renderWithProviders } from '@/test/render'
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
describe ('Help search', () => {
it ('initially lists each current article once with encoded detail links', async () => {
renderWithProviders (<HelpListPage/>, { route: '/help' })
expect (await screen.findByRole ('link', { name: 'ホーム' }))
.toHaveAttribute ('href', '/help/guide')
expect (screen.getByRole ('link', { name: '設定' }))
.toHaveAttribute ('href', `/help/${encodeURIComponent ('設定 手順')}`)
expect (screen.getByText ('2 件')).toBeInTheDocument ()
expect (screen.queryByText ('旧ホーム')).not.toBeInTheDocument ()
expect (screen.queryByText ('ホーム第十版')).not.toBeInTheDocument ()
})
it.each ([
['ホーム', '', ['ホーム']],
['', '検索', ['ホーム']],
['', 'タグ', ['ホーム', '設定']],
['ホーム', '設定', []],
['ホーム', '検索', ['ホーム']],
['旧ホーム', '', []],
['', '過去限定本文', []]]) (
'searches title %s and body %s together', async (title, body, expected) => {
const { container } = renderWithProviders (<HelpListPage/>)
await screen.findByText ('2 件')
fireEvent.change (screen.getByRole ('textbox', { name: 'タイトル' }), {
target: { value: title } })
fireEvent.change (screen.getByRole ('textbox', { name: '内容' }), {
target: { value: body } })
fireEvent.click (screen.getByRole ('button', { name: '検索' }))
expect (Array.from (container.querySelectorAll ('tbody a'), link => link.textContent))
.toEqual (expected)
if (expected.length === 0)
expect (screen.getByText ('該当する記事がありません.')).toBeInTheDocument ()
})
it ('displays loading failures without stale results', async () => {
helpFixture.raw['../../help/guide/editions/ja/2026-10-02.mdx']
.mockRejectedValueOnce (new Error ('fixture loading failed'))
renderWithProviders (<HelpListPage/>)
expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed')
expect (screen.queryByRole ('link', { name: 'ホーム' })).not.toBeInTheDocument ()
})
})
+143
ファイルの表示
@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import PrefetchLink from '@/components/PrefetchLink'
import FormField from '@/components/common/FormField'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { getHelpSearchEntries } from '@/lib/help'
import { dateString, inputClass } from '@/lib/utils'
import type { FormEvent, FC } from 'react'
import type { HelpSearchEntry } from '@/lib/help'
type SearchConditions = { title: string; text: string }
const HelpSearchPage: FC = () => {
const [title, setTitle] = useState ('')
const [text, setText] = useState ('')
const [conditions, setConditions] = useState<SearchConditions> ({ title: '', text: '' })
const [entries, setEntries] = useState<HelpSearchEntry[]> ([])
const [loading, setLoading] = useState (true)
const [error, setError] = useState<string | null> (null)
useEffect (() => {
let active = true
void (async () => {
try
{
if (active)
setEntries (await getHelpSearchEntries ('ja'))
}
catch (err)
{
if (active)
setError (err instanceof Error ? err.message : '取得に失敗しました.')
}
finally
{
if (active)
setLoading (false)
}
}) ()
return () => { active = false }
}, [])
const results = useMemo (() => {
const titleQuery = conditions.title.trim ().normalize ('NFKC').toLowerCase ()
const textQuery = conditions.text.trim ().normalize ('NFKC').toLowerCase ()
return (
entries.filter (entry => (
(!(titleQuery)
|| entry.title.normalize ('NFKC').toLowerCase ().includes (titleQuery))
&& (!(textQuery)
|| entry.body.normalize ('NFKC').toLowerCase ().includes (textQuery))))
.sort ((a, b) => a.title.localeCompare (b.title, 'ja')))
}, [entries, conditions])
const handleSearch = (ev: FormEvent<HTMLFormElement>) => {
ev.preventDefault ()
setConditions ({ title, text })
}
return (
<MainArea>
<Helmet>
<title>{`ヘルプ | ${ SITE_TITLE }`}</title>
</Helmet>
<div className="max-w-xl">
<PageTitle>ヘルプ</PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
<FormField label="タイトル">
{({ invalid }) => (
<input type="text"
aria-label="タイトル"
value={title}
onChange={e => setTitle (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<FormField label="内容">
{({ invalid }) => (
<input type="text"
aria-label="内容"
value={text}
onChange={e => setText (e.target.value)}
className={inputClass (invalid)}/>)}
</FormField>
<div className="py-3">
<button type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded">
検索
</button>
</div>
</form>
</div>
{loading
? <p className="mt-4">読み込み中...</p>
: (error
? <p className="mt-4 text-red-600" role="alert">{error}</p>
: (
<div className="mt-4">
<p className="mb-2 text-sm" aria-live="polite">{results.length} 件</p>
<table className="table-auto w-full border-collapse">
<thead className="border-b-2 border-black dark:border-white">
<tr>
<th className="p-2 text-left">タイトル</th>
<th className="p-2 text-left">最終更新</th>
</tr>
</thead>
<tbody>
{results.map (entry => {
const updatedAt = new Date(entry.edition.replace (/\..*$/, ''))
return (
<tr key={entry.identifier}
className="even:bg-gray-100 dark:even:bg-gray-700">
<td className="p-2">
<PrefetchLink
to={`/help/${encodeURIComponent (entry.identifier)}`}>
{entry.title}
</PrefetchLink>
</td>
<td className="p-2">{dateString (updatedAt, 'hour')}</td>
</tr>)
})}
</tbody>
</table>
{results.length === 0 && <p className="mt-4">該当する記事がありません.</p>}
</div>))}
</MainArea>)
}
export default HelpSearchPage
+91
ファイルの表示
@@ -0,0 +1,91 @@
import { screen, waitFor } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { apiGet } from '@/lib/api'
import HelpDetailPage from '@/pages/help/HelpDetailPage'
import WikiDetailPage from '@/pages/wiki/WikiDetailPage'
import { buildWikiPage } from '@/test/factories'
import { helpFixture } from '@/test/helpFixtures'
import { renderWithProviders } from '@/test/render'
vi.mock ('@/lib/api', () => ({ apiGet: vi.fn () }))
vi.mock ('@/lib/help', async () => (await import ('@/test/helpFixtures')).helpFixture.api)
vi.mock ('@/lib/tags', () => ({ fetchTagByName: async () => null }))
vi.mock ('@/lib/posts', () => ({ fetchPosts: async () => ({ posts: [], count: 0 }) }))
const Location = () => {
const location = useLocation ()
return <output data-testid="location">{location.pathname}{location.search}</output>
}
const renderWiki = (title: string) => {
const route = `/wiki/${encodeURIComponent (title)}`
renderWithProviders (
<>
<Location/>
<Routes>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
<Route path="/404" element={<div>unexpected redirect</div>}/>
</Routes>
</>, { route })
return route
}
describe ('Legacy Wiki to Help compatibility', () => {
beforeEach (() => {
vi.mocked (apiGet).mockReset ()
vi.mocked (apiGet).mockImplementation (async path => {
if (path === '/wiki')
return []
throw { isAxiosError: true, response: { status: 404 } }
})
})
it ('keeps a successful Wiki result even when Help has the same title', async () => {
vi.mocked (apiGet).mockResolvedValueOnce (buildWikiPage ({
title: 'ホーム', body: 'Wiki の現行本文' }))
const route = renderWiki ('ホーム')
await screen.findByText ('Wiki の現行本文')
expect (screen.getByTestId ('location')).toHaveTextContent (route)
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
})
it.each (['ホーム', 'ヘルプ:ホーム', '旧ホーム', 'ヘルプ:旧ホーム']) (
'redirects a unique current or historical Japanese title: %s', async title => {
renderWiki (title)
await waitFor (() => {
expect (screen.getByTestId ('location')).toHaveTextContent ('/help/guide')
})
expect (await screen.findByRole ('heading', { name: 'ホーム' })).toBeInTheDocument ()
expect (apiGet).toHaveBeenCalledWith (
`/wiki/title/${encodeURIComponent (title)}`, expect.anything ())
})
it ('keeps the original URL and displays NotFound when no title matches', async () => {
const route = renderWiki ('該当なし')
await screen.findByText ('404')
expect (screen.getByTestId ('location')).toHaveTextContent (route)
expect (screen.queryByText ('unexpected redirect')).not.toBeInTheDocument ()
})
it ('does not choose between articles with the same title', async () => {
const loader = helpFixture.raw['../../help/設定 手順/editions/ja/2026-10-03.mdx']
loader.mockResolvedValueOnce ('# ホーム\n\n別の記事')
const route = renderWiki ('ホーム')
await screen.findByText ('404')
expect (screen.getByTestId ('location')).toHaveTextContent (route)
})
it.each ([403, 500]) ('does not redirect HTTP %s to Help', async status => {
vi.mocked (apiGet).mockRejectedValueOnce ({ isAxiosError: true, response: { status } })
const route = renderWiki ('ホーム')
await waitFor (() => expect (apiGet).toHaveBeenCalled ())
expect (screen.getByTestId ('location')).toHaveTextContent (route)
expect (screen.queryByText ('タグを検索する')).not.toBeInTheDocument ()
})
})
+37
ファイルの表示
@@ -0,0 +1,37 @@
import * as jsxRuntime from 'react/jsx-runtime'
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
import { useMDXComponents } from '@/mdx-components'
import viteConfig from '../../vite.config'
import type { ComponentType } from 'react'
type Transform = (source: string, id: string) => Promise<{ code: string } | null>
export const transformMdx = async (source: string, suffix = '.mdx') => {
const plugins = viteConfig.plugins?.flat () as { name: string; transform: Transform }[]
const plugin = plugins.find (entry => entry.name === '@mdx-js/rollup')
if (plugin == null)
throw new Error ('The application MDX compiler is not registered')
return plugin.transform.call (plugin, source, `/test/help-table${suffix}`)
}
export const compileMdx = async (source: string): Promise<ComponentType> => {
const result = await transformMdx (source)
if (result == null)
throw new Error ('MDX was not compiled')
const code = transpileModule (result.code, {
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
const exports = { } as { default: ComponentType }
const requireFixture = (name: string) => {
if (name === 'react/jsx-runtime')
return jsxRuntime
if (name === '@/mdx-components')
return { useMDXComponents }
throw new Error (`Unexpected MDX dependency: ${name}`)
}
new Function ('require', 'exports', code) (requireFixture, exports)
return exports.default
}
+60
ファイルの表示
@@ -0,0 +1,60 @@
import { createElement } from 'react'
import { ModuleKind, ScriptTarget, transpileModule } from 'typescript'
import { vi } from 'vitest'
import { parse } from 'yaml'
import source from '@/lib/help.ts?raw'
import type { ReactElement } from 'react'
import type * as Help from '@/lib/help'
export const editionRows = [
['guide', 'ja', '2026-10-01', '旧ホーム', '過去限定本文'],
['guide', 'ja', '2026-10-02', 'ホーム', 'タグを検索する'],
['guide', 'ja', '2026-10-02.2', 'ホーム第二版', '第二版本文'],
['guide', 'ja', '2026-10-02.3', 'ホーム第三版', '第三版本文'],
['guide', 'ja', '2026-10-02.10', 'ホーム第十版', '第十版本文'],
['guide', 'en', '2026-10-02', 'Home', 'English body'],
['設定 手順', 'ja', '2026-10-03', '設定', 'タグを設定する']] as const
const compiled = transpileModule (source.replaceAll ('import.meta', 'helpMeta'), {
compilerOptions: { module: ModuleKind.CommonJS, target: ScriptTarget.ES2022 } }).outputText
// Replace Vite's file-discovery boundary, executing the actual Help module.
export const createHelpFixture = () => {
const articles: Record<string, string> = {
'../../help/guide/article.yml':
'identifier: guide\nlanguages:\n ja:\n current_edition: 2026-10-02\n'
+ ' en:\n current_edition: 2026-10-02\n',
'../../help/設定 手順/article.yml':
'identifier: 設定 手順\nlanguages:\n ja:\n current_edition: 2026-10-03\n' }
const raw = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [
`../../help/${id}/editions/${language}/${edition}.mdx`,
vi.fn (async () => `# ${title}\n\n${body}`)]))
const modules = Object.fromEntries (editionRows.map (([id, language, edition, title, body]) => [
`../../help/${id}/editions/${language}/${edition}.mdx`,
vi.fn (async (): Promise<{ default: () => ReactElement }> => ({
default: () => createElement ('div', null,
createElement ('h1', null, title), createElement ('p', null, body)) }))]))
const glob = (pattern: string, options?: { query?: string }) => {
if (pattern.endsWith ('article.yml'))
return articles
if (pattern.endsWith ('*.mdx'))
return options?.query === '?raw' ? raw : modules
throw new Error (`Unexpected Help glob: ${pattern}`)
}
const api = { } as typeof Help
const requireFixture = (name: string) => {
if (name === 'yaml')
return { parse }
throw new Error (`Unexpected Help dependency: ${name}`)
}
new Function ('require', 'exports', 'helpMeta', compiled) (
requireFixture, api, { glob })
return { api, articles, raw, modules }
}
export const helpFixture = createHelpFixture ()
+16 -2
ファイルの表示
@@ -1,12 +1,26 @@
import mdx from '@mdx-js/rollup' import mdx from '@mdx-js/rollup'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path' import path from 'path'
import remarkGFM from 'remark-gfm'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
const mdxPlugin = mdx ({ providerImportSource: '@/mdx-components',
remarkPlugins: [remarkGFM] })
const originalTransform = mdxPlugin.transform
if (typeof originalTransform === 'function')
{
mdxPlugin.transform =
function (code, id)
{
return (/[?&](raw|url)(?:&|$)/.test (id)
? null
: originalTransform.call (this, code, id))
}
}
// https://vite.dev/config/
export default defineConfig ({ export default defineConfig ({
plugins: [mdx ({ providerImportSource: '@/mdx-components' }), react ()], plugins: [mdxPlugin, react ()],
resolve: { alias: { '@': path.resolve (__dirname, './src') } }, resolve: { alias: { '@': path.resolve (__dirname, './src') } },
server: { host: true, server: { host: true,
port: 5173, port: 5173,