コミットを比較
6
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
4164c3c3f7 | ||
|
|
4c891604b5 | ||
|
|
88e691165a | ||
|
|
18415d7f55 | ||
|
|
534a66f90d | ||
|
|
a219cc350b |
@@ -8,7 +8,6 @@ class WikiPagesController < ApplicationController
|
|||||||
descriptions =
|
descriptions =
|
||||||
TagDescription.where(language_code: 'ja')
|
TagDescription.where(language_code: 'ja')
|
||||||
.includes(tag: :tag_names)
|
.includes(tag: :tag_names)
|
||||||
pages = unmigrated_wiki_pages.includes(tag_name: :tag)
|
|
||||||
|
|
||||||
tag_groups = TagGroup.all
|
tag_groups = TagGroup.all
|
||||||
if title.present?
|
if title.present?
|
||||||
@@ -19,12 +18,10 @@ class WikiPagesController < ApplicationController
|
|||||||
.where('tag_names.name LIKE ?', pattern).limit(20)
|
.where('tag_names.name LIKE ?', pattern).limit(20)
|
||||||
tag_groups = tag_groups.where(
|
tag_groups = tag_groups.where(
|
||||||
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
|
'CONCAT(?, name) LIKE ?', TagGroupRepr::PREFIX, pattern).limit(20)
|
||||||
pages = pages.where('tag_names.name LIKE ?', pattern).limit(20)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
json = descriptions.map { |description| WikiPageRepr.tag_description(description) }
|
||||||
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
json.concat(tag_groups.map { WikiPageRepr.tag_group(_1) })
|
||||||
json.concat(WikiPageRepr.base(pages))
|
|
||||||
json = json.first(20) if title.present?
|
json = json.first(20) if title.present?
|
||||||
render json:
|
render json:
|
||||||
end
|
end
|
||||||
@@ -37,11 +34,7 @@ class WikiPagesController < ApplicationController
|
|||||||
tag_group = TagGroup.find_by(id: params[:id])
|
tag_group = TagGroup.find_by(id: params[:id])
|
||||||
return render_tag_group tag_group if tag_group
|
return render_tag_group tag_group if tag_group
|
||||||
|
|
||||||
page = WikiPage.joins(:tag_name)
|
render_wiki_page_or_404 nil
|
||||||
.includes(tag_name: :tag)
|
|
||||||
.find_by(id: params[:id])
|
|
||||||
|
|
||||||
render_wiki_page_or_404 page
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def show_by_title
|
def show_by_title
|
||||||
@@ -58,20 +51,14 @@ class WikiPagesController < ApplicationController
|
|||||||
|
|
||||||
return render_wiki_page_by_tag_description tag_description if tag_description
|
return render_wiki_page_by_tag_description tag_description if tag_description
|
||||||
|
|
||||||
page =
|
render_wiki_page_or_404 nil
|
||||||
WikiPage
|
|
||||||
.joins(:tag_name)
|
|
||||||
.includes(tag_name: :tag)
|
|
||||||
.find_by(tag_name: { name: title })
|
|
||||||
|
|
||||||
render_wiki_page_or_404 page
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def exists
|
def exists
|
||||||
return head :no_content if TagDescription.exists?(id: params[:id])
|
return head :no_content if TagDescription.exists?(id: params[:id])
|
||||||
return head :no_content if TagGroup.exists?(id: params[:id])
|
return head :no_content if TagGroup.exists?(id: params[:id])
|
||||||
|
|
||||||
head WikiPage.exists?(id: params[:id]) ? :no_content : :not_found
|
head :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
def exists_by_title
|
def exists_by_title
|
||||||
@@ -82,8 +69,7 @@ class WikiPagesController < ApplicationController
|
|||||||
end
|
end
|
||||||
return head :no_content if tag_description_by_title(title)
|
return head :no_content if tag_description_by_title(title)
|
||||||
|
|
||||||
exists = WikiPage.joins(:tag_name).exists?(tag_names: { name: title })
|
head :not_found
|
||||||
head exists ? :no_content : :not_found
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def diff
|
def diff
|
||||||
@@ -112,15 +98,7 @@ class WikiPagesController < ApplicationController
|
|||||||
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
'title' => "#{ TagGroupRepr::PREFIX }#{ tag_group.name }",
|
||||||
'deprecated_at' => nil }
|
'deprecated_at' => nil }
|
||||||
else
|
else
|
||||||
page = WikiPage.joins(:tag_name).includes(tag_name: :tag).find(id)
|
raise 'お前やりませんねぇスギ!'
|
||||||
from_rev = from && page.wiki_revisions.find(from)
|
|
||||||
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
|
|
||||||
if ((from_rev && !from_rev.content?) || !to_rev&.content?)
|
|
||||||
return render_unprocessable_entity('差分を表示できない版です.')
|
|
||||||
end
|
|
||||||
from_body = from_rev&.body
|
|
||||||
to_body = to_rev.body
|
|
||||||
resource = WikiPageRepr.base(page)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -160,25 +138,17 @@ class WikiPagesController < ApplicationController
|
|||||||
status: :created)
|
status: :created)
|
||||||
end
|
end
|
||||||
|
|
||||||
page = if title.include?(':')
|
page =
|
||||||
ApplicationRecord.transaction do
|
if title.include?(':')
|
||||||
tag_name = TagName.find_or_create_by!(language_code: 'ja', name: title) do |name|
|
raise 'お前やりませんねぇスギ!'
|
||||||
name.script_code = 'Jpan'
|
else
|
||||||
name.primary_flg = true
|
tag_name = TagName.find_by(language_code: 'ja', name: title)
|
||||||
name.auto_generated = false
|
return render_unprocessable_entity field: :title unless tag_name&.tag
|
||||||
|
|
||||||
|
TagDescriptionOperation.create!(
|
||||||
|
tag: tag_name.tag, body:, created_by_user: current_user, message:)
|
||||||
end
|
end
|
||||||
|
|
||||||
Wiki::Commit.create_legacy_content!(
|
|
||||||
tag_name:, body:, created_by_user: current_user, message:)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
tag_name = TagName.find_by(language_code: 'ja', name: title)
|
|
||||||
return render_unprocessable_entity field: :title unless tag_name&.tag
|
|
||||||
|
|
||||||
TagDescriptionOperation.create!(
|
|
||||||
tag: tag_name.tag, body:, created_by_user: current_user, message:)
|
|
||||||
end
|
|
||||||
|
|
||||||
render json: WikiPageRepr.base(page), status: :created
|
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
|
||||||
@@ -273,23 +243,7 @@ class WikiPagesController < ApplicationController
|
|||||||
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
|
return render json: tag_group_changes(TagGroupVersion.where(tag_group_id: tag_group.id))
|
||||||
end
|
end
|
||||||
|
|
||||||
pages = id ? WikiPage.where(id:) : unmigrated_wiki_pages
|
json = []
|
||||||
revisions = WikiRevision.joins(wiki_page: :tag_name)
|
|
||||||
.where(wiki_page_id: pages.select(:id))
|
|
||||||
.includes(:created_user, wiki_page: { tag_name: :tag })
|
|
||||||
.order(id: :desc).limit(200)
|
|
||||||
json = revisions.map { |rev|
|
|
||||||
{ revision_id: rev.id,
|
|
||||||
pred: rev.base_revision_id,
|
|
||||||
succ: nil,
|
|
||||||
wiki_page: { id: rev.wiki_page_id,
|
|
||||||
title: rev.wiki_page.title,
|
|
||||||
deprecated_at: rev.wiki_page.deprecated_at },
|
|
||||||
user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name },
|
|
||||||
kind: rev.kind,
|
|
||||||
message: rev.message,
|
|
||||||
timestamp: rev.created_at }
|
|
||||||
}
|
|
||||||
unless id
|
unless id
|
||||||
json.concat(tag_group_changes(TagGroupVersion.all))
|
json.concat(tag_group_changes(TagGroupVersion.all))
|
||||||
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
|
json.concat(description_changes(TagDescription.where(language_code: 'ja')))
|
||||||
@@ -453,16 +407,6 @@ class WikiPagesController < ApplicationController
|
|||||||
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
|
.find_by(language_code: 'ja', tag_names: { language_code: 'ja', name: title })
|
||||||
end
|
end
|
||||||
|
|
||||||
def unmigrated_wiki_pages
|
|
||||||
descriptions = TagDescription.where(language_code: 'ja')
|
|
||||||
|
|
||||||
WikiPage
|
|
||||||
.joins(:tag_name)
|
|
||||||
.where.not('tag_names.name LIKE ?', "#{ TagGroupRepr::PREFIX }%")
|
|
||||||
.where('tag_names.tag_id IS NULL OR tag_names.tag_id NOT IN (?)',
|
|
||||||
descriptions.select(:tag_id))
|
|
||||||
end
|
|
||||||
|
|
||||||
def description_changes descriptions
|
def description_changes descriptions
|
||||||
versions = TagDescriptionVersion.where(language_code: 'ja',
|
versions = TagDescriptionVersion.where(language_code: 'ja',
|
||||||
tag_id: descriptions.select(:tag_id))
|
tag_id: descriptions.select(:tag_id))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
||||||
|
include WikiRecords
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let!(:tag_name) { create(:tag_name, name: 'description_contract') }
|
let!(:tag_name) { create(:tag_name, name: 'description_contract') }
|
||||||
let!(:page) do
|
let!(:page) do
|
||||||
Wiki::Commit.create_content!(
|
seed_wiki_page(
|
||||||
tag_name:, body: 'old-revision', created_by_user: user)
|
tag_name:, body: 'old-revision', created_by_user: user)
|
||||||
end
|
end
|
||||||
let!(:revisions) do
|
let!(:revisions) do
|
||||||
@@ -242,8 +243,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe 'mixed changes' do
|
describe 'mixed changes' do
|
||||||
it 'includes unmigrated history and ignores migrated legacy-only revisions' do
|
it 'excludes unmigrated history and migrated legacy-only revisions' do
|
||||||
legacy_page = Wiki::Commit.create_content!(
|
legacy_page = seed_wiki_page(
|
||||||
tag_name: create(:tag_name, name: 'Help_mixed'),
|
tag_name: create(:tag_name, name: 'Help_mixed'),
|
||||||
body: 'help', created_by_user: user)
|
body: 'help', created_by_user: user)
|
||||||
legacy_revision = legacy_page.current_revision
|
legacy_revision = legacy_page.current_revision
|
||||||
@@ -256,7 +257,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
|||||||
migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id }
|
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(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.map { |row| row.fetch('revision_id') }).not_to include(extra.id)
|
||||||
expect(json).to include(include(
|
expect(json).not_to include(include(
|
||||||
'revision_id' => legacy_revision.id,
|
'revision_id' => legacy_revision.id,
|
||||||
'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed')))
|
'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed')))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
||||||
|
include WikiRecords
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let!(:tag_name) { create(:tag_name, name: 'description_write') }
|
let!(:tag_name) { create(:tag_name, name: 'description_write') }
|
||||||
let(:tag) { create(:tag, primary_tag_name: tag_name) }
|
let(:tag) { create(:tag, primary_tag_name: tag_name) }
|
||||||
@@ -31,25 +32,18 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
|||||||
headers: headers
|
headers: headers
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates both current records and histories with a shared revision ID' do
|
it 'creates a description without implicitly creating Wiki records' do
|
||||||
expect { create_description }
|
expect { create_description }
|
||||||
.to change(WikiPage, :count).by(1)
|
.to change(WikiPage, :count).by(0)
|
||||||
.and change(WikiRevision, :count).by(1)
|
.and change(WikiRevision, :count).by(0)
|
||||||
.and change(WikiVersion, :count).by(1)
|
.and change(WikiVersion, :count).by(0)
|
||||||
.and change(TagDescription, :count).by(1)
|
.and change(TagDescription, :count).by(1)
|
||||||
.and change(TagDescriptionVersion, :count).by(1)
|
.and change(TagDescriptionVersion, :count).by(1)
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
|
|
||||||
page = WikiPage.find(json.fetch('id'))
|
|
||||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||||
version = TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')
|
expect(description).to have_attributes(body: 'initial', version_no: 1)
|
||||||
expect(description).to have_attributes(id: page.id, body: 'initial', version_no: 1)
|
expect(TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja'))
|
||||||
expect(version).to have_attributes(
|
.to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id)
|
||||||
id: page.current_revision.id, body: 'initial', version_no: 1,
|
|
||||||
event_type: 'create', created_by_user_id: user.id)
|
|
||||||
expect(page.body).to eq(description.body)
|
|
||||||
expect(page.current_revision.body).to eq(version.body)
|
|
||||||
expect(page.wiki_versions.last.body).to eq(version.body)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
['missing_description', 'orphan_description'].each do |title|
|
['missing_description', 'orphan_description'].each do |title|
|
||||||
@@ -65,48 +59,19 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a namespaced legacy Wiki resource without a Tag' do
|
[false, true].each do |linked|
|
||||||
expect do
|
it "rejects namespaced creation with Tag present: #{ linked }" do
|
||||||
post '/wiki', params: { title: 'Help:description_write', body: 'legacy' },
|
name = create(:tag_name, name: 'Help:frozen')
|
||||||
headers: headers
|
create(:tag, primary_tag_name: name) if linked
|
||||||
end.to change(WikiPage, :count).by(1)
|
before_state = stored_state
|
||||||
.and change(WikiRevision, :count).by(1)
|
post '/wiki', params: { title: name.name, body: 'legacy' }, headers: headers
|
||||||
.and change(WikiVersion, :count).by(1)
|
expect(response.status).to be_between(400, 499)
|
||||||
.and change(TagDescription, :count).by(0)
|
expect(stored_state).to eq(before_state)
|
||||||
.and change(TagDescriptionVersion, :count).by(0)
|
end
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
page = WikiPage.find(json.fetch('id'))
|
|
||||||
expect(page.tag_name.tag).to be_nil
|
|
||||||
expect(page.body).to eq('legacy')
|
|
||||||
expect(page.current_revision.body).to eq('legacy')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates only legacy Wiki for a namespaced title with an existing Tag' do
|
[TagDescription, TagDescriptionVersion].each do |model|
|
||||||
namespaced_name = create(:tag_name, name: 'help:foo')
|
it "rolls back new description creation when #{ model.name } persistence fails" do
|
||||||
namespaced_tag = create(:tag, primary_tag_name: namespaced_name)
|
|
||||||
|
|
||||||
expect do
|
|
||||||
post '/wiki', params: { title: namespaced_name.name, body: 'legacy' },
|
|
||||||
headers: headers
|
|
||||||
end.to change(WikiPage, :count).by(1)
|
|
||||||
.and change(WikiRevision, :count).by(1)
|
|
||||||
.and change(WikiVersion, :count).by(1)
|
|
||||||
.and change(TagName, :count).by(0)
|
|
||||||
.and change(TagDescription, :count).by(0)
|
|
||||||
.and change(TagDescriptionVersion, :count).by(0)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
page = WikiPage.find(json.fetch('id'))
|
|
||||||
expect(page).to have_attributes(tag_name_id: namespaced_name.id, body: 'legacy')
|
|
||||||
expect(page.tag_name.tag).to eq(namespaced_tag)
|
|
||||||
expect(page.current_revision.body).to eq('legacy')
|
|
||||||
expect(page.wiki_versions.last.body).to eq('legacy')
|
|
||||||
end
|
|
||||||
|
|
||||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
|
|
||||||
it "rolls back both systems when #{ model.name } persistence fails" do
|
|
||||||
tag_name
|
|
||||||
before_state = stored_state
|
before_state = stored_state
|
||||||
aggregate_failures do
|
aggregate_failures do
|
||||||
fail_save(model) { create_description }
|
fail_save(model) { create_description }
|
||||||
@@ -119,7 +84,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
|||||||
describe 'PUT /wiki/:id' do
|
describe 'PUT /wiki/:id' do
|
||||||
let!(:page) do
|
let!(:page) do
|
||||||
tag
|
tag
|
||||||
Wiki::Commit.create_content!(
|
seed_wiki_page(
|
||||||
tag_name:, body: 'before', created_by_user: user)
|
tag_name:, body: 'before', created_by_user: user)
|
||||||
end
|
end
|
||||||
let!(:first_revision) { page.current_revision }
|
let!(:first_revision) { page.current_revision }
|
||||||
@@ -138,7 +103,8 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
|||||||
expect(page.reload.version_no).to eq(1)
|
expect(page.reload.version_no).to eq(1)
|
||||||
expect(description.version_no).to eq(1)
|
expect(description.version_no).to eq(1)
|
||||||
expect { update_description }
|
expect { update_description }
|
||||||
.to change(WikiRevision, :count).by(1)
|
.to change(WikiPage, :count).by(0)
|
||||||
|
.and change(WikiRevision, :count).by(1)
|
||||||
.and change(WikiVersion, :count).by(1)
|
.and change(WikiVersion, :count).by(1)
|
||||||
.and change(TagDescriptionVersion, :count).by(1)
|
.and change(TagDescriptionVersion, :count).by(1)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
@@ -157,7 +123,8 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
|
|||||||
tag_name.update_columns(tag_id: nil)
|
tag_name.update_columns(tag_id: nil)
|
||||||
|
|
||||||
expect { update_description }
|
expect { update_description }
|
||||||
.to change(WikiRevision, :count).by(1)
|
.to change(WikiPage, :count).by(0)
|
||||||
|
.and change(WikiRevision, :count).by(1)
|
||||||
.and change(WikiVersion, :count).by(1)
|
.and change(WikiVersion, :count).by(1)
|
||||||
.and change(TagDescriptionVersion, :count).by(1)
|
.and change(TagDescriptionVersion, :count).by(1)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||||
|
include WikiRecords
|
||||||
include TagGroupRecords
|
include TagGroupRecords
|
||||||
|
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
@@ -69,34 +70,36 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'combines Tag Groups, Help and Tag Descriptions in the unfiltered list' do
|
it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do
|
||||||
help = Wiki::Commit.create_legacy_content!(
|
help = seed_wiki_page(
|
||||||
tag_name: create(:tag_name, name: 'Help:混在'), body: 'help', created_by_user: user)
|
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)
|
TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user)
|
||||||
get '/wiki'
|
get '/wiki'
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.map { _1.fetch('title') }).to include(
|
expect(json.map { _1.fetch('title') }).to include(
|
||||||
'タググループ:読取り契約', help.title, '新タグ')
|
'タググループ:読取り契約', '新タグ')
|
||||||
|
expect(json.map { _1.fetch('title') }).not_to include(help.title)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'keeps Help readable when its ID collides with a new group' do
|
it 'excludes Help when its ID collides with a new group' do
|
||||||
help = Wiki::Commit.create_legacy_content!(
|
help = seed_wiki_page(
|
||||||
tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body',
|
tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body',
|
||||||
created_by_user: user)
|
created_by_user: user)
|
||||||
seed_group(name: '別 ID重複', id: help.id, description: 'group body')
|
seed_group(name: '別 ID重複', id: help.id, description: 'group body')
|
||||||
|
|
||||||
get '/wiki'
|
get '/wiki'
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.map { _1.fetch('title') }).to include(help.title, 'タググループ:別 ID重複')
|
expect(json.map { _1.fetch('title') }).to include('タググループ:別 ID重複')
|
||||||
|
expect(json.map { _1.fetch('title') }).not_to include(help.title)
|
||||||
|
|
||||||
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
|
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:not_found)
|
||||||
expect(json.fetch('body')).to eq('help body')
|
|
||||||
|
|
||||||
get '/wiki/changes'
|
get '/wiki/changes'
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.map { _1.dig('wiki_page', 'title') }).to include(
|
expect(json.map { _1.dig('wiki_page', 'title') }).to include(
|
||||||
help.title, 'タググループ:別 ID重複')
|
'タググループ:別 ID重複')
|
||||||
|
expect(json.map { _1.dig('wiki_page', 'title') }).not_to include(help.title)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'finds groups by a partial prefix of their displayed title' do
|
it 'finds groups by a partial prefix of their displayed title' do
|
||||||
@@ -122,7 +125,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'orders mixed resource history by timestamp rather than independent IDs' do
|
it 'orders mixed resource history by timestamp rather than independent IDs' do
|
||||||
help = Wiki::Commit.create_legacy_content!(
|
help = seed_wiki_page(
|
||||||
tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help',
|
tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help',
|
||||||
created_by_user: user)
|
created_by_user: user)
|
||||||
TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user)
|
TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user)
|
||||||
@@ -136,8 +139,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
|
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
|
||||||
expect(timestamps).to eq(timestamps.sort.reverse)
|
expect(timestamps).to eq(timestamps.sort.reverse)
|
||||||
titles = json.map { _1.dig('wiki_page', 'title') }
|
titles = json.map { _1.dig('wiki_page', 'title') }
|
||||||
expect(titles.index('新タグ')).to be < titles.index(help.title)
|
expect(titles).not_to include(help.title)
|
||||||
expect(titles.index(help.title)).to be < titles.index('タググループ:読取り契約')
|
expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'renders nullable group editors without raising' do
|
it 'renders nullable group editors without raising' do
|
||||||
@@ -166,16 +169,16 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
|
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'includes Help and group history but excludes legacy-only migrated revisions' do
|
it 'includes group history but excludes all legacy-only revisions' do
|
||||||
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
|
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
|
||||||
help = Wiki::Commit.create_legacy_content!(
|
help = seed_wiki_page(
|
||||||
tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user)
|
tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user)
|
||||||
get '/wiki/changes'
|
get '/wiki/changes'
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' }
|
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') }).to eq([last_revision.id, first_revision.id])
|
||||||
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
|
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
|
||||||
expect(json).to include(include(
|
expect(json).not_to include(include(
|
||||||
'revision_id' => help.current_revision.id,
|
'revision_id' => help.current_revision.id,
|
||||||
'wiki_page' => include('title' => help.title)))
|
'wiki_page' => include('title' => help.title)))
|
||||||
end
|
end
|
||||||
@@ -214,7 +217,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
|
|
||||||
context 'when Help, description and group share the same numeric ID' do
|
context 'when Help, description and group share the same numeric ID' do
|
||||||
let(:colliding_help) do
|
let(:colliding_help) do
|
||||||
Wiki::Commit.create_legacy_content!(
|
seed_wiki_page(
|
||||||
tag_name: create(:tag_name, name: 'Help:三者ID衝突'),
|
tag_name: create(:tag_name, name: 'Help:三者ID衝突'),
|
||||||
body: 'help', created_by_user: user)
|
body: 'help', created_by_user: user)
|
||||||
end
|
end
|
||||||
@@ -239,13 +242,12 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
colliding_description_version
|
colliding_description_version
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'lists all three resources without deduplicating their numeric ID' do
|
it 'lists both new resources sharing a numeric ID without legacy Help' do
|
||||||
get '/wiki'
|
get '/wiki'
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
rows = json.select { _1.fetch('id') == colliding_help.id }
|
rows = json.select { _1.fetch('id') == colliding_help.id }
|
||||||
expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly(
|
expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly(
|
||||||
[colliding_help.id, colliding_help.title],
|
|
||||||
[colliding_description.id, '新タグ'],
|
[colliding_description.id, '新タグ'],
|
||||||
[colliding_group_id, 'タググループ:三者ID衝突'])
|
[colliding_group_id, 'タググループ:三者ID衝突'])
|
||||||
end
|
end
|
||||||
@@ -259,7 +261,6 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
[row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')]
|
[row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')]
|
||||||
end
|
end
|
||||||
expect(histories).to contain_exactly(
|
expect(histories).to contain_exactly(
|
||||||
[colliding_help.current_revision.id, colliding_help.title],
|
|
||||||
[colliding_description_version.id, '新タグ'],
|
[colliding_description_version.id, '新タグ'],
|
||||||
[group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突'])
|
[group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突'])
|
||||||
end
|
end
|
||||||
@@ -285,8 +286,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'resolves colliding group, Help and description IDs by title' do
|
it 'resolves new resources by title while excluding colliding legacy Help' do
|
||||||
help = Wiki::Commit.create_legacy_content!(
|
help = seed_wiki_page(
|
||||||
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
|
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
|
||||||
seed_group(name: 'ID衝突', id: help.id, description: 'group')
|
seed_group(name: 'ID衝突', id: help.id, description: 'group')
|
||||||
TagDescription.create!(
|
TagDescription.create!(
|
||||||
@@ -294,11 +295,15 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
|||||||
TagDescriptionVersion.create!(
|
TagDescriptionVersion.create!(
|
||||||
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
|
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
|
||||||
event_type: 'create', body: 'description', created_at: Time.current)
|
event_type: 'create', body: 'description', created_at: Time.current)
|
||||||
{ 'タググループ:ID衝突' => 'group', 'Help:ID衝突' => 'help', '新タグ' => 'description' }
|
{ 'タググループ:ID衝突' => 'group', '新タグ' => 'description' }
|
||||||
.each do |title, body|
|
.each do |title, body|
|
||||||
get "/wiki/title/#{ CGI.escapeURIComponent(title) }"
|
get "/wiki/title/#{ CGI.escapeURIComponent(title) }"
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.fetch('body').strip).to eq(body)
|
expect(json.fetch('body').strip).to eq(body)
|
||||||
end
|
end
|
||||||
|
without_group_legacy_reads do
|
||||||
|
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
|
||||||
|
end
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates the group, first snapshot and compatibility Wiki history' do
|
it 'creates the group and first snapshot without new Wiki records' do
|
||||||
create_group
|
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)
|
expect(response).to have_http_status(:created)
|
||||||
group = group_rows('tag_groups', name: '新規').sole
|
group = group_rows('tag_groups', name: '新規').sole
|
||||||
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
|
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
|
||||||
@@ -42,11 +45,6 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
version = group_version(group.fetch('id'), 1)
|
version = group_version(group.fetch('id'), 1)
|
||||||
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
|
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
|
||||||
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
|
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
|
||||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' })
|
|
||||||
expect(page.body.strip).to eq('- 洗澡鹿')
|
|
||||||
expect(page.current_revision.body).to eq(page.body)
|
|
||||||
expect(page.wiki_versions.sole.body).to eq(page.body)
|
|
||||||
# New IDs are independently allocated; no global identity assertion here.
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'keeps repeated placements but only one membership and one snapshot member' do
|
it 'keeps repeated placements but only one membership and one snapshot member' do
|
||||||
@@ -293,8 +291,9 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
|||||||
expect(group_state).to eq(before_state)
|
expect(group_state).to eq(before_state)
|
||||||
end
|
end
|
||||||
|
|
||||||
(TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
|
tables = TagGroupRecords::GROUP_TABLES
|
||||||
.each do |table|
|
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
|
it "rolls back all writes after a real #{ table } write fails" do
|
||||||
expect_atomic_group_write(table) { write_group(operation, headers) }
|
expect_atomic_group_write(table) { write_group(operation, headers) }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Tag group write integrity', type: :request do
|
RSpec.describe 'Tag group write integrity', type: :request do
|
||||||
|
include WikiRecords
|
||||||
include TagGroupRecords
|
include TagGroupRecords
|
||||||
include ActiveSupport::Testing::TimeHelpers
|
include ActiveSupport::Testing::TimeHelpers
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_help body
|
def create_help body
|
||||||
Wiki::Commit.create_legacy_content!(
|
seed_wiki_page(
|
||||||
tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user)
|
tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -136,14 +137,14 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
|||||||
id = seed_group(name: '更新', id: help.id, members:, placements:)
|
id = seed_group(name: '更新', id: help.id, members:, placements:)
|
||||||
help_before = help.attributes
|
help_before = help.attributes
|
||||||
|
|
||||||
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
|
expect do
|
||||||
|
put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
|
||||||
|
end.to change(WikiPage, :count).by(0)
|
||||||
|
.and change(WikiRevision, :count).by(0)
|
||||||
|
.and change(WikiVersion, :count).by(0)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:改名' })
|
expect(TagGroup.find(id).name).to eq('改名')
|
||||||
expect(page.id).not_to eq(help.id)
|
|
||||||
expect(page.body).to eq('- 洗澡鹿')
|
|
||||||
expect(page.current_revision.body).to eq(page.body)
|
|
||||||
expect(page.wiki_versions.sole.body).to eq(page.body)
|
|
||||||
expect(help.reload.attributes).to eq(help_before)
|
expect(help.reload.attributes).to eq(help_before)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -391,7 +391,18 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
[:create, :update].each do |operation|
|
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
|
it "synchronises Wiki current state and histories on native #{ operation }" do
|
||||||
native_write(operation)
|
native_write(operation)
|
||||||
expect(response).to have_http_status(operation == :create ? :created : :ok)
|
expect(response).to have_http_status(operation == :create ? :created : :ok)
|
||||||
@@ -418,19 +429,18 @@ RSpec.describe 'Tag group native API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'uses AUTO_INCREMENT for a new group instead of supplying a compatibility ID' do
|
it 'assigns the next group ID from the existing maximum' do
|
||||||
inserts = []
|
child_id
|
||||||
subscriber = lambda do |*args|
|
expected_id = (TagGroup.maximum(:id) || 0) + 1
|
||||||
sql = args.last.fetch(:sql)
|
native_write(:create)
|
||||||
inserts << sql if sql.match?(/\A\s*INSERT INTO `?tag_groups`?\s/i)
|
|
||||||
end
|
|
||||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
|
||||||
native_write(:create)
|
|
||||||
end
|
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
expect(inserts.size).to eq(1)
|
expect(json.fetch('id')).to eq(expected_id)
|
||||||
columns = inserts.sole[/\((.*?)\)/, 1].split(',').map { _1.strip.delete('`') }
|
end
|
||||||
expect(columns).not_to include('id')
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
it 'updates a native group through Wiki and then reads structured current data' do
|
it 'updates a native group through Wiki and then reads structured current data' do
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,41 +18,14 @@ 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: 'Help: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('Help:wiki_history_create_atomic')
|
|
||||||
expect(page.body).to eq("a\nb\nc")
|
|
||||||
|
|
||||||
expect(revision).to be_content
|
|
||||||
expect(revision.message).to eq('initial commit')
|
|
||||||
expect(revision.lines_count).to eq(3)
|
|
||||||
|
|
||||||
expect(version).to have_attributes(
|
|
||||||
version_no: 1,
|
|
||||||
event_type: 'create',
|
|
||||||
title: 'Help:wiki_history_create_atomic',
|
|
||||||
body: "a\nb\nc",
|
|
||||||
reason: 'initial commit',
|
|
||||||
created_by_user_id: user.id
|
|
||||||
)
|
|
||||||
end
|
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
|
||||||
@@ -71,21 +45,19 @@ RSpec.describe 'Wiki history integrity', type: :request do
|
|||||||
.where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist
|
.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: 'Help: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: 'Help: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)
|
expect(WikiPage.joins(:tag_name)
|
||||||
.where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1)
|
.where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,7 +19,7 @@ RSpec.describe 'Wiki API', type: :request do
|
|||||||
deprecated_at: Time.zone.local(2026, 6, 1))
|
deprecated_at: Time.zone.local(2026, 6, 1))
|
||||||
end
|
end
|
||||||
let!(:page) do
|
let!(:page) do
|
||||||
Wiki::Commit.create_content!(
|
seed_wiki_page(
|
||||||
tag_name: tn,
|
tag_name: tn,
|
||||||
body: 'init',
|
body: 'init',
|
||||||
created_by_user: user,
|
created_by_user: user,
|
||||||
@@ -108,94 +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: 'Help: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('Help:TestPage')
|
|
||||||
|
|
||||||
created_page = WikiPage.find(page_id)
|
|
||||||
version = created_page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(version).to have_attributes(
|
|
||||||
version_no: 1,
|
|
||||||
event_type: 'create',
|
|
||||||
title: 'Help:TestPage',
|
|
||||||
body: "a\nb\nc",
|
|
||||||
created_by_user_id: member.id
|
|
||||||
)
|
|
||||||
|
|
||||||
rev = created_page.current_revision
|
|
||||||
expect(rev).to be_present
|
|
||||||
expect(rev).to be_content
|
|
||||||
expect(rev.message).to eq('init')
|
|
||||||
|
|
||||||
expect(created_page.body).to eq("a\nb\nc")
|
|
||||||
|
|
||||||
expect(rev.lines_count).to eq(3)
|
|
||||||
expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2])
|
|
||||||
expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c'])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'reuses existing WikiLine rows by sha256' do
|
|
||||||
# 先に同じ行を作っておく
|
|
||||||
WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current)
|
|
||||||
|
|
||||||
post endpoint,
|
|
||||||
params: { title: 'Help:Reuse', body: "a\na" },
|
|
||||||
headers: auth_headers(member)
|
|
||||||
|
|
||||||
page = WikiPage.find(JSON.parse(response.body).fetch('id'))
|
|
||||||
rev = page.current_revision
|
|
||||||
expect(rev.lines_count).to eq(2)
|
|
||||||
|
|
||||||
# "a" の WikiLine が増殖しない(1行のはず)
|
|
||||||
expect(WikiLine.where(body: 'a').count).to eq(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'deduplicates duplicated new lines before upsert' do
|
|
||||||
duplicated = 'duplicated_line_for_wiki_line_upsert_spec'
|
|
||||||
|
|
||||||
post endpoint,
|
|
||||||
params: { title: 'Help:DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" },
|
|
||||||
headers: auth_headers(member)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
page = WikiPage.find(json.fetch('id'))
|
|
||||||
rev = page.current_revision
|
|
||||||
|
|
||||||
expect(rev.lines_count).to eq(2)
|
|
||||||
expect(WikiLine.where(body: duplicated).count).to eq(1)
|
|
||||||
expect(rev.wiki_revision_lines.count).to eq(2)
|
|
||||||
expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'normalises CRLF and strips trailing newlines' do
|
|
||||||
post endpoint,
|
|
||||||
params: { title: 'Help:NormalisedBody',
|
|
||||||
body: "a\r\nb\r\n\r\n", message: 'normalise' },
|
|
||||||
headers: auth_headers(member)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
page = WikiPage.find(json.fetch('id'))
|
|
||||||
rev = page.current_revision
|
|
||||||
version = page.wiki_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(page.body).to eq("a\nb")
|
|
||||||
expect(version.body).to eq("a\nb")
|
|
||||||
expect(rev.lines_count).to eq(2)
|
|
||||||
expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b'])
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -211,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,
|
||||||
@@ -249,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)
|
||||||
@@ -284,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,
|
||||||
@@ -358,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,
|
||||||
@@ -516,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,
|
||||||
@@ -539,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,
|
||||||
|
|||||||
@@ -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: 'Help: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: 'Help: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
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
|
RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
|
||||||
|
include WikiRecords
|
||||||
let!(:user) { create_member_user! }
|
let!(:user) { create_member_user! }
|
||||||
let!(:tag_name) { create(:tag_name, name: 'Help_contract') }
|
let!(:tag_name) { create(:tag_name, name: 'Help_contract') }
|
||||||
let!(:page) do
|
let!(:page) do
|
||||||
Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: user)
|
seed_wiki_page(tag_name:, body: 'before', created_by_user: user)
|
||||||
end
|
end
|
||||||
let!(:first_revision) { page.current_revision }
|
let!(:first_revision) { page.current_revision }
|
||||||
let!(:last_revision) do
|
let!(:last_revision) do
|
||||||
@@ -33,83 +34,45 @@ RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
|
|||||||
expect(page.current_revision).to have_attributes(message: 'legacy rename')
|
expect(page.current_revision).to have_attributes(message: 'legacy rename')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'shows legacy current and historical content by ID' do
|
include TagGroupRecords
|
||||||
get "/wiki/#{ page.id }"
|
|
||||||
expect(response).to have_http_status(:ok)
|
[:id, :title].each do |lookup|
|
||||||
expect(json).to include('body' => 'after', 'revision_id' => last_revision.id,
|
[nil, :historical].each do |edition|
|
||||||
'pred' => first_revision.id, 'succ' => nil)
|
it "rejects legacy #{ lookup } #{ edition || 'current' } without legacy SELECTs" do
|
||||||
get "/wiki/#{ page.id }", params: { version: first_revision.id }
|
path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }"
|
||||||
expect(response).to have_http_status(:ok)
|
params = edition ? { version: first_revision.id } : { }
|
||||||
expect(json).to include('body' => 'before', 'revision_id' => first_revision.id,
|
without_group_legacy_reads { get path, params: }
|
||||||
'pred' => nil, 'succ' => last_revision.id)
|
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
|
end
|
||||||
|
|
||||||
it 'shows legacy current and historical content by title' do
|
['/wiki', '/wiki/search', '/wiki/changes'].each do |path|
|
||||||
get "/wiki/title/#{ tag_name.name }"
|
it "excludes unmigrated content from #{ path } without legacy SELECTs" do
|
||||||
expect(response).to have_http_status(:ok)
|
without_group_legacy_reads { get path }
|
||||||
expect(json).to include('body' => 'after', 'revision_id' => last_revision.id)
|
|
||||||
get "/wiki/title/#{ tag_name.name }", params: { version: first_revision.id }
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json).to include('body' => 'before', 'revision_id' => first_revision.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'recognises legacy IDs' do
|
|
||||||
get "/wiki/#{ page.id }/exists"
|
|
||||||
expect(response).to have_http_status(:no_content)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'recognises legacy titles' do
|
|
||||||
get "/wiki/title/#{ tag_name.name }/exists"
|
|
||||||
expect(response).to have_http_status(:no_content)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'diffs legacy revisions with explicit and current target' do
|
|
||||||
[last_revision.id, nil].each do |to|
|
|
||||||
get "/wiki/#{ page.id }/diff", params: { from: first_revision.id, to: }
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json).to include(
|
expect(json).to eq([])
|
||||||
'wiki_page_id' => page.id, 'title' => tag_name.name,
|
|
||||||
'older_revision_id' => first_revision.id, 'newer_revision_id' => last_revision.id,
|
|
||||||
'diff' => [{ 'type' => 'removed', 'content' => 'before' },
|
|
||||||
{ 'type' => 'added', 'content' => 'after' }])
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'revision resource scope' do
|
it 'returns empty scoped history without legacy SELECTs' do
|
||||||
let!(:other_page) do
|
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
|
||||||
Wiki::Commit.create_content!(
|
|
||||||
tag_name: create(:tag_name, name: 'Help_other_contract'),
|
|
||||||
body: 'other page history', created_by_user: user)
|
|
||||||
end
|
|
||||||
let(:other_revision) { other_page.current_revision }
|
|
||||||
|
|
||||||
it 'returns 404 when show specifies another page revision' do
|
|
||||||
get "/wiki/#{ page.id }", params: { version: other_revision.id }
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 404 when diff from belongs to another page' do
|
|
||||||
get "/wiki/#{ page.id }/diff",
|
|
||||||
params: { from: other_revision.id, to: last_revision.id }
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 404 when diff to belongs to another page' do
|
|
||||||
get "/wiki/#{ page.id }/diff",
|
|
||||||
params: { from: first_revision.id, to: other_revision.id }
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'retains legacy history metadata and ordering' do
|
|
||||||
get '/wiki/changes', params: { id: page.id }
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.map { |row| row.fetch('revision_id') }).to eq(
|
expect(json).to eq([])
|
||||||
[last_revision.id, first_revision.id])
|
end
|
||||||
expect(json.first).to include(
|
|
||||||
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
|
[false, true].each do |explicit|
|
||||||
'message' => 'legacy edit', 'timestamp' => last_revision.created_at.iso8601(3),
|
it "rejects legacy diff with #{ explicit ? 'explicit' : 'current' } target" do
|
||||||
'wiki_page' => { 'id' => page.id, 'title' => tag_name.name, 'deprecated_at' => nil },
|
params = { from: first_revision.id }
|
||||||
'user' => { 'id' => user.id, 'name' => user.name })
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' }] },
|
||||||
|
|||||||
@@ -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 ()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 ('ホーム第三版 ヘルプ'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }`}>
|
||||||
|
< 古
|
||||||
|
</PrefetchLink>)
|
||||||
|
: '(最古)'}
|
||||||
|
|
||||||
|
<span>{edition}</span>
|
||||||
|
|
||||||
|
{succ
|
||||||
|
? (
|
||||||
|
<PrefetchLink
|
||||||
|
to={`/help/${ encodeURIComponent (identifier) }?edition=${ succ }`}>
|
||||||
|
新 >
|
||||||
|
</PrefetchLink>)
|
||||||
|
: '(最新)'}
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
<article className="prose dark:prose-invert mx-auto p-4">
|
||||||
|
{Content ? <Content/> : <div>Loading...</div>}
|
||||||
|
</article>
|
||||||
|
</MainArea>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default HelpDetailPage
|
||||||
@@ -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 ()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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 ()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする