Reviewed-on: #433 Co-authored-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #433 でマージされました。
このコミットが含まれているのは:
@@ -0,0 +1,54 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'API locale resolution', type: :request do
|
||||
let!(:tag) { create(:tag, primary_name: '日本語正本') }
|
||||
|
||||
it 'defaults an omitted locale to Japanese' do
|
||||
get "/tags/#{ tag.id }"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('name')).to eq('日本語正本')
|
||||
end
|
||||
|
||||
it 'accepts an explicitly requested active Japanese locale' do
|
||||
get "/tags/#{ tag.id }", params: { locale: 'ja' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('name')).to eq('日本語正本')
|
||||
end
|
||||
|
||||
['', 'en', 'ain', 'Jpan', 'JA', 'unknown', ['ja']].each do |code|
|
||||
it "rejects invalid locale #{ code.inspect } instead of defaulting to Japanese" do
|
||||
get "/tags/#{ tag.id }", params: { locale: code }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response.body).not_to include('日本語正本')
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a supported locale that has not been registered' do
|
||||
get "/tags/#{ tag.id }", params: { locale: 'ain-Kana' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'rejects an inactive locale' do
|
||||
Locale.nipponese.update!(deprecated_at: Time.current)
|
||||
|
||||
get "/tags/#{ tag.id }", params: { locale: 'ja' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'does not write posts or tag names under an invalid locale' do
|
||||
sign_in_as(create_member_user!)
|
||||
|
||||
expect do
|
||||
post '/posts', params: {
|
||||
locale: 'en', title: 'invalid locale', url: 'https://example.com/invalid-locale',
|
||||
tags: 'unwritten_tag' }
|
||||
end.to change(Post, :count).by(0).and change(TagName, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
@@ -14,7 +14,19 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
expect(tag_name).to have_attributes(
|
||||
script_code: expected_locale.script_code, primary_flg: true)
|
||||
expect(tag_name.tag_id).to be_present
|
||||
tag_name.tag
|
||||
tag = tag_name.tag
|
||||
version = tag.tag_versions.sole
|
||||
expect(version).to have_attributes(
|
||||
event_type: 'create', version_no: 1, created_by_user_id: member.id)
|
||||
expect(tag.reload.version_no).to eq(1)
|
||||
expect(version.names.keys).to match_array(
|
||||
Locale.where(deprecated_at: nil).distinct.pluck(:language_code))
|
||||
tag.tag_names.where(primary_flg: true).each do |primary|
|
||||
expect(version.names.fetch(primary.language_code)).to include(
|
||||
'name' => primary.name, 'script_code' => primary.script_code,
|
||||
'auto_generated' => primary.auto_generated, 'aliases' => [])
|
||||
end
|
||||
tag
|
||||
end
|
||||
|
||||
it 'confirms a generated Nipponese primary when a human renames it' do
|
||||
@@ -54,13 +66,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
['en', nil].each do |requested_locale|
|
||||
['ain-Kana', nil].each do |requested_locale|
|
||||
context "with locale #{ requested_locale.inspect }" do
|
||||
let(:locale_params) { requested_locale ? { locale: requested_locale } : { } }
|
||||
let(:expected_locale) { requested_locale ? locale : Locale.nipponese }
|
||||
|
||||
it 'creates post tags in the requested language or Nipponese fallback' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
post '/posts', params: locale_params.merge(
|
||||
title: 'Locale post', url: 'https://example.com/locale-post',
|
||||
@@ -72,7 +83,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'updates post tags in the requested language or Nipponese fallback' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
record = create(:post)
|
||||
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
|
||||
@@ -87,7 +97,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'passes the resolved locale to bulk creation' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
# Worker propagation is exercised in post_bulk_creator_spec.
|
||||
expect_any_instance_of(PostBulkCreator).to receive(:run)
|
||||
@@ -102,7 +111,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'creates materials with tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
post '/materials', params: locale_params.merge(
|
||||
tag: 'locale_material', url: 'https://example.com/material')
|
||||
@@ -113,7 +121,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'updates materials with tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
tag = create(:tag, category: :material)
|
||||
material = Material.create!(tag:, url: 'https://example.com/material')
|
||||
@@ -127,7 +134,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'links Nico external tags to internal tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
external = create(:external_tag)
|
||||
NicoTagVersionRecorder.record!(
|
||||
@@ -141,7 +147,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'creates parent tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
tag = create(:tag)
|
||||
|
||||
@@ -157,184 +162,179 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
context 'with existing names in both languages' do
|
||||
let(:english_name) do
|
||||
create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn')
|
||||
let(:ainu_name) do
|
||||
create(:tag_name, name: 'shared_name', language_code: 'ain', script_code: 'Kana')
|
||||
end
|
||||
let(:ainu_tag) do
|
||||
create(:tag, primary_tag_name: ainu_name, category: :material).tap do |tag|
|
||||
primary_tag_name_for(tag, 'ja').update!(name: 'ainu_in_japanese', auto_generated: false)
|
||||
end
|
||||
end
|
||||
let(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) }
|
||||
let(:nipponese_tag) do
|
||||
create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag|
|
||||
# Isolate lookup from the separately tested uniqueness validation.
|
||||
primary_tag_name_for(tag, 'ja').update_columns(name: 'shared_name')
|
||||
primary_tag_name_for(tag, 'ja').update!(name: 'shared_name')
|
||||
end
|
||||
end
|
||||
|
||||
it 'plans and creates the post using the English identity and category' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'plans and creates the post using the Ainu identity and category' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
post '/posts', params: {
|
||||
locale: 'en', title: 'English identity',
|
||||
url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' }
|
||||
locale: 'ain-Kana', title: 'Ainu identity',
|
||||
url: 'https://example.com/ainu-identity', tags: 'shared_name', parent_post_ids: '' }
|
||||
|
||||
expect(response).to have_http_status(:created), response.body
|
||||
record = Post.find(json.fetch('id'))
|
||||
expect(record.tags).to include(english_tag)
|
||||
expect(record.tags).to include(ainu_tag)
|
||||
expect(record.tags).not_to include(nipponese_tag)
|
||||
expect(english_tag.reload.category).to eq('material')
|
||||
expect(ainu_tag.reload.category).to eq('material')
|
||||
expect(nipponese_tag.reload.category).to eq('general')
|
||||
end
|
||||
|
||||
it 'updates the post using the English identity' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'updates the post using the Ainu identity' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
record = create(:post)
|
||||
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
|
||||
|
||||
put "/posts/#{ record.id }", params: {
|
||||
locale: 'en', title: record.title, tags: 'shared_name', parent_post_ids: '',
|
||||
locale: 'ain-Kana', title: record.title, tags: 'shared_name', parent_post_ids: '',
|
||||
base_version_no: record.reload.version_no }
|
||||
|
||||
expect(response).to have_http_status(:ok), response.body
|
||||
expect(record.reload.tags).to include(english_tag)
|
||||
expect(record.reload.tags).to include(ainu_tag)
|
||||
expect(record.tags).not_to include(nipponese_tag)
|
||||
end
|
||||
|
||||
it 'resolves the English material tag' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'resolves the Ainu material tag' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
post '/materials', params: {
|
||||
locale: 'en', tag: 'shared_name', url: 'https://example.com/english-material' }
|
||||
locale: 'ain-Kana', tag: 'shared_name', url: 'https://example.com/ainu-material' }
|
||||
|
||||
expect(response).to have_http_status(:created), response.body
|
||||
expect(Material.find(json.fetch('id')).tag).to eq(english_tag)
|
||||
expect(Material.find(json.fetch('id')).tag).to eq(ainu_tag)
|
||||
expect(Material.where(tag: nipponese_tag)).to be_empty
|
||||
end
|
||||
|
||||
it 'resolves the English parent tag' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'resolves the Ainu parent tag' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
child = create(:tag)
|
||||
|
||||
put "/tags/#{ child.id }", params: {
|
||||
locale: 'en', name: primary_tag_name_for(child, 'ja').name,
|
||||
locale: 'ain-Kana', name: primary_tag_name_for(child, 'ja').name,
|
||||
category: child.category, deprecated: false,
|
||||
aliases: '', parent_tags: 'shared_name' }
|
||||
|
||||
expect(response).to have_http_status(:ok), response.body
|
||||
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([english_tag.id])
|
||||
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([ainu_tag.id])
|
||||
end
|
||||
|
||||
it 'resolves the English internal tag for a Nico link' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'resolves the Ainu internal tag for a Nico link' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
external = create(:external_tag)
|
||||
NicoTagVersionRecorder.record!(
|
||||
external_tag: external, event_type: :create, created_by_user: member)
|
||||
|
||||
put "/tags/nico/#{ external.id }", params: { locale: 'en', tags: 'shared_name' }
|
||||
put "/tags/nico/#{ external.id }", params: { locale: 'ain-Kana', tags: 'shared_name' }
|
||||
|
||||
expect(response).to have_http_status(:ok), response.body
|
||||
expect(external.reload.linked_tags).to contain_exactly(english_tag)
|
||||
expect(external.reload.linked_tags).to contain_exactly(ainu_tag)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when renaming or updating aliases in English' do
|
||||
let(:english_name) do
|
||||
create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn')
|
||||
context 'when renaming or updating aliases in Ainu' do
|
||||
let(:ainu_name) do
|
||||
create(:tag_name, name: 'ainu_original', language_code: 'ain', script_code: 'Kana')
|
||||
end
|
||||
let(:tag) { create(:tag, primary_tag_name: english_name) }
|
||||
let(:tag) { create(:tag, primary_tag_name: ainu_name) }
|
||||
|
||||
it 'marks a human-renamed generated primary as no longer auto-generated' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
generated_tag = create(:tag, primary_name: 'rename_generated_source')
|
||||
generated_name = primary_tag_name_for(generated_tag, 'en')
|
||||
generated_name = primary_tag_name_for(generated_tag, 'ain')
|
||||
expect(generated_name.auto_generated).to be(true)
|
||||
|
||||
patch "/tags/#{ generated_tag.id }", params: {
|
||||
locale: 'en', name: 'human_english_name' }
|
||||
locale: 'ain-Kana', name: 'human_ainu_name' }
|
||||
|
||||
expect(response).to have_http_status(:ok), response.body
|
||||
expect(generated_name.reload).to have_attributes(
|
||||
name: 'human_english_name', auto_generated: false)
|
||||
name: 'human_ainu_name', auto_generated: false)
|
||||
end
|
||||
|
||||
it 'does not steal another tag primary for an alias' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
other_tag = create(:tag, primary_name: 'other_nipponese')
|
||||
other_primary = primary_tag_name_for(other_tag, 'en')
|
||||
other_primary.update!(name: 'other_english_primary', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, name: 'existing_english_alias',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
primary_name_before = primary_tag_name_for(tag, 'en').name
|
||||
other_primary = primary_tag_name_for(other_tag, 'ain')
|
||||
other_primary.update!(name: 'other_ainu_primary', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, name: 'existing_ainu_alias',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
primary_name_before = primary_tag_name_for(tag, 'ain').name
|
||||
aliases_before =
|
||||
tag.tag_names.where(language_code: 'en', primary_flg: false)
|
||||
tag.tag_names.where(language_code: 'ain', primary_flg: false)
|
||||
.order(:name).pluck(:name)
|
||||
category_before = tag.category
|
||||
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
locale: 'en', name: 'renamed_before_alias_collision',
|
||||
locale: 'ain-Kana', name: 'renamed_before_alias_collision',
|
||||
category: 'character', deprecated: false,
|
||||
aliases: 'other_english_primary', parent_tags: '' }
|
||||
aliases: 'other_ainu_primary', parent_tags: '' }
|
||||
|
||||
aggregate_failures do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(other_primary.reload).to have_attributes(
|
||||
tag_id: other_tag.id, language_code: 'en', primary_flg: true)
|
||||
expect(TagName.where(tag: other_tag, language_code: 'en', primary_flg: true))
|
||||
tag_id: other_tag.id, language_code: 'ain', primary_flg: true)
|
||||
expect(TagName.where(tag: other_tag, language_code: 'ain', primary_flg: true))
|
||||
.to contain_exactly(other_primary)
|
||||
expect(primary_tag_name_for(tag.reload, 'en').name).to eq(primary_name_before)
|
||||
expect(primary_tag_name_for(tag.reload, 'ain').name).to eq(primary_name_before)
|
||||
expect(tag.category).to eq(category_before)
|
||||
aliases_after =
|
||||
tag.tag_names.where(language_code: 'en', primary_flg: false)
|
||||
tag.tag_names.where(language_code: 'ain', primary_flg: false)
|
||||
.order(:name).pluck(:name)
|
||||
expect(aliases_after).to eq(aliases_before)
|
||||
end
|
||||
end
|
||||
|
||||
it 'allows a rename to a name already used in Nipponese' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'rename_target')
|
||||
primary_tag_name_for(nipponese, 'ain').update!(
|
||||
name: 'different_ainu_target', auto_generated: false)
|
||||
|
||||
patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' }
|
||||
patch "/tags/#{ tag.id }", params: { locale: 'ain-Kana', name: 'rename_target' }
|
||||
|
||||
expect(response).to have_http_status(:ok), response.body
|
||||
expect(primary_tag_name_for(tag.reload, 'en')).to have_attributes(
|
||||
name: 'rename_target', language_code: 'en', tag_id: tag.id)
|
||||
expect(primary_tag_name_for(tag.reload, 'ain')).to have_attributes(
|
||||
name: 'rename_target', language_code: 'ain', tag_id: tag.id)
|
||||
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
|
||||
name: 'rename_target', language_code: 'ja', tag_id: nipponese.id)
|
||||
end
|
||||
|
||||
it 'creates aliases in the requested language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
locale: 'en', name: english_name.name,
|
||||
locale: 'ain-Kana', name: ainu_name.name,
|
||||
category: tag.category, deprecated: false,
|
||||
aliases: 'english_alias', parent_tags: '' }
|
||||
aliases: 'ainu_alias', parent_tags: '' }
|
||||
|
||||
expect(response).to have_http_status(:ok), response.body
|
||||
expect(TagName.find_by!(language_code: 'en', name: 'english_alias'))
|
||||
expect(TagName.find_by!(language_code: 'ain', name: 'ainu_alias'))
|
||||
.to have_attributes(
|
||||
script_code: 'Latn',
|
||||
tag_id: tag.id, primary_flg: false, language_code: 'en')
|
||||
primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
|
||||
expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn')
|
||||
script_code: 'Kana',
|
||||
tag_id: tag.id, primary_flg: false, language_code: 'ain')
|
||||
primary = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
|
||||
expect(primary).to have_attributes(name: 'ainu_original', script_code: 'Kana')
|
||||
end
|
||||
|
||||
it 'does not take an alias from another language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'nipponese_owner')
|
||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
@@ -344,7 +344,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
primary_flg: false, language_code: 'ja')
|
||||
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
locale: 'en', name: english_name.name,
|
||||
locale: 'ain-Kana', name: ainu_name.name,
|
||||
category: tag.category, deprecated: false,
|
||||
aliases: 'shared_alias', parent_tags: '' }
|
||||
|
||||
@@ -352,12 +352,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
expect(nipponese_alias.reload).to have_attributes(
|
||||
tag_id: nipponese.id,
|
||||
primary_flg: false, language_code: 'ja')
|
||||
expect(TagName.find_by!(language_code: 'en', name: 'shared_alias'))
|
||||
expect(TagName.find_by!(language_code: 'ain', name: 'shared_alias'))
|
||||
.to have_attributes(
|
||||
tag_id: tag.id, script_code: 'Latn',
|
||||
primary_flg: false, language_code: 'en')
|
||||
tag_id: tag.id, script_code: 'Kana',
|
||||
primary_flg: false, language_code: 'ain')
|
||||
expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id))
|
||||
.to contain_exactly(['ja', nipponese.id], ['en', tag.id])
|
||||
.to contain_exactly(['ja', nipponese.id], ['ain', tag.id])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -231,11 +231,14 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
replacement: 'valid'
|
||||
)
|
||||
nico_tag
|
||||
before_counts = [Tag.count, TagName.count, TagVersion.count]
|
||||
|
||||
expect {
|
||||
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'created_first invalid' }
|
||||
}.not_to change(TagName, :count)
|
||||
|
||||
expect([Tag.count, TagName.count, TagVersion.count]).to eq(before_counts)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors').fetch('tags')).to include(
|
||||
a_string_including('タグ名 “invalid”:', '名前に使用できない文字が含まれてゐます.'))
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Post filter identity across locales', type: :request do
|
||||
include_context 'Ainu locale'
|
||||
|
||||
let!(:activated_locale) { locale }
|
||||
let!(:tag) { create(:tag, primary_name: 'legacy_union_source') }
|
||||
let!(:external) { create(:external_tag, name: 'shared') }
|
||||
let!(:internal_post) { create(:post) }
|
||||
let!(:external_post) { create(:post) }
|
||||
let!(:other_post) { create(:post) }
|
||||
|
||||
before do
|
||||
# Legacy data can predate the current prohibition on internal nico: names.
|
||||
primary_tag_name_for(tag, 'ja').update_columns(name: 'nico:shared')
|
||||
primary_tag_name_for(tag, 'ain').update!(name: 'シロシ', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, name: 'union_alias', language_code: 'ja')
|
||||
PostTag.create!(post: internal_post, tag:)
|
||||
PostExternalTag.create!(post: external_post, external_tag: external)
|
||||
end
|
||||
|
||||
['nico:shared', 'union_alias', 'not:nico:shared'].each do |literal|
|
||||
it "preserves the complete match set for #{ literal }" do
|
||||
get '/posts', params: { locale: 'ja', tags: literal }
|
||||
expect(response).to have_http_status(:ok)
|
||||
original_ids = json.fetch('posts').map { _1.fetch('id') }
|
||||
expected = literal.start_with?('not:') ? [other_post.id] :
|
||||
[internal_post.id, external_post.id]
|
||||
expect(original_ids).to match_array(expected)
|
||||
|
||||
get '/posts/tag_filters', params: {
|
||||
locale: 'ja', target_locale: 'ain-Kana', tags: literal }
|
||||
expect(response).to have_http_status(:ok)
|
||||
references = json.fetch('references')
|
||||
expect(references.first).to include('tag_id' => tag.id, 'external_tag_id' => external.id)
|
||||
|
||||
get '/posts', params: {
|
||||
locale: 'ain-Kana', tags: references.map { _1.fetch('name') }.join(' '),
|
||||
tag_refs: references.to_json }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('posts').map { _1.fetch('id') }).to match_array(original_ids)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -900,7 +900,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
query_count =
|
||||
count_sql_queries do
|
||||
get "/posts/#{ post_record.id }",
|
||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
headers: cookie_headers(user)
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
@@ -2323,8 +2323,10 @@ RSpec.describe 'Posts API', type: :request do
|
||||
'prev' => nil
|
||||
)
|
||||
expect(latest.fetch('tags')).to include(
|
||||
{ 'name' => 'spec_tag_2', 'type' => 'added' },
|
||||
{ 'name' => 'spec_tag', 'type' => 'removed' }
|
||||
{ 'name' => 'spec_tag_2', 'restore_name' => 'spec_tag_2',
|
||||
'external' => false, 'type' => 'added' },
|
||||
{ 'name' => 'spec_tag', 'restore_name' => 'spec_tag',
|
||||
'external' => false, 'type' => 'removed' }
|
||||
)
|
||||
expect(latest.fetch('original_created_from')).to eq(
|
||||
'current' => oc_from.iso8601,
|
||||
@@ -2351,7 +2353,8 @@ RSpec.describe 'Posts API', type: :request do
|
||||
'prev' => nil
|
||||
)
|
||||
expect(first.fetch('tags')).to include(
|
||||
{ 'name' => 'spec_tag', 'type' => 'added' }
|
||||
{ 'name' => 'spec_tag', 'restore_name' => 'spec_tag',
|
||||
'external' => false, 'type' => 'added' }
|
||||
)
|
||||
expect(first.fetch('created_at')).to eq(t_v1.iso8601)
|
||||
end
|
||||
@@ -2435,7 +2438,9 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(3)
|
||||
expect(json.fetch('versions').first.fetch('tags')).to include(
|
||||
'name' => primary_tag_name_for(tag2, 'ja').name, 'type' => 'context')
|
||||
'name' => primary_tag_name_for(tag2, 'ja').name,
|
||||
'restore_name' => primary_tag_name_for(tag2, 'ja').name,
|
||||
'external' => false, 'type' => 'context')
|
||||
end
|
||||
|
||||
it 'filters versions by tag when the current snapshot includes the tag' do
|
||||
@@ -2449,7 +2454,8 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(versions[0]['post_id']).to eq(post_record.id)
|
||||
expect(versions[0]['version_no']).to eq(2)
|
||||
expect(versions[0]['tags']).to include(
|
||||
{ 'name' => 'spec_tag_2', 'type' => 'added' }
|
||||
{ 'name' => 'spec_tag_2', 'restore_name' => 'spec_tag_2',
|
||||
'external' => false, 'type' => 'added' }
|
||||
)
|
||||
end
|
||||
|
||||
@@ -2467,10 +2473,12 @@ RSpec.describe 'Posts API', type: :request do
|
||||
first = versions[1]
|
||||
|
||||
expect(latest['tags']).to include(
|
||||
{ 'name' => 'spec_tag', 'type' => 'removed' }
|
||||
{ 'name' => 'spec_tag', 'restore_name' => 'spec_tag',
|
||||
'external' => false, 'type' => 'removed' }
|
||||
)
|
||||
expect(first['tags']).to include(
|
||||
{ 'name' => 'spec_tag', 'type' => 'added' }
|
||||
{ 'name' => 'spec_tag', 'restore_name' => 'spec_tag',
|
||||
'external' => false, 'type' => 'added' }
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Sitemap entries API', type: :request do
|
||||
include_context 'Ainu locale'
|
||||
|
||||
let(:user) { create(:user, :member) }
|
||||
|
||||
it 'serves public resource counts with the default locale and page size' do
|
||||
create(:post)
|
||||
create(:tag, primary_name: 'sitemap_count_tag')
|
||||
|
||||
get '/sitemap_entries'
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('page_size')).to eq(1_000)
|
||||
counts = json.fetch('resources').to_h { |entry|
|
||||
[entry.fetch('resource'), entry.fetch('count')]
|
||||
}
|
||||
expect(counts).to eq(
|
||||
'posts' => Post.count, 'tags' => Tag.count, 'deerjikists' => 0,
|
||||
'materials' => 0, 'descriptions' => 0, 'tag_groups' => 0, 'theatres' => 0)
|
||||
end
|
||||
|
||||
it 'paginates public post IDs in ascending order without exposing post contents' do
|
||||
stub_const('SitemapEntriesController::PAGE_SIZE', 2)
|
||||
posts = create_list(:post, 3)
|
||||
|
||||
get '/sitemap_entries/posts'
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq('ids' => posts.first(2).map(&:id))
|
||||
|
||||
get '/sitemap_entries/posts', params: { page: 2 }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq('ids' => [posts.last.id])
|
||||
|
||||
get '/sitemap_entries/posts', params: { page: 3 }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
['0', '-1', 'invalid', '1.5'].each do |page|
|
||||
it "rejects invalid sitemap page #{ page.inspect }" do
|
||||
get '/sitemap_entries/posts', params: { page: }
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects unsupported resource names' do
|
||||
get '/sitemap_entries/users'
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns an empty first page for a resource without entries' do
|
||||
get '/sitemap_entries/posts'
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq('ids' => [])
|
||||
end
|
||||
|
||||
it 'rejects unregistered, deprecated and unsupported locales' do
|
||||
get '/sitemap_entries', params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
|
||||
locale.update!(deprecated_at: Time.current)
|
||||
['/sitemap_entries', '/sitemap_entries/posts'].each do |path|
|
||||
get path, params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
|
||||
get path, params: { locale: 'en' }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
it 'only lists live materials and theatres' do
|
||||
material = Material.create!(url: 'https://example.com/sitemap-material')
|
||||
discarded_material = Material.create!(url: 'https://example.com/sitemap-discarded')
|
||||
discarded_material.discard!
|
||||
theatre = create(:theatre)
|
||||
create(:theatre).discard!
|
||||
|
||||
get '/sitemap_entries/materials'
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq('ids' => [material.id])
|
||||
|
||||
get '/sitemap_entries/theatres'
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq('ids' => [theatre.id])
|
||||
end
|
||||
|
||||
context 'with an active Ainu locale' do
|
||||
before { locale }
|
||||
|
||||
it 'uses tag IDs and only includes deerjikists with linked accounts' do
|
||||
tagged = create(:tag, primary_name: 'sitemap_deer', category: :deerjikist)
|
||||
unlinked = create(:tag, primary_name: 'sitemap_unlinked', category: :deerjikist)
|
||||
Deerjikist.create!(tag: tagged, platform: 'youtube', code: 'sitemap-channel')
|
||||
Deerjikist.create!(tag: tagged, platform: 'nico', code: 'sitemap-nico')
|
||||
|
||||
get '/sitemap_entries/tags', params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('ids')).to contain_exactly(tagged.id, unlinked.id)
|
||||
|
||||
get '/sitemap_entries/deerjikists', params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq('ids' => [tagged.id])
|
||||
end
|
||||
|
||||
it 'lists description IDs for the requested language with a nonempty current version' do
|
||||
tag = create(:tag, primary_name: 'sitemap_description')
|
||||
japanese = TagDescriptionOperation.create!(
|
||||
Locale.nipponese, tag:, body: 'Japanese sitemap body', created_by_user: user)
|
||||
ainu = TagDescriptionOperation.create!(
|
||||
locale, tag:, body: 'Ainu sitemap body', created_by_user: user)
|
||||
unversioned_tag = create(:tag, primary_name: 'sitemap_unversioned')
|
||||
TagDescription.create!(tag: unversioned_tag, language_code: 'ain',
|
||||
body: 'No revision', version_no: 1)
|
||||
empty_tag = create(:tag, primary_name: 'sitemap_empty')
|
||||
# Legacy empty bodies can exist even though the write service rejects them.
|
||||
empty = TagDescription.create!(tag: empty_tag, language_code: 'ain',
|
||||
body: '', version_no: 1)
|
||||
TagDescriptionVersion.create!(tag_id: empty_tag.id, language_code: 'ain',
|
||||
body: '', version_no: 1, event_type: 'create',
|
||||
created_by_user_id: user.id, created_at: Time.current)
|
||||
|
||||
get '/sitemap_entries/descriptions', params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq('ids' => [ainu.id])
|
||||
expect(json.fetch('ids')).not_to include(japanese.id, empty.id)
|
||||
|
||||
get '/sitemap_entries/descriptions', params: { locale: 'ja' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq('ids' => [japanese.id])
|
||||
|
||||
get '/sitemap_entries', params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
descriptions = json.fetch('resources').find { |entry|
|
||||
entry.fetch('resource') == 'descriptions'
|
||||
}
|
||||
expect(descriptions.fetch('count')).to eq(1)
|
||||
end
|
||||
|
||||
it 'excludes groups without a revision matching the current version' do
|
||||
group = TagGroupOperation.create!(
|
||||
Locale.nipponese, name: 'Sitemap group', description: 'Group description',
|
||||
members: [], placements: [], actor: user)
|
||||
TagGroup.create!(name: 'Unversioned sitemap group', version_no: 1)
|
||||
|
||||
get '/sitemap_entries/tag_groups', params: { locale: 'ain-Kana' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq('ids' => [group.id])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@ RSpec.describe 'Tag description Wiki facade writes', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { create(:tag, primary_name: 'description_write') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
|
||||
def stored_state
|
||||
[Tag, TagName, TagVersion, TagDescription, TagDescriptionVersion].map do |model|
|
||||
|
||||
@@ -7,7 +7,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
let(:editor) { create(:user, :member, name: 'description editor') }
|
||||
let(:tag_name) { create(:tag_name, name: 'native_description') }
|
||||
let(:tag) { create(:tag, primary_tag_name: tag_name) }
|
||||
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } }
|
||||
let(:headers) { cookie_headers(editor) }
|
||||
let(:endpoint) { "/tags/#{ tag.id }/description" }
|
||||
|
||||
def stored_state
|
||||
@@ -285,7 +285,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
guest = create(:user, :guest)
|
||||
before_state = stored_state
|
||||
native_write(body: 'forbidden',
|
||||
request_headers: { 'X-Transfer-Code' => guest.inheritance_code })
|
||||
request_headers: cookie_headers(guest))
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
@@ -294,7 +294,7 @@ RSpec.describe 'Tag description native API', type: :request do
|
||||
banned = create(:user, :member, :banned)
|
||||
before_state = stored_state
|
||||
native_write(body: 'forbidden',
|
||||
request_headers: { 'X-Transfer-Code' => banned.inheritance_code })
|
||||
request_headers: cookie_headers(banned))
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
|
||||
@@ -7,7 +7,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { create(:tag, primary_name: 'japanese_member') }
|
||||
let!(:ainu_name) { add_ainu_name(tag, 'ainu_member') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
let(:members) { [{ 'tag_id' => tag.id }] }
|
||||
let(:placements) do
|
||||
[{ 'section_name' => 'section', 'description' => nil, 'children' => [
|
||||
@@ -22,7 +22,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
|
||||
it 'uses selected-language names in native collection and nested placements' do
|
||||
["/tag_groups/#{ group.id }", '/tag_groups'].each do |path|
|
||||
get path, params: { locale: 'ain' }
|
||||
get path, params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
representation = json.is_a?(Array) ? json.sole : json
|
||||
expect(representation.fetch('members').sole.fetch('tag').fetch('name'))
|
||||
@@ -37,14 +37,14 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
it 'uses snapshot names recursively for history and current names for the latest view' do
|
||||
version = group.versions.sole
|
||||
ainu_name.update!(name: 'renamed_ainu')
|
||||
get "/wiki/#{ group.id }", params: { locale: 'ain' }
|
||||
get "/wiki/#{ group.id }", params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body')).to include('renamed_ainu')
|
||||
get "/wiki/#{ group.id }", params: { locale: 'ain', version: version.id }
|
||||
get "/wiki/#{ group.id }", params: { locale: 'ain-Kana', version: version.id }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body').scan('ainu_member').length).to eq(2)
|
||||
expect(json.fetch('body')).not_to include('renamed_ainu', 'japanese_member')
|
||||
get "/wiki/#{ group.id }/diff", params: { locale: 'ain', from: version.id }
|
||||
get "/wiki/#{ group.id }/diff", params: { locale: 'ain-Kana', from: version.id }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('diff').map { _1.fetch('type') }.uniq).to eq(['context'])
|
||||
end
|
||||
@@ -52,23 +52,24 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
['タググループ:', 'tag_group:', 'TAG_GROUP:'].each do |prefix|
|
||||
it "round trips #{ prefix } through create, lookup, search and update" do
|
||||
post '/wiki', params: {
|
||||
locale: 'ain', title: "#{ prefix }parent", body: '- tag_group:locale group' },
|
||||
locale: 'ain-Kana', title: "#{ prefix }parent", body: '- tag_group:locale group' },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
expect(json.fetch('body')).to include('タググループ:locale group')
|
||||
path = "/wiki/title/#{ CGI.escapeURIComponent("#{ prefix }parent") }"
|
||||
get path, params: { locale: 'ain' }
|
||||
get path, params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('id')).to eq(id)
|
||||
get "#{ path }/exists", params: { locale: 'ain' }
|
||||
get "#{ path }/exists", params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:no_content)
|
||||
get '/wiki/search', params: { locale: 'ain', title: "#{ prefix }parent" }
|
||||
get '/wiki/search', params: { locale: 'ain-Kana', title: "#{ prefix }parent" }
|
||||
expect(json.map { _1.fetch('id') }).to eq([id])
|
||||
put "/wiki/#{ id }", params: {
|
||||
locale: 'ain', title: "#{ prefix }renamed", body: '- ainu_member' }, headers: headers
|
||||
locale: 'ain-Kana', title: "#{ prefix }renamed", body: '- ainu_member' },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
get "/tag_groups/#{ id }", params: { locale: 'ain' }
|
||||
get "/tag_groups/#{ id }", params: { locale: 'ain-Kana' }
|
||||
expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member')
|
||||
end
|
||||
end
|
||||
@@ -81,7 +82,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
end.to raise_error(TagGroupMarkdownParser::InvalidDocument)
|
||||
before_state = group_state
|
||||
put "/wiki/#{ group.id }", params: {
|
||||
locale: 'ain', title: 'タググループ:partial rename', body: '- japanese_member' },
|
||||
locale: 'ain-Kana', title: 'タググループ:partial rename', body: '- japanese_member' },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
@@ -89,15 +90,15 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
|
||||
it 'passes locale into native validation and facade compatibility checks' do
|
||||
post '/tag_groups', params: {
|
||||
locale: 'ain', name: 'native', description: nil, members:, placements: },
|
||||
locale: 'ain-Kana', name: 'native', description: nil, members:, placements: },
|
||||
headers:, as: :json
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member')
|
||||
get "/wiki/#{ id }", params: { locale: 'ain' }
|
||||
get "/wiki/#{ id }", params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = json.fetch('body')
|
||||
put "/wiki/#{ id }", params: { locale: 'ain', title: 'tag_group:native', body: },
|
||||
put "/wiki/#{ id }", params: { locale: 'ain-Kana', title: 'tag_group:native', body: },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(TagGroup.find(id).version_no).to eq(1)
|
||||
@@ -115,7 +116,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
representation = TagGroupRepr.historical(ainu_locale, version)
|
||||
expect(representation.fetch('members').sole.fetch('tag').fetch('name')).to be_nil
|
||||
expect(representation.fetch('placements').sole.fetch('tag').fetch('name')).to be_nil
|
||||
get "/wiki/#{ old_group.id }", params: { locale: 'ain', version: version.id }
|
||||
get "/wiki/#{ old_group.id }", params: { locale: 'ain-Kana', version: version.id }
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.body).not_to include('historical_japanese', 'current_ainu')
|
||||
end
|
||||
|
||||
@@ -37,7 +37,7 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :
|
||||
markdown.lines.map(&:chomp).reject(&:empty?))
|
||||
|
||||
post '/wiki', params: { title: 'タググループ:再解析', body: },
|
||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
headers: cookie_headers(user)
|
||||
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:)
|
||||
|
||||
@@ -7,7 +7,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { group_tag('洗澡鹿') }
|
||||
let!(:other_tag) { group_tag('伊地知ニジカ') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
|
||||
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
||||
|
||||
@@ -148,7 +148,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
body = group_markdown(page)
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id: page.id) do
|
||||
update_group(body:, request_headers: { 'X-Transfer-Code' => editor.inheritance_code })
|
||||
update_group(body:, request_headers: cookie_headers(editor))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -212,7 +212,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
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 })
|
||||
update_group(request_headers: cookie_headers(editor))
|
||||
expect(response).to have_http_status(:ok)
|
||||
current = group_rows('tag_groups', id: page.id).sole
|
||||
version = group_version(page.id, 2)
|
||||
@@ -271,7 +271,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
|
||||
when :guest then create(:user, :guest)
|
||||
when :banned then create(:user, :member, :banned)
|
||||
end
|
||||
request_headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
request_headers = actor ? cookie_headers(actor) : { }
|
||||
before_state = group_state
|
||||
write_group(operation, request_headers)
|
||||
expect(response).to have_http_status(role ? :forbidden : :unauthorized)
|
||||
|
||||
@@ -8,7 +8,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { group_tag('洗澡鹿') }
|
||||
let!(:other_tag) { group_tag('伊地知ニジカ') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
let(:members) { [{ 'tag_id' => tag.id, 'version_no' => tag.version_no }] }
|
||||
let(:placements) { [{ 'tag_id' => tag.id, 'children' => [] }] }
|
||||
|
||||
@@ -141,7 +141,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
|
||||
end
|
||||
let!(:id) { seed_group(name: '更新', members:, placements:) }
|
||||
let!(:editor) { create(:user, :member) }
|
||||
let(:headers) { { 'X-Transfer-Code' => editor.inheritance_code } }
|
||||
let(:headers) { cookie_headers(editor) }
|
||||
|
||||
it 'keeps all membership and placement audit fields on a name-only edit' do
|
||||
before_rows = group_state.slice('tag_group_tags', 'tag_group_groups', 'tag_group_placements')
|
||||
|
||||
@@ -47,7 +47,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
||||
when :guest then create(:user, :guest)
|
||||
when :banned then create(:user, :member, :banned)
|
||||
end
|
||||
headers = actor ? { 'X-Transfer-Code' => actor.inheritance_code } : { }
|
||||
headers = actor ? cookie_headers(actor) : { }
|
||||
before_state = group_state
|
||||
if operation == :create
|
||||
post '/tag_groups', params: { }, headers:, as: :json
|
||||
@@ -61,7 +61,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
||||
|
||||
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 }
|
||||
headers = cookie_headers(user)
|
||||
before_state = group_state
|
||||
if operation == :create
|
||||
post '/tag_groups', params: { }, headers:, as: :json
|
||||
@@ -77,7 +77,7 @@ RSpec.describe 'Tag group native API', type: :request 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(:headers) { cookie_headers(user) }
|
||||
let(:members) do
|
||||
[{ 'tag_id' => tag.id }, { 'tag_group_id' => child_id }, { 'tag_id' => unplaced.id }]
|
||||
end
|
||||
@@ -300,7 +300,7 @@ RSpec.describe 'Tag group native API', type: :request do
|
||||
travel_to(1.hour.from_now) do
|
||||
expect_noop_group_update(group_id:) do
|
||||
put "/tag_groups/#{ group_id }", params: synchronised_payload,
|
||||
headers: { 'X-Transfer-Code' => editor.inheritance_code }, as: :json
|
||||
headers: cookie_headers(editor), as: :json
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ require 'rails_helper'
|
||||
RSpec.describe 'Tag resource ID allocation', type: :request do
|
||||
include WikiRecords
|
||||
let!(:user) { create_member_user! }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
|
||||
def create_description name
|
||||
tag = create(:tag, primary_name: name)
|
||||
|
||||
@@ -10,7 +10,31 @@ RSpec.describe 'Users', type: :request do
|
||||
end
|
||||
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
describe 'GET /users/session' do
|
||||
it 'does not accept the retired transfer-code header' do
|
||||
user = create(:user)
|
||||
get '/users/session', headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'requires authentication' do
|
||||
get '/users/session'
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns public user fields without recording a visit' do
|
||||
user = create(:user, name: 'SSR user')
|
||||
expect {
|
||||
get '/users/session', headers: auth_headers(user)
|
||||
}.not_to change(UserIp, :count)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq('id' => user.id, 'name' => user.name, 'role' => user.role)
|
||||
expect(response.body).not_to include(user.inheritance_code)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /users' do
|
||||
@@ -233,17 +257,102 @@ RSpec.describe 'Users', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Cookie sessions with CSRF enabled', csrf: true do
|
||||
def csrf_headers
|
||||
get '/users/csrf'
|
||||
expect(response).to have_http_status(:ok)
|
||||
{ 'X-CSRF-Token' => json.fetch('token'), 'Origin' => 'http://www.example.com' }
|
||||
end
|
||||
|
||||
def establish_session(user)
|
||||
post '/users/verify', params: { code: user.inheritance_code }, headers: csrf_headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('valid')).to be(true)
|
||||
end
|
||||
|
||||
it 'rejects a write without a token before creating an account' do
|
||||
expect { post '/users' }.not_to change(User, :count)
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(response.headers['X-CSRF-Invalid']).to eq('1')
|
||||
end
|
||||
|
||||
it 'uses the encrypted persistent Cookie for CSR and SSR session reads' do
|
||||
user = create(:user)
|
||||
establish_session(user)
|
||||
cookie = Array(response.headers['set-cookie']).join
|
||||
expect(cookie).to include('httponly', 'samesite=lax')
|
||||
expect(cookie).not_to include(user.inheritance_code)
|
||||
expires = Time.httpdate(cookie.match(/expires=([^;]+)/i)[1])
|
||||
expect(expires).to be > 19.years.from_now
|
||||
get '/users/session'
|
||||
expect(json.fetch('id')).to eq(user.id)
|
||||
expect(response.body).not_to include(user.inheritance_code)
|
||||
get '/users/me'
|
||||
expect(json.fetch('inheritance_code')).to eq(user.inheritance_code)
|
||||
expect(response.headers['Cache-Control']).to include('no-store')
|
||||
end
|
||||
|
||||
it 'rejects a cross-origin write with an otherwise valid token' do
|
||||
headers = csrf_headers.merge('Origin' => 'https://other.invalid')
|
||||
expect { post '/users', headers: }.not_to change(User, :count)
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
|
||||
it 'does not replace an existing Cookie identity during recovery' do
|
||||
current = create(:user)
|
||||
old = create(:user)
|
||||
establish_session(current)
|
||||
post '/users/verify', params: { code: old.inheritance_code, restore: true },
|
||||
headers: csrf_headers
|
||||
expect(json.fetch('user').fetch('id')).to eq(current.id)
|
||||
get '/users/session'
|
||||
expect(json.fetch('id')).to eq(current.id)
|
||||
end
|
||||
|
||||
it 'allows explicit transfer and refreshes the active session' do
|
||||
first = create(:user)
|
||||
second = create(:user)
|
||||
establish_session(first)
|
||||
establish_session(second)
|
||||
get '/users/session'
|
||||
expect(json.fetch('id')).to eq(second.id)
|
||||
end
|
||||
|
||||
it 'keeps the renewed session and rejects the previous credential' do
|
||||
user = create(:user)
|
||||
establish_session(user)
|
||||
old_cookie = Array(response.headers['set-cookie']).first.split(';').first
|
||||
old_code = user.inheritance_code
|
||||
post '/users/code/renew', headers: csrf_headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('code')).not_to eq(old_code)
|
||||
get '/users/session'
|
||||
expect(json.fetch('id')).to eq(user.id)
|
||||
other_browser = ActionDispatch::Integration::Session.new(Rails.application)
|
||||
other_browser.get('/users/session', headers: { 'Cookie' => old_cookie })
|
||||
expect(other_browser.response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'clears browser authentication on session deletion' do
|
||||
establish_session(create(:user))
|
||||
delete '/users/session', headers: csrf_headers
|
||||
expect(response).to have_http_status(:no_content)
|
||||
get '/users/session'
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /users/me' do
|
||||
it 'returns 404 when code not found' do
|
||||
it 'requires a session even when a code is supplied in the query' do
|
||||
get '/users/me', params: { code: 'nope' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'returns user slice when found' do
|
||||
user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest')
|
||||
|
||||
get '/users/me', params: { code: user.inheritance_code }
|
||||
get '/users/me', headers: cookie_headers(user)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json['id']).to eq(user.id)
|
||||
|
||||
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki conflict handling', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
it 'returns 409 when base_revision_id is stale' do
|
||||
|
||||
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki creation freeze', type: :request do
|
||||
include WikiRecords
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
|
||||
|
||||
context 'existing description write protection' do
|
||||
@@ -18,7 +18,7 @@ RSpec.describe 'Wiki creation freeze', type: :request do
|
||||
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 } : { }
|
||||
request_headers = actor ? cookie_headers(actor) : { }
|
||||
before_state = group_state
|
||||
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: 'after' },
|
||||
headers: request_headers
|
||||
|
||||
@@ -8,7 +8,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
let!(:tag) { create(:tag, primary_name: 'japanese_title') }
|
||||
let!(:ainu_name) { add_ainu_name(tag, 'ainu_title') }
|
||||
let(:headers) { { 'X-Transfer-Code' => user.inheritance_code } }
|
||||
let(:headers) { cookie_headers(user) }
|
||||
let!(:japanese) do
|
||||
TagDescriptionOperation.create!(
|
||||
Locale.nipponese, tag:, body: 'Japanese body', created_by_user: user)
|
||||
@@ -20,12 +20,12 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
|
||||
['/wiki', '/wiki/search'].each do |path|
|
||||
it "filters #{ path } by the selected language" do
|
||||
get path, params: { locale: 'ain' }
|
||||
get path, params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { _1.fetch('id') }).to eq([ainu.id])
|
||||
expect(json.sole).to include('title' => 'ainu_title', 'body' => 'Ainu body')
|
||||
|
||||
get path, params: { locale: 'ain', title: 'japanese' }
|
||||
get path, params: { locale: 'ain-Kana', title: 'japanese' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq([])
|
||||
end
|
||||
@@ -39,32 +39,50 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
|
||||
it 'resolves titles and aliases only in the selected language in both APIs' do
|
||||
['/wiki/title/japanese_title', '/tags/name/japanese_title/description'].each do |path|
|
||||
get path, params: { locale: 'ain' }
|
||||
get path, params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
['/wiki/title/ainu_title', '/tags/name/ainu_title/description'].each do |path|
|
||||
get path, params: { locale: 'ain' }
|
||||
get path, params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('body')).to eq('Ainu body')
|
||||
end
|
||||
get '/wiki/title/ainu_title/exists', params: { locale: 'ain' }
|
||||
get '/wiki/title/ainu_title/exists', params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:no_content)
|
||||
get '/wiki/title/japanese_title/exists', params: { locale: 'ain' }
|
||||
get '/wiki/title/japanese_title/exists', params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'does not disclose another language through ID, existence, diff or history' do
|
||||
it 'resolves a foreign description ID to only the requested language body' do
|
||||
get "/wiki/#{ japanese.id }", params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('id' => ainu.id, 'body' => 'Ainu body')
|
||||
expect(response.body).not_to include('Japanese body')
|
||||
end
|
||||
|
||||
it 'does not reuse foreign revision IDs when resolving a description' do
|
||||
get "/wiki/#{ japanese.id }", params: {
|
||||
locale: 'ain-Kana', version: current_description_version(japanese).id }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response.body).not_to include('Japanese body')
|
||||
end
|
||||
|
||||
it 'keeps existence and diff scoped to the specified language and description ID' do
|
||||
revision = current_description_version(japanese)
|
||||
["/wiki/#{ japanese.id }", "/wiki/#{ japanese.id }/exists",
|
||||
"/wiki/#{ japanese.id }/diff"].each do |path|
|
||||
get path, params: { locale: 'ain', from: revision.id }
|
||||
["/wiki/#{ japanese.id }/exists", "/wiki/#{ japanese.id }/diff"].each do |path|
|
||||
get path, params: { locale: 'ain-Kana', from: revision.id }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response.body).not_to include('Japanese body')
|
||||
end
|
||||
get '/wiki/changes', params: { locale: 'ain', id: japanese.id }
|
||||
get "/wiki/#{ ainu.id }/exists", params: { locale: 'ain-Kana' }
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'keeps history scoped to the requested language without foreign revisions' do
|
||||
get '/wiki/changes', params: { locale: 'ain-Kana', id: japanese.id }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq([])
|
||||
get '/wiki/changes', params: { locale: 'ain' }
|
||||
get '/wiki/changes', params: { locale: 'ain-Kana' }
|
||||
expect(json.map { _1.fetch('revision_id') })
|
||||
.to eq([current_description_version(ainu).id])
|
||||
end
|
||||
@@ -73,7 +91,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
snapshot = [japanese.attributes, ainu.attributes, ainu_name.attributes]
|
||||
expect do
|
||||
put "/wiki/#{ japanese.id }", params: {
|
||||
locale: 'ain', title: 'renamed', body: 'wrong target' }, headers: headers
|
||||
locale: 'ain-Kana', title: 'renamed', body: 'wrong target' }, headers: headers
|
||||
end.not_to change(TagDescriptionVersion, :count)
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect([japanese.reload.attributes, ainu.reload.attributes, ainu_name.reload.attributes])
|
||||
@@ -89,10 +107,10 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
snapshot = original.attributes
|
||||
expect do
|
||||
if api == :wiki
|
||||
post '/wiki', params: { locale: 'ain', title: 'other_ainu', body: 'created' },
|
||||
post '/wiki', params: { locale: 'ain-Kana', title: 'other_ainu', body: 'created' },
|
||||
headers: headers
|
||||
else
|
||||
put "/tags/#{ other.id }/description", params: { locale: 'ain', body: 'created' },
|
||||
put "/tags/#{ other.id }/description", params: { locale: 'ain-Kana', body: 'created' },
|
||||
headers: headers
|
||||
end
|
||||
end.to change(TagDescription, :count).by(1)
|
||||
@@ -108,20 +126,20 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
it 'shares selected-language edits and revision identity across both APIs' do
|
||||
snapshot = japanese.attributes
|
||||
first = current_description_version(ainu)
|
||||
put "/tags/#{ tag.id }/description", params: { locale: 'ain', body: 'native edit' },
|
||||
put "/tags/#{ tag.id }/description", params: { locale: 'ain-Kana', body: 'native edit' },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
second = current_description_version(ainu)
|
||||
expect(second).to have_attributes(language_code: 'ain', version_no: 2)
|
||||
expect(second.id).not_to eq(first.id)
|
||||
get "/wiki/#{ ainu.id }", params: { locale: 'ain' }
|
||||
get "/wiki/#{ ainu.id }", params: { locale: 'ain-Kana' }
|
||||
expect(json).to include('body' => 'native edit', 'revision_id' => second.id)
|
||||
|
||||
put "/wiki/#{ ainu.id }", params: {
|
||||
locale: 'ain', title: 'ainu_title', body: 'facade edit',
|
||||
locale: 'ain-Kana', title: 'ainu_title', body: 'facade edit',
|
||||
base_revision_id: second.id }, headers: headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
get "/tags/#{ tag.id }/description", params: { locale: 'ain' }
|
||||
get "/tags/#{ tag.id }/description", params: { locale: 'ain-Kana' }
|
||||
expect(json).to include('body' => 'facade edit', 'language_code' => 'ain', 'version_no' => 3)
|
||||
expect(japanese.reload.attributes).to eq(snapshot)
|
||||
end
|
||||
@@ -133,7 +151,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
snapshot = [ainu.reload.attributes, ainu_name.attributes, japanese.attributes]
|
||||
expect do
|
||||
put "/wiki/#{ ainu.id }", params: {
|
||||
locale: 'ain', title: 'conflicting_name', body: 'current',
|
||||
locale: 'ain-Kana', title: 'conflicting_name', body: 'current',
|
||||
base_revision_id: stale.id }, headers: headers
|
||||
end.to change(TagDescriptionVersion, :count).by(0).and change(TagVersion, :count).by(0)
|
||||
expect(response).to have_http_status(:conflict)
|
||||
@@ -145,14 +163,14 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
first = current_description_version(ainu)
|
||||
TagDescriptionOperation.update!(
|
||||
ainu_locale, tag:, body: 'later', updated_by_user: user)
|
||||
get "/wiki/#{ ainu.id }", params: { locale: 'ain', version: first.id }
|
||||
get "/wiki/#{ ainu.id }", params: { locale: 'ain-Kana', version: first.id }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('body' => 'Ainu body', 'revision_id' => first.id)
|
||||
get "/wiki/#{ ainu.id }/diff", params: { locale: 'ain', from: first.id }
|
||||
get "/wiki/#{ ainu.id }/diff", params: { locale: 'ain-Kana', from: first.id }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('newer_revision_id')).to eq(current_description_version(ainu).id)
|
||||
get "/wiki/#{ ainu.id }/diff", params: {
|
||||
locale: 'ain', from: current_description_version(japanese).id }
|
||||
locale: 'ain-Kana', from: current_description_version(japanese).id }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ require 'securerandom'
|
||||
RSpec.describe 'Wiki API', type: :request do
|
||||
include WikiRecords
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
let!(:user) { create_member_user! }
|
||||
@@ -80,7 +80,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
let(:guest) { create(:user, role: 'guest') }
|
||||
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
context 'when not logged in' do
|
||||
@@ -126,7 +126,7 @@ RSpec.describe 'Wiki API', type: :request do
|
||||
let(:guest) { create(:user, role: 'guest', inheritance_code: SecureRandom.hex(16)) }
|
||||
|
||||
def auth_headers(user)
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
let!(:test_tag_name) { create(:tag, primary_name: 'TestPage').tag_name('ja') }
|
||||
|
||||
@@ -5,7 +5,7 @@ RSpec.describe 'Wiki title collision', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
cookie_headers(user)
|
||||
end
|
||||
|
||||
def create_wiki_page title:, body:
|
||||
|
||||
@@ -17,7 +17,7 @@ RSpec.describe 'Removed standalone Wiki resources', type: :request do
|
||||
user
|
||||
before_state = group_state
|
||||
post '/wiki', params: { title:, body: 'body' },
|
||||
headers: { 'X-Transfer-Code' => user.inheritance_code }
|
||||
headers: cookie_headers(user)
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(group_state).to eq(before_state)
|
||||
end
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする