このコミットが含まれているのは:
@@ -0,0 +1,110 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group persistence constraints' do
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { group_tag('制約確認') }
|
||||
let!(:group_id) do
|
||||
seed_group(
|
||||
name: '制約確認',
|
||||
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }])
|
||||
end
|
||||
|
||||
def placement attributes
|
||||
insert_group_row('tag_group_placements', {
|
||||
tag_group_id: group_id, position: 1,
|
||||
created_at: Time.current, updated_at: Time.current }.merge(attributes))
|
||||
end
|
||||
|
||||
def reject_statement
|
||||
before_state = group_state
|
||||
expect { yield }.to raise_error(ActiveRecord::StatementInvalid)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'allows membership without placement' do
|
||||
expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1)
|
||||
expect(group_rows('tag_group_placements', tag_group_id: group_id)).to be_empty
|
||||
end
|
||||
|
||||
it 'allows repeated Tag placements with unique membership' do
|
||||
placement(tag_id: tag.id)
|
||||
placement(tag_id: tag.id, position: 2)
|
||||
expect(group_rows('tag_group_tags', tag_group_id: group_id).size).to eq(1)
|
||||
expect(group_rows('tag_group_placements', tag_group_id: group_id).size).to eq(2)
|
||||
end
|
||||
|
||||
it 'rejects duplicate Tag membership' do
|
||||
reject_statement do
|
||||
insert_group_row('tag_group_tags', {
|
||||
tag_group_id: group_id, tag_id: tag.id, created_at: Time.current })
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects duplicate child membership' do
|
||||
child_id = seed_group(name: '子')
|
||||
attributes = { tag_group_id: group_id, child_tag_group_id: child_id,
|
||||
created_at: Time.current }
|
||||
insert_group_row('tag_group_groups', attributes)
|
||||
reject_statement { insert_group_row('tag_group_groups', attributes) }
|
||||
end
|
||||
|
||||
it 'rejects direct self membership' do
|
||||
reject_statement do
|
||||
insert_group_row('tag_group_groups', {
|
||||
tag_group_id: group_id, child_tag_group_id: group_id, created_at: Time.current })
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a placement without a kind' do
|
||||
reject_statement { placement({ }) }
|
||||
end
|
||||
|
||||
it 'rejects simultaneous Section and Tag kinds' do
|
||||
reject_statement { placement(section_name: '区分', tag_id: tag.id) }
|
||||
end
|
||||
|
||||
it 'rejects a description attached to a Tag placement' do
|
||||
reject_statement { placement(tag_id: tag.id, description: '不正') }
|
||||
end
|
||||
|
||||
it 'allows an optional description on a Section' do
|
||||
placement(section_name: '区分', description: '説明')
|
||||
expect(group_rows('tag_group_placements', tag_group_id: group_id).sole)
|
||||
.to include('section_name' => '区分', 'description' => '説明')
|
||||
end
|
||||
|
||||
it 'rejects a Tag placement absent from membership' do
|
||||
other = group_tag('非メンバー')
|
||||
reject_statement { placement(tag_id: other.id) }
|
||||
end
|
||||
|
||||
it 'rejects a missing parent placement' do
|
||||
reject_statement { placement(section_name: '子', parent_position: 9) }
|
||||
end
|
||||
|
||||
it 'rejects a child ordered before its parent' do
|
||||
placement(section_name: '親', position: 2)
|
||||
reject_statement { placement(section_name: '子', position: 1, parent_position: 2) }
|
||||
end
|
||||
|
||||
it 'rejects duplicate group names' do
|
||||
reject_statement { seed_group(name: '制約確認') }
|
||||
end
|
||||
|
||||
it 'rejects a missing mandatory group name' do
|
||||
reject_statement { seed_group(name: nil) }
|
||||
end
|
||||
|
||||
it 'rejects a six-level snapshot' do
|
||||
nodes = []
|
||||
6.times { nodes = [{ 'tag_id' => tag.id, 'children' => nodes }] }
|
||||
reject_statement do
|
||||
insert_group_row('tag_group_versions', {
|
||||
tag_group_id: group_id, version_no: 2, event_type: 'update', name: '制約確認',
|
||||
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }].to_json,
|
||||
placements: nodes.to_json, created_at: Time.current })
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group migration identity and structure' do
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
it 'preserves page and revision IDs, timestamps and repeated historical structures' do
|
||||
tag = group_tag('洗澡鹿')
|
||||
body = "説明文\n\n- 洗澡鹿\n- 洗澡鹿"
|
||||
page = legacy_group('伊地知ニジカ亜種', body)
|
||||
revisions = [page.current_revision]
|
||||
[body, '# 空の区分', body].each do |text|
|
||||
revisions << Wiki::Commit.legacy_content!(page:, body: text, created_user: user)
|
||||
end
|
||||
before_state = group_state.except(*TagGroupRecords::GROUP_TABLES)
|
||||
|
||||
migrate_groups
|
||||
|
||||
versions = group_rows('tag_group_versions', tag_group_id: page.id)
|
||||
.sort_by { _1.fetch('version_no') }
|
||||
expect(versions.map { _1.fetch('id') }).to eq(revisions.map(&:id))
|
||||
expect(versions.map { _1.fetch('version_no') }).to eq([1, 2, 3, 4])
|
||||
expect(versions.map { _1.fetch('created_at') }).to eq(revisions.map(&:created_at))
|
||||
expect(versions.map { _1.fetch('created_by_user_id') }).to eq([user.id] * 4)
|
||||
expect(versions.map { _1.fetch('event_type') }).to eq(['create', 'update', 'update', 'update'])
|
||||
expect(group_version(page.id, 1).fetch('placements')).to eq(
|
||||
group_version(page.id, 2).fetch('placements'))
|
||||
expect(group_version(page.id, 3).fetch('placements')).to eq([
|
||||
{ 'section_name' => '空の区分', 'description' => nil, 'children' => [] }])
|
||||
expect_group_snapshot(
|
||||
page.id, number: 4, description: '説明文',
|
||||
members: [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }],
|
||||
placements: [{ 'tag_id' => tag.id, 'children' => [] }] * 2)
|
||||
expect(group_rows('tag_groups', id: page.id).sole).to include(
|
||||
'created_at' => page.created_at, 'updated_at' => page.reload.updated_at)
|
||||
expect(group_state.except(*TagGroupRecords::GROUP_TABLES)).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'migrates nested list Sections and child groups as different placement kinds' do
|
||||
series = group_tag('ぼざろクリーチャーシリーズ')
|
||||
deer = group_tag('洗澡鹿')
|
||||
nijika = group_tag('伊地知ニジカ')
|
||||
child = legacy_group('伊地知ニジカ亜種', <<~MARKDOWN)
|
||||
- ぼざろクリーチャーシリーズ
|
||||
- 伊地知ニジカ亜種:
|
||||
- 洗澡鹿
|
||||
MARKDOWN
|
||||
parent = legacy_group('ぼざろクリーチャーシリーズのキャラクター', <<~MARKDOWN)
|
||||
- ぼざろクリーチャーシリーズ
|
||||
- 伊地知ニジカ
|
||||
- タググループ:伊地知ニジカ亜種
|
||||
MARKDOWN
|
||||
|
||||
migrate_groups
|
||||
|
||||
expect_group_snapshot(
|
||||
child.id, number: 1,
|
||||
members: [series, deer].map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
|
||||
placements: [{ 'tag_id' => series.id, 'children' => [
|
||||
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
|
||||
'children' => [{ 'tag_id' => deer.id, 'children' => [] }] }] }])
|
||||
expect_group_snapshot(
|
||||
parent.id, number: 1,
|
||||
members: [
|
||||
{ 'tag_id' => series.id, 'version_no' => series.version_no },
|
||||
{ 'tag_id' => nijika.id, 'version_no' => nijika.version_no },
|
||||
{ 'tag_group_id' => child.id, 'version_no' => 1 }],
|
||||
placements: [{ 'tag_id' => series.id, 'children' => [
|
||||
{ 'tag_id' => nijika.id, 'children' => [
|
||||
{ 'tag_group_id' => child.id, 'children' => [] }] }] }])
|
||||
end
|
||||
|
||||
it 'preserves heading order, group paragraphs and Section descriptions' do
|
||||
names = ['タグ希望', 'キャラクター名希望', '楽曲情報希望', 'bot操作', 'ニジラー情報不詳']
|
||||
tags = names.map { group_tag(_1) }
|
||||
markdown = <<~MARKDOWN
|
||||
全体の説明
|
||||
|
||||
# 要求
|
||||
要求の説明
|
||||
|
||||
- タグ希望
|
||||
- キャラクター名希望
|
||||
- 楽曲情報希望
|
||||
|
||||
# 情報
|
||||
- bot操作
|
||||
- ニジラー情報不詳
|
||||
MARKDOWN
|
||||
page = legacy_group('メタタグ', markdown)
|
||||
expect(page.current_revision.body).to eq(markdown.chomp)
|
||||
|
||||
migrate_groups
|
||||
|
||||
leaves = tags.map { { 'tag_id' => _1.id, 'children' => [] } }
|
||||
leaves.first['children'] = leaves[1..2]
|
||||
expect_group_snapshot(
|
||||
page.id, number: 1, description: '全体の説明',
|
||||
members: tags.map { { 'tag_id' => _1.id, 'version_no' => _1.version_no } },
|
||||
placements: [
|
||||
{ 'section_name' => '要求', 'description' => '要求の説明',
|
||||
'children' => [leaves.first] },
|
||||
{ 'section_name' => '情報', 'description' => nil,
|
||||
'children' => leaves[3..4] }])
|
||||
end
|
||||
|
||||
it 'leaves Help in the legacy domain' do
|
||||
page = Wiki::Commit.create_legacy_content!(
|
||||
tag_name: create(:tag_name, name: 'Help:移行対象外'),
|
||||
body: 'Help 本文', created_by_user: user)
|
||||
before_state = group_state
|
||||
migrate_groups
|
||||
expect(group_state).to eq(before_state)
|
||||
expect(page.reload.body).to eq('Help 本文')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,117 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :request do
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:series) { group_tag('ぼざろクリーチャーシリーズ') }
|
||||
let!(:deer) { group_tag('洗澡鹿') }
|
||||
let!(:nijika) { group_tag('伊地知ニジカ') }
|
||||
let!(:wanted) { group_tag('タグ希望') }
|
||||
let!(:character) { group_tag('キャラクター名希望') }
|
||||
let!(:music) { group_tag('楽曲情報希望') }
|
||||
let!(:bot) { group_tag('bot操作') }
|
||||
let!(:unknown) { group_tag('ニジラー情報不詳') }
|
||||
let!(:child_id) { seed_group(name: '伊地知ニジカ亜種', description: '子の説明') }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
def tag_node tag, children = []
|
||||
{ 'tag_id' => tag.id, 'children' => children }
|
||||
end
|
||||
|
||||
def tag_member tag
|
||||
{ 'tag_id' => tag.id, 'version_no' => tag.version_no }
|
||||
end
|
||||
|
||||
def check_round_trip placements:, members:, markdown:, description: nil
|
||||
id = seed_group(name: '変換元', placements:, members:, description:)
|
||||
without_group_legacy_reads { get group_title_path('変換元') }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('id' => id)
|
||||
body = json.fetch('body')
|
||||
expect(body).not_to include("\r")
|
||||
expect(body.lines.map(&:chomp)).to all(satisfy { |line| line == line.rstrip })
|
||||
# A trailing LF or a blank line between blocks is semantically irrelevant.
|
||||
expect(body.lines.map(&:chomp).reject(&:empty?)).to eq(
|
||||
markdown.lines.map(&:chomp).reject(&:empty?))
|
||||
|
||||
post '/wiki', params: { title: 'タググループ:再解析', body: },
|
||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
expect(response).to have_http_status(:created)
|
||||
parsed = group_rows('tag_groups', name: '再解析').sole
|
||||
expect_group_snapshot(parsed.fetch('id'), number: 1, placements:, members:, description:)
|
||||
end
|
||||
|
||||
it 'renders a list Section under a Tag with two-space indentation' do
|
||||
check_round_trip(
|
||||
members: [series, deer].map { tag_member(_1) },
|
||||
placements: [tag_node(series, [
|
||||
{ 'section_name' => '伊地知ニジカ亜種', 'description' => nil,
|
||||
'children' => [tag_node(deer)] }])],
|
||||
markdown: <<~MARKDOWN)
|
||||
- ぼざろクリーチャーシリーズ
|
||||
- 伊地知ニジカ亜種:
|
||||
- 洗澡鹿
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'renders a child Tag Group distinctly from its parent Tags' do
|
||||
check_round_trip(
|
||||
members: [tag_member(series), tag_member(nijika),
|
||||
{ 'tag_group_id' => child_id, 'version_no' => 1 }],
|
||||
placements: [tag_node(series, [tag_node(nijika, [
|
||||
{ 'tag_group_id' => child_id, 'children' => [] }])])],
|
||||
markdown: <<~MARKDOWN)
|
||||
- ぼざろクリーチャーシリーズ
|
||||
- 伊地知ニジカ
|
||||
- タググループ:伊地知ニジカ亜種
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'renders top-level heading Sections, descriptions and ordered nested Tags' do
|
||||
check_round_trip(
|
||||
description: '全体の説明',
|
||||
members: [wanted, character, music, bot, unknown].map { tag_member(_1) },
|
||||
placements: [
|
||||
{ 'section_name' => '要求', 'description' => '要求の説明',
|
||||
'children' => [tag_node(wanted, [tag_node(character), tag_node(music)])] },
|
||||
{ 'section_name' => '情報', 'description' => nil,
|
||||
'children' => [tag_node(bot), tag_node(unknown)] }],
|
||||
markdown: <<~MARKDOWN)
|
||||
全体の説明
|
||||
|
||||
# 要求
|
||||
要求の説明
|
||||
|
||||
- タグ希望
|
||||
- キャラクター名希望
|
||||
- 楽曲情報希望
|
||||
|
||||
# 情報
|
||||
- bot操作
|
||||
- ニジラー情報不詳
|
||||
MARKDOWN
|
||||
end
|
||||
|
||||
it 'renders nested heading Sections and all repeated Tag placements' do
|
||||
check_round_trip(
|
||||
members: [tag_member(deer)],
|
||||
placements: [
|
||||
{ 'section_name' => '親', 'description' => '親の説明', 'children' => [
|
||||
tag_node(deer),
|
||||
{ 'section_name' => '子', 'description' => '子の説明',
|
||||
'children' => [tag_node(deer)] }] }],
|
||||
markdown: <<~MARKDOWN)
|
||||
# 親
|
||||
親の説明
|
||||
|
||||
- 洗澡鹿
|
||||
|
||||
## 子
|
||||
子の説明
|
||||
|
||||
- 洗澡鹿
|
||||
MARKDOWN
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,155 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group Wiki facade reads', type: :request do
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:old_tag) { group_tag('旧タグ') }
|
||||
let!(:new_tag) { group_tag('新タグ') }
|
||||
let!(:page) { legacy_group('読取り契約', '- 旧タグ') }
|
||||
let!(:first_revision) { page.current_revision }
|
||||
let!(:last_revision) do
|
||||
Wiki::Commit.legacy_content!(page:, body: '- 新タグ', created_user: user)
|
||||
end
|
||||
|
||||
before do
|
||||
I18n.locale = :ja
|
||||
migrate_groups
|
||||
page.update_columns(body: 'legacy stale current')
|
||||
page.wiki_revisions.each do |revision|
|
||||
revision.wiki_lines.update_all(body: 'legacy stale revision')
|
||||
end
|
||||
page.wiki_versions.update_all(body: 'legacy stale version')
|
||||
end
|
||||
|
||||
[:id, :title].each do |lookup|
|
||||
context "with #{ lookup } lookup" do
|
||||
let(:path) { lookup == :id ? "/wiki/#{ page.id }" : group_title_path('読取り契約') }
|
||||
|
||||
it 'reads current structure without consulting migrated Wiki tables' do
|
||||
without_group_legacy_reads { get path }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'id' => page.id, 'title' => 'タググループ:読取り契約',
|
||||
'revision_id' => last_revision.id, 'pred' => first_revision.id, 'succ' => nil)
|
||||
expect(json.fetch('body').strip).to eq('- 新タグ')
|
||||
end
|
||||
|
||||
it 'reads the old snapshot rather than current membership or placement' do
|
||||
without_group_legacy_reads { get path, params: { version: first_revision.id } }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body').strip).to eq('- 旧タグ')
|
||||
expect(json).to include(
|
||||
'revision_id' => first_revision.id, 'pred' => nil, 'succ' => last_revision.id,
|
||||
'updated_at' => first_revision.created_at.iso8601(3))
|
||||
end
|
||||
|
||||
it 'checks existence without consulting migrated Wiki tables' do
|
||||
without_group_legacy_reads { get "#{ path }/exists" }
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'does not fall back to legacy history when a group version is missing' do
|
||||
group_connection.execute(
|
||||
"DELETE FROM tag_group_versions WHERE id = #{ first_revision.id }")
|
||||
without_group_legacy_reads { get path, params: { version: first_revision.id } }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
['/wiki', '/wiki/search'].each do |path|
|
||||
it "lists migrated groups once with current titles at #{ path }" do
|
||||
get path, params: { title: 'タググループ:読取り' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
rows = json.select { _1.fetch('title') == 'タググループ:読取り契約' }
|
||||
expect(rows.size).to eq(1)
|
||||
expect(rows.sole).to include('id' => page.id)
|
||||
expect(response.body).not_to include('legacy stale')
|
||||
end
|
||||
end
|
||||
|
||||
it 'combines Tag Groups, Help and Tag Descriptions in the unfiltered list' do
|
||||
help = Wiki::Commit.create_legacy_content!(
|
||||
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, '新タグ')
|
||||
end
|
||||
|
||||
it 'reads group version IDs, editor and timestamps in scoped changes' do
|
||||
without_group_legacy_reads { get '/wiki/changes', params: { id: page.id } }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { _1.fetch('revision_id') }).to eq([last_revision.id, first_revision.id])
|
||||
expect(json.first).to include(
|
||||
'pred' => first_revision.id, 'succ' => nil, 'kind' => 'content',
|
||||
'timestamp' => last_revision.created_at.iso8601(3),
|
||||
'user' => { 'id' => user.id, 'name' => user.name },
|
||||
'wiki_page' => include('id' => page.id, 'title' => 'タググループ:読取り契約'))
|
||||
end
|
||||
|
||||
it 'includes Help and group history but excludes legacy-only migrated revisions' do
|
||||
extra = Wiki::Commit.legacy_content!(page:, body: 'legacy only', created_user: user)
|
||||
help = Wiki::Commit.create_legacy_content!(
|
||||
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(
|
||||
'revision_id' => help.current_revision.id,
|
||||
'wiki_page' => include('title' => help.title)))
|
||||
end
|
||||
|
||||
[false, true].each do |explicit|
|
||||
it "diffs structured snapshots with #{ explicit ? 'explicit' : 'current' } target" do
|
||||
params = { from: first_revision.id }
|
||||
params[:to] = last_revision.id if explicit
|
||||
without_group_legacy_reads { get "/wiki/#{ page.id }/diff", params: }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'wiki_page_id' => page.id, 'older_revision_id' => first_revision.id,
|
||||
'newer_revision_id' => last_revision.id)
|
||||
differences = json.fetch('diff').map { [_1.fetch('type'), _1.fetch('content').strip] }
|
||||
expect(differences).to include(['removed', '- 旧タグ'], ['added', '- 新タグ'])
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not use a version belonging to another group' do
|
||||
other_id = seed_group(name: '別グループ', description: '別本文')
|
||||
other_version = group_version(other_id, 1)
|
||||
get group_title_path('読取り契約'), params: { version: other_version.fetch('id') }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
get "/wiki/#{ page.id }/diff",
|
||||
params: { from: other_version.fetch('id'), to: last_revision.id }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'resolves a group without any compatibility Wiki page' do
|
||||
id = seed_group(name: '独立グループ', description: '独立した説明')
|
||||
without_group_legacy_reads { get group_title_path('独立グループ') }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('id' => id, 'title' => 'タググループ:独立グループ')
|
||||
expect(json.fetch('body').strip).to eq('独立した説明')
|
||||
end
|
||||
|
||||
it 'resolves colliding group, Help and description IDs by title' do
|
||||
help = Wiki::Commit.create_legacy_content!(
|
||||
tag_name: create(:tag_name, name: 'Help:ID衝突'), body: 'help', created_by_user: user)
|
||||
seed_group(name: 'ID衝突', id: help.id, description: 'group')
|
||||
TagDescription.create!(
|
||||
id: help.id, tag: new_tag, language_code: 'ja', body: 'description', version_no: 1)
|
||||
TagDescriptionVersion.create!(
|
||||
tag_id: new_tag.id, language_code: 'ja', version_no: 1,
|
||||
event_type: 'create', body: 'description', created_at: Time.current)
|
||||
{ 'タググループ:ID衝突' => 'group', 'Help:ID衝突' => 'help', '新タグ' => '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
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,232 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
include TagGroupRecords
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { group_tag('洗澡鹿') }
|
||||
let!(:other_tag) { group_tag('伊地知ニジカ') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
|
||||
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
def create_group title: 'タググループ:新規', body: '- 洗澡鹿', request_headers: headers
|
||||
post '/wiki', params: { title:, body: }, headers: request_headers
|
||||
end
|
||||
|
||||
def expect_rejected_without_writes
|
||||
before_state = group_state
|
||||
yield
|
||||
aggregate_failures do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
expect(response.media_type).to eq('application/json')
|
||||
expect(response.body).not_to be_empty
|
||||
next if response.body.empty?
|
||||
|
||||
expect(json).to include(
|
||||
'type' => 'validation_error', 'errors' => a_kind_of(Hash),
|
||||
'base_errors' => a_kind_of(Array))
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates the group, first snapshot and compatibility Wiki history' do
|
||||
create_group
|
||||
expect(response).to have_http_status(:created)
|
||||
group = group_rows('tag_groups', name: '新規').sole
|
||||
expect(json).to include('id' => group.fetch('id'), 'title' => 'タググループ:新規')
|
||||
expect_group_snapshot(group.fetch('id'), number: 1, members:, placements:)
|
||||
version = group_version(group.fetch('id'), 1)
|
||||
expect(version).to include('event_type' => 'create', 'created_by_user_id' => user.id)
|
||||
expect(group).to include('created_by_user_id' => user.id, 'updated_by_user_id' => user.id)
|
||||
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
|
||||
create_group(body: "- 洗澡鹿\n# 再掲\n- 洗澡鹿")
|
||||
expect(response).to have_http_status(:created)
|
||||
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
|
||||
expect_group_snapshot(
|
||||
id, number: 1, members:,
|
||||
placements: placements + [
|
||||
{ 'section_name' => '再掲', 'description' => nil, 'children' => placements }])
|
||||
end
|
||||
|
||||
it 'resolves a Japanese alias through tag_names.tag_id' do
|
||||
create(:tag_name, :alias, tag:, name: '鹿の別名')
|
||||
create_group(body: '- 鹿の別名')
|
||||
expect(response).to have_http_status(:created)
|
||||
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
|
||||
expect_group_snapshot(id, number: 1, members:, placements:)
|
||||
get group_title_path('新規')
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body').strip).to eq('- 洗澡鹿')
|
||||
end
|
||||
|
||||
{
|
||||
'unknown Tag' => '- 解決不能',
|
||||
'unknown Tag Group' => '- タググループ:存在しない',
|
||||
'empty Section' => '- :',
|
||||
'unsupported placement syntax' => "```\n洗澡鹿\n```",
|
||||
'six levels' => "- 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n - 洗澡鹿\n" +
|
||||
" - 洗澡鹿\n - 洗澡鹿",
|
||||
'blank body' => '',
|
||||
'normalised blank body' => "\r\n\r\n" }.each do |label, body|
|
||||
it "rejects #{ label } without fallback or partial create" do
|
||||
expect_rejected_without_writes { create_group(body:) }
|
||||
end
|
||||
end
|
||||
|
||||
['', 'タググループ:', "タググループ:#{ '長' * 256 }"].each do |title|
|
||||
it "rejects an invalid required title of length #{ title.length }" do
|
||||
expect_rejected_without_writes { create_group(title:) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a duplicate group name even without a matching legacy Wiki' do
|
||||
seed_group(name: '新規', description: '既存')
|
||||
expect_rejected_without_writes { create_group }
|
||||
end
|
||||
|
||||
it 'accepts the existing five-level placement depth boundary' do
|
||||
body = (0...5).map { "#{ ' ' * _1 }- 洗澡鹿" }.join("\n")
|
||||
create_group(body:)
|
||||
expect(response).to have_http_status(:created)
|
||||
id = group_rows('tag_groups', name: '新規').sole.fetch('id')
|
||||
tree = []
|
||||
5.times { tree = [{ 'tag_id' => tag.id, 'children' => tree }] }
|
||||
expect_group_snapshot(id, number: 1, members:, placements: tree)
|
||||
end
|
||||
|
||||
context 'updating a migrated group' do
|
||||
let!(:page) { legacy_group('更新', '- 洗澡鹿') }
|
||||
let!(:old_revision) { page.current_revision }
|
||||
|
||||
before { migrate_groups }
|
||||
|
||||
def update_group body: '- 伊地知ニジカ', title: 'タググループ:更新', request_headers: headers
|
||||
put "/wiki/#{ page.id }", params: { title:, body: }, headers: request_headers
|
||||
end
|
||||
|
||||
it 'replaces current membership and placement, retaining the old snapshot' do
|
||||
old = group_version(page.id, 1)
|
||||
update_group
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_group_snapshot(
|
||||
page.id, number: 2,
|
||||
members: [{ 'tag_id' => other_tag.id, 'version_no' => other_tag.version_no }],
|
||||
placements: [{ 'tag_id' => other_tag.id, 'children' => [] }])
|
||||
expect(group_version(page.id, 1)).to eq(old)
|
||||
expect(group_version(page.id, 2)).to include(
|
||||
'event_type' => 'update', 'created_by_user_id' => user.id)
|
||||
expect(page.reload.body.strip).to eq('- 伊地知ニジカ')
|
||||
expect(page.wiki_revisions.count).to eq(2)
|
||||
expect(page.wiki_versions.count).to eq(2)
|
||||
expect(page.current_revision.body).to eq(page.body)
|
||||
expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body)
|
||||
end
|
||||
|
||||
it 'accepts the current base_revision_id used by the existing frontend' do
|
||||
put "/wiki/#{ page.id }",
|
||||
params: { title: page.title, body: '- 伊地知ニジカ',
|
||||
base_revision_id: old_revision.id }, headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_rows('tag_groups', id: page.id).sole.fetch('version_no')).to eq(2)
|
||||
end
|
||||
|
||||
it 'records the editor and update time' do
|
||||
editor = create(:user, :member, name: '編輯者')
|
||||
travel_to(Time.current + 1.hour) do
|
||||
update_group(request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
|
||||
expect(response).to have_http_status(:ok)
|
||||
current = group_rows('tag_groups', id: page.id).sole
|
||||
version = group_version(page.id, 2)
|
||||
expect(current).to include('updated_by_user_id' => editor.id)
|
||||
expect(version).to include('created_by_user_id' => editor.id)
|
||||
expect(current.fetch('updated_at')).to be_within(1.second).of(Time.current)
|
||||
expect(version.fetch('created_at')).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
['- 不明なタグ', '- タググループ:不明', '', '- :'].each do |body|
|
||||
it "rejects invalid update #{ body.inspect } atomically" do
|
||||
expect_rejected_without_writes { update_group(body:) }
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects direct self inclusion' do
|
||||
expect_rejected_without_writes { update_group(body: '- タググループ:更新') }
|
||||
end
|
||||
|
||||
it 'rejects indirect cyclic membership' do
|
||||
seed_group(
|
||||
name: '子', members: [{ 'tag_group_id' => page.id, 'version_no' => 1 }],
|
||||
placements: [{ 'tag_group_id' => page.id, 'children' => [] }])
|
||||
expect_rejected_without_writes { update_group(body: '- タググループ:子') }
|
||||
end
|
||||
|
||||
it 'rejects duplicate rename without retaining a partial title or version' do
|
||||
seed_group(name: '既存', description: '既存')
|
||||
expect_rejected_without_writes { update_group(title: 'タググループ:既存') }
|
||||
end
|
||||
end
|
||||
|
||||
[:create, :update].each do |operation|
|
||||
context "#{ operation } access and atomicity" do
|
||||
let!(:page) do
|
||||
next unless operation == :update
|
||||
|
||||
legacy = legacy_group('更新', '- 洗澡鹿')
|
||||
seed_group(name: '更新', id: legacy.id, members:, placements:)
|
||||
legacy
|
||||
end
|
||||
let!(:child_id) { seed_group(name: '子グループ', description: '子') }
|
||||
|
||||
def write_group operation, request_headers
|
||||
body = "- 伊地知ニジカ\n- タググループ:子グループ"
|
||||
if operation == :create
|
||||
create_group(body:, request_headers:)
|
||||
else
|
||||
put "/wiki/#{ page.id }",
|
||||
params: { title: 'タググループ:更新', body: }, headers: request_headers
|
||||
end
|
||||
end
|
||||
|
||||
[nil, :guest, :banned].each do |role|
|
||||
it "rejects #{ role || 'anonymous' } without writes" do
|
||||
actor = case role
|
||||
when :guest then create(:user, :guest)
|
||||
when :banned then create(:user, :member, :banned)
|
||||
end
|
||||
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
before_state = group_state
|
||||
write_group(operation, request_headers)
|
||||
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a banned IP even for a member' do
|
||||
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
|
||||
before_state = group_state
|
||||
write_group(operation, headers)
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
(TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
|
||||
.each do |table|
|
||||
it "rolls back all writes after a real #{ table } write fails" do
|
||||
expect_atomic_group_write(table) { write_group(operation, headers) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,308 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag group native API', type: :request do
|
||||
include TagGroupRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:group_id) { seed_group(name: '専用API', description: '説明') }
|
||||
|
||||
before { I18n.locale = :ja }
|
||||
|
||||
def expect_native_group value
|
||||
expect(value).to include(
|
||||
'id' => group_id, 'name' => '専用API', 'description' => '説明',
|
||||
'version_no' => 1, 'members' => [], 'placements' => [],
|
||||
'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user))
|
||||
current = group_rows('tag_groups', id: group_id).sole
|
||||
expect(value).to include(
|
||||
'created_at' => current.fetch('created_at').iso8601(3),
|
||||
'updated_at' => current.fetch('updated_at').iso8601(3))
|
||||
end
|
||||
|
||||
it 'serves the collection as JSON without reading Wiki tables' do
|
||||
without_group_legacy_reads { get '/tag_groups' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.media_type).to eq('application/json')
|
||||
expect(json).to be_a(Array)
|
||||
expect_native_group(json.find { _1.fetch('id') == group_id })
|
||||
end
|
||||
|
||||
it 'serves an independent group as JSON without reading Wiki tables' do
|
||||
without_group_legacy_reads { get "/tag_groups/#{ group_id }" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.media_type).to eq('application/json')
|
||||
expect_native_group(json)
|
||||
end
|
||||
|
||||
it 'provides the confirmed PUT update route' do
|
||||
route = Rails.application.routes.recognize_path("/tag_groups/#{ group_id }", method: :put)
|
||||
expect(route).to include(action: 'update', id: group_id.to_s)
|
||||
end
|
||||
|
||||
[:create, :update].each do |operation|
|
||||
[nil, :guest, :banned].each do |role|
|
||||
it "rejects #{ role || 'anonymous' } native #{ operation } without writes" do
|
||||
actor = case role
|
||||
when :guest then create(:user, :guest)
|
||||
when :banned then create(:user, :member, :banned)
|
||||
end
|
||||
headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
before_state = group_state
|
||||
if operation == :create
|
||||
post '/tag_groups', params: { }, headers:, as: :json
|
||||
else
|
||||
put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json
|
||||
end
|
||||
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects a banned IP for native #{ operation }" do
|
||||
IpAddress.create!(ip_address: IPAddr.new('127.0.0.1').hton, banned_at: Time.current)
|
||||
headers = { 'X-Transfer-Code' => user.inheritance_code }
|
||||
before_state = group_state
|
||||
if operation == :create
|
||||
post '/tag_groups', params: { }, headers:, as: :json
|
||||
else
|
||||
put "/tag_groups/#{ group_id }", params: { }, headers:, as: :json
|
||||
end
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'structured membership and placements' do
|
||||
let!(:tag) { group_tag('掲載タグ') }
|
||||
let!(:unplaced) { group_tag('未掲載タグ') }
|
||||
let!(:child_id) { seed_group(name: '子', description: '子の説明') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:members) do
|
||||
[{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }]
|
||||
end
|
||||
let(:snapshot_members) do
|
||||
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
|
||||
{ 'tag_group_id' => child_id, 'version_no' => 1 },
|
||||
{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }]
|
||||
end
|
||||
let(:placements) do
|
||||
[{ 'section_name' => '区分', 'description' => '区分の説明', 'children' => [
|
||||
{ 'tag_id' => tag.id, 'children' => [
|
||||
{ 'tag_group_id' => child_id, 'children' => [] }] },
|
||||
{ 'tag_id' => tag.id, 'children' => [] }] }]
|
||||
end
|
||||
let(:payload) { { name: '構造化', description: '全体の説明', members:, placements: } }
|
||||
|
||||
def native_write operation, attributes = payload
|
||||
if operation == :create
|
||||
post '/tag_groups', params: attributes, headers:, as: :json
|
||||
else
|
||||
put "/tag_groups/#{ group_id }", params: attributes, headers:, as: :json
|
||||
end
|
||||
end
|
||||
|
||||
def expect_structured_response
|
||||
tag_repr = { 'id' => tag.id, 'name' => '掲載タグ', 'category' => tag.category }
|
||||
unplaced_repr = { 'id' => unplaced.id, 'name' => '未掲載タグ',
|
||||
'category' => unplaced.category }
|
||||
expect(json.fetch('members')).to contain_exactly(
|
||||
include('tag' => include(tag_repr)),
|
||||
include('tag' => include(unplaced_repr)),
|
||||
include('tag_group' => include('id' => child_id, 'name' => '子')))
|
||||
expect(json.fetch('placements')).to match([
|
||||
include('section_name' => '区分', 'description' => '区分の説明', 'children' => [
|
||||
include('tag' => include(tag_repr), 'children' => [
|
||||
include('tag_group' => include('id' => child_id, 'name' => '子'),
|
||||
'children' => [])]),
|
||||
include('tag' => include(tag_repr), 'children' => [])])])
|
||||
end
|
||||
|
||||
[:create, :update].each do |operation|
|
||||
it "#{ operation }s structured data and keeps unplaced membership" do
|
||||
previous = group_version(group_id, 1)
|
||||
native_write(operation)
|
||||
expect(response).to have_http_status(operation == :create ? :created : :ok)
|
||||
id = operation == :create ? json.fetch('id') : group_id
|
||||
number = operation == :create ? 1 : 2
|
||||
expect_group_snapshot(
|
||||
id, number:, members: snapshot_members, placements:, description: '全体の説明')
|
||||
expect(group_version(group_id, 1)).to eq(previous)
|
||||
get "/tag_groups/#{ id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'id' => id, 'name' => '構造化', 'description' => '全体の説明', 'version_no' => number,
|
||||
'created_by_user' => UserRepr.base(user), 'updated_by_user' => UserRepr.base(user))
|
||||
expect_structured_response
|
||||
end
|
||||
|
||||
{
|
||||
'blank name' => { name: '' },
|
||||
'missing name' => { name: nil },
|
||||
'oversized name' => { name: '長' * 256 },
|
||||
'duplicate name' => { name: '子' },
|
||||
'placement without a kind' => { placements: [{ children: [] }] },
|
||||
'empty Section name' => { placements: [{ section_name: '', children: [] }] } }
|
||||
.each do |label, invalid|
|
||||
it "rejects #{ label } on #{ operation } without partial writes" do
|
||||
before_state = group_state
|
||||
native_write(operation, payload.merge(invalid))
|
||||
aggregate_failures do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects multiple placement kinds on #{ operation }" do
|
||||
before_state = group_state
|
||||
invalid = [{ tag_id: tag.id, section_name: '不正', children: [] }]
|
||||
native_write(operation, payload.merge(placements: invalid))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it "rejects a missing referenced Tag on #{ operation }" do
|
||||
missing = Tag.maximum(:id).to_i + 1
|
||||
before_state = group_state
|
||||
native_write(operation, payload.merge(
|
||||
members: [{ tag_id: missing }], placements: [{ tag_id: missing, children: [] }]))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it "rejects a missing referenced group on #{ operation }" do
|
||||
missing = group_rows('tag_groups').map { _1.fetch('id') }.max + 1
|
||||
before_state = group_state
|
||||
native_write(operation, payload.merge(
|
||||
members: [{ tag_group_id: missing }],
|
||||
placements: [{ tag_group_id: missing, children: [] }]))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it "rejects six descendant placement levels on #{ operation }" do
|
||||
nodes = []
|
||||
6.times { nodes = [{ tag_id: tag.id, children: nodes }] }
|
||||
before_state = group_state
|
||||
native_write(operation, payload.merge(placements: nodes))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
it 'preserves membership-only data in structured reads without inventing placements' do
|
||||
id = seed_group(name: '未掲載のみ', members: [snapshot_members.last])
|
||||
without_group_legacy_reads { get "/tag_groups/#{ id }" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('placements')).to eq([])
|
||||
expect(json.fetch('members')).to match([
|
||||
include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))])
|
||||
end
|
||||
|
||||
it 'rejects indirect containment cycles' do
|
||||
parent = seed_group(
|
||||
name: '親', members: [{ 'tag_group_id' => group_id, 'version_no' => 1 }])
|
||||
before_state = group_state
|
||||
native_write(:update, payload.merge(
|
||||
members: [{ tag_group_id: parent }], placements: []))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'rejects direct containment even without a corresponding placement' do
|
||||
before_state = group_state
|
||||
native_write(:update, payload.merge(
|
||||
members: [{ tag_group_id: group_id }], placements: []))
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'preserves list Section descriptions in native structured data' do
|
||||
nodes = [{ tag_id: tag.id, children: [
|
||||
{ section_name: 'リスト内区分', description: '消してはいけない説明', children: [] }] }]
|
||||
native_write(:update, payload.merge(placements: nodes))
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(group_version(group_id, 2).fetch('placements')).to eq(
|
||||
JSON.parse(nodes.to_json))
|
||||
get "/tag_groups/#{ group_id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
section = json.fetch('placements').sole.fetch('children').sole
|
||||
expect(section).to include(
|
||||
'section_name' => 'リスト内区分', 'description' => '消してはいけない説明')
|
||||
end
|
||||
|
||||
context 'compatibility writes for representable structures' do
|
||||
let(:group_id) do
|
||||
page = legacy_group('専用API', '説明')
|
||||
seed_group(name: '専用API', id: page.id, description: '説明')
|
||||
end
|
||||
let(:members) { [{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }] }
|
||||
let(:snapshot_members) do
|
||||
[{ 'tag_id' => tag.id, 'version_no' => tag.version_no },
|
||||
{ 'tag_group_id' => child_id, 'version_no' => 1 }]
|
||||
end
|
||||
|
||||
[:create, :update].each do |operation|
|
||||
it "synchronises Wiki current state and histories on native #{ operation }" do
|
||||
native_write(operation)
|
||||
expect(response).to have_http_status(operation == :create ? :created : :ok)
|
||||
id = operation == :create ? json.fetch('id') : group_id
|
||||
number = operation == :create ? 1 : 2
|
||||
expect_group_snapshot(
|
||||
id, number:, members: snapshot_members, placements:, description: '全体の説明')
|
||||
page = WikiPage.joins(:tag_name).find_by!(tag_names: { name: 'タググループ:構造化' })
|
||||
expect(page.body).to include('全体の説明', '# 区分', '- 掲載タグ', '- タググループ:子')
|
||||
expect(page.current_revision.body).to eq(page.body)
|
||||
expect(page.wiki_versions.order(:version_no).last.body).to eq(page.body)
|
||||
expect(page.wiki_revisions.count).to eq(number)
|
||||
expect(page.wiki_versions.count).to eq(number)
|
||||
get group_title_path('構造化')
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body')).to eq(page.body)
|
||||
end
|
||||
|
||||
(TagGroupRecords::GROUP_TABLES + ['wiki_pages', 'wiki_revisions', 'wiki_versions'])
|
||||
.each do |table|
|
||||
it "rolls back native #{ operation } after #{ table } writes" do
|
||||
expect_atomic_group_write(table) { native_write(operation) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it '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
|
||||
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')
|
||||
end
|
||||
|
||||
it 'updates a native group through Wiki and then reads structured current data' do
|
||||
native_write(:create)
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
previous = group_version(id, 1)
|
||||
put "/wiki/#{ id }",
|
||||
params: { title: 'タググループ:構造化', body: '- 未掲載タグ' }, headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_group_snapshot(
|
||||
id, number: 2,
|
||||
members: [{ 'tag_id' => unplaced.id, 'version_no' => unplaced.version_no }],
|
||||
placements: [{ 'tag_id' => unplaced.id, 'children' => [] }])
|
||||
expect(group_version(id, 1)).to eq(previous)
|
||||
get "/tag_groups/#{ id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('version_no' => 2)
|
||||
expect(json.fetch('members')).to match([
|
||||
include('tag' => include('id' => unplaced.id, 'name' => '未掲載タグ'))])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,189 @@
|
||||
require 'cgi'
|
||||
|
||||
# SQL fixtures use the migration contract without inventing application models.
|
||||
# Include explicitly; these helpers must not affect unrelated examples.
|
||||
module TagGroupRecords
|
||||
GROUP_TABLES = [
|
||||
'tag_groups', 'tag_group_tags', 'tag_group_groups',
|
||||
'tag_group_placements', 'tag_group_versions'].freeze
|
||||
WRITE_TABLES = GROUP_TABLES + [
|
||||
'wiki_pages', 'wiki_revisions', 'wiki_versions', 'wiki_lines',
|
||||
'wiki_revision_lines', 'tag_names', 'tag_description_versions',
|
||||
'tag_descriptions']
|
||||
|
||||
def group_connection = ApplicationRecord.connection
|
||||
|
||||
def group_rows table, conditions = { }
|
||||
clauses = conditions.map do |column, value|
|
||||
"#{ group_connection.quote_column_name(column) } = #{ group_connection.quote(value) }"
|
||||
end
|
||||
sql = "SELECT * FROM #{ group_connection.quote_table_name(table) }"
|
||||
sql += " WHERE #{ clauses.join(' AND ') }" if clauses.any?
|
||||
group_connection.select_all(sql).to_a.sort_by { |row| row.values.map(&:to_s) }
|
||||
end
|
||||
|
||||
def insert_group_row table, attributes
|
||||
columns = attributes.keys.map { group_connection.quote_column_name(_1) }.join(', ')
|
||||
values = attributes.values.map { group_connection.quote(_1) }.join(', ')
|
||||
group_connection.insert(
|
||||
"INSERT INTO #{ group_connection.quote_table_name(table) } (#{ columns }) " +
|
||||
"VALUES (#{ values })")
|
||||
end
|
||||
|
||||
def group_state
|
||||
WRITE_TABLES.to_h { |table| [table, group_rows(table)] }
|
||||
end
|
||||
|
||||
def expect_atomic_group_write table
|
||||
before_state = group_state
|
||||
failure = Class.new(StandardError)
|
||||
injected = false
|
||||
subscriber = lambda do |*args|
|
||||
sql = args.last.fetch(:sql)
|
||||
next unless sql.match?(/\A\s*(?:INSERT INTO|UPDATE)\s+`?#{ table }`?\s/i)
|
||||
|
||||
injected = true
|
||||
raise failure, 'injected after SQL write'
|
||||
end
|
||||
begin
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
|
||||
rescue failure
|
||||
# Inspect the database after the operation's transaction unwinds.
|
||||
end
|
||||
aggregate_failures do
|
||||
expect(injected).to be(true), "No INSERT/UPDATE reached #{ table }"
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
|
||||
def group_version group_id, number
|
||||
row = group_rows('tag_group_versions', tag_group_id: group_id, version_no: number).sole
|
||||
row.merge('members' => JSON.parse(row.fetch('members')),
|
||||
'placements' => JSON.parse(row.fetch('placements')))
|
||||
end
|
||||
|
||||
def group_tag name
|
||||
tag = create(:tag, primary_name: name)
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user: user)
|
||||
tag.reload
|
||||
end
|
||||
|
||||
def legacy_group name, body
|
||||
# Historical fixtures preserve blank lines independently of today's writer.
|
||||
body = body.sub(/\n+\z/, '')
|
||||
page = WikiPage.create!(
|
||||
tag_name: create(:tag_name, name: "タググループ:#{ name }"),
|
||||
body:, created_user: user, updated_user: user)
|
||||
WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: user)
|
||||
lines = body.split("\n")
|
||||
shas = lines.map { Digest::SHA256.hexdigest(_1) }
|
||||
revision = WikiRevision.create!(
|
||||
wiki_page: page, kind: :content, created_user: user, lines_count: lines.size,
|
||||
tree_sha256: Digest::SHA256.hexdigest(shas.join(',')))
|
||||
lines.each_with_index do |line, position|
|
||||
sha = shas.fetch(position)
|
||||
line_id = WikiLine.find_by(sha256: sha)&.id || insert_group_row('wiki_lines', {
|
||||
body: line, sha256: sha, created_at: Time.current, updated_at: Time.current })
|
||||
WikiRevisionLine.create!(wiki_revision: revision, wiki_line_id: line_id, position:)
|
||||
end
|
||||
page
|
||||
end
|
||||
|
||||
def seed_group name:, placements: [], members: [], description: nil, id: nil
|
||||
now = Time.current.change(usec: 0)
|
||||
attributes = {
|
||||
name:, description:, version_no: 1,
|
||||
created_by_user_id: user.id, updated_by_user_id: user.id,
|
||||
created_at: now, updated_at: now }
|
||||
attributes[:id] = id if id
|
||||
group_id = insert_group_row('tag_groups', attributes)
|
||||
insert_group_row('tag_group_versions', {
|
||||
tag_group_id: group_id, version_no: 1, event_type: 'create',
|
||||
name:, description:, members: members.to_json, placements: placements.to_json,
|
||||
created_by_user_id: user.id, created_at: now })
|
||||
members.each do |member|
|
||||
tag_member = member.key?('tag_id')
|
||||
table = tag_member ? 'tag_group_tags' : 'tag_group_groups'
|
||||
key = tag_member ? :tag_id : :child_tag_group_id
|
||||
value = member.fetch(tag_member ? 'tag_id' : 'tag_group_id')
|
||||
insert_group_row(table, {
|
||||
tag_group_id: group_id, key => value,
|
||||
created_by_user_id: user.id, created_at: now })
|
||||
end
|
||||
position = 0
|
||||
append = lambda do |nodes, parent|
|
||||
nodes.each do |node|
|
||||
position += 1
|
||||
own_position = position
|
||||
insert_group_row('tag_group_placements', {
|
||||
tag_group_id: group_id, position:, parent_position: parent,
|
||||
section_name: node['section_name'], description: node['description'],
|
||||
tag_id: node['tag_id'], child_tag_group_id: node['tag_group_id'],
|
||||
created_by_user_id: user.id, updated_by_user_id: user.id,
|
||||
created_at: now, updated_at: now })
|
||||
append.call(node.fetch('children'), own_position)
|
||||
end
|
||||
end
|
||||
append.call(placements, nil)
|
||||
group_id
|
||||
end
|
||||
|
||||
def without_group_legacy_reads
|
||||
queries = []
|
||||
subscriber = lambda do |*args|
|
||||
sql = args.last.fetch(:sql)
|
||||
if (sql.match?(/\bSELECT\b/i) &&
|
||||
sql.match?(/\bwiki_(pages|revisions|versions|lines|revision_lines)\b/i))
|
||||
queries << sql
|
||||
end
|
||||
end
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
|
||||
expect(queries).to be_empty
|
||||
end
|
||||
|
||||
def group_title_path name
|
||||
"/wiki/title/#{ CGI.escapeURIComponent("タググループ:#{ name }") }"
|
||||
end
|
||||
|
||||
def migrate_groups
|
||||
require_relative '../../db/migrate/20261008010000_migrate_tag_groups_from_wiki_pages'
|
||||
migration = MigrateTagGroupsFromWikiPages.new
|
||||
@group_migration_messages = []
|
||||
allow(migration).to receive(:say) do |message, *_args|
|
||||
@group_migration_messages << message if message.start_with?('Wiki #', 'Tag Group')
|
||||
end
|
||||
migration.suppress_messages { migration.up }
|
||||
end
|
||||
|
||||
def expect_group_snapshot group_id, number:, placements:, members:, description: nil
|
||||
current = group_rows('tag_groups', id: group_id).sole
|
||||
version = group_version(group_id, number)
|
||||
expect(current).to include('version_no' => number, 'description' => description),
|
||||
@group_migration_messages&.join("\n")
|
||||
expect(version).to include(
|
||||
'name' => current.fetch('name'), 'description' => description,
|
||||
'version_no' => number, 'placements' => placements)
|
||||
expect(version.fetch('members')).to match_array(members)
|
||||
expect(group_rows('tag_group_tags', tag_group_id: group_id).map { _1.fetch('tag_id') })
|
||||
.to match_array(members.filter_map { _1['tag_id'] })
|
||||
children = group_rows('tag_group_groups', tag_group_id: group_id)
|
||||
expect(children.map { _1.fetch('child_tag_group_id') })
|
||||
.to match_array(members.filter_map { _1['tag_group_id'] })
|
||||
|
||||
rows = group_rows('tag_group_placements', tag_group_id: group_id)
|
||||
tree = lambda do |parent|
|
||||
rows.select { _1['parent_position'] == parent }.sort_by { _1.fetch('position') }
|
||||
.map do |row|
|
||||
node = if row['tag_id']
|
||||
{ 'tag_id' => row.fetch('tag_id') }
|
||||
elsif row['child_tag_group_id']
|
||||
{ 'tag_group_id' => row.fetch('child_tag_group_id') }
|
||||
else
|
||||
{ 'section_name' => row.fetch('section_name'), 'description' => row['description'] }
|
||||
end
|
||||
node.merge('children' => tree.call(row.fetch('position')))
|
||||
end
|
||||
end
|
||||
expect(tree.call(nil)).to eq(placements)
|
||||
end
|
||||
end
|
||||
新しいイシューから参照
ユーザーをブロックする