diff --git a/backend/spec/requests/tag_description_wiki_spec.rb b/backend/spec/requests/tag_description_wiki_spec.rb
index 90e2bbb..a7af93d 100644
--- a/backend/spec/requests/tag_description_wiki_spec.rb
+++ b/backend/spec/requests/tag_description_wiki_spec.rb
@@ -1,10 +1,11 @@
require 'rails_helper'
RSpec.describe 'Tag description Wiki compatibility', type: :request do
+ include WikiRecords
let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'description_contract') }
let!(:page) do
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name:, body: 'old-revision', created_by_user: user)
end
let!(:revisions) do
@@ -242,8 +243,8 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
end
describe 'mixed changes' do
- it 'includes unmigrated history and ignores migrated legacy-only revisions' do
- legacy_page = Wiki::Commit.create_content!(
+ it 'excludes unmigrated history and migrated legacy-only revisions' do
+ legacy_page = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help_mixed'),
body: 'help', created_by_user: user)
legacy_revision = legacy_page.current_revision
@@ -256,7 +257,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
migrated = json.select { |row| row.fetch('wiki_page').fetch('id') == description.id }
expect(migrated.map { |row| row.fetch('revision_id') }).to eq(versions.reverse.map(&:id))
expect(json.map { |row| row.fetch('revision_id') }).not_to include(extra.id)
- expect(json).to include(include(
+ expect(json).not_to include(include(
'revision_id' => legacy_revision.id,
'wiki_page' => include('id' => legacy_page.id, 'title' => 'Help_mixed')))
end
diff --git a/backend/spec/requests/tag_description_wiki_write_spec.rb b/backend/spec/requests/tag_description_wiki_write_spec.rb
index 798cffc..ab8d035 100644
--- a/backend/spec/requests/tag_description_wiki_write_spec.rb
+++ b/backend/spec/requests/tag_description_wiki_write_spec.rb
@@ -1,6 +1,7 @@
require 'rails_helper'
RSpec.describe 'Tag description Wiki dual writes', type: :request do
+ include WikiRecords
let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'description_write') }
let(:tag) { create(:tag, primary_tag_name: tag_name) }
@@ -31,25 +32,18 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
headers: headers
end
- it 'creates both current records and histories with a shared revision ID' do
+ it 'creates a description without implicitly creating Wiki records' do
expect { create_description }
- .to change(WikiPage, :count).by(1)
- .and change(WikiRevision, :count).by(1)
- .and change(WikiVersion, :count).by(1)
+ .to change(WikiPage, :count).by(0)
+ .and change(WikiRevision, :count).by(0)
+ .and change(WikiVersion, :count).by(0)
.and change(TagDescription, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:created)
-
- page = WikiPage.find(json.fetch('id'))
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
- version = TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja')
- expect(description).to have_attributes(id: page.id, body: 'initial', version_no: 1)
- expect(version).to have_attributes(
- id: page.current_revision.id, body: 'initial', version_no: 1,
- event_type: 'create', created_by_user_id: user.id)
- expect(page.body).to eq(description.body)
- expect(page.current_revision.body).to eq(version.body)
- expect(page.wiki_versions.last.body).to eq(version.body)
+ expect(description).to have_attributes(body: 'initial', version_no: 1)
+ expect(TagDescriptionVersion.find_by!(tag_id: tag.id, language_code: 'ja'))
+ .to have_attributes(body: 'initial', version_no: 1, created_by_user_id: user.id)
end
['missing_description', 'orphan_description'].each do |title|
@@ -65,48 +59,19 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
end
end
- it 'creates a namespaced legacy Wiki resource without a Tag' do
- expect do
- post '/wiki', params: { title: 'Help:description_write', body: 'legacy' },
- headers: headers
- end.to change(WikiPage, :count).by(1)
- .and change(WikiRevision, :count).by(1)
- .and change(WikiVersion, :count).by(1)
- .and change(TagDescription, :count).by(0)
- .and change(TagDescriptionVersion, :count).by(0)
-
- expect(response).to have_http_status(:created)
- page = WikiPage.find(json.fetch('id'))
- expect(page.tag_name.tag).to be_nil
- expect(page.body).to eq('legacy')
- expect(page.current_revision.body).to eq('legacy')
+ [false, true].each do |linked|
+ it "rejects namespaced creation with Tag present: #{ linked }" do
+ name = create(:tag_name, name: 'Help:frozen')
+ create(:tag, primary_tag_name: name) if linked
+ before_state = stored_state
+ post '/wiki', params: { title: name.name, body: 'legacy' }, headers: headers
+ expect(response.status).to be_between(400, 499)
+ expect(stored_state).to eq(before_state)
+ end
end
- it 'creates only legacy Wiki for a namespaced title with an existing Tag' do
- namespaced_name = create(:tag_name, name: 'help:foo')
- namespaced_tag = create(:tag, primary_tag_name: namespaced_name)
-
- expect do
- post '/wiki', params: { title: namespaced_name.name, body: 'legacy' },
- headers: headers
- end.to change(WikiPage, :count).by(1)
- .and change(WikiRevision, :count).by(1)
- .and change(WikiVersion, :count).by(1)
- .and change(TagName, :count).by(0)
- .and change(TagDescription, :count).by(0)
- .and change(TagDescriptionVersion, :count).by(0)
-
- expect(response).to have_http_status(:created)
- page = WikiPage.find(json.fetch('id'))
- expect(page).to have_attributes(tag_name_id: namespaced_name.id, body: 'legacy')
- expect(page.tag_name.tag).to eq(namespaced_tag)
- expect(page.current_revision.body).to eq('legacy')
- expect(page.wiki_versions.last.body).to eq('legacy')
- end
-
- [WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
- it "rolls back both systems when #{ model.name } persistence fails" do
- tag_name
+ [TagDescription, TagDescriptionVersion].each do |model|
+ it "rolls back new description creation when #{ model.name } persistence fails" do
before_state = stored_state
aggregate_failures do
fail_save(model) { create_description }
@@ -119,7 +84,7 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
describe 'PUT /wiki/:id' do
let!(:page) do
tag
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name:, body: 'before', created_by_user: user)
end
let!(:first_revision) { page.current_revision }
@@ -138,7 +103,8 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
expect(page.reload.version_no).to eq(1)
expect(description.version_no).to eq(1)
expect { update_description }
- .to change(WikiRevision, :count).by(1)
+ .to change(WikiPage, :count).by(0)
+ .and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
expect(response).to have_http_status(:ok)
@@ -157,7 +123,8 @@ RSpec.describe 'Tag description Wiki dual writes', type: :request do
tag_name.update_columns(tag_id: nil)
expect { update_description }
- .to change(WikiRevision, :count).by(1)
+ .to change(WikiPage, :count).by(0)
+ .and change(WikiRevision, :count).by(1)
.and change(WikiVersion, :count).by(1)
.and change(TagDescriptionVersion, :count).by(1)
diff --git a/backend/spec/requests/tag_group_wiki_spec.rb b/backend/spec/requests/tag_group_wiki_spec.rb
index 2104beb..b42805c 100644
--- a/backend/spec/requests/tag_group_wiki_spec.rb
+++ b/backend/spec/requests/tag_group_wiki_spec.rb
@@ -1,6 +1,7 @@
require 'rails_helper'
RSpec.describe 'Tag group Wiki facade reads', type: :request do
+ include WikiRecords
include TagGroupRecords
let!(:user) { create_member_user! }
@@ -69,34 +70,36 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end
end
- it 'combines Tag Groups, Help and Tag Descriptions in the unfiltered list' do
- help = Wiki::Commit.create_legacy_content!(
+ it 'combines Tag Groups and Tag Descriptions without Help in the unfiltered list' do
+ help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:混在'), body: 'help', created_by_user: user)
TagDescriptionOperation.create!(tag: new_tag, body: 'tag description', created_by_user: user)
get '/wiki'
expect(response).to have_http_status(:ok)
expect(json.map { _1.fetch('title') }).to include(
- 'タググループ:読取り契約', help.title, '新タグ')
+ 'タググループ:読取り契約', '新タグ')
+ expect(json.map { _1.fetch('title') }).not_to include(help.title)
end
- it 'keeps Help readable when its ID collides with a new group' do
- help = Wiki::Commit.create_legacy_content!(
+ it 'excludes Help when its ID collides with a new group' do
+ help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:ID重複'), body: 'help body',
created_by_user: user)
seed_group(name: '別 ID重複', id: help.id, description: 'group body')
get '/wiki'
expect(response).to have_http_status(:ok)
- expect(json.map { _1.fetch('title') }).to include(help.title, 'タググループ:別 ID重複')
+ expect(json.map { _1.fetch('title') }).to include('タググループ:別 ID重複')
+ expect(json.map { _1.fetch('title') }).not_to include(help.title)
get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
- expect(response).to have_http_status(:ok)
- expect(json.fetch('body')).to eq('help body')
+ expect(response).to have_http_status(:not_found)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
expect(json.map { _1.dig('wiki_page', 'title') }).to include(
- help.title, 'タググループ:別 ID重複')
+ 'タググループ:別 ID重複')
+ expect(json.map { _1.dig('wiki_page', 'title') }).not_to include(help.title)
end
it 'finds groups by a partial prefix of their displayed title' do
@@ -122,7 +125,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end
it 'orders mixed resource history by timestamp rather than independent IDs' do
- help = Wiki::Commit.create_legacy_content!(
+ help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:時系列'), body: 'help',
created_by_user: user)
TagDescriptionOperation.create!(tag: new_tag, body: 'description', created_by_user: user)
@@ -136,8 +139,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
timestamps = json.map { Time.iso8601(_1.fetch('timestamp')) }
expect(timestamps).to eq(timestamps.sort.reverse)
titles = json.map { _1.dig('wiki_page', 'title') }
- expect(titles.index('新タグ')).to be < titles.index(help.title)
- expect(titles.index(help.title)).to be < titles.index('タググループ:読取り契約')
+ expect(titles).not_to include(help.title)
+ expect(titles.index('新タグ')).to be < titles.index('タググループ:読取り契約')
end
it 'renders nullable group editors without raising' do
@@ -166,16 +169,16 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
end
- it 'includes Help and group history but excludes legacy-only migrated revisions' do
+ it 'includes group history but excludes all legacy-only revisions' do
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
- help = Wiki::Commit.create_legacy_content!(
+ help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:変更一覧'), body: 'help', created_by_user: user)
get '/wiki/changes'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('wiki_page').fetch('title') == 'タググループ:読取り契約' }
expect(rows.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
expect(rows.map { _1.fetch('revision_id') }).not_to include(extra.id)
- expect(json).to include(include(
+ expect(json).not_to include(include(
'revision_id' => help.current_revision.id,
'wiki_page' => include('title' => help.title)))
end
@@ -214,7 +217,7 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
context 'when Help, description and group share the same numeric ID' do
let(:colliding_help) do
- Wiki::Commit.create_legacy_content!(
+ seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:三者ID衝突'),
body: 'help', created_by_user: user)
end
@@ -239,13 +242,12 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
colliding_description_version
end
- it 'lists all three resources without deduplicating their numeric ID' do
+ it 'lists both new resources sharing a numeric ID without legacy Help' do
get '/wiki'
expect(response).to have_http_status(:ok)
rows = json.select { _1.fetch('id') == colliding_help.id }
expect(rows.map { [_1.fetch('id'), _1.fetch('title')] }).to contain_exactly(
- [colliding_help.id, colliding_help.title],
[colliding_description.id, '新タグ'],
[colliding_group_id, 'タググループ:三者ID衝突'])
end
@@ -259,7 +261,6 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
[row.fetch('revision_id'), row.fetch('wiki_page').fetch('title')]
end
expect(histories).to contain_exactly(
- [colliding_help.current_revision.id, colliding_help.title],
[colliding_description_version.id, '新タグ'],
[group_version(colliding_group_id, 1).fetch('id'), 'タググループ:三者ID衝突'])
end
@@ -285,8 +286,8 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
end
end
- it 'resolves colliding group, Help and description IDs by title' do
- help = Wiki::Commit.create_legacy_content!(
+ it 'resolves new resources by title while excluding colliding legacy Help' do
+ help = seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
seed_group(name: 'ID衝突', id: help.id, description: 'group')
TagDescription.create!(
@@ -294,11 +295,15 @@ RSpec.describe 'Tag group Wiki facade reads', type: :request do
TagDescriptionVersion.create!(
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
event_type: 'create', body: 'description', created_at: Time.current)
- { 'タググループ:ID衝突' => 'group', 'Help:ID衝突' => 'help', '新タグ' => 'description' }
+ { 'タググループ:ID衝突' => 'group', '新タグ' => 'description' }
.each do |title, body|
get "/wiki/title/#{ CGI.escapeURIComponent(title) }"
expect(response).to have_http_status(:ok)
expect(json.fetch('body').strip).to eq(body)
end
+ without_group_legacy_reads do
+ get "/wiki/title/#{ CGI.escapeURIComponent(help.title) }"
+ end
+ expect(response).to have_http_status(:not_found)
end
end
diff --git a/backend/spec/requests/tag_group_wiki_write_spec.rb b/backend/spec/requests/tag_group_wiki_write_spec.rb
index 4c80a63..bf48402 100644
--- a/backend/spec/requests/tag_group_wiki_write_spec.rb
+++ b/backend/spec/requests/tag_group_wiki_write_spec.rb
@@ -33,8 +33,11 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
end
end
- it 'creates the group, first snapshot and compatibility Wiki history' do
- create_group
+ it 'creates the group and first snapshot without new Wiki records' do
+ expect { create_group }
+ .to change(WikiPage, :count).by(0)
+ .and change(WikiRevision, :count).by(0)
+ .and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:created)
group = group_rows('tag_groups', name: '新規').sole
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
@@ -42,11 +45,6 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
version = group_version(group.fetch('id'), 1)
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
- page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:新規' })
- expect(page.body.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
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)
end
- (TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
- .each do |table|
+ tables = TagGroupRecords::GROUP_TABLES
+ tables += ['wiki_pages', 'wiki_revisions', 'wiki_versions'] if operation == :update
+ tables.each do |table|
it "rolls back all writes after a real #{ table } write fails" do
expect_atomic_group_write(table) { write_group(operation, headers) }
end
diff --git a/backend/spec/requests/tag_group_write_integrity_spec.rb b/backend/spec/requests/tag_group_write_integrity_spec.rb
index 2d6ce0e..b2f75b9 100644
--- a/backend/spec/requests/tag_group_write_integrity_spec.rb
+++ b/backend/spec/requests/tag_group_write_integrity_spec.rb
@@ -1,6 +1,7 @@
require 'rails_helper'
RSpec.describe 'Tag group write integrity', type: :request do
+ include WikiRecords
include TagGroupRecords
include ActiveSupport::Testing::TimeHelpers
@@ -24,7 +25,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
end
def create_help body
- Wiki::Commit.create_legacy_content!(
+ seed_wiki_page(
tag_name: create(:tag_name, name: 'Help:既存'), body:, created_by_user: user)
end
@@ -136,14 +137,14 @@ RSpec.describe 'Tag group write integrity', type: :request do
id = seed_group(name: '更新', id: help.id, members:, placements:)
help_before = help.attributes
- put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
+ expect do
+ put "/tag_groups/#{ id }", params: native_payload(name: '改名'), headers:, as: :json
+ end.to change(WikiPage, :count).by(0)
+ .and change(WikiRevision, :count).by(0)
+ .and change(WikiVersion, :count).by(0)
expect(response).to have_http_status(:ok)
- page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:改名' })
- expect(page.id).not_to eq(help.id)
- expect(page.body).to eq('- 洗澡鹿')
- expect(page.current_revision.body).to eq(page.body)
- expect(page.wiki_versions.sole.body).to eq(page.body)
+ expect(TagGroup.find(id).name).to eq('改名')
expect(help.reload.attributes).to eq(help_before)
end
diff --git a/backend/spec/requests/tag_groups_spec.rb b/backend/spec/requests/tag_groups_spec.rb
index 33ba293..e5fe142 100644
--- a/backend/spec/requests/tag_groups_spec.rb
+++ b/backend/spec/requests/tag_groups_spec.rb
@@ -391,7 +391,18 @@ RSpec.describe 'Tag group native API', type: :request do
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
native_write(operation)
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
- it 'uses AUTO_INCREMENT for a new group instead of supplying a compatibility ID' do
- inserts = []
- subscriber = lambda do |*args|
- sql = args.last.fetch(:sql)
- inserts << sql if sql.match?(/\A\s*INSERT INTO `?tag_groups`?\s/i)
- end
- ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
- native_write(:create)
- end
+ it 'assigns the next group ID from the existing maximum' do
+ child_id
+ expected_id = (TagGroup.maximum(:id) || 0) + 1
+ native_write(:create)
expect(response).to have_http_status(:created)
- expect(inserts.size).to eq(1)
- columns = inserts.sole[/\((.*?)\)/, 1].split(',').map { _1.strip.delete('`') }
- expect(columns).not_to include('id')
+ expect(json.fetch('id')).to eq(expected_id)
+ end
+
+ TagGroupRecords::GROUP_TABLES.each do |table|
+ it "rolls back native creation after #{ table } writes" do
+ expect_atomic_group_write(table) { native_write(:create) }
+ end
end
it 'updates a native group through Wiki and then reads structured current data' do
diff --git a/backend/spec/requests/tag_resource_ids_spec.rb b/backend/spec/requests/tag_resource_ids_spec.rb
new file mode 100644
index 0000000..a0655f0
--- /dev/null
+++ b/backend/spec/requests/tag_resource_ids_spec.rb
@@ -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
diff --git a/backend/spec/requests/tag_wiki_history_integrity_spec.rb b/backend/spec/requests/tag_wiki_history_integrity_spec.rb
index 356e7e0..665eee2 100644
--- a/backend/spec/requests/tag_wiki_history_integrity_spec.rb
+++ b/backend/spec/requests/tag_wiki_history_integrity_spec.rb
@@ -1,6 +1,7 @@
require 'rails_helper'
RSpec.describe 'Tag and wiki history integrity', type: :request do
+ include WikiRecords
let(:member_user) { create(:user, role: 'member') }
def stub_current_user user
@@ -13,7 +14,7 @@ RSpec.describe 'Tag and wiki history integrity', type: :request do
end
def create_wiki_for_tag! tag:, body: 'wiki body', user: member_user
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name: primary_tag_name_for(tag, 'ja'),
body:,
created_by_user: user,
diff --git a/backend/spec/requests/wiki_conflict_spec.rb b/backend/spec/requests/wiki_conflict_spec.rb
index 39c5458..fb99dc7 100644
--- a/backend/spec/requests/wiki_conflict_spec.rb
+++ b/backend/spec/requests/wiki_conflict_spec.rb
@@ -1,6 +1,7 @@
require 'rails_helper'
RSpec.describe 'Wiki conflict handling', type: :request do
+ include WikiRecords
let!(:user) { create_member_user! }
def auth_headers user
@@ -9,7 +10,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do
it 'returns 409 when base_revision_id is stale' do
page =
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name: create(:tag_name, name: 'wiki_conflict_request'),
body: 'first',
created_by_user: user,
diff --git a/backend/spec/requests/wiki_creation_freeze_spec.rb b/backend/spec/requests/wiki_creation_freeze_spec.rb
new file mode 100644
index 0000000..e5f000a
--- /dev/null
+++ b/backend/spec/requests/wiki_creation_freeze_spec.rb
@@ -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
diff --git a/backend/spec/requests/wiki_history_integrity_spec.rb b/backend/spec/requests/wiki_history_integrity_spec.rb
index 50de843..0a12c73 100644
--- a/backend/spec/requests/wiki_history_integrity_spec.rb
+++ b/backend/spec/requests/wiki_history_integrity_spec.rb
@@ -2,6 +2,7 @@ require 'cgi'
require 'rails_helper'
RSpec.describe 'Wiki history integrity', type: :request do
+ include WikiRecords
let!(:user) { create_member_user! }
def auth_headers user
@@ -9,7 +10,7 @@ RSpec.describe 'Wiki history integrity', type: :request do
end
def create_wiki_page title:, body: 'body', message: 'init', user: self.user
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name: create(:tag_name, name: title),
body:,
created_by_user: user,
@@ -17,41 +18,14 @@ RSpec.describe 'Wiki history integrity', type: :request do
end
describe 'POST /wiki' do
- it 'creates wiki_page, wiki_revision, and wiki_version atomically' do
- expect {
- post '/wiki',
- params: {
- title: 'Help:wiki_history_create_atomic',
- body: "a\nb\nc",
- message: 'initial commit',
- },
+ it 'rejects frozen creation without partial page or history' do
+ expect do
+ post '/wiki', params: { title: 'Help:wiki_history_create_atomic', body: 'initial' },
headers: auth_headers(user)
- }
- .to change(WikiPage, :count).by(1)
- .and change(WikiRevision, :count).by(1)
- .and change(WikiVersion, :count).by(1)
-
- expect(response).to have_http_status(:created)
-
- page = WikiPage.find(json.fetch('id'))
- revision = page.current_revision
- version = page.wiki_versions.order(:version_no).last
-
- expect(page.title).to eq('Help:wiki_history_create_atomic')
- expect(page.body).to eq("a\nb\nc")
-
- expect(revision).to be_content
- expect(revision.message).to eq('initial commit')
- expect(revision.lines_count).to eq(3)
-
- expect(version).to have_attributes(
- version_no: 1,
- event_type: 'create',
- title: 'Help:wiki_history_create_atomic',
- body: "a\nb\nc",
- reason: 'initial commit',
- created_by_user_id: user.id
- )
+ end.to change(WikiPage, :count).by(0)
+ .and change(WikiRevision, :count).by(0)
+ .and change(WikiVersion, :count).by(0)
+ expect(response.status).to be_between(400, 499)
end
it 'returns 422 and creates nothing when normalised body is blank' do
@@ -71,21 +45,19 @@ RSpec.describe 'Wiki history integrity', type: :request do
.where(tag_names: { name: 'Help:wiki_history_blank_body' })).not_to exist
end
- it 'returns 422 and creates no partial page when title already exists' do
+ it 'rejects frozen creation without modifying an existing page or history' do
create_wiki_page(title: 'Help:wiki_history_duplicate_title', body: 'first')
- expect {
+ expect do
post '/wiki',
- params: {
- title: 'Help:wiki_history_duplicate_title',
- body: 'second',
- message: 'duplicate',
- },
+ params: { title: 'Help:wiki_history_duplicate_title',
+ body: 'second', message: 'duplicate' },
headers: auth_headers(user)
- }
- .not_to change(WikiPage, :count)
+ end.to change(WikiPage, :count).by(0)
+ .and change(WikiRevision, :count).by(0)
+ .and change(WikiVersion, :count).by(0)
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.status).to be_between(400, 499)
expect(WikiPage.joins(:tag_name)
.where(tag_names: { name: 'Help:wiki_history_duplicate_title' }).count).to eq(1)
end
diff --git a/backend/spec/requests/wiki_spec.rb b/backend/spec/requests/wiki_spec.rb
index 5a4eeae..012b919 100644
--- a/backend/spec/requests/wiki_spec.rb
+++ b/backend/spec/requests/wiki_spec.rb
@@ -4,6 +4,7 @@ require 'securerandom'
RSpec.describe 'Wiki API', type: :request do
+ include WikiRecords
def auth_headers(user)
{ 'X-Transfer-Code' => user.inheritance_code }
end
@@ -18,7 +19,7 @@ RSpec.describe 'Wiki API', type: :request do
deprecated_at: Time.zone.local(2026, 6, 1))
end
let!(:page) do
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name: tn,
body: 'init',
created_by_user: user,
@@ -108,94 +109,14 @@ RSpec.describe 'Wiki API', type: :request do
end
end
- context 'when success' do
- it 'creates wiki_page and first content revision' do
+ ['Help:TestPage', 'ヘルプ:ホーム'].each do |title|
+ it "rejects frozen creation of #{ title } without history writes" do
expect do
- post endpoint, params: { title: 'Help:TestPage', body: "a\nb\nc", message: 'init' },
- headers: auth_headers(member)
- end
- .to change(WikiPage, :count).by(1)
- .and change(WikiRevision, :count).by(1)
- .and change(WikiVersion, :count).by(1)
-
- expect(response).to have_http_status(:created)
-
- page_id = json.fetch('id')
- expect(json.fetch('title')).to eq('Help:TestPage')
-
- created_page = WikiPage.find(page_id)
- version = created_page.wiki_versions.order(:version_no).last
-
- expect(version).to have_attributes(
- version_no: 1,
- event_type: 'create',
- title: 'Help:TestPage',
- body: "a\nb\nc",
- created_by_user_id: member.id
- )
-
- rev = created_page.current_revision
- expect(rev).to be_present
- expect(rev).to be_content
- expect(rev.message).to eq('init')
-
- expect(created_page.body).to eq("a\nb\nc")
-
- expect(rev.lines_count).to eq(3)
- expect(rev.wiki_revision_lines.order(:position).pluck(:position)).to eq([0, 1, 2])
- expect(rev.wiki_lines.pluck(:body)).to match_array(['a', 'b', 'c'])
- end
-
- it 'reuses existing WikiLine rows by sha256' do
- # 先に同じ行を作っておく
- WikiLine.create!(sha256: Digest::SHA256.hexdigest('a'), body: 'a', created_at: Time.current, updated_at: Time.current)
-
- post endpoint,
- params: { title: 'Help:Reuse', body: "a\na" },
- headers: auth_headers(member)
-
- page = WikiPage.find(JSON.parse(response.body).fetch('id'))
- rev = page.current_revision
- expect(rev.lines_count).to eq(2)
-
- # "a" の WikiLine が増殖しない(1行のはず)
- expect(WikiLine.where(body: 'a').count).to eq(1)
- end
-
- it 'deduplicates duplicated new lines before upsert' do
- duplicated = 'duplicated_line_for_wiki_line_upsert_spec'
-
- post endpoint,
- params: { title: 'Help:DuplicateNewLine', body: "#{ duplicated }\n#{ duplicated }" },
- headers: auth_headers(member)
-
- expect(response).to have_http_status(:created)
-
- page = WikiPage.find(json.fetch('id'))
- rev = page.current_revision
-
- expect(rev.lines_count).to eq(2)
- expect(WikiLine.where(body: duplicated).count).to eq(1)
- expect(rev.wiki_revision_lines.count).to eq(2)
- expect(rev.wiki_revision_lines.pluck(:wiki_line_id).uniq.size).to eq(1)
- end
-
- it 'normalises CRLF and strips trailing newlines' do
- post endpoint,
- params: { title: 'Help:NormalisedBody',
- body: "a\r\nb\r\n\r\n", message: 'normalise' },
- headers: auth_headers(member)
-
- expect(response).to have_http_status(:created)
-
- page = WikiPage.find(json.fetch('id'))
- rev = page.current_revision
- version = page.wiki_versions.order(:version_no).last
-
- expect(page.body).to eq("a\nb")
- expect(version.body).to eq("a\nb")
- expect(rev.lines_count).to eq(2)
- expect(rev.wiki_lines.order('wiki_revision_lines.position').map(&:body)).to eq(['a', 'b'])
+ post endpoint, params: { title:, body: 'initial' }, headers: auth_headers(member)
+ end.to change(WikiPage, :count).by(0)
+ .and change(WikiRevision, :count).by(0)
+ .and change(WikiVersion, :count).by(0)
+ expect(response.status).to be_between(400, 499)
end
end
end
@@ -211,7 +132,7 @@ RSpec.describe 'Wiki API', type: :request do
let!(:test_tag_name) { create(:tag_name, name: 'TestPage') }
let!(:page) do
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name: test_tag_name,
body: "a\nb",
created_by_user: member,
@@ -249,7 +170,8 @@ RSpec.describe 'Wiki API', type: :request do
expect do
put "/wiki/#{page.id}",
- params: { title: 'TestPage', body: "x\ny", message: 'edit', base_revision_id: current_id },
+ params: { title: 'TestPage', body: "x\ny", message: 'edit',
+ base_revision_id: current_id },
headers: auth_headers(member)
end
.to change(WikiRevision, :count).by(1)
@@ -284,7 +206,7 @@ RSpec.describe 'Wiki API', type: :request do
created_by_user: member)
linked_page =
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name: linked_tag_name,
body: 'before',
created_by_user: member,
@@ -358,13 +280,14 @@ RSpec.describe 'Wiki API', type: :request do
describe 'GET /wiki/search' do
before do
- Wiki::Commit.create_content!(
- tag_name: create(:tag_name, name: 'spec_wiki_title_2'),
+ search_tag = create(:tag, primary_name: 'spec_wiki_title_2')
+ seed_wiki_page(
+ tag_name: search_tag.tag_name('ja'),
body: 'search body 2',
created_by_user: user,
message: 'init')
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name: create(:tag_name, name: 'unrelated_title'),
body: 'unrelated body',
created_by_user: user,
@@ -516,7 +439,7 @@ RSpec.describe 'Wiki API', type: :request do
it 'raises because redirect revisions are deprecated' do
target_tag_name = create(:tag_name, name: 'redirect_deprecated_target')
target =
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name: target_tag_name,
body: 'target',
created_by_user: user,
@@ -539,7 +462,7 @@ RSpec.describe 'Wiki API', type: :request do
linked_tag = create(:tag, primary_tag_name: linked_tag_name, category: :general)
linked_page =
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name: linked_tag_name,
body: 'before',
created_by_user: user,
diff --git a/backend/spec/requests/wiki_title_collision_spec.rb b/backend/spec/requests/wiki_title_collision_spec.rb
index a8df14b..d61122f 100644
--- a/backend/spec/requests/wiki_title_collision_spec.rb
+++ b/backend/spec/requests/wiki_title_collision_spec.rb
@@ -1,6 +1,7 @@
require 'rails_helper'
RSpec.describe 'Wiki title collision', type: :request do
+ include WikiRecords
let!(:user) { create_member_user! }
def auth_headers user
@@ -8,7 +9,7 @@ RSpec.describe 'Wiki title collision', type: :request do
end
def create_wiki_page title:, body:
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name: create(:tag_name, name: title),
body:,
created_by_user: user,
@@ -43,20 +44,18 @@ RSpec.describe 'Wiki title collision', type: :request do
expect(source.wiki_versions.count).to eq(source_version_count)
end
- it 'returns 422 when creating wiki with existing title' do
+ it 'rejects frozen creation with an existing title without adding history' do
create_wiki_page(title: 'Help:wiki_collision_create', body: 'already exists')
- expect {
+ expect do
post '/wiki',
- params: {
- title: 'Help:wiki_collision_create',
- body: 'new body',
- message: 'duplicate create',
- },
+ params: { title: 'Help:wiki_collision_create', body: 'new body',
+ message: 'duplicate create' },
headers: auth_headers(user)
- }
- .not_to change(WikiPage, :count)
+ end.to change(WikiPage, :count).by(0)
+ .and change(WikiRevision, :count).by(0)
+ .and change(WikiVersion, :count).by(0)
- expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.status).to be_between(400, 499)
end
end
diff --git a/backend/spec/requests/wiki_unmigrated_contract_spec.rb b/backend/spec/requests/wiki_unmigrated_contract_spec.rb
index 9cd106b..26f21a9 100644
--- a/backend/spec/requests/wiki_unmigrated_contract_spec.rb
+++ b/backend/spec/requests/wiki_unmigrated_contract_spec.rb
@@ -1,10 +1,11 @@
require 'rails_helper'
RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
+ include WikiRecords
let!(:user) { create_member_user! }
let!(:tag_name) { create(:tag_name, name: 'Help_contract') }
let!(:page) do
- Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: user)
+ seed_wiki_page(tag_name:, body: 'before', created_by_user: user)
end
let!(:first_revision) { page.current_revision }
let!(:last_revision) do
@@ -33,83 +34,45 @@ RSpec.describe 'Unmigrated Wiki compatibility', type: :request do
expect(page.current_revision).to have_attributes(message: 'legacy rename')
end
- it 'shows legacy current and historical content by ID' do
- get "/wiki/#{ page.id }"
- expect(response).to have_http_status(:ok)
- expect(json).to include('body' => 'after', 'revision_id' => last_revision.id,
- 'pred' => first_revision.id, 'succ' => nil)
- get "/wiki/#{ page.id }", params: { version: first_revision.id }
- expect(response).to have_http_status(:ok)
- expect(json).to include('body' => 'before', 'revision_id' => first_revision.id,
- 'pred' => nil, 'succ' => last_revision.id)
+ include TagGroupRecords
+
+ [:id, :title].each do |lookup|
+ [nil, :historical].each do |edition|
+ it "rejects legacy #{ lookup } #{ edition || 'current' } without legacy SELECTs" do
+ path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }"
+ params = edition ? { version: first_revision.id } : { }
+ without_group_legacy_reads { get path, params: }
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ it "does not recognise legacy #{ lookup } existence" do
+ path = lookup == :id ? "/wiki/#{ page.id }" : "/wiki/title/#{ tag_name.name }"
+ without_group_legacy_reads { get "#{ path }/exists" }
+ expect(response).to have_http_status(:not_found)
+ end
end
- it 'shows legacy current and historical content by title' do
- get "/wiki/title/#{ tag_name.name }"
- expect(response).to have_http_status(:ok)
- expect(json).to include('body' => 'after', 'revision_id' => last_revision.id)
- get "/wiki/title/#{ tag_name.name }", params: { version: first_revision.id }
- expect(response).to have_http_status(:ok)
- expect(json).to include('body' => 'before', 'revision_id' => first_revision.id)
- end
-
- it 'recognises legacy IDs' do
- get "/wiki/#{ page.id }/exists"
- expect(response).to have_http_status(:no_content)
- end
-
- it 'recognises legacy titles' do
- get "/wiki/title/#{ tag_name.name }/exists"
- expect(response).to have_http_status(:no_content)
- end
-
- it 'diffs legacy revisions with explicit and current target' do
- [last_revision.id, nil].each do |to|
- get "/wiki/#{ page.id }/diff", params: { from: first_revision.id, to: }
+ ['/wiki', '/wiki/search', '/wiki/changes'].each do |path|
+ it "excludes unmigrated content from #{ path } without legacy SELECTs" do
+ without_group_legacy_reads { get path }
expect(response).to have_http_status(:ok)
- expect(json).to include(
- 'wiki_page_id' => page.id, 'title' => tag_name.name,
- 'older_revision_id' => first_revision.id, 'newer_revision_id' => last_revision.id,
- 'diff' => [{ 'type' => 'removed', 'content' => 'before' },
- { 'type' => 'added', 'content' => 'after' }])
+ expect(json).to eq([])
end
end
- describe 'revision resource scope' do
- let!(:other_page) do
- Wiki::Commit.create_content!(
- tag_name: create(:tag_name, name: 'Help_other_contract'),
- body: 'other page history', created_by_user: user)
- end
- let(:other_revision) { other_page.current_revision }
-
- it 'returns 404 when show specifies another page revision' do
- get "/wiki/#{ page.id }", params: { version: other_revision.id }
- expect(response).to have_http_status(:not_found)
- end
-
- it 'returns 404 when diff from belongs to another page' do
- get "/wiki/#{ page.id }/diff",
- params: { from: other_revision.id, to: last_revision.id }
- expect(response).to have_http_status(:not_found)
- end
-
- it 'returns 404 when diff to belongs to another page' do
- get "/wiki/#{ page.id }/diff",
- params: { from: first_revision.id, to: other_revision.id }
- expect(response).to have_http_status(:not_found)
- end
- end
-
- it 'retains legacy history metadata and ordering' do
- get '/wiki/changes', params: { id: page.id }
+ it 'returns empty scoped history without legacy SELECTs' do
+ without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
expect(response).to have_http_status(:ok)
- expect(json.map { |row| row.fetch('revision_id') }).to eq(
- [last_revision.id, first_revision.id])
- expect(json.first).to include(
- 'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
- 'message' => 'legacy edit', 'timestamp' => last_revision.created_at.iso8601(3),
- 'wiki_page' => { 'id' => page.id, 'title' => tag_name.name, 'deprecated_at' => nil },
- 'user' => { 'id' => user.id, 'name' => user.name })
+ expect(json).to eq([])
+ end
+
+ [false, true].each do |explicit|
+ it "rejects legacy diff with #{ explicit ? 'explicit' : 'current' } target" do
+ params = { from: first_revision.id }
+ params[:to] = last_revision.id if explicit
+ without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: }
+ expect(response).to have_http_status(:not_found)
+ end
end
end
diff --git a/backend/spec/services/wiki/commit_integrity_spec.rb b/backend/spec/services/wiki/commit_integrity_spec.rb
index 42c42b7..637e7c6 100644
--- a/backend/spec/services/wiki/commit_integrity_spec.rb
+++ b/backend/spec/services/wiki/commit_integrity_spec.rb
@@ -2,10 +2,11 @@ require 'digest'
require 'rails_helper'
RSpec.describe Wiki::Commit do
+ include WikiRecords
let(:user) { create_member_user! }
def create_page title:, body: 'initial body'
- described_class.create_content!(
+ seed_wiki_page(
tag_name: create(:tag_name, name: title),
body:,
created_by_user: user,
@@ -13,26 +14,18 @@ RSpec.describe Wiki::Commit do
end
describe '.create_content!' do
- it 'creates page, revision, and version with normalised body' do
- expect {
- described_class.create_content!(
- tag_name: create(:tag_name, name: 'commit_integrity_create'),
- body: "a\r\nb\r\n\r\n",
- created_by_user: user,
- message: 'init')
- }
- .to change(WikiPage, :count).by(1)
- .and change(WikiRevision, :count).by(1)
- .and change(WikiVersion, :count).by(1)
-
- page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'commit_integrity_create' })
- revision = page.current_revision
- version = page.wiki_versions.order(:version_no).last
-
- expect(page.body).to eq("a\nb")
- expect(revision.lines_count).to eq(2)
- expect(version.body).to eq("a\nb")
- expect(version.reason).to eq('init')
+ [:create_content!, :create_legacy_content!].each do |operation|
+ it "freezes #{ operation } without adding page or history" do
+ name = create(:tag_name, name: 'Help:frozen_service')
+ counts = [WikiPage.count, WikiRevision.count, WikiVersion.count]
+ begin
+ described_class.public_send(operation, tag_name: name, body: 'initial',
+ created_by_user: user)
+ rescue ActiveRecord::RecordInvalid
+ # A validation rejection is allowed; persistence is not.
+ end
+ expect([WikiPage.count, WikiRevision.count, WikiVersion.count]).to eq(counts)
+ end
end
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)
page =
- described_class.create_content!(
+ seed_wiki_page(
tag_name:,
body: 'before',
created_by_user: user,
diff --git a/backend/spec/services/wiki/commit_spec.rb b/backend/spec/services/wiki/commit_spec.rb
index 122ac58..f540a86 100644
--- a/backend/spec/services/wiki/commit_spec.rb
+++ b/backend/spec/services/wiki/commit_spec.rb
@@ -1,12 +1,13 @@
require 'rails_helper'
RSpec.describe Wiki::Commit do
+ include WikiRecords
let(:user) { create_member_user! }
def create_page(title: 'commit_spec_page', body: 'initial body')
tag_name = create(:tag_name, name: title)
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name:,
body:,
created_by_user: user,
@@ -84,7 +85,7 @@ RSpec.describe Wiki::Commit do
tag = create(:tag, primary_tag_name: tag_name, category: :general)
page =
- described_class.create_content!(
+ seed_wiki_page(
tag_name:,
body: 'before',
created_by_user: user,
@@ -111,7 +112,7 @@ RSpec.describe Wiki::Commit do
tag = create(:tag, primary_tag_name: tag_name, category: :general)
page =
- described_class.create_content!(
+ seed_wiki_page(
tag_name:,
body: 'before',
created_by_user: user,
diff --git a/backend/spec/services/wiki_version_recorder_spec.rb b/backend/spec/services/wiki_version_recorder_spec.rb
index c8c4dfa..6c1457c 100644
--- a/backend/spec/services/wiki_version_recorder_spec.rb
+++ b/backend/spec/services/wiki_version_recorder_spec.rb
@@ -1,10 +1,11 @@
require 'rails_helper'
RSpec.describe WikiVersionRecorder do
+ include WikiRecords
let(:user) { create_member_user! }
def create_page title:, body: 'body'
- Wiki::Commit.create_content!(
+ seed_wiki_page(
tag_name: create(:tag_name, name: title),
body:,
created_by_user: user,
diff --git a/backend/spec/support/wiki_records.rb b/backend/spec/support/wiki_records.rb
new file mode 100644
index 0000000..f321aba
--- /dev/null
+++ b/backend/spec/support/wiki_records.rb
@@ -0,0 +1,20 @@
+# Historical records must remain seedable after application creation is frozen.
+module WikiRecords
+ def seed_wiki_page(tag_name:, body:, created_by_user:, message: nil)
+ page = WikiPage.create!(tag_name:, body:,
+ created_user: created_by_user, updated_user: created_by_user)
+ revision = Wiki::Commit.legacy_content!(
+ page:, body:, created_user: created_by_user, message:)
+ if tag_name.tag && tag_name.language_code == 'ja'
+ TagDescription.create!(
+ id: page.id, tag: tag_name.tag, language_code: 'ja', body: page.reload.body,
+ version_no: 1, created_by_user_id: created_by_user.id,
+ updated_by_user_id: created_by_user.id)
+ TagDescriptionVersion.create!(
+ id: revision.id, tag_id: tag_name.tag_id, language_code: 'ja',
+ version_no: 1, event_type: 'create', body: page.body,
+ created_by_user_id: created_by_user.id, created_at: revision.created_at)
+ end
+ page
+ end
+end
diff --git a/frontend/src/lib/help.test.ts b/frontend/src/lib/help.test.ts
new file mode 100644
index 0000000..efecaa8
--- /dev/null
+++ b/frontend/src/lib/help.test.ts
@@ -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 ()
+ })
+})
diff --git a/frontend/src/lib/helpPrefetch.test.tsx b/frontend/src/lib/helpPrefetch.test.tsx
new file mode 100644
index 0000000..dbf05ca
--- /dev/null
+++ b/frontend/src/lib/helpPrefetch.test.tsx
@@ -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
+}
+
+
+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 (
+