q
このコミットが含まれているのは:
@@ -0,0 +1,361 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Tag description native API', type: :request do
|
||||
let!(:author) { create_member_user! }
|
||||
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(:endpoint) { "/tags/#{ tag.id }/description" }
|
||||
|
||||
def stored_state
|
||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion,
|
||||
WikiLine, WikiRevisionLine, TagName, TagVersion].map do |model|
|
||||
model.order(:id).map(&:attributes)
|
||||
end
|
||||
end
|
||||
|
||||
def fail_save model
|
||||
failure = Class.new(StandardError)
|
||||
callback = ->(_record) { raise failure, 'injected persistence failure' }
|
||||
model.set_callback(:save, :after, callback)
|
||||
expect { yield }.to raise_error(failure)
|
||||
ensure
|
||||
model.skip_callback(:save, :after, callback)
|
||||
end
|
||||
|
||||
def without_legacy_reads
|
||||
queries = []
|
||||
subscriber = lambda do |*args|
|
||||
sql = args.last.fetch(:sql)
|
||||
if sql.match?(/\bSELECT\b/i) && sql.match?(/\bwiki_(pages|revisions|versions)\b/i)
|
||||
queries << sql
|
||||
end
|
||||
end
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
|
||||
expect(queries).to be_empty
|
||||
end
|
||||
|
||||
def native_get path, action: 'show'
|
||||
get path
|
||||
# A missing route must not make the resource-not-found examples pass.
|
||||
expect(request.path_parameters).to include(controller: 'tag_descriptions', action:)
|
||||
end
|
||||
|
||||
def native_write method, body:, request_headers: headers
|
||||
public_send(method, endpoint, params: { body: }, headers: request_headers)
|
||||
action = method == :post ? 'create' : 'update'
|
||||
expect(request.path_parameters).to include(controller: 'tag_descriptions', action:)
|
||||
end
|
||||
|
||||
def expect_current description
|
||||
expect(json).to include(
|
||||
'tag_id' => description.tag_id,
|
||||
'language_code' => 'ja', 'body' => description.body,
|
||||
'version_no' => description.version_no,
|
||||
'created_by_user_id' => description.created_by_user_id,
|
||||
'updated_by_user_id' => description.updated_by_user_id,
|
||||
'created_at' => description.created_at.iso8601(3),
|
||||
'updated_at' => description.updated_at.iso8601(3))
|
||||
expect(json.keys).not_to include('title', 'name', 'wiki_page', 'tag_name_id')
|
||||
end
|
||||
|
||||
describe 'routing', type: :routing do
|
||||
it 'routes GET by Tag ID to show' do
|
||||
expect(get: '/tags/123/description').to route_to(
|
||||
controller: 'tag_descriptions', action: 'show', id: '123')
|
||||
end
|
||||
|
||||
it 'routes GET by TagName to show_by_title' do
|
||||
expect(get: '/tags/name/native_description/description').to route_to(
|
||||
controller: 'tag_descriptions', action: 'show_by_title', name: 'native_description')
|
||||
end
|
||||
|
||||
it 'routes POST by Tag ID to create' do
|
||||
expect(post: '/tags/123/description').to route_to(
|
||||
controller: 'tag_descriptions', action: 'create', id: '123')
|
||||
end
|
||||
|
||||
it 'routes PUT by Tag ID to update' do
|
||||
expect(put: '/tags/123/description').to route_to(
|
||||
controller: 'tag_descriptions', action: 'update', id: '123')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'native reads' do
|
||||
let!(:description) do
|
||||
TagDescription.create!(
|
||||
id: tag.id + 1_000_000, tag:, language_code: 'ja', body: 'native current',
|
||||
version_no: 1, created_by_user_id: author.id, updated_by_user_id: editor.id)
|
||||
end
|
||||
let!(:version) do
|
||||
TagDescriptionVersion.create!(
|
||||
tag_id: tag.id, language_code: 'ja', body: 'native version', version_no: 1,
|
||||
event_type: 'create', created_by_user_id: author.id, created_at: Time.current)
|
||||
end
|
||||
let(:name_endpoint) { "/tags/name/#{ tag_name.name }/description" }
|
||||
|
||||
it 'reads the current resource by Tag ID without a WikiPage' do
|
||||
expect(description.id).not_to eq(tag.id)
|
||||
expect(WikiPage.exists?(id: description.id)).to be(false)
|
||||
without_legacy_reads { native_get(endpoint) }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_current(description)
|
||||
end
|
||||
|
||||
it 'does not interpret a compatibility handle as a Tag ID' do
|
||||
other_tag = create(:tag, id: description.id, primary_name: 'handle_collision')
|
||||
other = TagDescription.create!(
|
||||
tag: other_tag, language_code: 'ja', body: 'other current', version_no: 1,
|
||||
created_by_user_id: author.id, updated_by_user_id: author.id)
|
||||
without_legacy_reads { native_get("/tags/#{ other_tag.id }/description") }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_current(other)
|
||||
end
|
||||
|
||||
context 'with another language for the same Tag' do
|
||||
before do
|
||||
# Seed only the reference row; V1 does not enable an English locale.
|
||||
Language.connection.execute(<<~SQL)
|
||||
INSERT IGNORE INTO languages (code, name, created_at)
|
||||
VALUES ('en', 'English', CURRENT_TIMESTAMP)
|
||||
SQL
|
||||
end
|
||||
|
||||
it 'selects the Japanese resource' do
|
||||
TagDescription.create!(
|
||||
tag:, language_code: 'en', body: 'English current', version_no: 4,
|
||||
created_by_user_id: author.id, updated_by_user_id: author.id)
|
||||
without_legacy_reads { native_get(endpoint) }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_current(description)
|
||||
end
|
||||
|
||||
it 'returns 404 if only the English description exists' do
|
||||
description.update!(language_code: 'en')
|
||||
without_legacy_reads { native_get(endpoint) }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
it 'resolves the primary Japanese TagName without a WikiPage' do
|
||||
without_legacy_reads { native_get(name_endpoint, action: 'show_by_title') }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_current(description)
|
||||
end
|
||||
|
||||
it 'resolves an alias through the existing TagName canonicalisation semantics' do
|
||||
alias_name = create(:tag_name, :alias, tag:, name: 'native_alias')
|
||||
expect(TagName.canonicalise(Locale.nipponese, [alias_name.name])).to eq([tag_name.name])
|
||||
without_legacy_reads do
|
||||
native_get("/tags/name/#{ alias_name.name }/description", action: 'show_by_title')
|
||||
end
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_current(description)
|
||||
end
|
||||
|
||||
[:id, :name].each do |lookup|
|
||||
it "ignores divergent legacy body and history for #{ lookup } lookup" do
|
||||
page = WikiPage.create!(
|
||||
id: description.id, tag_name:, body: 'old current',
|
||||
created_user: author, updated_user: author)
|
||||
revision = WikiRevision.create!(
|
||||
wiki_page: page, created_user: author, kind: :content,
|
||||
lines_count: 1, tree_sha256: Digest::SHA256.hexdigest('old revision'))
|
||||
line = WikiLine.create!(body: 'old revision', sha256: revision.tree_sha256)
|
||||
WikiRevisionLine.create!(wiki_revision: revision, wiki_line: line, position: 0)
|
||||
WikiVersionRecorder.record!(page:, event_type: :create, created_by_user: author)
|
||||
path = lookup == :id ? endpoint : name_endpoint
|
||||
action = lookup == :id ? 'show' : 'show_by_title'
|
||||
|
||||
without_legacy_reads { native_get(path, action:) }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect_current(description)
|
||||
original = json
|
||||
page.update_columns(body: 'changed old current')
|
||||
line.update_columns(body: 'changed old revision')
|
||||
revision.update_columns(message: 'changed old history', created_at: 1.day.ago)
|
||||
without_legacy_reads { native_get(path, action:) }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to eq(original)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'missing native resources' do
|
||||
it 'returns 404 for a Tag without a description' do
|
||||
without_legacy_reads { native_get(endpoint) }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns 404 for a primary name without a description' do
|
||||
tag
|
||||
without_legacy_reads do
|
||||
native_get("/tags/name/#{ tag_name.name }/description", action: 'show_by_title')
|
||||
end
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'returns 404 for a missing Tag' do
|
||||
missing_id = Tag.maximum(:id).to_i + 1
|
||||
without_legacy_reads { native_get("/tags/#{ missing_id }/description") }
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /tags/:id/description' do
|
||||
before { tag }
|
||||
|
||||
it 'creates both current resources and histories with matching compatibility IDs' do
|
||||
expect { native_write(:post, body: 'created natively') }
|
||||
.to change(TagDescription, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
.and change(WikiPage, :count).by(1)
|
||||
.and change(WikiRevision, :count).by(1)
|
||||
.and change(WikiVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:created)
|
||||
description = TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
page = WikiPage.find(description.id)
|
||||
revision = page.current_revision
|
||||
version = TagDescriptionVersion.find(revision.id)
|
||||
expect(description).to have_attributes(
|
||||
body: 'created natively', version_no: 1,
|
||||
created_by_user_id: editor.id, updated_by_user_id: editor.id)
|
||||
expect(version).to have_attributes(
|
||||
tag_id: tag.id, language_code: 'ja', body: description.body,
|
||||
version_no: 1, event_type: 'create', created_by_user_id: editor.id)
|
||||
expect(page).to have_attributes(tag_name_id: tag_name.id, body: description.body)
|
||||
expect(revision.body).to eq(version.body)
|
||||
expect(page.wiki_versions.last.body).to eq(version.body)
|
||||
expect_current(description)
|
||||
|
||||
get "/wiki/#{ description.id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('body' => description.body, 'revision_id' => version.id)
|
||||
end
|
||||
|
||||
it 'returns the existing validation error format on duplicate creation' do
|
||||
existing = TagDescription.create!(
|
||||
tag:, language_code: 'ja', body: 'existing', version_no: 1,
|
||||
created_by_user_id: author.id, updated_by_user_id: author.id)
|
||||
before_state = stored_state
|
||||
native_write(:post, body: 'duplicate')
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json).to include('type' => 'validation_error', 'errors' => a_kind_of(Hash),
|
||||
'base_errors' => a_kind_of(Array), 'message' => a_kind_of(String))
|
||||
expect(stored_state).to eq(before_state)
|
||||
expect(existing.reload.body).to eq('existing')
|
||||
end
|
||||
|
||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
|
||||
it "rolls back native create when #{ model.name } persistence fails" do
|
||||
headers
|
||||
before_state = stored_state
|
||||
aggregate_failures do
|
||||
fail_save(model) { native_write(:post, body: 'rollback create') }
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /tags/:id/description' do
|
||||
let!(:page) do
|
||||
tag
|
||||
Wiki::Commit.create_content!(tag_name:, body: 'before', created_by_user: author)
|
||||
end
|
||||
let!(:second_revision) do
|
||||
Wiki::Commit.content!(page:, body: 'before', created_user: author)
|
||||
end
|
||||
let!(:description) do
|
||||
TagDescription.find_by!(tag_id: tag.id, language_code: 'ja')
|
||||
end
|
||||
|
||||
['after', 'before'].each do |body|
|
||||
it "appends matching revisions for body '#{ body }'" do
|
||||
history = TagDescriptionVersion.where(tag_id: tag.id).order(:version_no).map(&:attributes)
|
||||
expect(page.reload.version_no).to eq(1)
|
||||
expect(description.version_no).to eq(2)
|
||||
expect { native_write(:put, body:) }
|
||||
.to change(WikiRevision, :count).by(1)
|
||||
.and change(TagDescriptionVersion, :count).by(1)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(description.reload).to have_attributes(
|
||||
body:, version_no: 3, created_by_user_id: author.id, updated_by_user_id: editor.id)
|
||||
expect(page.reload.body).to eq(body)
|
||||
revision = page.current_revision
|
||||
version = TagDescriptionVersion.find(revision.id)
|
||||
expect(version).to have_attributes(
|
||||
tag_id: tag.id, language_code: 'ja', version_no: 3, event_type: 'update',
|
||||
body:, created_by_user_id: editor.id)
|
||||
expect(revision.body).to eq(body)
|
||||
expect(page.wiki_versions.order(:version_no).last.body).to eq(body)
|
||||
expect(TagDescriptionVersion.where(id: history.pluck('id')).order(:version_no)
|
||||
.map(&:attributes)).to eq(history)
|
||||
|
||||
get "/wiki/#{ description.id }"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('body' => body, 'revision_id' => version.id)
|
||||
end
|
||||
end
|
||||
|
||||
[WikiPage, WikiRevision, WikiVersion, TagDescription, TagDescriptionVersion].each do |model|
|
||||
it "rolls back native update when #{ model.name } persistence fails" do
|
||||
headers
|
||||
before_state = stored_state
|
||||
aggregate_failures do
|
||||
fail_save(model) { native_write(:put, body: 'rollback update') }
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'write access and validation' do
|
||||
[:post, :put].each do |method|
|
||||
context "with #{ method.upcase }" do
|
||||
before do
|
||||
tag
|
||||
if method == :put
|
||||
Wiki::Commit.create_content!(tag_name:, body: 'existing', created_by_user: author)
|
||||
end
|
||||
end
|
||||
|
||||
it 'requires authentication without changing either system' do
|
||||
before_state = stored_state
|
||||
native_write(method, body: 'unauthorised', request_headers: { })
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'rejects a guest without changing either system' do
|
||||
guest = create(:user, :guest)
|
||||
before_state = stored_state
|
||||
native_write(method, body: 'forbidden',
|
||||
request_headers: { 'X-Transfer-Code' => guest.inheritance_code })
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'rejects a banned member without changing either system' do
|
||||
banned = create(:user, :member, :banned)
|
||||
before_state = stored_state
|
||||
native_write(method, body: 'forbidden',
|
||||
request_headers: { 'X-Transfer-Code' => banned.inheritance_code })
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
|
||||
it 'rejects blank body using the existing validation error format' do
|
||||
headers
|
||||
before_state = stored_state
|
||||
native_write(method, body: '')
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json).to include('type' => 'validation_error',
|
||||
'errors' => include('body' => a_kind_of(Array)))
|
||||
expect(stored_state).to eq(before_state)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
新しいイシューから参照
ユーザーをブロックする