コミットを比較

..
作成者 SHA1 メッセージ 日付
みてるぞとCodex Night Worker dda3d87073 外部タグの履歴管理方式を変更 (#445) (#480)
Co-authored-by: Codex Night Worker <codex-night@localhost>
Reviewed-on: #480
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-11-15 16:18:27 +09:00
60個のファイルの変更、3845行の追加、230行の削除
-35
ファイルの表示
@@ -1,35 +0,0 @@
## 背景
なぜ必要か。
## 対象範囲
- backend:
- frontend:
- docs:
- migration:
## やること
- [ ]
## 受け入れ条件
- [ ]
## 実行すべき確認
- [ ] `cd backend && bundle exec rspec`
- [ ] `cd frontend && npm run build`
- [ ] `cd frontend && npm run lint`
## 禁止事項
- unrelated refactor はしない
- 既存 API response shape を壊さない
- 認証・認可・BAN を弱めない
## Codex への指示
この issue を読んで実装してください。
不明点があれば、実装前に調査結果と選択肢を提示してください。
+1 -1
ファイルの表示
@@ -424,7 +424,7 @@ races according to the target constraint's business meaning.
and JSON keys are generally snake_case.
- Preserve existing HTTP status conventions:
`:unauthorized` for no user, `:forbidden` for insufficient role or banned
user, `:not_found` for missing records, and `:unprocessable_content` for
user, `:not_found` for missing records, and `:unprocessable_entity` for
validation failures.
- For diagnostic or internal helper JSON, prefer a deliberately light response
shape over full representation classes when callers only need identifiers,
+1 -1
ファイルの表示
@@ -95,7 +95,7 @@ class ApplicationController < ActionController::API
render_validation_error base: ['すでに存在してゐます.']
end
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_content
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity
errors = { }
if record
+3 -3
ファイルの表示
@@ -18,7 +18,7 @@ class CountsExportsController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_content
status: :unprocessable_entity
rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
@@ -63,7 +63,7 @@ class CountsExportsController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_content
status: :unprocessable_entity
rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
@@ -107,7 +107,7 @@ class CountsExportsController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_content
status: :unprocessable_entity
rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
+1 -1
ファイルの表示
@@ -24,7 +24,7 @@ class CountsTablesController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_content
status: :unprocessable_entity
rescue CountsPivotTotals::TimedOut
Rails.logger.warn('counts_query_timeout: pivot_totals')
render json: { error: 'counts_query_timeout', stage: 'totals',
+2 -2
ファイルの表示
@@ -15,7 +15,7 @@ class GekanatorGamesController < ApplicationController
answers:)
if game.invalid?
render json: { errors: game.errors.full_messages }, status: :unprocessable_content
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
return
end
@@ -31,7 +31,7 @@ class GekanatorGamesController < ApplicationController
learned_example_count:
}, status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_content
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end
def extra_questions
+1 -1
ファイルの表示
@@ -536,7 +536,7 @@ class MaterialsController < ApplicationController
errors: { },
base_errors: ['ZIP export に必要な素材ファイルが欠損しています.'],
missing_files: },
status: :unprocessable_content
status: :unprocessable_entity
end
def build_uploaded_material_blob! file, file_sha256
+1 -10
ファイルの表示
@@ -100,18 +100,9 @@ class PostVersionsController < ApplicationController
def external_tag_names_for snapshots
ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq
names = ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
[id, "#{ platform }:#{ name }"]
}
missing_ids = ids - names.keys
HistoryAccess.visible(NicoTagVersion.all)
.where(tag_id: missing_ids)
.order(:tag_id, version_no: :desc)
.pluck(:tag_id, :name)
.each { |id, name| names[id] ||= name }
names
end
def snapshot_tag_literals snapshots, tag_names, external_tag_names
+2 -2
ファイルの表示
@@ -429,7 +429,7 @@ class PostsController < ApplicationController
filters = literals.map do |literal|
internal, = source_ids.fetch(literal[:name])
name = internal.empty? ? literal[:name] : names[internal.first]
return head :unprocessable_content unless name && internal.length <= 1
return head :unprocessable_entity unless name && internal.length <= 1
literal[:negative] ? "not:#{ name }" : name
end
@@ -438,7 +438,7 @@ class PostsController < ApplicationController
literals.zip(translated).each do |literal, translation|
unless (source_ids.fetch(literal[:name]).map(&:sort) ==
target_ids.fetch(translation[:name]).map(&:sort))
return head :unprocessable_content
return head :unprocessable_entity
end
end
index = 0
+2 -2
ファイルの表示
@@ -55,7 +55,7 @@ class TheatresController < ApplicationController
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_content if requested_post_id <= 0
return head :unprocessable_entity if requested_post_id <= 0
skipped = false
conflicted = false
@@ -95,7 +95,7 @@ class TheatresController < ApplicationController
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_content if requested_post_id <= 0
return head :unprocessable_entity if requested_post_id <= 0
conflicted = false
+19 -6
ファイルの表示
@@ -1,8 +1,11 @@
class ExternalTag < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
# Must run before dependent association callbacks.
before_destroy :prevent_destroy
enum :platform, nico: 'nico'
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
validate :immutable_identity, on: :update
has_many :post_external_tags, dependent: :delete_all
has_many :posts, through: :post_external_tags
@@ -16,11 +19,21 @@ class ExternalTag < ApplicationRecord
has_many :linked_tags, through: :nico_tag_relations, source: :tag
def snapshot_linked_tag_names language_code = 'ja'
linked_tags
.joins(:tag_names)
.where(tag_names: { language_code:, primary_flg: true })
.order('tag_names.name')
.pluck('tag_names.name')
def snapshot_linked_tag_ids = linked_tags.distinct.order(:id).pluck(:id)
def delete
raise ActiveRecord::ReadOnlyRecord, '外部タグは削除できません.'
end
private
def immutable_identity
errors.add(:platform, 'は変更できません.') if will_save_change_to_platform?
errors.add(:name, 'は変更できません.') if will_save_change_to_name?
end
def prevent_destroy
errors.add(:base, '外部タグは削除できません.')
throw(:abort)
end
end
+1 -15
ファイルの表示
@@ -106,21 +106,7 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
snapshot_reference(TagVersion, row),
snapshot_reference(NicoTagVersion, row, external: true)]
when 'nico_tag_versions'
# Legacy linked_tags snapshots contain names, not IDs. Match recorded names
# conservatively; never expose a private linked name through an external tag.
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
# DISTINCT prevents merging this independent name set into the correlated scan.
# Keep byte-distinct names even when the column collation considers them equal.
private_names = 'SELECT DISTINCT private_name.name, ' +
'CAST(private_name.name AS BINARY) AS name_bytes FROM tag_versions private_tag ' +
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
'ON blocked.tag_id = private_tag.tag_id ' +
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
"WHERE private_name.name <> ''"
["NOT EXISTS (SELECT 1 FROM (#{ private_names }) private_name WHERE " +
"LOCATE(CONCAT(' ', private_name.name, ' '), " +
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
[list_reference('tag_versions', 'tag_id', "#{ row }.linked_tag_ids")]
when 'tag_versions'
[list_reference('tag_versions', 'tag_id', "#{ row }.parent_tag_ids")]
when 'tag_description_versions'
+6 -1
ファイルの表示
@@ -3,5 +3,10 @@ class NicoTagVersion < ApplicationRecord
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
validates :name, presence: true
validates :event_type, inclusion: { in: ['create', 'update'] }
def name
tag = external_tag
"#{ tag.platform }:#{ tag.name }" if tag
end
end
+4 -1
ファイルの表示
@@ -139,6 +139,8 @@ class CountsVersionRepr
result = {}
table = model.table_name
comparison = @inclusive ? '<=' : '<'
name = model == NicoTagVersion ?
CountsResourceDefinition.new('nico_tags').expression('name', table) : 'name'
requests.each_slice(100) do |batch|
queries = batch.map do |id, point|
scope = HistoryAccess.visible(model.where(tag_id: id))
@@ -148,7 +150,8 @@ class CountsVersionRepr
"AND newer.version_no > #{ table }.version_no)", point)
quoted = model.connection.quote(point.iso8601(6))
names = model == TagVersion ? 'names' : 'JSON_OBJECT() AS names'
scope.select("tag_id, name, #{ names }, #{ quoted } AS reference_point").to_sql
scope.select("tag_id, #{ name } AS name, #{ names }, " +
"#{ quoted } AS reference_point").to_sql
end
next if queries.empty?
+4 -2
ファイルの表示
@@ -28,16 +28,18 @@ class CountsCandidates
[external, 'external', 'NULL', 'JSON_OBJECT()']]
sources = sources.map do |population, kind, category, names|
scope = population.scope.except(:select)
name = population.definition.expression('name')
if @params['query'].present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%"
scope = scope.where("(v.name LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
scope = scope.where("(#{ name } LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
"SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " +
"JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " +
'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern)
end
scope.select(Arel.sql(
"CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " +
"v.name AS name, #{ names } AS names, #{ category } AS category, '#{ kind }' AS kind"))
"#{ name } AS name, #{ names } AS names, " +
"#{ category } AS category, '#{ kind }' AS kind"))
.to_sql
end
clauses = []
+1 -1
ファイルの表示
@@ -113,7 +113,7 @@ class CountsPopulation
else
raise ArgumentError unless definition.axes(history).include?(key)
scope = scope.where(definition.expression(key) => entry['value'])
scope = scope.where("#{ definition.expression(key) } <=> ?", entry['value'])
end
scope
end
+18 -3
ファイルの表示
@@ -105,12 +105,25 @@ class CountsResourceDefinition
def time_column = 'created_at'
def column(key)
raise ArgumentError unless fields.key?(key)
# Keep the V1 Counts field name at the API boundary only.
return 'linked_tag_ids' if resource == 'nico_tags' && key == 'linked_tags'
{ 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key)
end
def expression(key, row = 'v') = "#{ row }.#{ column(key) }"
def changes = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
def expression(key, row = 'v')
if resource == 'nico_tags' && key == 'name'
return "(SELECT CONCAT(external_tags.platform, ':', external_tags.name) " +
"FROM external_tags WHERE external_tags.id = #{ row }.tag_id)"
end
"#{ row }.#{ column(key) }"
end
def changes
columns = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
resource == 'nico_tags' ? columns - ['name'] : columns
end
def axes(history)
fields.reject { |_key, type| type == :json }.keys +
(resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : [])
@@ -118,7 +131,9 @@ class CountsResourceDefinition
def metadata
fields.map do |key, type|
{ key:, type:, nullable: model.columns_hash.fetch(column(key)).null,
nullable = (resource == 'nico_tags' && key == 'name') ||
model.columns_hash.fetch(column(key)).null
{ key:, type:, nullable:,
operators: OPERATORS.fetch(type, []) }
end
end
+3 -1
ファイルの表示
@@ -201,6 +201,8 @@ class CountsStateQuery
end
def listing
external_name = CountsResourceDefinition.new('nico_tags')
.expression('name', 'external_version')
catalog = <<~SQL
, tag_counts AS (
SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count
@@ -209,7 +211,7 @@ class CountsStateQuery
SELECT 'internal' AS kind, tag_id, name, names,
deprecated_at FROM tag_at WHERE event_type <> 'discard'
UNION ALL
SELECT tc.kind, tc.tag_id, external_version.name, JSON_OBJECT() AS names,
SELECT tc.kind, tc.tag_id, #{ external_name } AS name, JSON_OBJECT() AS names,
NULL AS deprecated_at
FROM tag_counts tc
LEFT JOIN tag_at internal_version
+2
ファイルの表示
@@ -182,6 +182,7 @@ class CountsTableQuery
order = sort_sql { detail_expression(_1) }
query = query.order(Arel.sql((order + ['v.id ASC']).join(', ')))
.limit(limit).offset((page - 1) * limit)
query = query.preload(:external_tag) if @definition.resource == 'nico_tags'
rows = query.to_a
if ['posts', 'tags'].include?(@definition.resource)
point = population.history ? population.at :
@@ -486,6 +487,7 @@ class CountsTableQuery
table = model.table_name
scope = HistoryAccess.visible(model.where(tag_id: ids)).where('created_at <= ?', point)
scope = scope.includes(:external_tag) if model == NicoTagVersion
bound = @connection.quote(point)
if population.history && @definition.resource == 'posts'
key = kind == 'internal' ? 'tag_id' : 'external_tag_id'
+2 -2
ファイルの表示
@@ -1,4 +1,5 @@
class NicoTagVersionRecorder < VersionRecorder
EVENT_TYPES = ['create', 'update'].freeze
def self.record! external_tag:, event_type:, created_by_user:
new(external_tag:, event_type:, created_by_user:).record!
end
@@ -14,8 +15,7 @@ class NicoTagVersionRecorder < VersionRecorder
def record_key = :external_tag
def snapshot_attributes
{ name: "#{ @record.platform }:#{ @record.name }",
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
{ linked_tag_ids: @record.snapshot_linked_tag_ids.join(' ') }
end
def tracks_version_no_on_record? = false
+5
ファイルの表示
@@ -0,0 +1,5 @@
class AddLinkedTagIdsToNicoTagVersions < ActiveRecord::Migration[8.0]
def change
add_column :nico_tag_versions, :linked_tag_ids, :text
end
end
+42
ファイルの表示
@@ -0,0 +1,42 @@
class MigrateLinkedTagsInNicoTagVersions < ActiveRecord::Migration[8.0]
class MigrationTagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class MigrationNicoTagVersion < ActiveRecord::Base
self.table_name = 'nico_tag_versions'
end
def up
# 除票外部タグの恢復はないので除票履歴があれば全消しでよいはず.
execute <<~SQL
DELETE
t1
FROM
nico_tag_versions t1
INNER JOIN
nico_tag_versions t2
ON
t1.tag_id = t2.tag_id
AND t2.event_type = 'discard'
SQL
MigrationNicoTagVersion.find_each do |ntv|
linked_tag_ids = ntv.linked_tags.split.map { |name|
# 厳密には一意ぢゃないけどめんだぅだからこれでいいや.
MigrationTagVersion
.order(created_at: :desc, id: :desc)
.find_by("names ->> '$.ja.name' = ?", name)
&.tag_id
}.compact.uniq.sort.join(' ')
ntv.update_columns(linked_tag_ids:)
end
change_column_null :nico_tag_versions, :linked_tag_ids, false
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class RemoveLinkedTagsFromNicoTagVersions < ActiveRecord::Migration[8.0]
def change
remove_column :nico_tag_versions, :linked_tags
remove_column :nico_tag_versions, :name
end
end
生成ファイル
+2 -3
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
ActiveRecord::Schema[8.0].define(version: 2026_11_08_020000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -301,10 +301,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.bigint "tag_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "name", null: false
t.text "linked_tags", null: false
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.text "linked_tag_ids", null: false
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
+43 -8
ファイルの表示
@@ -11,28 +11,63 @@ RSpec.describe ExternalTag, type: :model do
expect(external.reload.name).to eq('raw tag[]')
end
it 'deletes post associations without deleting posts or version history' do
it 'does not allow a persisted external tag to change its platform or name' do
external = create(:external_tag, name: 'immutable')
expect { external.update!(name: 'renamed') }
.to raise_error(ActiveRecord::RecordInvalid)
expect(external.errors[:name]).to include('は変更できません.')
external.reload
external.platform = nil
expect { external.save! }.to raise_error(ActiveRecord::RecordInvalid)
expect(external.errors[:platform]).to include('は変更できません.')
expect(external.reload).to have_attributes(platform: 'nico', name: 'immutable')
end
it 'rejects delete, which bypasses destroy callbacks' do
external = create(:external_tag)
expect { external.delete }.to raise_error(ActiveRecord::ReadOnlyRecord)
expect(external.reload).to be_persisted
end
it 'increments the counter through post associations without changing identity' do
external = create(:external_tag, name: 'counter')
post = create(:post)
expect {
PostExternalTag.create!(post:, external_tag: external)
}.to change { external.reload.post_count }.by(1)
expect(external.reload).to have_attributes(platform: 'nico', name: 'counter')
end
it 'does not destroy external tags or their post associations and history' do
external = create(:external_tag)
post = create(:post)
PostExternalTag.create!(post:, external_tag: external)
version = NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: nil)
external.destroy!
expect { external.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed)
expect(PostExternalTag.where(external_tag_id: external.id)).to be_empty
expect(post.reload.external_tags).to be_empty
expect(external.reload).to be_persisted
expect(PostExternalTag.exists?(post:, external_tag: external)).to be(true)
expect(post.reload.external_tags).to contain_exactly(external)
expect(version.reload.tag_id).to eq(external.id)
end
it 'deletes external links without deleting linked internal tags' do
it 'does not destroy an external tag or its links to internal tags' do
external = create(:external_tag)
tag = create(:tag)
NicoTagRelation.create!(nico_tag: external, tag:)
relation = NicoTagRelation.create!(nico_tag: external, tag:)
external.destroy!
expect { external.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed)
expect(NicoTagRelation.where(nico_tag_id: external.id)).to be_empty
expect(external.reload).to be_persisted
expect(relation.reload).to be_persisted
expect(Tag.exists?(tag.id)).to be(true)
end
end
+3 -3
ファイルの表示
@@ -76,7 +76,7 @@ RSpec.describe 'Counts artifact policy and external references' do
end
end
it 'does not expose a private linked tag name through an external history snapshot' do
it 'does not expose a private linked tag ID through an external history snapshot' do
tag = create(:tag, primary_name: 'private-linked-name')
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024),
@@ -84,7 +84,7 @@ RSpec.describe 'Counts artifact policy and external references' do
auto_generated: false, aliases: [] } })
external = create(:external_tag, name: 'public-external')
row = NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
name: 'nico:public-external', linked_tags: 'private-linked-name',
linked_tag_ids: tag.id.to_s,
created_at: Time.utc(2024))
post = create(:post)
snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create',
@@ -99,7 +99,7 @@ RSpec.describe 'Counts artifact policy and external references' do
expect(HistoryAccess.visible(NicoTagVersion.where(id: row.id))).to exist
end
public_row = NicoTagVersion.create!(external_tag: external, version_no: 2, event_type: 'update',
name: 'nico:public-external', linked_tags: 'private-linked-name-extra',
linked_tag_ids: '',
created_at: Time.utc(2024, 7))
HistoryAccess.set(user: nil) do
expect(HistoryAccess.visible(NicoTagVersion.where(id: public_row.id))).to exist
+3 -4
ファイルの表示
@@ -10,23 +10,22 @@ RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
expect(next_tag.id).to be > external.id
end
it 'does not reuse physically deleted IDs in either table' do
it 'does not reuse IDs after internal deletion while external tags remain' do
tag = create(:tag)
external = create(:external_tag)
TagName.where(tag_id: tag.id).delete_all
tag.destroy!
external.destroy!
expect(ExternalTag.exists?(external.id)).to be(true)
expect(create(:tag).id).to be > external.id
expect(create(:external_tag).id).to be > external.id
end
it 'retains existing overlapping IDs without renumbering on update or insert' do
it 'retains overlapping IDs after a tag update and a new allocation' do
tag = create(:tag)
external = create(:external_tag)
external.update_columns(id: tag.id)
tag.update!(category: :meta)
external.update!(name: 'updated_external')
before_tag = tag.reload.attributes
before_external = external.reload.attributes
+15 -5
ファイルの表示
@@ -47,24 +47,34 @@ RSpec.describe VersionRecord, type: :model do
external_tag: nico_tag,
version_no: 1,
event_type: 'create',
name: "nico:#{ nico_tag.name }",
linked_tags: '',
linked_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)
expect {
version.update!(name: 'nico:changed')
version.update!(linked_tag_ids: '123')
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end
it 'rejects discard and restore versions for external tags' do
[:discard, :restore].each do |event_type|
version = NicoTagVersion.new(
external_tag: nico_tag, version_no: 1, event_type:,
linked_tag_ids: '', created_at: Time.current)
expect(version).not_to be_valid
expect(version.errors[:event_type]).not_to be_empty
end
end
it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!(
external_tag: nico_tag,
version_no: 1,
event_type: 'create',
name: "nico:#{ nico_tag.name }",
linked_tags: '',
linked_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)
+115 -7
ファイルの表示
@@ -19,7 +19,7 @@ RSpec.describe 'Counts table API', type: :request do
names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } },
created_at: Time.utc(2023))
NicoTagVersion.create!(external_tag: external, version_no: 1,
event_type: 'create', name: 'beta', linked_tags: '', created_at: Time.utc(2023))
event_type: 'create', linked_tag_ids: '', created_at: Time.utc(2023))
snapshots = [
{ tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] },
{ external_tag_id: external.id }]
@@ -50,6 +50,115 @@ RSpec.describe 'Counts table API', type: :request do
def versions(result) = result.fetch('versions').map { _1.fetch('version_no') }
context 'nico link ID snapshots through the V1 field name' do
let(:base) do
super().merge(resource: 'nico_tags')
end
before do
NicoTagVersion.create!(external_tag: external, version_no: 2,
event_type: 'update', linked_tag_ids: tag.id.to_s,
created_at: Time.utc(2024, 1, 1))
NicoTagVersion.create!(external_tag: external, version_no: 3,
event_type: 'update', linked_tag_ids: '',
created_at: Time.utc(2024, 2, 1))
end
it 'returns ID values under linked_tags without exposing the physical column key' do
result = query
expect(versions(result)).to eq([1, 2, 3])
expect(result['versions'].map { _1.fetch('linked_tags') }).to eq(['', tag.id.to_s, ''])
result['versions'].each do |row|
expect(row).not_to have_key('linked_tag_ids')
expect(row.fetch('name')).to eq('nico:beta')
end
end
it 'preloads external names separately for aliased link-change details' do
statements = []
listener = lambda do |_name, _start, _finish, _id, payload|
statements << payload[:sql]
end
ActiveSupport::Notifications.subscribed(listener, 'sql.active_record') do
result = query(axis('changed_field', 'linked_tags'))
expect(versions(result)).to eq([2, 3])
expect(result.fetch('versions').map { _1.fetch('name') })
.to eq(['nico:beta', 'nico:beta'])
end
details = statements.find { _1.include?('AS counts_latest') }
expect(details).not_to be_nil
expect(details).not_to include('JOIN `external_tags`')
names = statements.select { _1.match?(/SELECT .* FROM `external_tags`/) }
expect(names.length).to eq(1)
end
it 'advertises the legacy field, label and default columns only' do
get '/counts/definitions/nico_tags', params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
body = response.parsed_body
['fields', 'filter_fields', 'output_fields'].each do |collection|
keys = body.fetch(collection).map { _1.fetch('key') }
expect(keys).to include('linked_tags')
expect(keys).not_to include('linked_tag_ids')
end
field = body.fetch('output_fields').find { _1['key'] == 'linked_tags' }
expect(field.fetch('label')).to eq('counts:attributes.linked_tags')
body.fetch('axes').each_value do |keys|
expect(keys).to include('linked_tags')
expect(keys).not_to include('linked_tag_ids')
end
body.fetch('defaults').select { _1['mode'] == 'details' }.each do |entry|
settings = entry.fetch('params').to_h { [_1.fetch('key'), _1.fetch('value')] }
expect(settings.fetch('columns').split).to include('name', 'linked_tags')
expect(settings.fetch('columns').split).not_to include('linked_tag_ids')
end
end
it 'filters IDs and legacy drills and compares link changes through linked_tags' do
predicate = { conditions: '', 'conditions.0.field' => 'linked_tags',
'conditions.0.operator' => 'eq', 'conditions.0.value' => tag.id.to_s }
expect(versions(query(predicate))).to eq([2])
expect(versions(query(axis('linked_tags', tag.id.to_s, root: 'drill')))).to eq([2])
expect(versions(query(axis('changed_field', 'linked_tags')))).to eq([2, 3])
expect(versions(query(predicate.merge('conditions.0.value' => 'alpha')))).to be_empty
end
it 'sorts ID snapshots through the public field in both directions' do
expect(versions(query(sort: 'linked_tags:asc version_no:asc'))).to eq([1, 3, 2])
expect(versions(query(sort: 'linked_tags:desc version_no:asc'))).to eq([2, 1, 3])
end
it 'restores historical link IDs before applying a latest snapshot filter' do
predicate = { definition: 'latest', at: '2024-01-15T00:00:00Z',
conditions: '', 'conditions.0.field' => 'linked_tags',
'conditions.0.operator' => 'eq', 'conditions.0.value' => tag.id.to_s }
expect(versions(query(predicate))).to eq([2])
expect(versions(query(predicate.except(:at)))).to be_empty
end
it 'groups by linked_tags ID values and accepts the resulting drill condition' do
sign_in_as(create(:user, :admin))
result = query(mode: 'summary', rows: 'linked_tags', cols: '', sort: '')
expect(result.fetch('cells')).to match_array([
{ 'values' => [''], 'count' => 2 },
{ 'values' => [tag.id.to_s], 'count' => 1 }])
expect(versions(query(axis('linked_tags', tag.id.to_s)))).to eq([2])
end
it 'rejects the physical column as a public filter, axis, sort or change field' do
sign_in_as(create(:user, :admin))
[
axis('linked_tag_ids', tag.id.to_s),
axis('changed_field', 'linked_tag_ids'),
{ mode: 'summary', rows: 'linked_tag_ids', cols: '', sort: '' },
{ sort: 'linked_tag_ids:asc' }].each do |invalid|
get '/counts/query', params: base.merge(invalid)
expect(response).to have_http_status(:bad_request), invalid.inspect
end
end
end
it 'identifies a totals timeout without exposing SQL in the error response' do
allow_any_instance_of(CountsPivotTotals).to receive(:call)
.and_raise(CountsPivotTotals::TimedOut, 'private SQL')
@@ -223,11 +332,10 @@ RSpec.describe 'Counts table API', type: :request do
expect(versions(query(tags: 'renamed-alpha'))).to eq([1, 2, 4])
end
it 'resolves external names from current registrations too' do
external.update!(name: 'renamed-beta')
expect(versions(query(tags: 'nico:renamed-beta'))).to eq([1])
expect(versions(query(tags: 'nico:beta'))).to eq([])
expect(versions(query(tags: 'not:nico:renamed-beta'))).to eq([2, 3, 4])
it 'resolves immutable external names across historical snapshots' do
expect(versions(query(tags: 'nico:beta'))).to eq([1])
expect(versions(query(tags: 'nico:renamed-beta'))).to eq([])
expect(versions(query(tags: 'not:nico:beta'))).to eq([2, 3, 4])
end
end
@@ -262,7 +370,7 @@ RSpec.describe 'Counts table API', type: :request do
tags: '', tags_json: [], parent_post_ids: '', created_at: Time.utc(2024, 3))
end
get '/counts/query', params: base.merge(mode: 'summary', rows: '', cols: 'title', sort: '')
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq('error' => 'too_many_columns', 'max_columns' => 100)
end
+2 -2
ファイルの表示
@@ -20,7 +20,7 @@ RSpec.describe 'error responses', type: :request do
patch "/tags/#{ tag.id }", params: { category: 'nico' }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
@@ -37,7 +37,7 @@ RSpec.describe 'error responses', type: :request do
put "/users/#{ user.id }", params: { name: 'a' * 256 }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.')
+1 -1
ファイルの表示
@@ -49,7 +49,7 @@ RSpec.describe 'Gekanator games API', type: :request do
question_count: 4,
answers: [{ question_id: 'tag:1', answer: 'no' }] }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns unauthorized without a user' do
+4 -4
ファイルの表示
@@ -126,7 +126,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
}
}.not_to change { GekanatorGame.count }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'stores a game result for a non-admin user' do
@@ -785,7 +785,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
}
}.not_to change { GekanatorQuestionExample.count }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects non-accepted questions' do
@@ -805,7 +805,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
]
}
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects non-post_similarity questions' do
@@ -829,7 +829,7 @@ RSpec.describe 'Gekanator learning API', type: :request do
]
}
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'allows a non-admin user to answer extra questions for their own game' do
+2 -2
ファイルの表示
@@ -55,7 +55,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
deprecated: false, aliases: 'other_primary', parent_tags: '' }
aggregate_failures do
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(other_primary.reload).to have_attributes(tag_id: other_tag.id, primary_flg: true)
expect(TagName.where(tag: other_tag, language_code: 'ja', primary_flg: true))
.to contain_exactly(other_primary)
@@ -289,7 +289,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
aliases: 'other_ainu_primary', parent_tags: '' }
aggregate_failures do
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(other_primary.reload).to have_attributes(
tag_id: other_tag.id, language_code: 'ain', primary_flg: true)
expect(TagName.where(tag: other_tag, language_code: 'ain', primary_flg: true))
+6 -6
ファイルの表示
@@ -289,7 +289,7 @@ RSpec.describe 'Materials API', type: :request do
it 'returns 422 when tag is blank' do
post '/materials', params: { tag: ' ', file: dummy_upload }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tag' => ['タグは必須です.'])
end
@@ -297,7 +297,7 @@ RSpec.describe 'Materials API', type: :request do
it 'returns 422 when both file and url are blank' do
post '/materials', params: { tag: 'material_create_blank' }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
@@ -381,7 +381,7 @@ RSpec.describe 'Materials API', type: :request do
file: dummy_upload
}
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'persists url-only material' do
@@ -424,7 +424,7 @@ RSpec.describe 'Materials API', type: :request do
}
end.not_to change(Material, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'file' => ['抑止された素材です: copyright_high_risk']
)
@@ -484,7 +484,7 @@ RSpec.describe 'Materials API', type: :request do
file: dummy_upload
}
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tag' => ['タグは必須です.'])
end
@@ -695,7 +695,7 @@ RSpec.describe 'Materials API', type: :request do
file: dummy_upload(filename: 'blocked.png', body: 'blocked-update')
}
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(material.reload.file.blob.filename.to_s).to eq('old.png')
end
end
+10 -16
ファイルの表示
@@ -38,12 +38,9 @@ RSpec.describe 'NicoTags', type: :request do
end
it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:external_tag)
linked.update!(name: 'search_linked')
unlinked = create(:external_tag)
unlinked.update!(name: 'search_unlinked')
other = create(:external_tag)
other.update!(name: 'other')
linked = create(:external_tag, name: 'search_linked')
unlinked = create(:external_tag, name: 'search_unlinked')
other = create(:external_tag, name: 'other')
destination = create(:tag, category: :general)
primary_tag_name_for(destination, 'ja').update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination)
@@ -80,11 +77,9 @@ RSpec.describe 'NicoTags', type: :request do
end
it 'sorts by name and timestamps' do
older = create(:external_tag)
older.update!(name: 'ordered_a')
older = create(:external_tag, name: 'ordered_a')
older.update_columns(created_at: 2.days.ago)
newer = create(:external_tag)
newer.update!(name: 'ordered_b')
newer = create(:external_tag, name: 'ordered_b')
newer.update_columns(created_at: 1.day.ago)
older_post_tag =
PostExternalTag.create!(post: create(:post), external_tag: older)
@@ -189,9 +184,8 @@ RSpec.describe 'NicoTags', type: :request do
versions = nico_tag.reload.nico_tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([
'nico_linked_a',
'nico_linked_b'])
expect(versions.last.linked_tag_ids.split.map(&:to_i))
.to match_array([linked_a.id, linked_b.id])
expect(versions.last.created_by_user_id).to eq(admin.id)
end
@@ -210,7 +204,7 @@ RSpec.describe 'NicoTags', type: :request do
expect(json).to eq([])
expect(nico_tag.reload.linked_tags).to be_empty
expect(nico_tag.nico_tag_versions.order(:version_no).last)
.to have_attributes(linked_tags: '', created_by_user: member)
.to have_attributes(linked_tag_ids: '', created_by_user: member)
end
it 'returns the tags field error when a nico tag is specified directly' do
@@ -218,7 +212,7 @@ RSpec.describe 'NicoTags', type: :request do
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'nico:linked_ng' }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
end
@@ -239,7 +233,7 @@ RSpec.describe 'NicoTags', type: :request do
expect([Tag.count, TagName.count, TagVersion.count]).to eq(before_counts)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors').fetch('tags')).to include(
a_string_including('タグ名 “invalid”:', '名前に使用できない文字が含まれてゐます.'))
end
+1 -1
ファイルの表示
@@ -20,7 +20,7 @@ RSpec.describe 'Post history privacy', type: :request do
created_at: origin + index.hours)
end
NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
name: 'nico:external-history-tag', linked_tags: 'private-history-tag',
linked_tag_ids: tag.id.to_s,
created_at: origin)
['create', 'discard', 'restore', 'update'].each_with_index do |event, index|
snapshots =
+1 -1
ファイルの表示
@@ -48,7 +48,7 @@ RSpec.describe 'Post filter identity across locales', type: :request do
primary_tag_name_for(tag, 'ja').update_columns(name: 'nico:shared')
get '/posts/tag_filters', params: {
locale: 'ja', target_locale: 'ain-Kana', tags: 'nico:shared' }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects removed reference parameters instead of overriding the name' do
+24 -24
ファイルの表示
@@ -1042,7 +1042,7 @@ RSpec.describe 'Posts API', type: :request do
get '/posts/metadata', params: { url: 'file:///etc/passwd' }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to have_key('url')
end
end
@@ -1180,7 +1180,7 @@ RSpec.describe 'Posts API', type: :request do
)
}.not_to change(Post, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'thumbnail' => ['サムネイル画像の変換に失敗しました.']
)
@@ -1219,7 +1219,7 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['廃止済みタグがあります: deprecated_direct_tag']
)
@@ -1265,7 +1265,7 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.'
@@ -1333,7 +1333,7 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'video_ms' => ['タグ区間の終端が動画時間を超えてゐます.']
)
@@ -1444,7 +1444,7 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'video_ms' => ['タグ区間の開始が動画時間以上です.']
)
@@ -1465,7 +1465,7 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload
)
expect(response).to have_http_status(:unprocessable_content), response.body
expect(response).to have_http_status(:unprocessable_entity), response.body
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
@@ -1485,7 +1485,7 @@ RSpec.describe 'Posts API', type: :request do
tags: 'spec_tag', # 既存タグ名を投げる
thumbnail: dummy_upload)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
end
@@ -1499,7 +1499,7 @@ RSpec.describe 'Posts API', type: :request do
tags: 'spec_tag', # 既存タグ名を投げる
thumbnail: dummy_upload)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
end
@@ -1601,7 +1601,7 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload }
}.not_to change(Post, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to be_present
end
end
@@ -1620,7 +1620,7 @@ RSpec.describe 'Posts API', type: :request do
)
}.not_to change(Post, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to be_present
end
end
@@ -1639,7 +1639,7 @@ RSpec.describe 'Posts API', type: :request do
)
}.not_to change(PostImplication, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to be_present
end
end
@@ -1770,7 +1770,7 @@ RSpec.describe 'Posts API', type: :request do
tags: 'deprecated_update_tag'
)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['廃止済みタグは付与できません.']
)
@@ -1789,7 +1789,7 @@ RSpec.describe 'Posts API', type: :request do
title: 'updated title',
tags: 'nico:nico_tag')
expect(response).to have_http_status(:unprocessable_content), response.body
expect(response).to have_http_status(:unprocessable_entity), response.body
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
@@ -1896,7 +1896,7 @@ RSpec.describe 'Posts API', type: :request do
title: 'updated title',
tags: 'spec_tag' }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to be_present
end
end
@@ -1922,7 +1922,7 @@ RSpec.describe 'Posts API', type: :request do
parent_post_ids: 'abc'
)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id])
end
end
@@ -1948,7 +1948,7 @@ RSpec.describe 'Posts API', type: :request do
parent_post_ids: '999999999'
)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id])
end
end
@@ -1964,7 +1964,7 @@ RSpec.describe 'Posts API', type: :request do
parent_post_ids: post_record.id.to_s
)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(PostImplication.exists?(
post_id: post_record.id,
@@ -2669,7 +2669,7 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload)
end.not_to change(PostVersion, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'does not create a version when PUT /posts/:id is invalid' do
@@ -2685,7 +2685,7 @@ RSpec.describe 'Posts API', type: :request do
original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601)
end.not_to change(PostVersion, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'rejects second-bearing original created timestamps on POST /posts' do
@@ -2698,7 +2698,7 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload,
original_created_from: '2020-01-01T00:00:01Z')
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
)
@@ -2715,7 +2715,7 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload,
original_created_before: '2020-01-01T00:00:00.123Z')
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_before' => ['オリジナルの作成日時は分単位で入力してください.']
)
@@ -2733,7 +2733,7 @@ RSpec.describe 'Posts API', type: :request do
original_created_from: '2020-01-01T00:00Z',
original_created_before: '2020-01-01T00:00Z')
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_at' => ['オリジナルの作成日時の順番がをかしぃです.']
)
@@ -2767,7 +2767,7 @@ RSpec.describe 'Posts API', type: :request do
tags: 'spec_tag',
original_created_from: 'not-a-time')
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'original_created_from' => ['オリジナルの作成日時の形式が不正です.']
)
+1 -1
ファイルの表示
@@ -64,7 +64,7 @@ RSpec.describe "TagChildren", type: :request do
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(TagImplication.where(tag: child, parent_tag: parent)).not_to exist
end
end
+1 -1
ファイルの表示
@@ -303,7 +303,7 @@ RSpec.describe 'Tag description native API', type: :request do
headers
before_state = stored_state
native_write(body: '')
expect(response).to have_http_status(:unprocessable_content)
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)
+2 -2
ファイルの表示
@@ -84,7 +84,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
put "/wiki/#{ group.id }", params: {
locale: 'ain-Kana', title: 'タググループ:partial rename', body: '- japanese_member' },
headers: headers
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
@@ -117,7 +117,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
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-Kana', version: version.id }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).not_to include('historical_japanese', 'current_ainu')
end
end
+2 -2
ファイルの表示
@@ -133,7 +133,7 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :
get group_title_path('混在構造')
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
end
@@ -142,7 +142,7 @@ RSpec.describe 'Tag group Markdown round trips through the Wiki facade', type: :
get group_title_path('未配置構造')
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_rows('tag_groups', id:).sole.fetch('id')).to eq(id)
end
end
+1 -1
ファイルの表示
@@ -21,7 +21,7 @@ RSpec.describe 'Tag group Wiki facade writes', type: :request do
before_state = group_state
yield
aggregate_failures do
expect(response).to have_http_status(:unprocessable_content)
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
+1 -1
ファイルの表示
@@ -27,7 +27,7 @@ RSpec.describe 'Tag group write integrity', type: :request do
def expect_no_writes
before_state = group_state
yield
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
+7 -7
ファイルの表示
@@ -148,7 +148,7 @@ RSpec.describe 'Tag group native API', type: :request do
before_state = group_state
native_write(operation, payload.merge(invalid))
aggregate_failures do
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
end
@@ -158,7 +158,7 @@ RSpec.describe 'Tag group native API', type: :request 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_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
@@ -167,7 +167,7 @@ RSpec.describe 'Tag group native API', type: :request do
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_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
@@ -177,7 +177,7 @@ RSpec.describe 'Tag group native API', type: :request do
native_write(operation, payload.merge(
members: [{ tag_group_id: missing }],
placements: [{ tag_group_id: missing, children: [] }]))
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
@@ -186,7 +186,7 @@ RSpec.describe 'Tag group native API', type: :request do
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_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
end
@@ -206,7 +206,7 @@ RSpec.describe 'Tag group native API', type: :request do
before_state = group_state
native_write(:update, payload.merge(
members: [{ tag_group_id: parent }], placements: []))
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
@@ -214,7 +214,7 @@ RSpec.describe 'Tag group native API', type: :request do
before_state = group_state
native_write(:update, payload.merge(
members: [{ tag_group_id: group_id }], placements: []))
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
+3 -3
ファイルの表示
@@ -297,7 +297,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do
do_request
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
@@ -321,7 +321,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do
do_request
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deerjikists.0.platform' => [be_present],
)
@@ -352,7 +352,7 @@ RSpec.describe 'Tags deerjikists API', type: :request do
do_request
}.not_to change { Deerjikist.order(:platform, :code).pluck(:platform, :code, :tag_id) }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deerjikists.1.code' => [include('existing-deerjikist')],
)
+10 -10
ファイルの表示
@@ -696,7 +696,7 @@ RSpec.describe 'Tags API', type: :request do
it 'nico category への変更は 422 を返す' do
patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag')
expect(tag.category).to eq('general')
end
@@ -740,7 +740,7 @@ RSpec.describe 'Tags API', type: :request do
patch "/tags/#{tag.id}", params: { category: 'nico' }
}.not_to change(TagVersion, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(tag.reload.category).to eq('general')
end
@@ -824,7 +824,7 @@ RSpec.describe 'Tags API', type: :request do
patch "/tags/#{ tag.id }", params: { name: 'unknown' }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'name' => ['その名前は既に使はれてゐます.']
)
@@ -840,7 +840,7 @@ RSpec.describe 'Tags API', type: :request do
patch "/tags/#{ system_tag.id }", params: { name: 'patch_system_tag_renamed' }
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(primary_tag_name_for(system_tag.reload, 'ja').name).to eq('bot操作')
end
end
@@ -1144,7 +1144,7 @@ RSpec.describe 'Tags API', type: :request do
parent_tags: '',
}
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag')
end
@@ -1156,7 +1156,7 @@ RSpec.describe 'Tags API', type: :request do
parent_tags: '',
}
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag')
expect(tag.category).to eq('general')
end
@@ -1169,7 +1169,7 @@ RSpec.describe 'Tags API', type: :request do
parent_tags: '',
}
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deprecated' => ['廃止状態は必須です.']
)
@@ -1366,7 +1366,7 @@ RSpec.describe 'Tags API', type: :request do
}
}.not_to change(TagVersion, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(primary_tag_name_for(tag.reload, 'ja').name).to eq('spec_tag')
expect(tag.category).to eq('general')
@@ -1387,7 +1387,7 @@ RSpec.describe 'Tags API', type: :request do
}
}.not_to change(TagVersion, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(primary_tag_name_for(system_tag.reload, 'ja').name).to eq(old_name)
expect(system_tag.category).to eq(old_category)
@@ -1569,7 +1569,7 @@ RSpec.describe 'Tags API', type: :request do
deprecated: '0',
}
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(TagImplication.where(tag:, parent_tag: child)).not_to exist
end
+1 -1
ファイルの表示
@@ -131,7 +131,7 @@ RSpec.describe 'TheatreComments', type: :request do
post "/theatres/#{theatre.id}/comments", params: { content: ' ' }
}.not_to change(TheatreComment, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'theatre が存在しなければ 404 を返す' do
+1 -1
ファイルの表示
@@ -376,7 +376,7 @@ RSpec.describe 'Theatres API', type: :request do
put "/theatres/#{theatre.id}/skip_vote", params: { post_id: 'invalid' }
}.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'records a vote and returns the current vote status before majority' do
+2 -2
ファイルの表示
@@ -79,7 +79,7 @@ RSpec.describe 'user settings', type: :request do
patch '/users/settings', params: { theme: 1 }, as: :json
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(json.fetch('errors')).to include(
'theme' => ['値の型が不正です.'])
@@ -90,7 +90,7 @@ RSpec.describe 'user settings', type: :request do
patch '/users/settings', params: { theme: 'neon' }, as: :json
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include('type' => 'validation_error')
expect(json.fetch('errors').fetch('theme')).to be_present
end
+2 -2
ファイルの表示
@@ -84,7 +84,7 @@ RSpec.describe 'user theme slots', type: :request do
params: { tokens: 'not-object' },
as: :json
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'base_theme' => ['値が不正です.'])
end
@@ -96,7 +96,7 @@ RSpec.describe 'user theme slots', type: :request do
params: { tokens: 'not-object' },
as: :json
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tokens' => ['JSON object で指定してください.'])
end
+1 -1
ファイルの表示
@@ -119,7 +119,7 @@ RSpec.describe 'Users', type: :request do
params: { name: ' ' },
headers: auth_headers(user)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'name' => ['名前は必須です.'])
end
+1 -1
ファイルの表示
@@ -39,7 +39,7 @@ RSpec.describe 'Wiki creation freeze', type: :request do
it "rejects invalid description body #{ body.inspect } atomically" do
before_state = group_state
put "/wiki/#{ page.id }", params: { title: tag.name('ja'), body: }, headers: headers
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
end
+3 -3
ファイルの表示
@@ -100,12 +100,12 @@ RSpec.describe 'Wiki API', type: :request do
context 'when params invalid' do
it 'returns 422 when title blank' do
post endpoint, params: { title: '', body: 'Hello' }, headers: auth_headers(member)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns 422 when body blank' do
post endpoint, params: { title: 'Test', body: '' }, headers: auth_headers(member)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
end
@@ -160,7 +160,7 @@ RSpec.describe 'Wiki API', type: :request do
put "/wiki/#{page.id}",
params: { title: 'TestPage', body: '' },
headers: auth_headers(member)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
end
end
+1 -1
ファイルの表示
@@ -34,7 +34,7 @@ RSpec.describe 'Wiki title collision', type: :request do
},
headers: auth_headers(user)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
source.reload
+1 -1
ファイルの表示
@@ -18,7 +18,7 @@ RSpec.describe 'Removed standalone Wiki resources', type: :request do
before_state = group_state
post '/wiki', params: { title:, body: 'body' },
headers: cookie_headers(user)
expect(response).to have_http_status(:unprocessable_content)
expect(response).to have_http_status(:unprocessable_entity)
expect(group_state).to eq(before_state)
end
end
+49
ファイルの表示
@@ -22,6 +22,55 @@ RSpec.describe 'Counts export contracts' do
.and_return(@counts_export_dir)
end
context 'nico link IDs with the V1 export field name' do
let(:tag) { create(:tag, primary_name: 'linked export tag') }
let(:external) { create(:external_tag, name: 'external export tag') }
let(:payload) do
super().merge('resource' => 'nico_tags', 'columns' => 'linked_tags',
'headers' => '["linked_tags"]')
end
before do
NicoTagVersion.create!(external_tag: external, version_no: 1,
event_type: 'create', linked_tag_ids: '', created_at: Time.utc(2023))
NicoTagVersion.create!(external_tag: external, version_no: 2,
event_type: 'update', linked_tag_ids: tag.id.to_s, created_at: Time.utc(2024))
end
it 'exports recorded IDs with the legacy JSON key and metadata' do
result = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
expect(result.fetch('data')).to eq([
{ 'linked_tags' => '' }, { 'linked_tags' => tag.id.to_s }])
expect(result.fetch('metadata').fetch('columns')).to eq(['linked_tags'])
keys = result.fetch('metadata').fetch('fields').map { _1.fetch('key') }
expect(keys).to include('linked_tags')
expect(keys).not_to include('linked_tag_ids')
end
it 'exports the selected legacy CSV header and ID values' do
body = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
expect(CSV.parse(body.delete_prefix("\uFEFF")))
.to eq([['linked_tags'], [''], [tag.id.to_s]])
end
it 'keeps the legacy link column in default JSON exports' do
options = payload.except('columns', 'headers')
result = JSON.parse(CountsTableExport.new(options).body.to_a.join)
expect(result.fetch('metadata').fetch('columns')).to include('linked_tags')
result.fetch('data').each do |row|
expect(row).to have_key('linked_tags')
expect(row).not_to have_key('linked_tag_ids')
end
end
it 'rejects the physical column as a selected CSV or JSON field' do
['csv', 'json'].each do |format|
options = payload.merge('format' => format, 'columns' => 'linked_tag_ids')
expect { CountsTableExport.new(options) }.to raise_error(ArgumentError)
end
end
end
context 'serialization after the authorised query' do
let(:rows) { [{ title: "=formula\n\"quoted\"" }, { title: 'second' }] }
let(:page) do
+18 -8
ファイルの表示
@@ -8,21 +8,23 @@ RSpec.describe NicoTagVersionRecorder do
described_class.record!(external_tag:, event_type:, created_by_user: member)
end
it 'records the external association and platform-qualified name' do
it 'records the external association and derives the platform-qualified name' do
version = record(:create)
expect(version).to have_attributes(
external_tag:, version_no: 1, event_type: 'create',
name: 'nico:raw tag[]', linked_tags: '', created_by_user: member)
name: 'nico:raw tag[]', linked_tag_ids: '', created_by_user: member)
expect(external_tag.reload.nico_tag_versions).to contain_exactly(version)
end
it 'uses the latest history number when the record has no version_no column' do
first = record(:create)
external_tag.update!(name: 'changed')
tag = create(:tag)
NicoTagRelation.create!(nico_tag: external_tag, tag:)
second = record(:update)
expect(second).to have_attributes(version_no: 2, name: 'nico:changed')
expect(second).to have_attributes(version_no: 2, name: 'nico:raw tag[]',
linked_tag_ids: tag.id.to_s)
expect(first.reload.name).to eq('nico:raw tag[]')
expect(external_tag.reload.has_attribute?(:version_no)).to be(false)
end
@@ -40,7 +42,7 @@ RSpec.describe NicoTagVersionRecorder do
expect(version).to have_attributes(
external_tag: external, name: 'registered_external:foo', version_no: 1,
event_type: 'create', linked_tags: '', created_by_user: member)
event_type: 'create', linked_tag_ids: '', created_by_user: member)
end
it 'returns the latest version without appending an unchanged snapshot' do
@@ -64,13 +66,21 @@ RSpec.describe NicoTagVersionRecorder do
expect(external_tag.nico_tag_versions).to contain_exactly(first)
end
it 'records sorted linked internal names and later link removal' do
it 'rejects discard and restore events for external tags' do
[:discard, :restore].each do |event_type|
expect { record(event_type) }
.to raise_error(ArgumentError, "Invalid event_type: #{ event_type }")
end
end
it 'records sorted linked internal IDs and later link removal' do
tags = ['z_link', 'a_link'].map { |name| create(:tag, primary_name: name, category: :general) }
tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) }
expect(record(:create).linked_tags).to eq('a_link z_link')
expect(record(:create).linked_tag_ids).to eq(tags.map(&:id).sort.join(' '))
external_tag.linked_tags = [tags.first]
expect(record(:update)).to have_attributes(version_no: 2, linked_tags: 'z_link')
expect(record(:update)).to have_attributes(version_no: 2, linked_tag_ids: tags.first.id.to_s)
end
end
+3366
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読み込み
+12 -4
ファイルの表示
@@ -158,7 +158,15 @@ private ID の正誤に応じて許可可否を変えないため、条件の一
非公開列を含む列順変更は API の column_values_editable でも不可と通知する。
公開列の値を使うソートは引き続き利用できる。
明細には元の認可を適用し、抽出条件自体や取得済みの全言語値を表示設定で改変しない。
外部タグ履歴の linked_tags は名前しか記録していないため、非公開タグの記録名を含む
スナップショットを保守的に非公開とする。名前全体を空白区切りの境界で照合する。
区切りが曖昧な旧データでは過剰な秘匿の可能性はあるが、参照 ID がない旧形式から
名前の公開可否を楽観的に推定しない。
外部タグ履歴の linked_tag_ids は、連携する内部タグの ID を半角スペース区切りで
記録する。除票履歴のある内部タグの ID を含むスナップショットを非管理者へ公開しない。
名前や UI ロカールを参照 ID の判定に使わない。
外部タグ名は履歴に保存せず、external_tags の platform と name を参照する。
V1 Counts の公開フィールド名は linked_tags を維持し、DB の linked_tag_ids へ対応付ける。
明細・フィルタ・集計軸・変更項目・ソート・CSV / JSON 出力で同じ対応付けを使う。
既定列と既存の表示ラベルも linked_tags のままとし、値は ID のスペース区切りとする。
linked_tag_ids は物理カラム名であり、Counts の公開フィールドとしては受け付けない。
旧 URL や保存済み設定のフィールド指定は維持できるが、値にタグ名を指定した検索条件や
ドリル条件は ID 値への見直しが必要となる。旧履歴の名称は移行後の ID 列から復元できず、
現在の名称からの推定変換も行わない。