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 {location.pathname} +} + + +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 ( + }/>, + { 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 (<>Help) + 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 (<>Help) + fireEvent.click (screen.getByRole ('link', { name: 'Help' })) + await waitFor (() => expect (screen.getByTestId ('location')) + .toHaveTextContent ('/help/guide')) + }) +}) diff --git a/frontend/src/lib/wiki.test.ts b/frontend/src/lib/wiki.test.ts index 6a9984d..eafcfe2 100644 --- a/frontend/src/lib/wiki.test.ts +++ b/frontend/src/lib/wiki.test.ts @@ -2,9 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { fetchWikiPage, fetchWikiPageByTitle, fetchWikiPages } from '@/lib/wiki' -const api = vi.hoisted (() => ({ - apiGet: vi.fn (), -})) +const api = vi.hoisted (() => ({ apiGet: vi.fn () })) vi.mock ('@/lib/api', () => api) @@ -21,28 +19,37 @@ describe ('wiki API functions', () => { await fetchWikiPage ('1', { version: '3' }) expect (api.apiGet).toHaveBeenNthCalledWith ( - 1, - '/wiki', - { params: { title: '虹' } }, - ) + 1, + '/wiki', + { params: { title: '虹' } }) expect (api.apiGet).toHaveBeenNthCalledWith ( - 2, - '/wiki/1', - { params: { version: '3' } }, - ) + 2, + '/wiki/1', + { params: { version: '3' } }) }) it ('encodes title path segments and returns null on misses', async () => { 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 expect (fetchWikiPageByTitle ('missing', {})).resolves.toBeNull () + await expect (fetchWikiPageByTitle ('missing', { })).resolves.toBeNull () expect (api.apiGet).toHaveBeenNthCalledWith ( - 1, - '/wiki/title/a%2Fb%20c', - { params: { version: undefined } }, - ) + 1, + '/wiki/title/a%2Fb%20c', + { 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) }) }) diff --git a/frontend/src/mdx-components.test.tsx b/frontend/src/mdx-components.test.tsx new file mode 100644 index 0000000..6c51ce0 --- /dev/null +++ b/frontend/src/mdx-components.test.tsx @@ -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 () + 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 () + 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 ( + }/>, + { 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 ( + }/>, + { 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 () + fireEvent.mouseEnter (screen.getByRole ('link', { name: '内部リンク' })) + expect (prefetch).toHaveBeenCalledWith (expect.anything (), + new URL ('/help/guide', window.location.origin).href) + }) +}) diff --git a/frontend/src/pages/help/HelpChangesPage.test.tsx b/frontend/src/pages/help/HelpChangesPage.test.tsx new file mode 100644 index 0000000..5d6ecdc --- /dev/null +++ b/frontend/src/pages/help/HelpChangesPage.test.tsx @@ -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 ( + + + ) + 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') + }) +}) diff --git a/frontend/src/pages/help/HelpDetailPage.test.tsx b/frontend/src/pages/help/HelpDetailPage.test.tsx new file mode 100644 index 0000000..ce31007 --- /dev/null +++ b/frontend/src/pages/help/HelpDetailPage.test.tsx @@ -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 ( + <> + {location.pathname}{location.search} + + + ) +} + + +const renderDetail = (route = '/help/guide') => renderWithProviders ( + <> + + + }/> + + , { 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: () =>

設定

})) + 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: () =>

遅延した旧本文

})) + expect (screen.queryByText ('遅延した旧本文')).not.toBeInTheDocument () + expect (screen.getByRole ('heading', { name: 'ホーム第三版' })).toBeInTheDocument () + await waitFor (() => expect (document.title).toContain ('ホーム第三版 ヘルプ')) + }) +}) diff --git a/frontend/src/pages/help/HelpListPage.test.tsx b/frontend/src/pages/help/HelpListPage.test.tsx new file mode 100644 index 0000000..1861cbb --- /dev/null +++ b/frontend/src/pages/help/HelpListPage.test.tsx @@ -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 (, { 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 () + 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 () + expect (await screen.findByRole ('alert')).toHaveTextContent ('fixture loading failed') + expect (screen.queryByRole ('link', { name: 'ホーム' })).not.toBeInTheDocument () + }) +}) diff --git a/frontend/src/pages/wiki/WikiHelpCompatibility.test.tsx b/frontend/src/pages/wiki/WikiHelpCompatibility.test.tsx new file mode 100644 index 0000000..930beff --- /dev/null +++ b/frontend/src/pages/wiki/WikiHelpCompatibility.test.tsx @@ -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 {location.pathname}{location.search} +} + + +const renderWiki = (title: string) => { + const route = `/wiki/${encodeURIComponent (title)}` + renderWithProviders ( + <> + + + }/> + }/> + unexpected redirect}/> + + , { 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 () + }) +}) diff --git a/frontend/src/test/compileMdx.ts b/frontend/src/test/compileMdx.ts new file mode 100644 index 0000000..e910fc7 --- /dev/null +++ b/frontend/src/test/compileMdx.ts @@ -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 => { + 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 +} diff --git a/frontend/src/test/helpFixtures.tsx b/frontend/src/test/helpFixtures.tsx new file mode 100644 index 0000000..4f10dad --- /dev/null +++ b/frontend/src/test/helpFixtures.tsx @@ -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 = { + '../../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 () diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 24da3f6..5f4d1d5 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,9 +1,11 @@ import mdx from '@mdx-js/rollup' import react from '@vitejs/plugin-react' import path from 'path' +import remarkGFM from 'remark-gfm' import { defineConfig } from 'vite' -const mdxPlugin = mdx ({ providerImportSource: '@/mdx-components' }) +const mdxPlugin = mdx ({ providerImportSource: '@/mdx-components', + remarkPlugins: [remarkGFM] }) const originalTransform = mdxPlugin.transform if (typeof originalTransform === 'function')