コミットを比較

..
6 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 4cd1104213 #354 2026-08-07 01:53:00 +09:00
みてるぞ 96b6ff3267 #354 2026-08-07 01:40:24 +09:00
みてるぞ acd8046a77 #354 2026-08-06 06:49:05 +09:00
みてるぞ b5b5391c8e #354 2026-08-05 12:43:33 +09:00
みてるぞ 5bb80d1a35 #354 2026-08-05 03:24:01 +09:00
みてるぞ c1d9e3638b #354 2026-08-04 02:12:47 +09:00
56個のファイルの変更841行の追加2620行の削除
+1 -1
ファイルの表示
@@ -237,7 +237,7 @@ class MaterialsController < ApplicationController
end
def resolve_material_tag! tag_name_raw
tag_name = TagName.find_or_create_by!(name: tag_name_raw)
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag || Tag.create!(tag_name:, category: :material)
end
+25 -34
ファイルの表示
@@ -15,29 +15,30 @@ class NicoTagsController < ApplicationController
limit = 1 if limit < 1
post_tag_max_sql =
PostExternalTag
.select('external_tag_id, MAX(created_at) AS max_created_at')
.group('external_tag_id')
PostTag
.select('tag_id, MAX(created_at) AS max_created_at')
.group('tag_id')
.to_sql
q =
ExternalTag
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.external_tag_id = external_tags.id')
q = q.where('external_tags.name LIKE ?', "%#{ name }%") if name
q = Tag.nico_tags
.joins(:tag_name)
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.tag_id = tags.id')
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
if linked_tag
linked_tag_ids =
Tag
.joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
Tag
.joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
q = q.where(id: linked_nico_tag_ids)
end
if link_status.in?(['linked', 'unlinked'])
exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' \
'WHERE nico_tag_relations.nico_tag_id = external_tags.id)'
'WHERE nico_tag_relations.nico_tag_id = tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end
@@ -45,21 +46,21 @@ class NicoTagsController < ApplicationController
sort_sql =
case order[0]
when 'name'
'external_tags.name'
'tag_names.name'
when 'updated_at'
'post_tag_max.max_created_at'
else
"external_tags.#{ order[0] }"
"tags.#{ order[0] }"
end
tags = q.reselect('external_tags.*',
tags = q.reselect('tags.*',
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, external_tags.id #{ order[1] }"))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset((page - 1) * limit)
.to_a
render json: { tags: tags.map { |tag|
external_tag_json(tag).merge(
TagRepr.base(tag).merge(
recent_post_tag_created_at: tag.recent_post_tag_created_at,
linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) })
}, count: }
@@ -71,7 +72,8 @@ class NicoTagsController < ApplicationController
id = params[:id].to_i
tag = ExternalTag.find(id)
tag = Tag.find(id)
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico?
linked_tag_names = params[:tags].to_s.split
linked_tags = nil
@@ -79,15 +81,16 @@ class NicoTagsController < ApplicationController
ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
if linked_tags.any? { |t| t.nico? }
raise Tag::NicoTagNormalisationError
end
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
tag.linked_tags = linked_tags
tag.save!
NicoTagVersionRecorder.record!(external_tag: tag,
event_type: :update,
created_by_user: current_user)
NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
end
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
@@ -99,18 +102,6 @@ class NicoTagsController < ApplicationController
private
def external_tag_json tag
{ id: tag.id,
name: "#{ tag.platform }:#{ tag.name }",
category: 'nico',
post_count: tag.post_count,
created_at: tag.created_at,
updated_at: tag.created_at,
deprecated_at: nil,
aliases: [],
parents: [] }
end
def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
+54 -54
ファイルの表示
@@ -27,8 +27,8 @@ class PostVersionsController < ApplicationController
'prev.original_created_before AS prev_original_created_before')
q = q.where('post_versions.post_id = ?', post_id) if post_id
if tag_id
q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', #{ tag_id })) " +
"OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', #{ tag_id }))")
q = q.where("JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('id', #{ tag_id })) " +
"OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('id', #{ tag_id }))")
end
count = q.except(:select, :order, :limit, :offset).count
@@ -43,19 +43,50 @@ class PostVersionsController < ApplicationController
private
def serialise_versions rows
rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
snapshots = rows.flat_map { |row|
[normalise_json(row.tags_json),
normalise_json(row.attributes['prev_tags_json']) || []]
}
external_tag_names = external_tag_names_for(snapshots)
rows.map do |row|
cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names)
prev_tags = snapshot_tag_literals(
normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names)
cur_tags =
normalise_json(row.tags_json)
.sort_by { [(case _1.fetch('category')
when 'deerjikist'
0
when 'meme'
1
when 'character'
2
when 'general'
3
when 'material'
4
when 'meta'
5
else
6
end),
_1.fetch('name').downcase] }
.map { Post.tag_snapshot_literal(_1) }
prev_tags =
(normalise_json(row.attributes['prev_tags_json']) || [])
.sort_by { [(case _1.fetch('category')
when 'deerjikist'
0
when 'meme'
1
when 'character'
2
when 'general'
3
when 'material'
4
when 'meta'
5
else
6
end),
_1.fetch('name').downcase] }
.map { Post.tag_snapshot_literal(_1) }
{ post_id: row.post_id,
version_no: row.version_no,
@@ -82,52 +113,21 @@ class PostVersionsController < ApplicationController
end
end
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|
[id, "#{ platform }:#{ name }"]
}
missing_ids = ids - names.keys
NicoTagVersion
.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, external_tag_names
snapshots
.filter_map { |snapshot|
if snapshot.key?('tag_id')
[tag_category_order(snapshot['category']),
Post.tag_snapshot_literal(snapshot)]
elsif external_tag_names[snapshot['external_tag_id']]
[6, external_tag_names[snapshot['external_tag_id']]]
end
}
.sort_by { |order, name| [order, name.downcase] }
.map(&:second)
end
def tag_category_order category
['deerjikist', 'meme', 'character', 'general', 'material', 'meta'].index(category) || 6
end
def build_version_tags cur_tags, prev_tags
def build_version_tags(cur_tags, prev_tags)
(cur_tags | prev_tags).map do |name|
type =
if cur_tags.include?(name) && prev_tags.include?(name)
'context'
elsif cur_tags.include?(name)
'added'
else
'removed'
end
if cur_tags.include?(name) && prev_tags.include?(name)
'context'
elsif cur_tags.include?(name)
'added'
else
'removed'
end
{ name:, type: }
{
name:,
type:
}
end
end
end
+71 -18
ファイルの表示
@@ -36,8 +36,8 @@ class PostsController < ApplicationController
offset = (page - 1) * limit
pt_max_sql =
PostVersion
.select('post_id, MAX(created_at) AS max_updated_at')
PostTag
.select('post_id, MAX(updated_at) AS max_updated_at')
.group('post_id')
.to_sql
@@ -50,8 +50,9 @@ class PostsController < ApplicationController
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(:uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -103,8 +104,9 @@ class PostsController < ApplicationController
def random
post = filtered_posts.preload(:uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.order('RAND()')
.first
@@ -188,8 +190,9 @@ class PostsController < ApplicationController
post =
Post
.includes(:uploaded_user, :parents, :children,
post_tags: [:sections, { tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
active_post_tags: [:sections,
{ tag: [:deerjikists, :materials,
{ tag_name: :wiki_page }] }])
.with_attached_thumbnail
.find_by(id: params[:id])
return head :not_found unless post
@@ -381,6 +384,50 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record
end
def changes
id = params[:id].presence
tag_id = params[:tag].presence
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
offset = (page - 1) * limit
pts = PostTag.with_discarded
pts = pts.where(post_id: id) if id.present?
pts = pts.where(tag_id:) if tag_id.present?
pts = pts.includes(:post, :created_user, :deleted_user,
tag: [:deerjikists, :materials, { tag_name: :wiki_page }])
events = []
pts.each do |pt|
tag = TagRepr.base(pt.tag)
post = pt.post
events << Event.new(
post:,
tag:,
user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name },
change_type: 'add',
timestamp: pt.created_at)
if pt.discarded_at
events << Event.new(
post:,
tag:,
user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name },
change_type: 'remove',
timestamp: pt.discarded_at)
end
end
events.sort_by!(&:timestamp)
events.reverse!
render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size }
end
private
def filtered_posts
@@ -455,13 +502,13 @@ class PostsController < ApplicationController
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.destroy!
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user)
end
end
def build_tag_tree_for post
post_tags = post.post_tags.reject { |post_tag| post_tag.tag.deprecated? }
post_tags = post.active_post_tags.reject { |post_tag| post_tag.tag.deprecated? }
tags = post_tags.map(&:tag)
tag_ids = tags.map(&:id)
@@ -653,7 +700,7 @@ class PostsController < ApplicationController
def editable_tag_names_from_version version
version.tags_json
.select { _1.key?('tag_id') }
.reject { _1.fetch('category') == 'nico' }
.map { Post.tag_snapshot_literal(_1) }
.sort
end
@@ -670,7 +717,9 @@ class PostsController < ApplicationController
def editable_tag_names_from_post post
post
.post_tags
.kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.merge(Tag.where(deprecated_at: nil))
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
@@ -688,7 +737,8 @@ class PostsController < ApplicationController
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
validate_original_created_values!(original_created_from, original_created_before)
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) => { tags:, sections: }
with_sections: true) =>
{ tags:, sections: }
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
video_ms = normalise_video_ms(tags, video_ms_param:, duration_param:)
@@ -831,12 +881,15 @@ class PostsController < ApplicationController
original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before])
Tag.normalise_tags!(snapshot[:tag_names],
with_tagme: false,
deny_deprecated: true,
with_sections: true) => { tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false,
deny_deprecated: true,
with_sections: true) =>
{ tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a
tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = tags.any? { _1.id == Tag.video.id } ? snapshot[:video_ms] : nil
+2
ファイルの表示
@@ -10,6 +10,7 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
@@ -32,6 +33,7 @@ class TagChildrenController < ApplicationController
parent = Tag.find(parent_id)
child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico?
ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
+50 -168
ファイルの表示
@@ -100,14 +100,13 @@ class TagsController < ApplicationController
def autocomplete
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
prefix = "#{ ActiveRecord::Base.sanitize_sql_like(q) }%"
with_nico = bool?(:nico, default: true)
present_only = bool?(:present, default: true)
alias_rows =
TagName
.where('name LIKE ?', prefix)
.where('name LIKE ?', "#{ q }%")
.where.not(canonical_id: nil)
.pluck(:canonical_id, :name)
@@ -119,57 +118,29 @@ class TagsController < ApplicationController
matched_alias_by_tag_name_id[canonical_id] ||= alias_name
end
base =
Tag
.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(deprecated_at: nil)
base = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(deprecated_at: nil)
base = base.where('tags.post_count > 0') if present_only
canonical_hit = base.where('tag_names.name LIKE ?', prefix)
canonical_hit =
base
.where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') +
'tag_names.name LIKE ?'),
*(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%")
internal_tags =
if with_nico
tags =
if canonical_ids.present?
canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
else
canonical_hit
end
internal_rows =
internal_tags
.order(Arel.sql('tags.post_count DESC, tag_names.name'))
.limit(20)
.map { |tag|
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
}
tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a
return render json: internal_rows unless with_nico
external_base = ExternalTag.all
external_base = external_base.where('post_count > 0') if present_only
external_rows =
external_base
.where("CONCAT(platform, ':', name) LIKE ? OR name LIKE ?", prefix, prefix)
.order(post_count: :desc, name: :asc)
.limit(20)
.map { |tag|
{ 'id' => tag.id,
'name' => "#{ tag.platform }:#{ tag.name }",
'category' => 'nico',
'deprecated_at' => nil,
'created_at' => tag.created_at,
'updated_at' => tag.created_at,
'post_count' => tag.post_count,
'matched_alias' => nil }
}
rows =
(internal_rows + external_rows)
.sort_by { |row| [-row['post_count'], row['name']] }
.first(20)
render json: rows
render json: tags.map { |tag|
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
}
end
def show
@@ -229,45 +200,14 @@ class TagsController < ApplicationController
.find_by(id: params[:id])
return head :not_found unless tag
rows = normalise_deerjikist_rows(tag)
return if performed?
ApplicationRecord.transaction do
tag.lock!
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
row_indexes_by_key = rows_by_key(rows)
locked_deerjikists = lock_deerjikists_for_tag_update(tag.id, requested_keys)
current_deerjikists = locked_deerjikists.filter { |deerjikist|
deerjikist.tag_id == tag.id
}
requested_deerjikists = locked_deerjikists.filter { |deerjikist|
row_indexes_by_key.key?([deerjikist.platform, deerjikist.code])
}
render_deerjikist_conflicts(requested_deerjikists, row_indexes_by_key, tag)
raise ActiveRecord::Rollback if performed?
requested_keys_set = requested_keys.to_set
current_deerjikists.each do |deerjikist|
key = [deerjikist.platform, deerjikist.code]
deerjikist.destroy! unless requested_keys_set.include?(key)
end
existing_keys = requested_deerjikists.to_h { |deerjikist|
[[deerjikist.platform, deerjikist.code], true]
}
requested_keys.each do |platform, code|
next if existing_keys[[platform, code]]
deerjikist = Deerjikist.new(platform:, code:, tag:)
row_index = row_indexes_by_key[[platform, code]].first
begin
render_deerjikist_form_record_invalid(deerjikist, row_index) unless deerjikist.save
rescue ActiveRecord::RecordNotUnique
conflicts = lock_deerjikists_for_tag_update(tag.id, [[platform, code]])
render_deerjikist_conflicts(conflicts, row_indexes_by_key, tag)
end
tag.deerjikists = []
params[:_json].each.with_index do |item, i|
platform = item[:platform]
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
raise ActiveRecord::Rollback if performed?
end
end
@@ -311,7 +251,11 @@ class TagsController < ApplicationController
parent_names = params[:parent_tags].to_s.split.uniq
deprecated = bool?(:deprecated)
if category == 'nico'
if tag.nico? && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
if tag.nico? || category == 'nico'
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end
@@ -359,9 +303,13 @@ class TagsController < ApplicationController
tag = Tag.find(params[:id])
if tag.nico? && deprecated_given && deprecated
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
end
return unless validate_tag_rename(tag, name)
if category.present? && category == 'nico'
if tag.nico? || (category.present? && category == 'nico')
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
end
@@ -568,6 +516,11 @@ class TagsController < ApplicationController
end
def record_tag_version! tag, event_type:, created_by_user:, name_changed: false, wiki_page: nil
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return
end
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
return unless name_changed
@@ -589,7 +542,7 @@ class TagsController < ApplicationController
return false
end
target_tag_name = TagName.find_by(name:)
target_tag_name = TagName.with_discarded.find_by(name:)
return true if target_tag_name.nil?
return true if target_tag_name.canonical_id?
@@ -601,14 +554,17 @@ class TagsController < ApplicationController
return if name == tag.name
current_tag_name = tag.tag_name
target_tag_name = TagName.find_by(name:)
target_tag_name = TagName.with_discarded.find_by(name:)
if target_tag_name.nil?
current_tag_name.update!(name:)
return
end
promote_tag_alias!(tag, current_tag_name:, promoted_tag_name: target_tag_name)
promote_tag_alias!(
tag,
current_tag_name:,
promoted_tag_name: target_tag_name)
end
def promote_tag_alias! tag, current_tag_name:, promoted_tag_name:
@@ -618,9 +574,11 @@ class TagsController < ApplicationController
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
end
promoted_tag_name.undiscard! if promoted_tag_name.discarded?
promoted_tag_name.update!(canonical: nil)
TagName.where(canonical_id: current_tag_name.id)
TagName.with_discarded
.where(canonical_id: current_tag_name.id)
.where.not(id: promoted_tag_name.id)
.find_each do |alias_tag_name|
alias_tag_name.update!(canonical: promoted_tag_name)
@@ -651,7 +609,7 @@ class TagsController < ApplicationController
end
alias_names.each do |alias_name|
alias_tag_name = TagName.find_or_create_by!(name: alias_name)
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
affected_tags << alias_tag_name.canonical&.tag
end
@@ -666,7 +624,7 @@ class TagsController < ApplicationController
end
alias_names.each do |alias_name|
alias_tag_name = TagName.find_or_create_by!(name: alias_name)
alias_tag_name = TagName.find_undiscard_or_create_by!(name: alias_name)
alias_tag_name.update!(canonical: tag.tag_name)
end
@@ -677,7 +635,8 @@ class TagsController < ApplicationController
def update_parent_tags! tag, parent_names
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
with_no_deerjikist: false)
with_no_deerjikist: false,
deny_nico: true)
old_parent_tags = tag.parents.to_a
@@ -694,7 +653,6 @@ class TagsController < ApplicationController
end
def normalise_deerjikist_code platform, code
code = code.to_s
return code if platform != 'youtube' || code[0] != '@'
url = "https://www.youtube.com/#{ code }"
@@ -711,82 +669,6 @@ class TagsController < ApplicationController
nil
end
def normalise_deerjikist_rows tag
rows = []
params[:_json].each.with_index do |item, index|
platform = item[:platform]
unless Deerjikist.platforms.key?(platform)
render_deerjikist_platform_invalid(index)
return rows
end
code = normalise_deerjikist_code(platform, item[:code])
deerjikist = Deerjikist.new(platform:, code:, tag:)
unless deerjikist.valid?
render_deerjikist_form_record_invalid(deerjikist, index)
return rows
end
rows << { index:, platform:, code: }
end
rows
end
def lock_deerjikists_for_tag_update tag_id, keys
clauses = ['tag_id = ?']
values = [tag_id]
keys.each do |platform, code|
clauses << '(platform = ? AND code = ?)'
values << platform << code
end
Deerjikist
.where(clauses.join(' OR '), *values)
.order(:platform, :code)
.lock
.to_a
end
def render_deerjikist_conflicts deerjikists, row_indexes_by_key, tag
conflicts = deerjikists.filter { |deerjikist| deerjikist.tag_id != tag.id }
return if conflicts.empty?
tag_names_by_id = Tag
.joins(:tag_name)
.where(id: conflicts.map(&:tag_id).uniq)
.pluck('tags.id', 'tag_names.name')
.to_h
fields = { }
conflicts.each do |deerjikist|
message = "この情報は既に「#{ tag_names_by_id[deerjikist.tag_id] }」に紐づいてゐます."
row_indexes_by_key[[deerjikist.platform, deerjikist.code]].each do |index|
field = :"deerjikists.#{ index }.code"
fields[field] ||= []
fields[field] << message
end
end
render_validation_error fields:
end
def render_deerjikist_platform_invalid index
render_validation_error fields: {
:"deerjikists.#{ index }.platform" => ['値が不正です.'],
}
end
def rows_by_key rows
rows.each_with_object({ }) do |row, result|
key = [row[:platform], row[:code]]
result[key] ||= []
result[key] << row[:index]
end
end
def render_deerjikist_form_record_invalid deerjikist, index
fields = { }
+1 -1
ファイルの表示
@@ -94,7 +94,7 @@ class WikiPagesController < ApplicationController
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag_name = TagName.find_or_create_by!(name: title)
tag_name = TagName.find_undiscard_or_create_by!(name: title)
page =
Wiki::Commit.create_content!(
-20
ファイルの表示
@@ -1,20 +0,0 @@
class ExternalTag < ApplicationRecord
enum :platform, nico: 'nico'
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
has_many :post_external_tags, dependent: :delete_all
has_many :posts, through: :post_external_tags
has_many :nico_tag_versions, foreign_key: :tag_id, inverse_of: :external_tag
has_many :nico_tag_relations,
foreign_key: :nico_tag_id,
inverse_of: :nico_tag,
dependent: :destroy
has_many :linked_tags, through: :nico_tag_relations, source: :tag
def snapshot_linked_tag_names
linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
end
end
+19 -5
ファイルの表示
@@ -1,10 +1,24 @@
class NicoTagRelation < ApplicationRecord
belongs_to :nico_tag,
class_name: 'ExternalTag',
foreign_key: :nico_tag_id,
inverse_of: :nico_tag_relations
belongs_to :tag, class_name: 'Tag', foreign_key: :tag_id
belongs_to :nico_tag, class_name: 'Tag'
belongs_to :tag, class_name: 'Tag'
validates :nico_tag_id, presence: true
validates :tag_id, presence: true
validate :nico_tag_must_be_nico
validate :tag_mustnt_be_nico
private
def nico_tag_must_be_nico
if nico_tag && nico_tag.category != 'nico'
errors.add :nico_tag_id, 'タグのカテゴリがニコニコである必要があります.'
end
end
def tag_mustnt_be_nico
if tag && tag.category == 'nico'
errors.add :tag_id, '連携先タグのカテゴリはニコニコであってはなりません.'
end
end
end
+1 -1
ファイルの表示
@@ -1,7 +1,7 @@
class NicoTagVersion < ApplicationRecord
include VersionRecord
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
belongs_to :tag
validates :name, presence: true
end
+19 -30
ファイルの表示
@@ -55,27 +55,25 @@ class Post < ApplicationRecord
belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy, inverse_of: :post
has_many :tags, through: :post_tags
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :tags, through: :active_post_tags
has_many :active_tags, -> { where(tags: { deprecated_at: nil }) },
through: :post_tags,
source: :tag
through: :active_post_tags, source: :tag
has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all
has_many :post_versions
has_many :gekanator_guessed_games,
class_name: 'GekanatorGame',
foreign_key: :guessed_post_id,
dependent: :delete_all,
inverse_of: :guessed_post
has_many :gekanator_correct_games,
class_name: 'GekanatorGame',
foreign_key: :correct_post_id,
dependent: :delete_all,
inverse_of: :correct_post
has_many :gekanator_question_examples, dependent: :delete_all
has_many :parent_post_implications,
@@ -92,9 +90,6 @@ class Post < ApplicationRecord
inverse_of: :parent_post
has_many :children, through: :child_post_implications, source: :post
has_many :post_external_tags, dependent: :destroy
has_many :external_tags, through: :post_external_tags
has_one_attached :thumbnail
attribute :version_no, :integer, default: 1
@@ -128,6 +123,7 @@ class Post < ApplicationRecord
def snapshot_tag_names
post_tags
.kept
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
@@ -153,27 +149,20 @@ class Post < ApplicationRecord
end
def snapshot_tags_json
tag_snapshots =
post_tags
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tags.id')
.map { |pt|
{ 'tag_id' => pt.tag.id,
'version_no' => pt.tag.version_no,
'name' => pt.tag.name,
'category' => pt.tag.category,
'sections' => pt.sections.sort_by(&:begin_ms).map {
{ 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms }
} }
}
external_tag_snapshots =
post_external_tags.order(:external_tag_id).map {
{ 'external_tag_id' => _1.external_tag_id }
}
tag_snapshots + external_tag_snapshots
post_tags
.kept
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tags.id')
.map do |pt|
{ 'id' => pt.tag.id,
'version_no' => pt.tag.version_no,
'name' => pt.tag.name,
'category' => pt.tag.category,
'sections' => pt.sections.sort_by(&:begin_ms).map {
{ 'begin_ms' => _1.begin_ms, 'end_ms' => _1.end_ms }
} }
end
end
def self.section_literal section
-4
ファイルの表示
@@ -1,4 +0,0 @@
class PostExternalTag < ApplicationRecord
belongs_to :post
belongs_to :external_tag, counter_cache: :post_count
end
+21 -1
ファイルの表示
@@ -1,7 +1,14 @@
class PostTag < ApplicationRecord
include Discard::Model
before_destroy do
raise ActiveRecord::ReadOnlyRecord, '消さないでください.'
end
belongs_to :post
belongs_to :tag, counter_cache: :post_count
belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id],
@@ -11,5 +18,18 @@ class PostTag < ApplicationRecord
validates :post_id, presence: true
validates :tag_id, presence: true
validates :post_id, uniqueness: { scope: :tag_id }
validates :post_id, uniqueness: {
scope: :tag_id,
conditions: -> { where(discarded_at: nil) } }
def discard_by! deleted_user
return self if discarded?
transaction do
update!(discarded_at: Time.current, deleted_user:)
Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)')
end
self
end
end
+4 -4
ファイルの表示
@@ -4,10 +4,10 @@ class PostTagSection < ApplicationRecord
belongs_to :post
belongs_to :tag
belongs_to :post_tag, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
+42 -19
ファイルの表示
@@ -2,6 +2,8 @@ require 'set'
class Tag < ApplicationRecord
include MyDiscard
class NicoTagNormalisationError < ArgumentError
;
end
@@ -26,12 +28,15 @@ class Tag < ApplicationRecord
end
has_many :post_tags, inverse_of: :tag
has_many :posts, through: :post_tags
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :posts, through: :active_post_tags
has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
has_many :linked_tags, through: :nico_tag_relations, source: :tag
has_many :reversed_nico_tag_relations,
class_name: 'NicoTagRelation',
foreign_key: :tag_id,
dependent: :destroy
class_name: 'NicoTagRelation', foreign_key: :tag_id, dependent: :destroy
has_many :linked_nico_tags, through: :reversed_nico_tag_relations, source: :nico_tag
has_many :tag_implications, foreign_key: :parent_tag_id, dependent: :destroy
@@ -49,6 +54,7 @@ class Tag < ApplicationRecord
has_many :materials
has_many :tag_versions
has_many :nico_tag_versions
belongs_to :tag_name
delegate :wiki_page, to: :tag_name
@@ -63,13 +69,17 @@ class Tag < ApplicationRecord
character: 'character',
general: 'general',
material: 'material',
nico: 'nico',
meta: 'meta'
validates :category, presence: true, inclusion: { in: Tag.categories.keys }
validate :tag_name_mustnt_start_with_nico
validate :nico_tag_name_must_start_with_nico
validate :tag_name_must_be_canonical
validate :category_must_be_deerjikist_with_deerjikists
validate :nico_tags_cannot_be_deprecated
scope :nico_tags, -> { nico }
CATEGORY_PREFIXES = {
'general:' => :general,
@@ -104,9 +114,10 @@ class Tag < ApplicationRecord
def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true,
deny_nico: true,
deny_deprecated: false,
with_sections: false
if tag_names.any? { |n| n.downcase.start_with?('nico:') }
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError
end
@@ -223,10 +234,10 @@ class Tag < ApplicationRecord
end
def self.find_or_create_by_tag_name! name, category:
tn = TagName.find_or_create_by!(name: name.to_s.strip)
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
tn = tn.canonical if tn.canonical_id?
Tag.find_or_create_by!(tag_name_id: tn.id) do |t|
Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t|
t.category = category
end
rescue ActiveRecord::RecordNotUnique
@@ -248,11 +259,11 @@ class Tag < ApplicationRecord
TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
source_tag.post_tags.find_each do |source_pt|
source_tag.post_tags.kept.find_each do |source_pt|
post_id = source_pt.post_id
affected_post_ids << post_id
source_pt.destroy!
unless PostTag.exists?(post_id:, tag: target_tag)
source_pt.discard_by!(created_by_user)
unless PostTag.kept.exists?(post_id:, tag: target_tag)
PostTag.create!(post_id:, tag: target_tag)
end
end
@@ -264,10 +275,14 @@ class Tag < ApplicationRecord
end
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
source_tag.destroy!
source_tag.discard!
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
if source_tag.nico?
source_tag_name.discard!
else
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
updated_at: Time.current)
end
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
end
@@ -278,13 +293,13 @@ class Tag < ApplicationRecord
end
# 投稿件数を再集計
target_tag.update_columns(post_count: PostTag.where(tag: target_tag).count)
target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count)
end
target_tag.reload
end
def snapshot_aliases = tag_name.aliases.order(:name).pluck(:name)
def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name)
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
@@ -294,9 +309,11 @@ class Tag < ApplicationRecord
private
def tag_name_mustnt_start_with_nico
if name.to_s.downcase.start_with?('nico:')
errors.add :name, 'タグの命名規則に反してゐます.'
def nico_tag_name_must_start_with_nico
n = name.to_s
if ((nico? && !(n.downcase.start_with?('nico:'))) ||
(!(nico?) && n.downcase.start_with?('nico:')))
errors.add :name, 'ニコニコ・タグの命名規則に反してゐます.'
end
end
@@ -337,4 +354,10 @@ class Tag < ApplicationRecord
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
end
def nico_tags_cannot_be_deprecated
if nico? && deprecated_at.present?
errors.add :deprecated_at, 'ニコタグは廃止できません.'
end
end
end
+2
ファイルの表示
@@ -1,4 +1,6 @@
class TagName < ApplicationRecord
include MyDiscard
has_one :tag
has_one :wiki_page
+1 -1
ファイルの表示
@@ -32,7 +32,7 @@ class TagNameSanitisationRule < ApplicationRecord
elsif source_tag
source_tag.update_columns(tag_name_id: existing_tn.id, updated_at: Time.current)
end
tn.destroy!
tn.discard!
next
end
+3 -3
ファイルの表示
@@ -88,14 +88,14 @@ module PostRepr
def tag_json post
post
.post_tags
.active_post_tags
.reject { _1.tag.deprecated? }
.sort_by { _1.tag.name }
.map do |post_tag|
.map { |post_tag|
TagRepr.inline(post_tag.tag).merge(
'children' => [],
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
end
}
end
def thumbnail_url post, host: nil
+6 -9
ファイルの表示
@@ -1,22 +1,19 @@
class NicoTagVersionRecorder < VersionRecorder
def self.record! external_tag:, event_type:, created_by_user:
new(external_tag:, event_type:, created_by_user:).record!
def self.record! tag:, event_type:, created_by_user:
new(tag:, event_type:, created_by_user:).record!
end
def initialize external_tag:, event_type:, created_by_user:
super(record: external_tag, event_type:, created_by_user:)
def initialize tag:, event_type:, created_by_user:
super(record: tag, event_type:, created_by_user:)
end
private
def version_class = NicoTagVersion
def version_association = :nico_tag_versions
def record_key = :external_tag
def record_key = :tag
def snapshot_attributes
{ name: "#{ @record.platform }:#{ @record.name }",
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
{ name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') }
end
def tracks_version_no_on_record? = false
end
+23 -21
ファイルの表示
@@ -20,22 +20,23 @@ class PostCreatePlan
parent_post_ids = normalise_parent_post_ids
validate_parent_post_ids!(parent_post_ids)
{ url: @attributes[:url],
title: @attributes[:title].to_s,
thumbnail_base: @attributes[:thumbnail_base].presence,
original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence,
tags: serialised_tags(direct_tag_specs, tag_sections),
display_tags: display_tags(direct_tag_specs, tag_sections),
duration: @attributes[:duration].to_s,
video_ms: video_ms,
parent_post_ids: parent_post_ids.join(' '),
direct_tag_specs: direct_tag_specs,
default_tag_specs: default_tag_specs,
snapshot_tag_specs: snapshot_tag_specs,
post_tag_specs: post_tag_specs,
tag_sections: tag_sections,
normalised_parent_post_ids: parent_post_ids }
{
url: @attributes[:url],
title: @attributes[:title].to_s,
thumbnail_base: @attributes[:thumbnail_base].presence,
original_created_from: @attributes[:original_created_from].presence,
original_created_before: @attributes[:original_created_before].presence,
tags: serialised_tags(direct_tag_specs, tag_sections),
display_tags: display_tags(direct_tag_specs, tag_sections),
duration: @attributes[:duration].to_s,
video_ms: video_ms,
parent_post_ids: parent_post_ids.join(' '),
direct_tag_specs: direct_tag_specs,
default_tag_specs: default_tag_specs,
snapshot_tag_specs: snapshot_tag_specs,
post_tag_specs: post_tag_specs,
tag_sections: tag_sections,
normalised_parent_post_ids: parent_post_ids }
end
private
@@ -49,6 +50,7 @@ class PostCreatePlan
tag_names.each do |raw_name|
tag_name, category, sections = parse_raw_tag_name(raw_name)
existing_tag = existing_tags_by_name[tag_name]
raise Tag::NicoTagNormalisationError if existing_tag&.nico?
raise Tag::DeprecatedTagNormalisationError, [existing_tag.name] if existing_tag&.deprecated?
direct_tag_specs << {
@@ -137,14 +139,14 @@ class PostCreatePlan
Tag.expand_parent_tags(existing_snapshot_tags)
.reject(&:deprecated?)
.map { |tag|
{ name: tag.name,
category: tag.category.to_sym }
}
{
name: tag.name,
category: tag.category.to_sym } }
merge_tag_specs(snapshot_tag_specs + expanded_parent_specs)
end
def merge_tag_specs specs
specs.each_with_object({ }) { |spec, merged|
specs.each_with_object({ }) do |spec, merged|
merged[spec[:name]] =
if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general
merged[spec[:name]]
@@ -153,7 +155,7 @@ class PostCreatePlan
name: spec[:name],
category: spec[:category] }
end
}.values.sort_by { _1[:name] }
end.values.sort_by { _1[:name] }
end
def existing_tags_by_name
+3 -4
ファイルの表示
@@ -121,11 +121,9 @@ class PostCreator
def sync_post_tags! post, desired_tags, sections
desired_ids = desired_tags.map(&:id).to_set
current_ids = post.tags.pluck(:id).to_set
Tag.where(id: desired_ids - current_ids).find_each do |tag|
PostTag.create_or_find_by!(post:, tag:, created_user: @actor)
end
PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms|
@@ -135,9 +133,10 @@ class PostCreator
end_ms:)
end
end
PostTag.where(post_id: post.id,
tag_id: (current_ids - desired_ids).to_a).destroy_all
tag_id: (current_ids - desired_ids).to_a).kept.find_each do |post_tag|
post_tag.discard_by!(@actor)
end
end
def sync_parent_posts! post, ids
+20 -4
ファイルの表示
@@ -1,16 +1,32 @@
class TagVersioning
def self.record! tag, event_type:, created_by_user:
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
if tag.nico?
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
else
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
end
end
def self.ensure_snapshot! tag, created_by_user:
return if tag.tag_versions.exists?
if tag.nico?
return if tag.nico_tag_versions.exists?
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
else
return if tag.tag_versions.exists?
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
end
end
def self.record_tag_snapshot! tag, created_by_user:
event_type = tag.tag_versions.exists? ? :update : :create
event_type =
if tag.nico?
tag.nico_tag_versions.exists? ? :update : :create
else
tag.tag_versions.exists? ? :update : :create
end
record!(tag, event_type:, created_by_user:)
end
+1 -5
ファイルの表示
@@ -47,14 +47,10 @@ class VersionRecorder
end
def update_record_version_no! version_no
return unless tracks_version_no_on_record?
@record.update_columns(version_no:)
@record.version_no = version_no
end
def tracks_version_no_on_record? = true
def validate_version_sequence! latest
if !(latest) && @event_type != 'create'
raise "#{ version_class.name } first event must be create"
@@ -64,7 +60,7 @@ class VersionRecorder
raise "#{ version_class.name } create event already exists"
end
return if !(latest) || !(tracks_version_no_on_record?)
return unless latest
if @record.version_no != latest.version_no
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
+3 -3
ファイルの表示
@@ -103,7 +103,7 @@ module Youtube
end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids
@@ -117,8 +117,8 @@ module Youtube
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each do |pt|
pt.destroy!
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
end
end
+1
ファイルの表示
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
get :metadata
post :bulk
get :random
get :changes
get :versions, to: 'post_versions#index'
end
-53
ファイルの表示
@@ -1,53 +0,0 @@
class DeleteInactiveRecordsFromPostTags < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
post_tags
WHERE
discarded_at IS NOT NULL
SQL
remove_index :post_tags, [:tag_id, :discarded_at]
remove_index :post_tags, [:post_id, :discarded_at]
remove_index :post_tags, name: 'idx_post_tags_active_unique'
remove_index :post_tags, :discarded_at
remove_foreign_key :post_tags, column: :deleted_user_id
remove_index :post_tags, :deleted_user_id
remove_column :post_tags, :active_unique_key
remove_column :post_tags, :is_active
remove_column :post_tags, :discarded_at
remove_column :post_tags, :deleted_user_id
remove_column :post_tags, :updated_at
execute <<~SQL
ALTER TABLE
post_tags
MODIFY COLUMN
id BIGINT NOT NULL
SQL
execute <<~SQL
ALTER TABLE
post_tags
DROP PRIMARY KEY
SQL
remove_column :post_tags, :id
execute <<~SQL
ALTER TABLE
post_tags
ADD PRIMARY KEY
(post_id, tag_id)
SQL
remove_index :post_tags, :post_id
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
@@ -1,11 +0,0 @@
class AddForeignKeyOnPostIdAndTagIdInPostTagSections < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :post_tag_sections, :posts, column: :post_id
remove_foreign_key :post_tag_sections, :tags, column: :tag_id
add_foreign_key :post_tag_sections, :post_tags,
column: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
on_delete: :cascade
end
end
-46
ファイルの表示
@@ -1,46 +0,0 @@
class DeleteDiscardedRecordsFromTags < ActiveRecord::Migration[8.0]
def up
remove_foreign_key :tag_versions, :tags, column: :tag_id
remove_foreign_key :nico_tag_versions, :tags, column: :tag_id
remove_foreign_key :material_versions, :tags, column: :tag_id
execute <<~SQL
DELETE
ntr
FROM
nico_tag_relations ntr
INNER JOIN
tags t
ON
t.discarded_at IS NOT NULL
AND t.id IN (ntr.tag_id, ntr.nico_tag_id)
SQL
execute <<~SQL
DELETE
ti
FROM
tag_implications ti
INNER JOIN
tags t
ON
t.discarded_at IS NOT NULL
AND t.id IN (ti.tag_id, ti.parent_tag_id)
SQL
execute <<~SQL
DELETE
FROM
tags
WHERE
discarded_at IS NOT NULL
SQL
remove_index :tags, :discarded_at
remove_column :tags, :discarded_at
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
-18
ファイルの表示
@@ -1,18 +0,0 @@
class DeleteDiscardedRecordsFromTagNames < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
FROM
tag_names
WHERE
discarded_at IS NOT NULL
SQL
remove_index :tag_names, :discarded_at
remove_column :tag_names, :discarded_at
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
-54
ファイルの表示
@@ -1,54 +0,0 @@
class CreateExternalTags < ActiveRecord::Migration[8.0]
def up
create_table :external_tags do |t|
t.string :platform, limit: 16, null: false
t.string :name, limit: 255, null: false
t.integer :post_count, null: false, default: 0
t.datetime :created_at, null: false
t.index [:platform, :name], unique: true
end
execute <<~SQL
INSERT INTO
external_tags(id, platform, name, post_count, created_at)
SELECT
t.id
, 'nico' AS platform
, SUBSTR(tn.name, 6) AS name
, t.post_count
, t.created_at
FROM
tags t
INNER JOIN
tag_names tn
ON
tn.id = t.tag_name_id
AND t.category = 'nico'
SQL
execute <<~SQL
INSERT INTO
external_tags(id, platform, name, post_count, created_at)
SELECT
ntv.tag_id
, 'nico' AS platform
, SUBSTR(ntv.name, 6) AS name
, 0 AS post_count
, ntv.created_at
FROM
nico_tag_versions ntv
LEFT JOIN
external_tags et
ON
et.id = ntv.tag_id
WHERE
ntv.version_no = 1
AND et.id IS NULL
SQL
end
def down
drop_table :external_tags
end
end
-29
ファイルの表示
@@ -1,29 +0,0 @@
class CreatePostExternalTags < ActiveRecord::Migration[8.0]
def up
create_table :post_external_tags, primary_key: [:post_id, :external_tag_id] do |t|
t.references :post, null: false, index: false, foreign_key: true
t.references :external_tag, null: false, foreign_key: true
t.datetime :created_at, null: false
end
execute <<~SQL
INSERT INTO
post_external_tags(post_id, external_tag_id, created_at)
SELECT
pt.post_id
, pt.tag_id AS external_tag_id
, pt.created_at
FROM
post_tags pt
INNER JOIN
tags t
ON
pt.tag_id = t.id
AND t.category = 'nico'
SQL
end
def down
drop_table :post_external_tags
end
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
class ChangeForeignKeyOnNicoTagRelations < ActiveRecord::Migration[8.0]
def change
remove_foreign_key :nico_tag_relations, :tags, column: :nico_tag_id
add_foreign_key :nico_tag_relations, :external_tags, column: :nico_tag_id
end
end
-199
ファイルの表示
@@ -1,199 +0,0 @@
class MigrateExternalTags < ActiveRecord::Migration[8.0]
class MigrationPostVersion < ActiveRecord::Base
self.table_name = 'post_versions'
end
def up
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
post_tag_sections pts
INNER JOIN
tags t
ON
t.id = pts.tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "post_tag_sections に #{ x } 件のチンカスがあります!"
end
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
materials m
INNER JOIN
tags t
ON
t.id = m.tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "materials に #{ x } 件のチンカスがあります!"
end
x = connection.select_value(<<~SQL)
SELECT
COUNT(0)
FROM
tag_implications ti
INNER JOIN
tags t
ON
t.id = ti.tag_id
OR t.id = ti.parent_tag_id
WHERE
t.category = 'nico'
SQL
if x > 0
raise "tag_implications に #{ x } 件のチンカスがあります!"
end
execute <<~SQL
DELETE
ts
FROM
tag_similarities ts
INNER JOIN
tags t
ON
t.category = 'nico'
AND (t.id = ts.tag_id
OR t.id = ts.target_tag_id)
SQL
execute <<~SQL
DELETE
tset
FROM
theatre_skip_event_tags tset
INNER JOIN
tags t
ON
t.category = 'nico'
AND t.id = tset.tag_id
SQL
remove_check_constraint :post_versions, name: 'chk_post_versions_tags_json_schema'
say_with_time 'Migrate post_versions.tags_json' do
count = 0
MigrationPostVersion.find_each(batch_size: 500) do |version|
tags = version.tags_json.map do |tag|
if tag.fetch('category') == 'nico'
{ 'external_tag_id' => tag.fetch('id') }
else
{ 'tag_id' => tag.fetch('id'),
'version_no' => tag.fetch('version_no'),
'name' => tag.fetch('name'),
'category' => tag.fetch('category'),
'sections' => tag.fetch('sections') }
end
end
version.update_columns(tags_json: tags)
count += 1
end
count
end
add_tags_json_constraint!
tag_name_ids = connection.select_values(<<~SQL)
SELECT
tag_name_id
FROM
tags
WHERE
category = 'nico'
SQL
connection.transaction do
execute <<~SQL
DELETE
pt
FROM
post_tags pt
INNER JOIN
tags t
ON
t.category = 'nico'
AND t.id = pt.tag_id
SQL
execute <<~SQL
DELETE
FROM
tags
WHERE
category = 'nico'
SQL
unless tag_name_ids.empty?
execute <<~SQL
DELETE
FROM
tag_names
WHERE
id IN (#{ tag_name_ids.join(', ') })
SQL
end
end
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
private
def add_tags_json_constraint!
schema = { type: 'array',
items: { oneOf: [internal_tag_schema, external_tag_schema] } }
quoted_schema = connection.quote(JSON.generate(schema))
add_check_constraint :post_versions,
"JSON_SCHEMA_VALID(#{ quoted_schema }, tags_json)",
name: 'chk_post_versions_tags_json_schema'
end
def internal_tag_schema
{ type: 'object',
properties: { tag_id: { type: 'integer', minimum: 1 },
version_no: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1 },
category: { type: 'string',
enum: ['deerjikist',
'meme',
'character',
'general',
'material',
'meta'] },
sections: sections_schema },
required: ['tag_id', 'version_no', 'sections'],
additionalProperties: false }
end
def external_tag_schema
{ type: 'object',
properties: { external_tag_id: { type: 'integer', minimum: 1 } },
required: ['external_tag_id'],
additionalProperties: false }
end
def sections_schema
{ type: 'array',
items: { type: 'object',
properties: { begin_ms: { type: 'integer', minimum: 0 },
end_ms: { type: ['integer', 'null'], minimum: 0 } },
required: ['begin_ms', 'end_ms'],
additionalProperties: false } }
end
end
生成ファイル
+38 -35
ファイルの表示
@@ -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_09_21_230000) do
ActiveRecord::Schema[8.0].define(version: 2026_07_27_123600) 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
@@ -48,14 +48,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
t.index ["tag_id"], name: "index_deerjikists_on_tag_id"
end
create_table "external_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "platform", limit: 16, null: false
t.string "name", null: false
t.integer "post_count", default: 0, null: false
t.datetime "created_at", null: false
t.index ["platform", "name"], name: "index_external_tags_on_platform_and_name", unique: true
end
create_table "gekanator_ai_runs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "model", null: false
t.integer "input_tokens", default: 0, null: false
@@ -289,13 +281,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end
create_table "post_external_tags", primary_key: ["post_id", "external_tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "external_tag_id", null: false
t.datetime "created_at", null: false
t.index ["external_tag_id"], name: "index_post_external_tags_on_external_tag_id"
end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "parent_post_id", null: false
@@ -326,12 +311,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end
create_table "post_tags", primary_key: ["post_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
t.bigint "created_user_id"
t.bigint "deleted_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.virtual "is_active", type: :boolean, as: "(`discarded_at` is null)", stored: true
t.virtual "active_unique_key", type: :string, as: "(case when (`discarded_at` is null) then concat(`post_id`,_utf8mb4':',`tag_id`) else NULL end)", stored: true
t.index ["active_unique_key"], name: "idx_post_tags_active_unique", unique: true
t.index ["created_user_id"], name: "index_post_tags_on_created_user_id"
t.index ["deleted_user_id"], name: "index_post_tags_on_deleted_user_id"
t.index ["discarded_at"], name: "index_post_tags_on_discarded_at"
t.index ["post_id", "discarded_at"], name: "index_post_tags_on_post_id_and_discarded_at"
t.index ["post_id"], name: "index_post_tags_on_post_id"
t.index ["tag_id", "discarded_at"], name: "index_post_tags_on_tag_id_and_discarded_at"
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
end
@@ -367,7 +363,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"oneOf\":[{\"type\":\"object\",\"properties\":{\"tag_id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"tag_id\",\"version_no\",\"sections\"],\"additionalProperties\":false},{\"type\":\"object\",\"properties\":{\"external_tag_id\":{\"type\":\"integer\",\"minimum\":1}},\"required\":[\"external_tag_id\"],\"additionalProperties\":false}]}}',`tags_json`)", name: "chk_post_versions_tags_json_schema"
t.check_constraint "json_schema_valid(_utf8mb4'{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\",\"minimum\":1},\"version_no\":{\"type\":\"integer\",\"minimum\":1},\"name\":{\"type\":\"string\",\"minLength\":1},\"category\":{\"type\":\"string\",\"enum\":[\"deerjikist\",\"meme\",\"character\",\"general\",\"material\",\"meta\",\"nico\"]},\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"begin_ms\":{\"type\":\"integer\",\"minimum\":0},\"end_ms\":{\"type\":[\"integer\",\"null\"],\"minimum\":0}},\"required\":[\"begin_ms\",\"end_ms\"],\"additionalProperties\":false}}},\"required\":[\"id\",\"version_no\",\"name\",\"category\",\"sections\"],\"additionalProperties\":false}}',`tags_json`)", name: "chk_post_versions_tags_json_schema"
end
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -399,6 +395,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
t.index ["user_id"], name: "index_settings_on_user_id", unique: true
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "tag_implications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_id", null: false
t.bigint "parent_tag_id", null: false
@@ -424,7 +433,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
t.bigint "canonical_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.index ["canonical_id"], name: "index_tag_names_on_canonical_id"
t.index ["discarded_at"], name: "index_tag_names_on_discarded_at"
t.index ["name"], name: "index_tag_names_on_name", unique: true
end
@@ -461,8 +472,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false
t.datetime "discarded_at"
t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
@@ -603,19 +616,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
t.index ["banned_at"], name: "index_users_on_banned_at"
end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false
t.text "body", null: false
@@ -709,25 +709,27 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
add_foreign_key "material_sync_suppressions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
add_foreign_key "material_versions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "users", column: "updated_by_user_id"
add_foreign_key "materials", "materials", column: "parent_id"
add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "external_tags", column: "nico_tag_id"
add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "nico_tag_versions", "tags"
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
add_foreign_key "post_external_tags", "external_tags"
add_foreign_key "post_external_tags", "posts"
add_foreign_key "post_implications", "posts"
add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "post_tags", column: ["post_id", "tag_id"], primary_key: ["post_id", "tag_id"], on_delete: :cascade
add_foreign_key "post_tag_sections", "posts"
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_tags", "users", column: "deleted_user_id"
add_foreign_key "post_versions", "posts"
add_foreign_key "post_versions", "users", column: "created_by_user_id"
add_foreign_key "posts", "users", column: "uploaded_user_id"
@@ -737,6 +739,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) do
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
add_foreign_key "tag_similarities", "tags"
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
add_foreign_key "tag_versions", "tags"
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres"
+33 -57
ファイルの表示
@@ -16,7 +16,7 @@ namespace :nico do
end
def sync_post_tags! post, desired_tag_ids, current_tag_ids: nil
current_tag_ids ||= PostTag.where(post_id: post.id).pluck(:tag_id).to_set
current_tag_ids ||= PostTag.kept.where(post_id: post.id).pluck(:tag_id).to_set
desired_tag_ids = desired_tag_ids.compact.to_set
to_add = desired_tag_ids - current_tag_ids
@@ -30,28 +30,9 @@ namespace :nico do
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).find_each(&:destroy!)
end
def sync_post_external_tags! post, desired_external_tag_ids, current_external_tag_ids: nil
current_external_tag_ids ||=
PostExternalTag.where(post_id: post.id).pluck(:external_tag_id).to_set
desired_external_tag_ids = desired_external_tag_ids.compact.to_set
to_add = desired_external_tag_ids - current_external_tag_ids
to_remove = current_external_tag_ids - desired_external_tag_ids
ExternalTag.where(id: to_add.to_a).find_each do |external_tag|
begin
PostExternalTag.create!(post:, external_tag:)
rescue ActiveRecord::RecordNotUnique
;
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
end
PostExternalTag
.where(post_id: post.id, external_tag_id: to_remove.to_a)
.find_each(&:destroy!)
end
mysql_user = ENV['MYSQL_USER']
@@ -130,63 +111,58 @@ namespace :nico do
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
end
tags = post.tags
# 既存のタグ Id. 集合
kept_tag_ids = post.tags.pluck(:id).to_set
# 既存の外部タグ Id. 集合
kept_external_tag_ids = post.external_tags.nico.pluck(:id).to_set
# 記載すべき外部タグ Id. のリスト
desired_external_tag_ids = []
kept_tag_ids = tags.pluck(:id).to_set
# うち内部タグ Id. 集合
kept_non_nico_tag_ids = tags.not_nico.pluck(:id).to_set
# 記載すべき外部タグ Id. および連携される内部タグ Id. のリスト
desired_nico_tag_based_ids = []
# 記載すべき内部タグ Id. のリスト
desired_tag_ids = kept_tag_ids.to_a
desired_non_nico_tag_ids = []
datum['tags'].each do |raw|
tag = ExternalTag.find_or_create_by!(platform: :nico, name: raw)
name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
unless tag.nico_tag_versions.exists?
NicoTagVersionRecorder.record!(external_tag: tag,
event_type: :create,
created_by_user: nil)
end
event_type = tag.nico_tag_versions.exists? ? :update : :create
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil)
desired_external_tag_ids << tag.id
desired_nico_tag_based_ids << tag.id
# 新たに記載される外部タグと連携される内部タグを記載
# 連携タグは記載すれども消除せず.
unless tag.id.in?(kept_external_tag_ids)
desired_tag_ids.concat(tag.linked_tags.pluck(:id))
unless tag.id.in?(kept_tag_ids)
linked_ids = tag.linked_tags.pluck(:id)
desired_non_nico_tag_ids.concat(linked_ids)
desired_nico_tag_based_ids.concat(linked_ids)
end
end
deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user'])
if deerjikist
desired_tag_ids << deerjikist.tag_id
elsif !(Tag.where(id: kept_tag_ids).where(category: :deerjikist).exists?)
desired_tag_ids << Tag.no_deerjikist.id
desired_non_nico_tag_ids << deerjikist.tag_id
desired_nico_tag_based_ids << deerjikist.tag_id
elsif !(Tag.where(id: kept_non_nico_tag_ids).where(category: :deerjikist).exists?)
desired_non_nico_tag_ids << Tag.no_deerjikist.id
desired_nico_tag_based_ids << Tag.no_deerjikist.id
end
desired_external_tag_ids.uniq!
desired_tag_ids.uniq!
desired_nico_tag_based_ids.uniq!
# 外部タグの記載に際しては “bot 操作” タグを記載しなぃ.
if kept_tag_ids != desired_tag_ids.to_set
desired_tag_ids << Tag.bot.id
desired_tag_ids.uniq!
desired_all_tag_ids = kept_non_nico_tag_ids.to_a + desired_nico_tag_based_ids
desired_non_nico_tag_ids.concat(kept_non_nico_tag_ids.to_a)
desired_non_nico_tag_ids.uniq!
if kept_non_nico_tag_ids != desired_non_nico_tag_ids.to_set
desired_all_tag_ids << Tag.bot.id
end
desired_all_tag_ids.uniq!
tags_changed =
kept_tag_ids != desired_tag_ids.to_set ||
kept_external_tag_ids != desired_external_tag_ids.to_set
sync_post_tags!(post, desired_tag_ids, current_tag_ids: kept_tag_ids)
sync_post_external_tags!(post, desired_external_tag_ids,
current_external_tag_ids: kept_external_tag_ids)
sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids)
if post_created
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
elsif post_changed || tags_changed
elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end
-123
ファイルの表示
@@ -1,123 +0,0 @@
require 'rails_helper'
require_relative '../../db/migrate/20260921020000_delete_discarded_records_from_tags'
require_relative '../../db/migrate/20260921030000_delete_discarded_records_from_tag_names'
RSpec.describe 'discarded tag cleanup migrations' do
[DeleteDiscardedRecordsFromTags, DeleteDiscardedRecordsFromTagNames].each do |migration_class|
it "rejects rollback of #{ migration_class.name }" do
expect { migration_class.new.down }
.to raise_error(ActiveRecord::IrreversibleMigration)
end
end
context 'with legacy records' do
self.use_transactional_tests = false
before do
record_class = Class.new(ActiveRecord::Base) do
self.abstract_class = true
end
stub_const('TagCleanupMigrationRecord', record_class)
config = ActiveRecord::Base.connection_db_config.configuration_hash
@database = "btrc_hub_test_tag_cleanup_#{ Process.pid }_#{ SecureRandom.hex(4) }"
record_class.establish_connection(config.merge(database: nil))
@connection = record_class.lease_connection
@connection.create_database(@database)
@database_created = true
@connection.execute("USE #{ @connection.quote_table_name(@database) }")
end
after do
@connection.drop_database(@database) if @database_created
ensure
TagCleanupMigrationRecord.remove_connection
end
before do
@connection.create_table(:tag_names) do |t|
t.string :name, null: false, index: { unique: true }
t.bigint :canonical_id
t.datetime :discarded_at, index: true
end
@connection.add_foreign_key(:tag_names, :tag_names, column: :canonical_id)
@connection.create_table(:tags) do |t|
t.references :tag_name, null: false, foreign_key: true, index: { unique: true }
t.datetime :discarded_at, index: true
end
@connection.create_table(:nico_tag_relations) do |t|
t.references :tag, null: false, foreign_key: true
t.references :nico_tag, null: false, foreign_key: { to_table: :tags }
end
@connection.create_table(:tag_implications) do |t|
t.references :tag, null: false, foreign_key: true
t.references :parent_tag, null: false, foreign_key: { to_table: :tags }
end
[:tag_versions, :nico_tag_versions, :material_versions].each do |table|
@connection.create_table(table) do |t|
t.references :tag, null: false, foreign_key: true
end
end
@connection.execute(<<~SQL)
INSERT INTO tag_names (id, name, canonical_id, discarded_at) VALUES
(1, 'kept', NULL, NULL),
(2, 'merged_alias', 1, NULL),
(3, 'nico:deleted', NULL, '2026-09-20'),
(4, 'nico:kept', NULL, NULL),
(5, 'deleted_name', NULL, '2026-09-20')
SQL
@connection.execute(<<~SQL)
INSERT INTO tags (id, tag_name_id, discarded_at) VALUES
(1, 1, NULL), (2, 2, '2026-09-20'),
(3, 3, '2026-09-20'), (4, 4, NULL)
SQL
@connection.execute(<<~SQL)
INSERT INTO nico_tag_relations (id, tag_id, nico_tag_id) VALUES
(1, 1, 4), (2, 2, 4), (3, 1, 3), (4, 2, 3)
SQL
@connection.execute(<<~SQL)
INSERT INTO tag_implications (id, tag_id, parent_tag_id) VALUES
(1, 1, 4), (2, 2, 1), (3, 1, 2), (4, 2, 3)
SQL
@connection.execute('INSERT INTO tag_versions (tag_id) VALUES (1), (2)')
@connection.execute('INSERT INTO nico_tag_versions (tag_id) VALUES (3), (4)')
@connection.execute('INSERT INTO material_versions (tag_id) VALUES (1), (2)')
end
it 'removes discarded records and their links while retaining aliases and history' do
[DeleteDiscardedRecordsFromTags, DeleteDiscardedRecordsFromTagNames].each do |klass|
migration = klass.new
allow(migration).to receive(:connection).and_return(@connection)
migration.suppress_messages { migration.up }
end
expect(@connection.select_values('SELECT id FROM tags ORDER BY id')).to eq([1, 4])
expect(@connection.select_rows('SELECT id, canonical_id FROM tag_names ORDER BY id'))
.to eq([[1, nil], [2, 1], [4, nil]])
expect(@connection.select_values('SELECT id FROM nico_tag_relations')).to eq([1])
expect(@connection.select_values('SELECT id FROM tag_implications')).to eq([1])
expect(@connection.select_values('SELECT tag_id FROM tag_versions ORDER BY tag_id'))
.to eq([1, 2])
expect(@connection.select_values('SELECT tag_id FROM nico_tag_versions ORDER BY tag_id'))
.to eq([3, 4])
expect(@connection.select_values('SELECT tag_id FROM material_versions ORDER BY tag_id'))
.to eq([1, 2])
[:tags, :tag_names].each do |table|
expect(@connection.column_exists?(table, :discarded_at)).to be(false)
expect(@connection.index_exists?(table, :discarded_at)).to be(false)
end
[:tag_versions, :nico_tag_versions, :material_versions].each do |table|
expect(@connection.foreign_key_exists?(table, :tags, column: :tag_id)).to be(false)
end
expect(@connection.foreign_key_exists?(:tags, :tag_names)).to be(true)
expect(@connection.foreign_key_exists?(:tag_names, :tag_names, column: :canonical_id))
.to be(true)
expect(@connection.index_exists?(:tag_names, :name, unique: true)).to be(true)
expect(@connection.index_exists?(:tags, :tag_name_id, unique: true)).to be(true)
[:nico_tag_relations, :tag_implications].each do |table|
expect(@connection.foreign_keys(table).map(&:to_table)).to eq(['tags', 'tags'])
end
end
end
end
-191
ファイルの表示
@@ -1,191 +0,0 @@
require 'rails_helper'
require_relative '../../db/migrate/20260921040000_create_external_tags'
require_relative '../../db/migrate/20260921050000_create_post_external_tags'
require_relative '../../db/migrate/20260921060000_change_foreign_key_on_nico_tag_relations'
require_relative '../../db/migrate/20260921230000_migrate_external_tags'
RSpec.describe 'external tag migrations' do
self.use_transactional_tests = false
before do
record_class = Class.new(ActiveRecord::Base) do
self.abstract_class = true
end
stub_const('ExternalTagMigrationRecord', record_class)
config = ActiveRecord::Base.connection_db_config.configuration_hash
@database = "btrc_hub_test_external_tags_#{ Process.pid }_#{ SecureRandom.hex(4) }"
record_class.establish_connection(config.merge(database: nil))
@connection = record_class.lease_connection
@connection.create_database(@database)
@database_created = true
@connection.execute("USE #{ @connection.quote_table_name(@database) }")
version_class = Class.new(record_class) do
self.table_name = 'post_versions'
end
stub_const('MigrateExternalTags::MigrationPostVersion', version_class)
create_legacy_tables
seed_legacy_records
end
after do
@connection.drop_database(@database) if @database_created
ensure
ExternalTagMigrationRecord.remove_connection
end
def migrate klass
migration = klass.new
allow(migration).to receive(:connection).and_return(@connection)
migration.suppress_messages { migration.migrate(:up) }
end
def create_legacy_tables
@connection.create_table(:tag_names) { |t| t.string :name }
@connection.create_table(:tags) do |t|
t.references :tag_name, foreign_key: true
t.string :category
t.integer :post_count
t.datetime :created_at
end
@connection.create_table(:posts)
@connection.create_table(:post_tags) do |t|
t.references :post, foreign_key: true
t.references :tag, foreign_key: true
t.datetime :created_at
end
@connection.create_table(:nico_tag_versions) do |t|
t.bigint :tag_id
t.integer :version_no
t.string :name
t.datetime :created_at
end
@connection.create_table(:nico_tag_relations) do |t|
t.references :tag, foreign_key: true
t.references :nico_tag, foreign_key: { to_table: :tags }
end
[:post_tag_sections, :materials, :theatre_skip_event_tags].each do |table|
@connection.create_table(table) { |t| t.references :tag, foreign_key: true }
end
@connection.create_table(:tag_implications) do |t|
t.references :tag, foreign_key: true
t.references :parent_tag, foreign_key: { to_table: :tags }
end
@connection.create_table(:tag_similarities) do |t|
t.references :tag, foreign_key: true
t.references :target_tag, foreign_key: { to_table: :tags }
end
@connection.create_table(:post_versions) { |t| t.json :tags_json, null: false }
@connection.add_check_constraint(:post_versions, 'JSON_VALID(tags_json)',
name: 'chk_post_versions_tags_json_schema')
end
def seed_legacy_records
@connection.execute(<<~SQL)
INSERT INTO tag_names (id, name) VALUES (1, 'internal'), (2, 'nico:raw tag[]');
SQL
@connection.execute(<<~SQL)
INSERT INTO tags (id, tag_name_id, category, post_count, created_at) VALUES
(1, 1, 'general', 1, '2026-09-01'), (2, 2, 'nico', 1, '2026-09-02')
SQL
@connection.execute('INSERT INTO posts (id) VALUES (1)')
@connection.execute(<<~SQL)
INSERT INTO post_tags (post_id, tag_id, created_at) VALUES
(1, 1, '2026-09-03'), (1, 2, '2026-09-04')
SQL
@connection.execute(<<~SQL)
INSERT INTO nico_tag_versions (tag_id, version_no, name, created_at) VALUES
(2, 1, 'nico:raw tag[]', '2026-09-02'),
(3, 1, 'nico:deleted', '2026-09-01'),
(3, 2, 'nico:deleted_later', '2026-09-02')
SQL
@connection.execute('INSERT INTO nico_tag_relations (tag_id, nico_tag_id) VALUES (1, 2)')
@connection.execute(<<~SQL)
INSERT INTO tag_similarities (tag_id, target_tag_id) VALUES (1, 1), (1, 2), (2, 1)
SQL
@connection.execute('INSERT INTO theatre_skip_event_tags (tag_id) VALUES (1), (2)')
@legacy_internal = { 'id' => 1, 'version_no' => 2,
'name' => 'internal', 'category' => 'general',
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => nil }] }
snapshots = [@legacy_internal,
{ 'id' => 2, 'category' => 'nico' },
{ 'id' => 3, 'category' => 'nico' }]
@connection.execute(<<~SQL)
INSERT INTO post_versions (tags_json) VALUES
(#{ @connection.quote(snapshots.to_json) }), ('[]')
SQL
end
def prepare_external_tables
migrate(CreateExternalTags)
migrate(CreatePostExternalTags)
migrate(ChangeForeignKeyOnNicoTagRelations)
end
it 'preserves ids, raw names, counts, timestamps, post links and historical-only tags' do
prepare_external_tables
rows = @connection.select_rows(<<~SQL)
SELECT id, platform, name, post_count, DATE_FORMAT(created_at, '%Y-%m-%d')
FROM external_tags ORDER BY id
SQL
expect(rows).to eq([
[2, 'nico', 'raw tag[]', 1, '2026-09-02'],
[3, 'nico', 'deleted', 0, '2026-09-01']])
expect(@connection.select_rows(<<~SQL)).to eq([[1, 2, '2026-09-04']])
SELECT post_id, external_tag_id, DATE_FORMAT(created_at, '%Y-%m-%d')
FROM post_external_tags
SQL
expect(@connection.foreign_key_exists?(:nico_tag_relations, :external_tags,
column: :nico_tag_id)).to be(true)
expect(@connection.foreign_key_exists?(:nico_tag_relations, :tags,
column: :tag_id)).to be(true)
end
it 'converts snapshots and removes only obsolete internal rows and derived links' do
prepare_external_tables
migrate(MigrateExternalTags)
expect(@connection.select_values('SELECT id FROM tags')).to eq([1])
expect(@connection.select_values('SELECT id FROM tag_names')).to eq([1])
expect(@connection.select_values('SELECT tag_id FROM post_tags')).to eq([1])
expect(@connection.select_rows('SELECT tag_id, nico_tag_id FROM nico_tag_relations'))
.to eq([[1, 2]])
expect(@connection.select_rows('SELECT tag_id, target_tag_id FROM tag_similarities'))
.to eq([[1, 1]])
expect(@connection.select_values('SELECT tag_id FROM theatre_skip_event_tags')).to eq([1])
expect(@connection.select_value('SELECT COUNT(*) FROM nico_tag_versions')).to eq(3)
versions = MigrateExternalTags::MigrationPostVersion.order(:id)
expect(versions.first.tags_json).to eq([
@legacy_internal.except('id').merge('tag_id' => 1),
{ 'external_tag_id' => 2 }, { 'external_tag_id' => 3 }])
expect(versions.last.tags_json).to eq([])
invalid_snapshots = [@legacy_internal,
{ 'external_tag_id' => 0 },
{ 'external_tag_id' => 2, 'tag_id' => 1 },
{ 'external_tag_id' => 2, 'name' => 'extra' },
{ 'tag_id' => 1, 'version_no' => 1 }]
invalid_snapshots.each do |snapshot|
expect {
MigrateExternalTags::MigrationPostVersion.create!(tags_json: [snapshot])
}.to raise_error(ActiveRecord::StatementInvalid, /check constraint/i)
end
expect { MigrateExternalTags.new.down }
.to raise_error(ActiveRecord::IrreversibleMigration)
end
{ post_tag_sections: '(tag_id) VALUES (2)',
materials: '(tag_id) VALUES (2)',
tag_implications: '(tag_id, parent_tag_id) VALUES (1, 2)' }.each do |table, values|
it "aborts before deleting data when #{ table } references a legacy nico tag" do
prepare_external_tables
@connection.execute("INSERT INTO #{ table } #{ values }")
expect { migrate(MigrateExternalTags) }.to raise_error(RuntimeError, /#{ table }/)
expect(@connection.select_values('SELECT id FROM tags ORDER BY id')).to eq([1, 2])
expect(MigrateExternalTags::MigrationPostVersion.first.tags_json.first)
.to eq(@legacy_internal)
end
end
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
FactoryBot.define do
factory :external_tag do
platform { :nico }
sequence(:name) { |n| "external_tag_#{ n }" }
end
end
+7
ファイルの表示
@@ -11,5 +11,12 @@ FactoryBot.define do
after(:build) do |tag, evaluator|
tag.name = evaluator.name if evaluator.name.present?
end
trait :nico do
category { :nico }
transient do
name { "nico:#{ SecureRandom.hex(4) }" }
end
end
end
end
-38
ファイルの表示
@@ -1,38 +0,0 @@
require 'rails_helper'
RSpec.describe ExternalTag, type: :model do
it 'preserves names without internal tag sanitisation or TagName records' do
external = nil
expect {
external = described_class.create!(platform: :nico, name: 'raw tag[]')
}.not_to change(TagName, :count)
expect(external.reload.name).to eq('raw tag[]')
end
it 'deletes post associations without deleting posts or version 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(PostExternalTag.where(external_tag_id: external.id)).to be_empty
expect(post.reload.external_tags).to be_empty
expect(version.reload.tag_id).to eq(external.id)
end
it 'deletes external links without deleting linked internal tags' do
external = create(:external_tag)
tag = create(:tag)
NicoTagRelation.create!(nico_tag: external, tag:)
external.destroy!
expect(NicoTagRelation.where(nico_tag_id: external.id)).to be_empty
expect(Tag.exists?(tag.id)).to be(true)
end
end
-27
ファイルの表示
@@ -1,27 +0,0 @@
require 'rails_helper'
RSpec.describe NicoTagRelation, type: :model do
it 'does not constrain the ExternalTag association to the nico platform' do
external_tag = create(:external_tag)
tag = create(:tag)
allow(external_tag).to receive(:platform).and_return('registered_external')
allow(external_tag).to receive(:nico?).and_return(false)
expect {
described_class.create!(nico_tag: external_tag, tag:)
}.to change(described_class, :count).by(1)
expect(tag.linked_nico_tags).to contain_exactly(external_tag)
end
it 'rejects an internal Tag through the external association type' do
expect {
described_class.new(nico_tag: create(:tag), tag: create(:tag))
}.to raise_error(ActiveRecord::AssociationTypeMismatch)
end
it 'rejects an ExternalTag through the internal association type' do
expect {
described_class.new(nico_tag: create(:external_tag), tag: create(:external_tag))
}.to raise_error(ActiveRecord::AssociationTypeMismatch)
end
end
-25
ファイルの表示
@@ -1,25 +0,0 @@
require 'rails_helper'
RSpec.describe PostExternalTag, type: :model do
it 'exposes both sides and enforces a unique post and external tag pair' do
post = create(:post)
external_tag = create(:external_tag)
described_class.create!(post:, external_tag:)
expect(post.external_tags).to contain_exactly(external_tag)
expect(external_tag.posts).to contain_exactly(post)
expect { described_class.create!(post:, external_tag:) }
.to raise_error(ActiveRecord::RecordNotUnique)
end
it 'removes associations when the post is deleted while retaining the external tag' do
post = create(:post)
external_tag = create(:external_tag)
described_class.create!(post:, external_tag:)
post.destroy!
expect(described_class.where(post_id: post.id)).to be_empty
expect(external_tag.reload.posts).to be_empty
end
end
-19
ファイルの表示
@@ -6,25 +6,6 @@ RSpec.describe Post, type: :model do
PostUrlSanitisationRule.unscoped.delete_all
end
describe '#snapshot_tags_json' do
it 'keeps internal snapshots and external identifiers distinct, even with the same id' do
post = create(:post)
tag = create(:tag)
external = create(:external_tag, id: tag.id)
create(:post_tag, post:, tag:)
create(:post_tag_section, post:, tag:, begin_ms: 2000, end_ms: nil)
PostExternalTag.create!(post:, external_tag: external)
expect(post.snapshot_tags_json).to eq([
{ 'tag_id' => tag.id,
'version_no' => tag.version_no,
'name' => tag.name,
'category' => tag.category,
'sections' => [{ 'begin_ms' => 2000, 'end_ms' => nil }] },
{ 'external_tag_id' => external.id }])
end
end
describe 'URL normalisation' do
it 'normalises the HTTP URL before applying sanitisation rules' do
PostUrlSanitisationRule.create!(
+4 -79
ファイルの表示
@@ -1,73 +1,5 @@
require 'rails_helper'
RSpec.describe PostTag, type: :model do
describe 'uniqueness' do
it 'rejects duplicate post and tag pairs but allows either to be reused' do
post_tag = create(:post_tag)
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect(duplicate).not_to be_valid
expect(duplicate.errors.of_kind?(:post_id, :taken)).to be(true)
expect(build(:post_tag, post: post_tag.post, tag: create(:tag))).to be_valid
expect(build(:post_tag, post: create(:post), tag: post_tag.tag)).to be_valid
end
it 'enforces uniqueness in the database when validation is bypassed' do
post_tag = create(:post_tag)
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect { duplicate.save!(validate: false) }
.to raise_error(ActiveRecord::RecordNotUnique)
end
end
describe '#destroy!' do
it 'deletes only the selected pair and its sections and updates the counter' do
post_tag = create(:post_tag)
same_post = create(:post_tag, post: post_tag.post)
same_tag = create(:post_tag, tag: post_tag.tag)
sections = [post_tag, same_post, same_tag].map do |link|
create(:post_tag_section, post: link.post, tag: link.tag,
begin_ms: 1000, end_ms: 2000)
end
expect { post_tag.destroy! }.to change(described_class, :count).by(-1)
.and change(PostTagSection, :count).by(-1)
.and change { post_tag.tag.reload.post_count }.from(2).to(1)
expect(described_class.exists?(post: post_tag.post, tag: post_tag.tag)).to be(false)
expect(same_post.reload).to be_persisted
expect(same_tag.reload).to be_persisted
expect(PostTagSection.all).to contain_exactly(*sections.drop(1))
expect(post_tag.post.reload.tags).to contain_exactly(same_post.tag)
expect(post_tag.tag.reload.posts).to contain_exactly(same_tag.post)
end
it 'allows a removed tag to be added again without restoring old sections' do
post_tag = create(:post_tag)
create(:post_tag_section, post: post_tag.post, tag: post_tag.tag,
begin_ms: 1000, end_ms: 2000)
post_tag.destroy!
replacement = create(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect(replacement.reload.sections).to be_empty
expect(replacement.tag.reload.post_count).to eq(1)
end
end
describe '#sections' do
it 'loads the owning post_tag from a section using both keys' do
post_tag = create(:post_tag)
create(:post_tag, post: post_tag.post)
create(:post_tag, tag: post_tag.tag)
section = create(:post_tag_section, post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000, end_ms: 2000)
expect(section.reload.post_tag).to eq(post_tag)
end
it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
@@ -80,25 +12,18 @@ RSpec.describe PostTag, type: :model do
end
it 'does not load sections for another tag on the same post' do
post_tag = create(:post_tag)
post = post_tag.post
post = create(:post)
tag = create(:tag)
other_tag = create(:tag)
own_section = create(:post_tag_section,
post:,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: 2000)
create(:post_tag, post:, tag: other_tag)
post_tag = create(:post_tag, post:, tag:)
create(:post_tag_section,
post:,
tag: other_tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.reload.sections).to contain_exactly(own_section)
expect(post_tag.sections).to be_empty
end
it 'allows open-ended sections' do
+6 -28
ファイルの表示
@@ -57,7 +57,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do
it 'deletes the source tag_name' do
described_class.apply!
expect(TagName.unscoped.exists?(source.id)).to be(false)
expect(TagName.exists?(source.id)).to be(false)
expect(existing.reload.name).to eq('foobar')
end
end
@@ -75,27 +75,7 @@ RSpec.describe TagNameSanitisationRule, type: :model do
described_class.apply!
expected_tag_name_id = existing.canonical_id || existing.id
expect(source_tag.reload.tag_name_id).to eq(expected_tag_name_id)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
end
end
context 'when the sanitised name is an alias of an existing tag' do
let!(:existing_tag) { create(:tag) }
let!(:alias_name) do
TagName.create!(name: 'foobar', canonical: existing_tag.tag_name)
end
let!(:source) do
TagName.create!(name: 'tmp').tap do |tn|
tn.update_columns(name: 'foo_bar', updated_at: Time.current)
end
end
it 'deletes only the source and preserves the alias and its canonical tag' do
described_class.apply!
expect(TagName.unscoped.exists?(source.id)).to be(false)
expect(alias_name.reload.canonical).to eq(existing_tag.tag_name)
expect(Tag.find(existing_tag.id)).to eq(existing_tag)
expect(TagName.exists?(source_tag_name_id)).to be(false)
end
end
@@ -112,15 +92,13 @@ RSpec.describe TagNameSanitisationRule, type: :model do
end
it 'merges the source tag into the existing tag and deletes the source tag_name' do
post = create(:post)
PostTag.create!(post:, tag: source_tag)
expect(TagName.find_by(name: 'foobar')&.tag&.id).to eq(existing_tag.id)
expect(TagName.find_by(name: 'foo_bar')&.tag&.id).to eq(source_tag.id)
described_class.apply!
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
expect(post.reload.tags).to contain_exactly(existing_tag)
expect(existing_tag.reload.name).to eq('foobar')
expect(Tag.exists?(source_tag.id)).to be(false)
expect(TagName.exists?(source_tag.tag_name_id)).to be(false)
end
end
end
+67 -171
ファイルの表示
@@ -1,29 +1,6 @@
require 'rails_helper'
RSpec.describe Tag, type: :model do
describe 'external tag separation' do
['nico:reserved', 'NiCo:reserved'].each do |name|
it "rejects the reserved prefix #{ name } for internal tags" do
tag = build(:tag, name:)
expect(tag).to be_invalid
expect(tag.errors[:name]).to be_present
expect {
described_class.normalise_tags!([name])
}.to raise_error(Tag::NicoTagNormalisationError)
end
end
it 'finds external links by the internal tag id even when ids differ' do
tag = create(:tag)
external = create(:external_tag, id: tag.id + 10_000)
# The migration retains existing links without running model validation.
NicoTagRelation.insert_all!([{ tag_id: tag.id, nico_tag_id: external.id }])
expect(tag.linked_nico_tags).to contain_exactly(external)
end
end
describe '.normalise_tags!' do
it 'rejects deprecated tags when deny_deprecated is enabled' do
tag_name = TagName.create!(name: 'normalise deprecated tag')
@@ -182,44 +159,17 @@ RSpec.describe Tag, type: :model do
end
end
describe '.find_or_create_by_tag_name!' do
it 'creates a tag and name with the requested category after stripping whitespace' do
tag = nil
describe 'deprecated validation' do
it 'rejects deprecated nico tags' do
tag = build(
:tag,
name: 'nico:deprecated_validation',
category: :nico,
deprecated_at: Time.current
)
expect {
tag = described_class.find_or_create_by_tag_name!(
' lookup_new ', category: :character)
}.to change(Tag, :count).by(1).and change(TagName, :count).by(1)
expect(tag.name).to eq('lookup_new')
expect(tag.category).to eq('character')
end
it 'reuses the canonical tag for an alias without changing its category' do
tag = create(:tag, category: :character)
alias_name = TagName.create!(name: 'lookup_alias', canonical: tag.tag_name)
found = nil
expect {
found = described_class.find_or_create_by_tag_name!(
alias_name.name, category: :general)
}.to change(Tag, :count).by(0).and change(TagName, :count).by(0)
expect(found).to eq(tag)
expect(found.category).to eq('character')
end
it 'creates a tag for an existing canonical name reached through an alias' do
canonical = create(:tag_name)
alias_name = TagName.create!(name: 'lookup_alias', canonical:)
tag = nil
expect {
tag = described_class.find_or_create_by_tag_name!(
alias_name.name, category: :general)
}.to change(Tag, :count).by(1).and change(TagName, :count).by(0)
expect(tag.tag_name).to eq(canonical)
expect(tag).not_to be_valid
expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.')
end
end
@@ -235,14 +185,18 @@ RSpec.describe Tag, type: :model do
context 'when merging a simple source tag' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'deletes the source tag, moves its post link, and keeps its name as an alias' do
it 'discards the source post_tag, creates an active target post_tag, discards the source tag, and aliases the source tag_name' do
described_class.merge_tags!(target_tag, [source_tag])
target_link = PostTag.find_by(post: post_record, tag: target_tag)
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1)
end
@@ -252,101 +206,38 @@ RSpec.describe Tag, type: :model do
let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) }
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'deletes the source link and preserves the existing target link' do
create(:post_tag_section, post: post_record, tag: source_tag,
begin_ms: 1000, end_ms: 2000)
target_section = create(:post_tag_section, post: post_record,
tag: target_tag,
begin_ms: 3000, end_ms: nil)
it 'discards the source post_tag, keeps one active target post_tag, discards the source tag, and aliases the source tag_name' do
described_class.merge_tags!(target_tag, [source_tag])
target_links = PostTag.where(post: post_record, tag: target_tag)
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active = PostTag.kept.where(post_id: post_record.id, tag_id: target_tag.id)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_links).to contain_exactly(target_post_tag)
expect(PostTagSection.where(post: post_record, tag: source_tag)).to be_empty
expect(target_post_tag.reload.sections).to contain_exactly(target_section)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active.count).to eq(1)
expect(active.first.id).to eq(target_post_tag.id)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1)
end
end
it 'keeps source history and records the new target alias after deleting the source' do
user = create_member_user!
source_name = source_tag.name
source_alias = TagName.create!(name: 'merge_alias', canonical: source_tag_name)
TagVersioning.ensure_snapshot!(source_tag, created_by_user: user)
original_version = source_tag.tag_versions.first
described_class.merge_tags!(target_tag, [source_tag], created_by_user: user)
versions = TagVersion.where(tag_id: source_tag.id).order(:version_no)
expect(versions.pluck(:version_no, :event_type))
.to eq([[1, 'create'], [2, 'discard']])
expect(versions.first).to eq(original_version)
expect(versions.last).to have_attributes(
name: source_name, aliases: source_alias.name, created_by_user: user)
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
target_versions = target_tag.tag_versions.order(:version_no)
expect(target_versions.pluck(:event_type)).to eq(['create', 'update'])
expect(target_versions.last.aliases.split).to eq([source_name])
end
it 'deletes source relationships while preserving unrelated relationships' do
parent = create(:tag)
child = create(:tag)
nico_tag = create(:external_tag)
TagImplication.create!(tag: source_tag, parent_tag: parent)
TagImplication.create!(tag: child, parent_tag: source_tag)
kept_implication = TagImplication.create!(tag: target_tag, parent_tag: parent)
NicoTagRelation.create!(tag: source_tag, nico_tag:)
kept_relation = NicoTagRelation.create!(tag: target_tag, nico_tag:)
TagSimilarity.create!(tag: source_tag, target_tag:, cos: 0.5)
TagSimilarity.create!(tag: target_tag, target_tag: source_tag, cos: 0.5)
kept_similarity = TagSimilarity.create!(tag: target_tag, target_tag: parent,
cos: 0.5)
described_class.merge_tags!(target_tag, [source_tag])
expect(TagImplication.all).to contain_exactly(kept_implication)
expect(NicoTagRelation.all).to contain_exactly(kept_relation)
expect(TagSimilarity.all).to contain_exactly(kept_similarity)
expect(TagVersion.where(tag_id: source_tag.id).order(:version_no).last.parent_tag_ids)
.to eq(parent.id.to_s)
end
it 'preserves material history referencing the deleted source tag' do
source_tag.update!(category: :material)
target_tag.update!(category: :material)
material = Material.create!(tag: source_tag, url: 'https://example.com/material')
version = MaterialVersionRecorder.record!(
material:, event_type: :create, created_by_user: nil)
material.update!(tag: target_tag)
described_class.merge_tags!(target_tag, [source_tag])
expect(version.reload).to have_attributes(
tag_id: source_tag.id, tag_name: source_tag_name.name, tag_category: 'material')
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(material.reload.tag).to eq(target_tag)
end
context 'when source_tags includes the target itself' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'ignores the target in source_tags while still merging the source tag' do
described_class.merge_tags!(target_tag, [source_tag, target_tag])
target_link = PostTag.find_by(post: post_record, tag: target_tag)
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
expect(Tag.find(target_tag.id)).to be_present
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1)
end
@@ -369,16 +260,18 @@ RSpec.describe Tag, type: :model do
)
end
it 'still merges and keeps the source name as an alias without validating it' do
it 'still merges, but discards the source tag_name instead of aliasing it' do
described_class.merge_tags!(target_tag, [source_tag])
target_link = PostTag.find_by(post: post_record, tag: target_tag)
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name.id)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(target_tag.reload.post_count).to eq(1)
end
end
@@ -395,32 +288,38 @@ RSpec.describe Tag, type: :model do
message: 'init')
end
it 'rolls back earlier deletions, links, and history when a later source has a wiki' do
earlier_source = create(:tag)
earlier_name = earlier_source.tag_name
source_section = create(:post_tag_section, post: post_record,
tag: source_tag,
begin_ms: 1000, end_ms: 2000)
it 'rolls back the transaction' do
expect {
described_class.merge_tags!(target_tag, [earlier_source, source_tag])
described_class.merge_tags!(target_tag, [source_tag])
}.to raise_error(ActiveRecord::RecordInvalid)
expect(Tag.unscoped.exists?(earlier_source.id)).to be(true)
expect(earlier_name.reload.canonical_id).to be_nil
expect(TagVersion.where(tag_id: [earlier_source.id, source_tag.id, target_tag.id]))
.to be_empty
expect(Tag.unscoped.exists?(source_tag.id)).to be(true)
expect(TagName.unscoped.exists?(source_tag_name.id)).to be(true)
expect(source_post_tag.reload.tag_id).to eq(source_tag.id)
expect(source_post_tag.sections).to contain_exactly(source_section)
expect(PostTag.find_by(post: post_record, tag: target_tag)).to be_nil
expect(source_tag.reload.post_count).to eq(1)
expect(Tag.with_discarded.find(source_tag.id)).not_to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(PostTag.kept.find(source_post_tag.id).tag_id).to eq(source_tag.id)
expect(PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)).to be_nil
expect(source_tag_name.reload.canonical_id).to be_nil
expect(target_tag.reload.post_count).to eq(0)
end
end
context 'when merging a nico source tag' do
let!(:target_tag) { create(:tag, category: :nico, name: 'nico:foo') }
let!(:source_tag) { create(:tag, category: :nico, name: 'nico:bar') }
let!(:source_tag_name_id) { source_tag.tag_name_id }
it 'discards the source tag_name instead of aliasing it' do
described_class.merge_tags!(target_tag, [source_tag])
discarded_source_tag = Tag.with_discarded.find(source_tag.id)
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name_id)
expect(discarded_source_tag).to be_discarded
expect(discarded_source_tag_name).to be_discarded
expect(discarded_source_tag_name.canonical_id).to be_nil
expect(target_tag.reload.post_count).to eq(0)
end
end
def snapshot_tags(post)
post.snapshot_tag_names.join(' ')
end
@@ -466,15 +365,12 @@ RSpec.describe Tag, type: :model do
expect(latest.event_type).to eq('update')
expect(latest.created_by_user).to be_nil
expect(latest.tags).to eq(snapshot_tags(post_record.reload))
expect(latest.tags_json.map { |item| item.fetch('tag_id') }).to eq([target_tag.id])
expect(affected_versions.first.tags_json.map { |item| item.fetch('tag_id') })
.to eq([source_tag.id])
expect(unaffected_post.reload.post_versions.count).to eq(1)
end
end
context 'when the source tag has no post_tags' do
context 'when the source tag has no active post_tags' do
let!(:another_post) do
Post.create!(url: 'https://example.com/posts/3', title: 'another post')
end
+5 -5
ファイルの表示
@@ -2,7 +2,7 @@ require 'rails_helper'
RSpec.describe VersionRecord, type: :model do
let!(:tag) { create(:tag, name: 'version_record_tag') }
let!(:nico_tag) { create(:external_tag, name: 'version_record_tag') }
let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') }
it 'makes TagVersion read only after create' do
version = TagVersion.create!(
@@ -42,10 +42,10 @@ RSpec.describe VersionRecord, type: :model do
it 'makes NicoTagVersion read only after create' do
version = NicoTagVersion.create!(
external_tag: nico_tag,
tag: nico_tag,
version_no: 1,
event_type: 'create',
name: "nico:#{ nico_tag.name }",
name: nico_tag.name,
linked_tags: '',
created_at: Time.current,
created_by_user: nil
@@ -58,10 +58,10 @@ RSpec.describe VersionRecord, type: :model do
it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!(
external_tag: nico_tag,
tag: nico_tag,
version_no: 1,
event_type: 'create',
name: "nico:#{ nico_tag.name }",
name: nico_tag.name,
linked_tags: '',
created_at: Time.current,
created_by_user: nil
+36 -75
ファイルの表示
@@ -3,34 +3,10 @@ require 'rails_helper'
RSpec.describe 'NicoTags', type: :request do
describe 'GET /tags/nico' do
it 'returns the legacy Tag-compatible external fields' do
external = create(:external_tag, name: 'legacy_external', post_count: 3)
get '/tags/nico', params: { name: 'legacy_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('tags')).to contain_exactly(
a_hash_including(
'id' => external.id,
'name' => 'nico:legacy_external',
'category' => 'nico',
'post_count' => 3,
'created_at' => external.created_at.as_json,
'updated_at' => external.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false,
'linked_tags' => []))
end
it 'returns paginated tags and total count' do
3.times { |i| create(:external_tag, name: "pagination_#{ i }") }
create_list(:tag, 3, :nico)
get '/tags/nico', params: { page: 2, limit: 2, name: 'pagination_' }
get '/tags/nico', params: { page: 2, limit: 2 }
expect(response).to have_http_status(:ok)
expect(json['tags'].size).to eq(1)
@@ -38,12 +14,12 @@ 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(:tag, :nico)
linked.tag_name.update!(name: 'nico:search_linked')
unlinked = create(:tag, :nico)
unlinked.tag_name.update!(name: 'nico:search_unlinked')
other = create(:tag, :nico)
other.tag_name.update!(name: 'nico:other')
destination = create(:tag, :general)
destination.tag_name.update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination)
@@ -65,41 +41,27 @@ RSpec.describe 'NicoTags', type: :request do
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id])
end
it 'filters by the qualified legacy name as well as the raw name' do
external = create(:external_tag, name: 'qualified_filter')
create(:external_tag, name: 'unrelated_filter')
['qualified_filter', 'nico:qualified_filter'].each do |name|
get '/tags/nico', params: { name: }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('tags')).to contain_exactly(
a_hash_including('id' => external.id, 'name' => 'nico:qualified_filter'))
end
end
it 'sorts by name and timestamps' do
older = create(:external_tag)
older.update!(name: 'ordered_a')
older = create(:tag, :nico)
older.tag_name.update!(name: 'nico:a')
older.update_columns(created_at: 2.days.ago)
newer = create(:external_tag)
newer.update!(name: 'ordered_b')
newer = create(:tag, :nico)
newer.tag_name.update!(name: 'nico:b')
newer.update_columns(created_at: 1.day.ago)
older_post_tag =
PostExternalTag.create!(post: create(:post), external_tag: older)
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older)
older_post_tag.update_columns(created_at: 1.hour.ago)
newer_post_tag =
PostExternalTag.create!(post: create(:post), external_tag: newer)
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer)
newer_post_tag.update_columns(created_at: 2.hours.ago)
get '/tags/nico', params: { order: 'name:desc', name: 'ordered_' }
get '/tags/nico', params: { order: 'name:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id])
get '/tags/nico', params: { order: 'created_at:asc', name: 'ordered_' }
get '/tags/nico', params: { order: 'created_at:asc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
get '/tags/nico', params: { order: 'updated_at:desc', name: 'ordered_' }
get '/tags/nico', params: { order: 'updated_at:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at')))
.to be_within(1.second).of(older_post_tag.created_at)
@@ -109,7 +71,7 @@ RSpec.describe 'NicoTags', type: :request do
describe 'PATCH /tags/nico/:id' do
let(:member) { create(:user, :member) }
let(:admin) { create(:user, :admin) }
let(:nico_tag) { create(:external_tag) }
let(:nico_tag) { create(:tag, :nico) }
it '401 when not logged in' do
sign_out
@@ -123,18 +85,18 @@ RSpec.describe 'NicoTags', type: :request do
expect(response).to have_http_status(:forbidden)
end
it '404 when only an internal tag exists for the target id' do
it '400 when target is not nico category' do
sign_in_as(member)
non_nico = create(:tag, :general)
expect(ExternalTag.exists?(non_nico.id)).to be(false)
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
expect(response).to have_http_status(:not_found)
expect(response).to have_http_status(:bad_request)
end
it '200 and updates linked tags while recording tag versions' do
sign_in_as(admin)
nico_tag = create(:external_tag, name: 'nico_tags_spec_source')
nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
linked_a_name = TagName.create!(name: 'nico_linked_a')
linked_a = Tag.create!(tag_name: linked_a_name, category: :general)
@@ -142,8 +104,7 @@ RSpec.describe 'NicoTags', type: :request do
linked_b_name = TagName.create!(name: 'nico_linked_b')
linked_b = Tag.create!(tag_name: linked_b_name, category: :general)
NicoTagVersionRecorder.record!(external_tag: nico_tag,
event_type: :create, created_by_user: admin)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin)
expect {
patch "/tags/nico/#{nico_tag.id}", params: {
@@ -165,26 +126,26 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([
'nico_linked_a',
'nico_linked_b'])
'nico_linked_b'
])
expect(versions.last.created_by_user_id).to eq(admin.id)
end
it 'clears existing links and records the empty mapping for a member' do
it 'returns 422 when linked tag normalises to nico tag' do
sign_in_as(member)
linked = create(:tag)
NicoTagRelation.insert_all!([{ nico_tag_id: nico_tag.id, tag_id: linked.id }])
NicoTagVersionRecorder.record!(external_tag: nico_tag,
event_type: :create, created_by_user: member)
other_nico = create(:tag, :nico, name: 'nico:linked_ng')
TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name)
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)
expect {
patch "/tags/nico/#{ nico_tag.id }", params: { tags: '' }
}.to change(NicoTagVersion, :count).by(1)
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:ok)
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)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
end
it 'returns the tags field error when a nico tag is specified directly' do
+125 -389
ファイルの表示
@@ -17,7 +17,7 @@ RSpec.describe 'Posts API', type: :request do
end
def create_nico_tag!(name)
ExternalTag.find_or_create_by!(platform: :nico, name: name.delete_prefix('nico:'))
Tag.find_or_create_by_tag_name!(name, category: :nico)
end
def dummy_upload
@@ -93,26 +93,6 @@ RSpec.describe 'Posts API', type: :request do
count
end
def expect_external_tag_json tag_json, external_tag
external_tag.reload
expect(tag_json).to include(
'id' => external_tag.id,
'name' => "#{ external_tag.platform }:#{ external_tag.name }",
'category' => 'nico',
'created_at' => external_tag.created_at.as_json,
'updated_at' => external_tag.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'post_count' => external_tag.post_count,
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false,
'children' => [],
'sections' => [])
end
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
@@ -168,15 +148,12 @@ RSpec.describe 'Posts API', type: :request do
it 'keeps children and sections keys in non-detail tag responses' do
PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil)
deprecated_tag = create(:tag, deprecated_at: Time.current)
create(:post_tag, post: hit_post, tag: deprecated_tag)
get '/posts'
expect(response).to have_http_status(:ok)
hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id }
expect(hit_json.fetch('tags').map { |item| item.fetch('id') }).to eq([tag.id])
tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' }
expect(tag_json.fetch('children')).to eq([])
@@ -185,26 +162,6 @@ RSpec.describe 'Posts API', type: :request do
])
end
it 'preloads tag details and sections as the number of posts grows' do
5.times do
link = create(:post_tag, post: create(:post, uploaded_user: user))
create(:post_tag_section, post: link.post, tag: link.tag,
begin_ms: 1000, end_ms: 2000)
end
get '/posts', params: { limit: 1 }
one_post_queries = count_sql_queries do
get '/posts', params: { limit: 1 }
end
many_post_queries = count_sql_queries do
get '/posts', params: { limit: 20 }
end
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').size).to eq(8)
expect(many_post_queries).to be <= one_post_queries
end
context 'when q is provided' do
it 'filters posts by q (hit case)' do
get '/posts', params: { tags: 'spec_tag' }
@@ -253,93 +210,6 @@ RSpec.describe 'Posts API', type: :request do
end
end
context 'with legacy external tag name searches' do
let!(:external_tag) { create(:external_tag, id: tag.id, name: 'search_external') }
let!(:both_post) do
create(:post).tap do |post|
PostTag.create!(post:, tag:)
PostExternalTag.create!(post:, external_tag:)
end
end
before do
PostExternalTag.create!(post: miss_post, external_tag:)
end
it 'keeps internal name searches independent of colliding external ids' do
get '/posts', params: { tags: tag.name }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(post_record.id, hit_post.id, both_post.id)
end
it 'finds posts through PostExternalTag by the qualified legacy name' do
get '/posts', params: { tags: 'nico:search_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(miss_post.id, both_post.id)
end
[nil, 'all'].each do |match|
it "intersects internal and external matches with match=#{ match || 'omitted' }" do
params = { tags: "#{ tag.name } nico:search_external" }
params[:match] = match if match
get '/posts', params: params
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('posts').map { _1.fetch('id') }).to eq([both_post.id])
end
end
it 'unions internal alias and external matches without duplicate posts' do
get '/posts', params: { tags: 'manko nico:search_external', match: 'any' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(4)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(post_record.id, hit_post.id, miss_post.id, both_post.id)
end
it 'excludes external matches from an internal tag search' do
get '/posts', params: { tags: "#{ tag.name } not:nico:search_external" }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(post_record.id, hit_post.id)
end
it 'unions an external match with a negated internal match' do
get '/posts', params: { tags: "nico:search_external not:#{ tag.name }", match: 'any' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('posts').map { _1.fetch('id') })
.to contain_exactly(miss_post.id, both_post.id)
end
it 'keeps a missing qualified external name empty' do
get '/posts', params: { tags: 'nico:missing_search_external' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(0)
expect(json.fetch('posts')).to be_empty
end
it 'applies the same mixed name search to the existing random endpoint' do
get '/posts/random', params: { tags: "#{ tag.name } nico:search_external", match: 'all' }
expect(response).to have_http_status(:ok)
expect(json.fetch('id')).to eq(both_post.id)
end
end
context 'when tags contain not:' do
let!(:foo_tag_name) { TagName.create!(name: 'not_spec_foo') }
let!(:foo_tag) { Tag.create!(tag_name: foo_tag_name, category: :general) }
@@ -590,71 +460,6 @@ RSpec.describe 'Posts API', type: :request do
end
end
context 'when update times include version history' do
let(:t0) { Time.zone.parse('2020-01-01 12:00:00') }
let(:t1) { t0 + 1.day }
let(:t2) { t0 + 2.days }
let(:t3) { t0 + 3.days }
let!(:history_post) do
create(:post, url: 'https://example.com/version-time/history',
created_at: t0, updated_at: t0)
end
let!(:plain_post) do
create(:post, url: 'https://example.com/version-time/plain',
created_at: t1, updated_at: t1)
end
let!(:newer_post) do
create(:post, url: 'https://example.com/version-time/newer',
created_at: t0, updated_at: t3)
end
before do
link = create(:post_tag, post: history_post, tag:)
travel_to(t0) do
PostVersionRecorder.record!(post: history_post,
event_type: :create, created_by_user: nil)
PostVersionRecorder.record!(post: newer_post,
event_type: :create, created_by_user: nil)
end
travel_to(t2) do
link.destroy!
PostVersionRecorder.record!(post: history_post,
event_type: :update, created_by_user: nil)
end
create(:post_tag, post: plain_post, tag:, created_at: t3)
end
['asc', 'desc'].each do |direction|
it "sorts by the later of post update and latest version time (#{ direction })" do
get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok)
expected_ids = [plain_post.id, history_post.id, newer_post.id]
expected_ids.reverse! if direction == 'desc'
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq(expected_ids)
expect(json.fetch('count')).to eq(3)
times = json.fetch('posts').to_h do |item|
[item.fetch('id'), Time.zone.parse(item.fetch('updated_at'))]
end
expect(times).to eq({ plain_post.id => t1,
history_post.id => t2,
newer_post.id => t3 })
expect(history_post.reload.updated_at).to eq(t0)
end
end
it 'filters inclusively by the latest version time after a tag is deleted' do
get '/posts', params: { url: '/version-time/',
updated_from: t2.iso8601,
updated_to: t2.iso8601 }
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id])
expect(json.fetch('count')).to eq(1)
end
end
context 'when original_created_from/original_created_to are provided' do
# 注意: controller の現状ロジックに合わせてる
# original_created_from は `original_created_before > ?`
@@ -719,30 +524,6 @@ RSpec.describe 'Posts API', type: :request do
expect(json.fetch('count')).to eq(2)
end
end
it 'returns internal and external tags with colliding ids in the legacy tags array' do
external_tag = create(:external_tag, id: tag.id, name: 'post_index_external')
PostExternalTag.create!(post: hit_post, external_tag:)
get '/posts'
expect(response).to have_http_status(:ok)
post_json =
json
.fetch('posts')
.find { _1.fetch('id') == hit_post.id }
external_json =
post_json
.fetch('tags')
.find { _1['name'] == 'nico:post_index_external' }
expect(post_json.fetch('tags')).to include(
a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'))
expect(external_json).not_to be_nil
expect_external_tag_json(external_json, external_tag)
end
end
describe 'GET /posts/:id' do
@@ -900,25 +681,6 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:ok)
expect(query_count).to be <= 45
end
it 'returns external tags as root nodes in the legacy tag tree' do
external_tag = create(:external_tag, id: tag.id, name: 'post_detail_external')
PostExternalTag.create!(post: post_record, external_tag:)
request
expect(response).to have_http_status(:ok)
expect(json.fetch('tags')).to include(
a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'))
external_json =
json
.fetch('tags')
.find { _1['name'] == 'nico:post_detail_external' }
expect(external_json).not_to be_nil
expect_external_tag_json(external_json, external_tag)
end
end
context 'when post does not exist' do
@@ -1400,9 +1162,11 @@ RSpec.describe 'Posts API', type: :request do
)
end
context 'when the external nico tag already exists' do
context 'when nico tag already exists in tags' do
before do
create(:external_tag, name: 'nico_tag')
Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
category: :nico)
end
it 'returns 422 with tag field errors' do
@@ -1655,11 +1419,9 @@ RSpec.describe 'Posts API', type: :request do
it '200 and updates title + resync tags when member' do
sign_in_as(member)
create(:post_tag_section, post: post_record, tag:,
begin_ms: 1000, end_ms: 2000)
tn2 = TagName.create!(name: 'spec_tag_2')
replacement_tag = Tag.create!(tag_name: tn2, category: :general)
Tag.create!(tag_name: tn2, category: :general)
put "/posts/#{post_record.id}", params: post_update_params(
post_record,
@@ -1672,38 +1434,6 @@ RSpec.describe 'Posts API', type: :request do
names = json['tags'].map { |n| n['name'] }
expect(names).to include('spec_tag_2')
expect(names).not_to include('spec_tag')
expect(PostTag.exists?(post: post_record, tag:)).to be(false)
expect(PostTagSection.exists?(post: post_record, tag:)).to be(false)
expect(tag.reload.post_count).to eq(0)
expect(replacement_tag.reload.post_count).to eq(1)
versions = post_record.post_versions.order(:version_no)
expect(versions.first.tags_json).to include(
a_hash_including('tag_id' => tag.id,
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => 2000 }]))
expect(versions.last.tags_json.map { |item| item.fetch('tag_id') })
.not_to include(tag.id)
end
it 'can add a removed tag again and records both changes' do
sign_in_as(member)
put "/posts/#{ post_record.id }", params: post_update_params(post_record, tags: '')
expect(response).to have_http_status(:ok)
expect(PostTag.exists?(post: post_record, tag:)).to be(false)
put "/posts/#{ post_record.id }", params: post_update_params(
post_record, tags: 'spec_tag')
expect(response).to have_http_status(:ok)
expect(PostTag.where(post: post_record, tag:).count).to eq(1)
expect(PostTag.find_by!(post: post_record, tag:).created_user).to eq(member)
expect(tag.reload.post_count).to eq(1)
snapshots = post_record.post_versions.order(:version_no).map do |version|
version.tags_json.map { |item| item.fetch('tag_id') }
end
expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true])
end
it 'rejects a deprecated tag specified directly' do
@@ -1726,9 +1456,11 @@ RSpec.describe 'Posts API', type: :request do
)
end
context 'when the external nico tag already exists' do
context 'when nico tag already exists in tags' do
before do
create(:external_tag, name: 'nico_tag')
Tag.find_undiscard_or_create_by!(
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
category: :nico)
end
it 'returns 422 with tag field errors' do
@@ -2045,7 +1777,7 @@ RSpec.describe 'Posts API', type: :request do
base_version = create_post_version_for!(post_record.reload)
nico_tag = create_nico_tag!('nico:optimistic_lock_nico')
PostExternalTag.create!(post: post_record, external_tag: nico_tag)
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostVersionRecorder.record!(
post: post_record.reload,
@@ -2065,14 +1797,14 @@ RSpec.describe 'Posts API', type: :request do
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(post_record.external_tags).to contain_exactly(nico_tag)
expect(names).to include(nico_tag.name)
end
it 'keeps nico tags even when they are not included in PUT tags' do
sign_in_as(member)
nico_tag = create_nico_tag!('nico:readonly_update_nico')
PostExternalTag.create!(post: post_record, external_tag: nico_tag)
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
base_version = create_post_version_for!(post_record.reload)
@@ -2087,15 +1819,7 @@ RSpec.describe 'Posts API', type: :request do
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(post_record.external_tags).to contain_exactly(nico_tag)
external_json =
json
.fetch('tags')
.find { _1['name'] == "nico:#{ nico_tag.name }" }
expect(external_json).not_to be_nil
expect_external_tag_json(external_json, nico_tag)
expect(names).to include(nico_tag.name)
end
it 'allows non-nico tags linked from nico tags to be removed by normal post update' do
@@ -2105,7 +1829,7 @@ RSpec.describe 'Posts API', type: :request do
linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general)
NicoTagRelation.create!(nico_tag:, tag: linked_tag)
PostExternalTag.create!(post: post_record, external_tag: nico_tag)
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
PostTag.create!(post: post_record, tag: linked_tag, created_user: member)
base_version = create_post_version_for!(post_record.reload)
@@ -2119,7 +1843,7 @@ RSpec.describe 'Posts API', type: :request do
names = post_record.reload.tags.map(&:name)
expect(post_record.external_tags).to contain_exactly(nico_tag)
expect(names).to include(nico_tag.name)
expect(names).to include('spec_tag')
expect(names).to include(Tag.no_deerjikist.name)
expect(names).not_to include(linked_tag.name)
@@ -2155,29 +1879,123 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:not_found)
end
it 'returns viewed state and current tags with their sections' do
create(:post_tag_section, post: post_record, tag:,
begin_ms: 1000, end_ms: nil)
deprecated_tag = create(:tag, deprecated_at: Time.current)
create(:post_tag, post: post_record, tag: deprecated_tag)
it '200 and returns viewed boolean' do
get '/posts/random'
expect(response).to have_http_status(:ok)
expect(json).to have_key('viewed')
expect([true, false]).to include(json['viewed'])
expect(json.fetch('tags')).to contain_exactly(
a_hash_including('id' => tag.id,
'children' => [],
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => nil }]))
end
end
describe 'GET /posts/changes' do
it 'returns 404 for the retired history endpoint' do
get '/posts/changes'
let(:member) { create(:user, :member) }
expect(response).to have_http_status(:not_found)
it 'returns add/remove events (history) for a post' do
# add
tn2 = TagName.create!(name: 'spec_tag2')
tag2 = Tag.create!(tag_name: tn2, category: :general)
pt = PostTag.create!(post: post_record, tag: tag2, created_user: member)
# remove (discard)
pt.discard_by!(member)
get '/posts/changes', params: { id: post_record.id }
expect(response).to have_http_status(:ok)
expect(json).to include('changes', 'count')
expect(json['changes']).to be_an(Array)
expect(json['count']).to be >= 2
types = json['changes'].map { |e| e['change_type'] }.uniq
expect(types).to include('add')
expect(types).to include('remove')
end
it 'filters history by tag' do
tn2 = TagName.create!(name: 'history_tag_hit')
tag2 = Tag.create!(tag_name: tn2, category: :general)
tn3 = TagName.create!(name: 'history_tag_miss')
tag3 = Tag.create!(tag_name: tn3, category: :general)
other_post = Post.create!(
title: 'other post',
url: 'https://example.com/history-other'
)
# hit: add
PostTag.create!(post: post_record, tag: tag2, created_user: member)
# hit: add + remove
pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member)
pt2.discard_by!(member)
# miss: add + remove
pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member)
pt3.discard_by!(member)
get '/posts/changes', params: { tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json).to include('changes', 'count')
expect(json['count']).to eq(3)
changes = json.fetch('changes')
expect(changes.map { |e| e.dig('tag', 'id') }.uniq).to eq([tag2.id])
expect(changes.map { |e| e['change_type'] }).to match_array(%w[add add remove])
expect(changes.map { |e| e.dig('post', 'id') }).to match_array([
post_record.id,
other_post.id,
other_post.id
])
end
it 'filters history by post and tag together' do
tn2 = TagName.create!(name: 'history_tag_combo_hit')
tag2 = Tag.create!(tag_name: tn2, category: :general)
tn3 = TagName.create!(name: 'history_tag_combo_miss')
tag3 = Tag.create!(tag_name: tn3, category: :general)
other_post = Post.create!(
title: 'other combo post',
url: 'https://example.com/history-combo-other'
)
# hit
PostTag.create!(post: post_record, tag: tag2, created_user: member)
# miss by post
pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member)
pt2.discard_by!(member)
# miss by tag
pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member)
pt3.discard_by!(member)
get '/posts/changes', params: { id: post_record.id, tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json).to include('changes', 'count')
expect(json['count']).to eq(1)
changes = json.fetch('changes')
expect(changes.size).to eq(1)
expect(changes[0]['change_type']).to eq('add')
expect(changes[0].dig('post', 'id')).to eq(post_record.id)
expect(changes[0].dig('tag', 'id')).to eq(tag2.id)
end
it 'returns empty history when tag does not match' do
tn2 = TagName.create!(name: 'history_tag_no_hit')
tag2 = Tag.create!(tag_name: tn2, category: :general)
get '/posts/changes', params: { tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('changes')).to eq([])
expect(json.fetch('count')).to eq(0)
end
end
@@ -2229,7 +2047,7 @@ RSpec.describe 'Posts API', type: :request do
end
let!(:v2) do
post_record.post_tags.find_by!(tag: tag).destroy!
post_record.post_tags.kept.find_by!(tag: tag).discard_by!(member)
PostTag.create!(post: post_record, tag: tag2, created_user: member)
post_record.update!(
title: 'updated spec post',
@@ -2338,88 +2156,6 @@ RSpec.describe 'Posts API', type: :request do
expect(first.fetch('created_at')).to eq(t_v1.iso8601)
end
context 'with external tag history' do
let(:external_id) { tag.id }
let(:external) { create(:external_tag, id: external_id) }
let(:external_post) { create(:post) }
before do
PostExternalTag.create!(post: external_post, external_tag: external)
PostVersionRecorder.record!(
post: external_post, event_type: :create, created_by_user: member)
unrelated_post = create(:post)
PostExternalTag.create!(post: unrelated_post, external_tag: create(:external_tag))
PostVersionRecorder.record!(
post: unrelated_post, event_type: :create, created_by_user: member)
end
it 'prefers Tag over ExternalTag for the legacy tag parameter' do
get '/posts/versions', params: { tag: tag.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3)
expect(json.fetch('versions').map { [_1.fetch('post_id'), _1.fetch('version_no')] })
.to contain_exactly(
[post_record.id, 1], [post_record.id, 2], [other_post_version.post_id, 1])
end
it 'explicitly filters ExternalTag even when its id collides with Tag' do
get '/posts/versions', params: { external_tag: external.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('versions')).to contain_exactly(
a_hash_including('post_id' => external_post.id, 'version_no' => 1))
end
# Temporary compatibility shim until the frontend sends external_tag explicitly.
context 'with legacy tag fallback and no internal Tag with the external id' do
let(:external_id) { Tag.maximum(:id).to_i + 10_000 }
it 'falls back to ExternalTag for the legacy tag parameter' do
expect(Tag.exists?(external.id)).to be(false)
get '/posts/versions', params: { tag: external.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(1)
expect(json.fetch('versions')).to contain_exactly(
a_hash_including('post_id' => external_post.id, 'version_no' => 1))
end
[:tag, :external_tag].each do |parameter|
it "includes external removal history through the legacy API's #{ parameter }" do
external_post.post_external_tags.destroy_all
PostVersionRecorder.record!(
post: external_post.reload, event_type: :update, created_by_user: member)
get '/posts/versions', params: { parameter => external.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(json.fetch('versions')).to contain_exactly(
a_hash_including('post_id' => external_post.id, 'version_no' => 1),
a_hash_including('post_id' => external_post.id, 'version_no' => 2))
end
end
end
end
it 'can render history containing external identifiers' do
PostExternalTag.create!(post: post_record, external_tag: create(:external_tag))
post_record.update_columns(version_no: 2)
PostVersionRecorder.record!(post: post_record,
event_type: :update, created_by_user: member)
get '/posts/versions', params: { post: post_record.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(3)
expect(json.fetch('versions').first.fetch('tags')).to include(
'name' => tag2.name, 'type' => 'context')
end
it 'filters versions by tag when the current snapshot includes the tag' do
get '/posts/versions', params: { post: post_record.id, tag: tag2.id }
+58
ファイルの表示
@@ -80,6 +80,38 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found)
end
end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400 and does not create relation' do
expect {
do_request
}.not_to change(TagImplication, :count)
expect(response).to have_http_status(:bad_request)
end
end
end
describe "DELETE /tag_children" do
@@ -154,5 +186,31 @@ RSpec.describe "TagChildren", type: :request do
expect(response).to have_http_status(:not_found)
end
end
context 'when parent is nico' do
before { stub_current_user(admin) }
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
context 'when child is nico' do
before { stub_current_user(admin) }
let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') }
let(:parent_id) { parent.id }
let(:child_id) { child.id }
it 'returns 400' do
do_request
expect(response).to have_http_status(:bad_request)
end
end
end
end
-83
ファイルの表示
@@ -226,14 +226,6 @@ RSpec.describe 'Tags deerjikists API', type: :request do
[platform2, code2],
)
end
it 'locks the tag before replacing the complete list' do
expect_any_instance_of(Tag).to receive(:lock!).and_call_original
do_request
expect(response).to have_http_status(:ok)
end
end
context 'when tag already has deerjikists' do
@@ -307,81 +299,6 @@ RSpec.describe 'Tags deerjikists API', type: :request do
end
end
context 'when platform is outside the enum' do
let(:payload) do
[
{ platform: 'invalid', code: code1 },
]
end
it 'returns 422 with an indexed platform error without changing the list' do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
expect {
do_request
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deerjikists.0.platform' => [be_present],
)
end
end
context 'when a requested deerjikist belongs to another tag' do
let!(:other_tag) { create(:tag, category: :deerjikist) }
let!(:owned_deerjikist) do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end
let!(:conflicting_deerjikist) do
Deerjikist.create!(platform: platform2, code: code2, tag: other_tag)
end
let(:payload) do
[
{ platform: 'nico', code: 'new-code' },
{ platform: platform2, code: code2 },
]
end
before do
other_tag.tag_name.update!(name: 'existing-deerjikist')
end
it 'returns an indexed 422 error and rolls back the complete replacement' do
expect {
do_request
}.not_to change { Deerjikist.order(:platform, :code).pluck(:platform, :code, :tag_id) }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'deerjikists.1.code' => [include('existing-deerjikist')],
)
expect(owned_deerjikist.reload.tag_id).to eq(tag.id)
expect(conflicting_deerjikist.reload.tag_id).to eq(other_tag.id)
end
end
context 'when a requested deerjikist already belongs to the same tag' do
let!(:existing_deerjikist) do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
end
let(:payload) do
[
{ platform: platform1, code: code1 },
]
end
it 'keeps the existing row' do
expect {
do_request
}.not_to change { existing_deerjikist.reload.created_at }
expect(response).to have_http_status(:ok)
expect(Deerjikist.where(tag: tag).pluck(:platform, :code))
.to eq([[platform1, code1]])
end
end
context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do
+70 -182
ファイルの表示
@@ -30,30 +30,6 @@ RSpec.describe 'Tags API', type: :request do
end
describe 'GET /tags' do
it 'includes legacy external JSON alongside an internal tag with the same id' do
external = create(:external_tag, id: tag.id, name: 'spec_external', post_count: 3)
get '/tags', params: { name: 'spec_' }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(2)
expect(response_tags).to contain_exactly(
a_hash_including('id' => tag.id, 'name' => tag.name, 'category' => 'general'),
a_hash_including(
'id' => external.id,
'name' => 'nico:spec_external',
'category' => 'nico',
'post_count' => 3,
'created_at' => external.created_at.as_json,
'updated_at' => external.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false))
end
it 'returns tags with count and metadata' do
get '/tags'
@@ -188,64 +164,20 @@ RSpec.describe 'Tags API', type: :request do
Tag.create!(tag_name: TagName.create!(name: 'cat_general'), category: :general)
Tag.create!(tag_name: TagName.create!(name: 'cat_material'), category: :material)
Tag.create!(tag_name: TagName.create!(name: 'cat_meta'), category: :meta)
create(:external_tag, name: 'cat_nico')
Tag.create!(tag_name: TagName.create!(name: 'nico:cat_nico'), category: :nico)
get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 }
expect(response).to have_http_status(:ok)
expect(response_names).to eq([
'cat_deerjikist', 'cat_meme', 'cat_character',
'cat_general', 'cat_material', 'cat_meta', 'nico:cat_nico'])
expect(json.fetch('count')).to eq(7)
end
context 'with mixed legacy pagination' do
let!(:first_tag) { create(:tag, name: 'a_mixed_page', category: :meme) }
let!(:middle_tag) { create(:tag, name: 'm_mixed_page', category: :meta) }
let!(:last_tag) { create(:tag, name: 'z_mixed_page', category: :general) }
let!(:first_external) do
create(:external_tag, id: first_tag.id, name: 'a_mixed_page')
end
let!(:last_external) do
create(:external_tag, id: last_tag.id, name: 'z_mixed_page')
end
let(:name_order) do
[
[first_tag.id, 'a_mixed_page'], [middle_tag.id, 'm_mixed_page'],
[first_external.id, 'nico:a_mixed_page'], [last_external.id, 'nico:z_mixed_page'],
[last_tag.id, 'z_mixed_page']]
end
let(:category_order) do
[
[first_tag.id, 'a_mixed_page'], [last_tag.id, 'z_mixed_page'],
[middle_tag.id, 'm_mixed_page'], [first_external.id, 'nico:a_mixed_page'],
[last_external.id, 'nico:z_mixed_page']]
end
['name', 'category'].each do |order|
['asc', 'desc'].each do |direction|
it "orders the combined records by #{ order }:#{ direction } before paging" do
ascending = order == 'name' ? name_order : category_order
expected = direction == 'asc' ? ascending : ascending.reverse
[2, 3].each do |limit|
pages = expected.each_slice(limit).to_a + [[]]
pages.each.with_index(1) do |expected_page, page|
get '/tags', params: {
name: 'mixed_page', order: "#{ order }:#{ direction }", page:, limit: }
expect(response).to have_http_status(:ok)
expect(json.fetch('count')).to eq(5)
expect(response_tags.size).to eq(expected_page.size)
expect(response_tags.size).to be <= limit
expect(response_tags.map { [_1.fetch('id'), _1.fetch('name')] })
.to eq(expected_page)
end
end
end
end
end
expect(response_names).to eq(%w[
cat_deerjikist
cat_meme
cat_character
cat_general
cat_material
cat_meta
nico:cat_nico
])
end
it 'paginates and keeps total count' do
@@ -367,89 +299,6 @@ RSpec.describe 'Tags API', type: :request do
end
describe 'GET /tags/autocomplete' do
it 'combines internal and external matches without conflating equal ids' do
internal = Tag.create!(category: :general, name: 'mixed_internal', post_count: 2)
external = create(:external_tag, id: internal.id,
name: 'mixed_external', post_count: 3)
get '/tags/autocomplete', params: { q: 'not:mixed' }
expect(response).to have_http_status(:ok)
expect(json.map { |row| row.fetch('name') })
.to eq(['nico:mixed_external', 'mixed_internal'])
expect(json.first).to include(
'id' => external.id, 'category' => 'nico', 'post_count' => 3,
'created_at' => external.created_at.as_json,
'updated_at' => external.created_at.as_json,
'deprecated_at' => nil, 'matched_alias' => nil,
'aliases' => [], 'parents' => [], 'has_wiki' => false,
'material_id' => nil, 'has_deerjikists' => false)
end
it 'matches an external tag by its platform prefix' do
create(:external_tag, name: 'prefix_match', post_count: 1)
get '/tags/autocomplete', params: { q: 'nico:prefix' }
expect(json.map { |row| row.fetch('name') }).to eq(['nico:prefix_match'])
end
it 'excludes unused external tags unless present is false' do
create(:external_tag, name: 'unused_external')
get '/tags/autocomplete', params: { q: 'unused' }
expect(json).to be_empty
get '/tags/autocomplete', params: { q: 'unused', present: '0' }
expect(json.map { |row| row.fetch('name') }).to eq(['nico:unused_external'])
end
it 'limits the combined results to 20 and sorts ties by displayed name' do
11.times do |i|
name = "combined_#{ i.to_s.rjust(2, '0') }"
Tag.create!(category: :general, name:, post_count: 1)
create(:external_tag, name:, post_count: 1)
end
get '/tags/autocomplete', params: { q: 'combined' }
expected = 11.times.map { |i| "combined_#{ i.to_s.rjust(2, '0') }" }
expected += expected.map { |name| "nico:#{ name }" }
expect(json.map { |row| row.fetch('name') }).to eq(expected.first(20))
end
it 'excludes external tags but preserves internal alias matches when nico is false' do
internal = Tag.create!(category: :general, name: 'switch_internal', post_count: 1)
alias_target = Tag.create!(category: :general, name: 'alias_target', post_count: 1)
TagName.create!(name: 'switch_alias', canonical: alias_target.tag_name)
create(:external_tag, name: 'switch_external', post_count: 1)
get '/tags/autocomplete', params: { q: 'switch', nico: '0' }
expect(response).to have_http_status(:ok)
expect(json).to contain_exactly(
a_hash_including('id' => internal.id, 'name' => internal.name),
a_hash_including('id' => alias_target.id, 'name' => alias_target.name,
'matched_alias' => 'switch_alias'))
end
['%', '_'].each do |wildcard|
it "treats #{ wildcard } literally for canonical, alias, and external names" do
literal = "literal#{ wildcard }match"
Tag.create!(category: :general, name: literal, post_count: 1)
alias_target = Tag.create!(category: :general, name: 'literal_alias_target', post_count: 1)
TagName.create!(name: "#{ literal }_alias", canonical: alias_target.tag_name)
create(:external_tag, name: literal, post_count: 1)
Tag.create!(category: :general, name: 'literalXmatch', post_count: 2)
create(:external_tag, name: 'literalXmatch', post_count: 2)
get '/tags/autocomplete', params: { q: "literal#{ wildcard }" }
expect(json.map { |row| row.fetch('name') })
.to contain_exactly(literal, alias_target.name, "nico:#{ literal }")
end
end
it 'returns matching tags by q' do
get '/tags/autocomplete', params: { q: 'spec' }
@@ -491,27 +340,6 @@ RSpec.describe 'Tags API', type: :request do
end
describe 'GET /tags/name/:name' do
it 'preserves qualified external name lookup used by wiki pages' do
external = create(:external_tag, name: 'detail_external', post_count: 3)
get "/tags/name/#{ CGI.escape('nico:detail_external') }"
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => external.id,
'name' => 'nico:detail_external',
'category' => 'nico',
'post_count' => 3,
'created_at' => external.created_at.as_json,
'updated_at' => external.created_at.as_json,
'deprecated_at' => nil,
'aliases' => [],
'parents' => [],
'has_wiki' => false,
'material_id' => nil,
'has_deerjikists' => false)
end
it 'returns tag by name' do
get "/tags/name/#{ CGI.escape('spec_tag') }"
@@ -659,6 +487,18 @@ RSpec.describe 'Tags API', type: :request do
expect(json.fetch('deprecated_at')).to be_present
end
it 'rejects deprecating a nico tag' do
nico_tag = Tag.create!(name: 'nico:deprecated_update', category: :nico)
patch "/tags/#{ nico_tag.id }", params: { deprecated: '1' }
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.deprecated_at).to be_nil
expect(json.fetch('errors')).to include(
'deprecated' => ['ニコタグは廃止できません.']
)
end
it 'returns 422 when changing normal tag category to nico' do
expect {
patch "/tags/#{tag.id}", params: { category: 'nico' }
@@ -668,6 +508,32 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.reload.category).to eq('general')
end
it 'returns 422 when updating nico tag name' do
nico_tag_name = TagName.create!(name: 'nico:tags_spec_source')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
expect {
patch "/tags/#{ nico_tag.id }", params: { name: 'nico:tags_spec_renamed' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.name).to eq('nico:tags_spec_source')
expect(nico_tag.category).to eq('nico')
end
it 'returns 422 when changing nico tag category to normal category' do
nico_tag_name = TagName.create!(name: 'nico:category_change_ng')
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
expect {
patch "/tags/#{nico_tag.id}", params: { category: 'general' }
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.category).to eq('nico')
end
it 'PATCH で tag の name を変更すると対応する wiki version を作成する' do
wiki_page =
Wiki::Commit.create_content!(
@@ -1303,6 +1169,28 @@ RSpec.describe 'Tags API', type: :request do
expect(tag.category).to eq('general')
end
it 'nico tag は更新できない' do
nico_tag = Tag.create!(
tag_name: TagName.create!(name: 'nico:put_update_all_ng'),
category: :nico
)
expect {
put "/tags/#{ nico_tag.id }", params: {
name: 'nico:put_update_all_renamed',
category: 'nico',
aliases: '',
parent_tags: '',
deprecated: '0',
}
}.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(nico_tag.reload.name).to eq('nico:put_update_all_ng')
expect(nico_tag.category).to eq('nico')
end
it 'system tag の name は変更できない' do
system_tag = Tag.tagme
old_name = system_tag.name
-76
ファイルの表示
@@ -1,76 +0,0 @@
require 'rails_helper'
RSpec.describe NicoTagVersionRecorder do
let(:external_tag) { create(:external_tag, name: 'raw tag[]') }
let(:member) { create(:user, :member) }
def record event_type
described_class.record!(external_tag:, event_type:, created_by_user: member)
end
it 'records the external association and 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)
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')
second = record(:update)
expect(second).to have_attributes(version_no: 2, name: 'nico:changed')
expect(first.reload.name).to eq('nico:raw tag[]')
expect(external_tag.reload.has_attribute?(:version_no)).to be(false)
end
it 'builds the qualified name from the registered platform value' do
external = create(:external_tag, name: 'foo')
locked_scope = instance_double(ActiveRecord::Relation)
allow(ExternalTag).to receive(:unscoped).and_return(locked_scope)
allow(locked_scope).to receive(:lock).and_return(locked_scope)
allow(locked_scope).to receive(:find).with(external.id).and_return(external)
allow(external).to receive(:platform).and_return('registered_external')
version = described_class.record!(
external_tag: external, event_type: :create, created_by_user: member)
expect(version).to have_attributes(
external_tag: external, name: 'registered_external:foo', version_no: 1,
event_type: 'create', linked_tags: '', created_by_user: member)
end
it 'returns the latest version without appending an unchanged snapshot' do
first = record(:create)
expect { expect(record(:update)).to eq(first) }
.not_to change(NicoTagVersion, :count)
end
it 'still requires a create event before any update' do
expect { record(:update) }
.to raise_error(RuntimeError, 'NicoTagVersion first event must be create')
expect(external_tag.nico_tag_versions).to be_empty
end
it 'still rejects a second create event' do
first = record(:create)
expect { record(:create) }
.to raise_error(RuntimeError, 'NicoTagVersion create event already exists')
expect(external_tag.nico_tag_versions).to contain_exactly(first)
end
it 'records sorted linked internal names and later link removal' do
tags = ['z_link', 'a_link'].map { |name| Tag.create!(name:, category: :general) }
tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) }
expect(record(:create).linked_tags).to eq('a_link z_link')
external_tag.linked_tags = [tags.first]
expect(record(:update)).to have_attributes(version_no: 2, linked_tags: 'z_link')
end
end
-13
ファイルの表示
@@ -50,19 +50,6 @@ RSpec.describe PostCreatePlan do
expect(plan[:video_ms]).to eq(60_000)
end
it 'rejects direct external tag input without creating internal records' do
create(:external_tag, name: 'reserved')
counts = [Tag.count, TagName.count, ExternalTag.count]
expect {
described_class.new(attributes: { tags: 'NiCo:reserved' }).build!
}.to raise_error(ActiveRecord::RecordInvalid) { |error|
expect(error.record.errors[:tags]).to be_present
}
expect([Tag.count, TagName.count, ExternalTag.count]).to eq(counts)
end
it 'validates a new tag name without persisting it' do
long_name = 'a' * 256
counts = [TagName.count, Tag.count]
-4
ファイルの表示
@@ -268,10 +268,6 @@ RSpec.describe Youtube::Sync do
expect(tag_ids).to include(deerjikist_tag.id)
expect(tag_ids).not_to include(Tag.no_deerjikist.id)
expect(PostTag.exists?(post:, tag: Tag.no_deerjikist)).to be(false)
expect(Tag.no_deerjikist.reload.post_count).to eq(0)
expect(deerjikist_tag.reload.post_count).to eq(1)
expect(PostVersionRecorder).to have_received(:ensure_snapshot!).with(
post,
created_by_user: nil
+19 -168
ファイルの表示
@@ -8,7 +8,8 @@ RSpec.describe 'nico:sync' do
end
def create_tag!(name, category:)
Tag.find_or_create_by_tag_name!(name, category:)
tn = TagName.find_undiscard_or_create_by!(name: name.to_s.strip)
Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) { |t| t.category = category }
end
def link_nico_to_tag!(nico_tag, tag)
@@ -29,7 +30,7 @@ RSpec.describe 'nico:sync' do
# 追加される linked tag を準備(nico tag に紐付く一般タグ)
linked = create_tag!('spec_linked', category: 'general')
nico = create_external_tag!('AAA')
nico = create_tag!('nico:AAA', category: 'nico')
link_nico_to_tag!(nico, linked)
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
@@ -53,7 +54,7 @@ RSpec.describe 'nico:sync' do
active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name')
expect(active_tag_names).to include('spec_kept')
expect(post.external_tags).to contain_exactly(nico)
expect(active_tag_names).to include('nico:AAA')
expect(active_tag_names).to include('spec_linked')
expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z'))
@@ -107,7 +108,7 @@ RSpec.describe 'nico:sync' do
expect(calls).to eq(2)
end
it '古い nico tag の関連を物理削除し、変更前後の履歴を version に残す' do
it '既存 post にあった古い nico tag は active から外され、履歴として discard される' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
@@ -115,12 +116,12 @@ RSpec.describe 'nico:sync' do
)
# 旧nicoタグ(今回の同期結果に含まれない)
old_nico = create_external_tag!('OLD')
PostExternalTag.create!(post:, external_tag: old_nico)
create_post_version_for!(post)
old_nico = create_tag!('nico:OLD', category: 'nico')
old_pt = PostTag.create!(post: post, tag: old_nico)
expect(old_pt.discarded_at).to be_nil
# 今回は NEW のみ欲しい
new_nico = create_external_tag!('NEW')
new_nico = create_tag!('nico:NEW', category: 'nico')
# bot/tagme 念のため
Tag.bot
@@ -131,21 +132,15 @@ RSpec.describe 'nico:sync' do
run_rake_task('nico:sync')
expect(PostExternalTag.exists?(post:, external_tag: old_nico)).to be(false)
expect(old_nico.reload.post_count).to eq(0)
expect(new_nico.reload.post_count).to eq(1)
versions = post.post_versions.order(:version_no)
expect(versions.first.tags_json.filter_map { |item| item['external_tag_id'] })
.to include(old_nico.id)
expect(versions.last.tags_json.filter_map { |item| item['external_tag_id'] })
.to include(new_nico.id)
expect(versions.last.tags_json.filter_map { |item| item['external_tag_id'] })
.not_to include(old_nico.id)
# OLD は active から外れる(discarded_at が入る)
old_pts = PostTag.where(post_id: post.id, tag_id: old_nico.id).order(:id).to_a
expect(old_pts.last.discarded_at).to be_present
# NEW は active にいる
post.reload
expect(post.external_tags).to contain_exactly(new_nico)
active_names = post.tags.joins(:tag_name).pluck('tag_names.name')
expect(active_names).to include('nico:NEW')
expect(active_names).not_to include('nico:OLD')
end
def snapshot_tags(post)
@@ -211,7 +206,7 @@ RSpec.describe 'nico:sync' do
create_post_version_for!(post)
linked = create_tag!('spec_linked', category: 'general')
nico = create_external_tag!('AAA')
nico = create_tag!('nico:AAA', category: 'nico')
link_nico_to_tag!(nico, linked)
Tag.bot
@@ -239,7 +234,7 @@ RSpec.describe 'nico:sync' do
end
it '既存 post に差分が無いときは新しい version を作らない' do
nico = create_external_tag!('AAA')
nico = create_tag!('nico:AAA', category: 'nico')
no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta')
post = Post.create!(
@@ -250,7 +245,7 @@ RSpec.describe 'nico:sync' do
original_created_before: Time.iso8601('2026-01-01T03:35:00Z')
)
PostExternalTag.create!(post:, external_tag: nico)
PostTag.create!(post: post, tag: nico)
PostTag.create!(post: post, tag: no_deerjikist)
create_post_version_for!(post)
@@ -293,7 +288,7 @@ RSpec.describe 'nico:sync' do
run_rake_task('nico:sync')
}.to change(NicoTagVersion, :count).by(1)
nico_tag = ExternalTag.find_by!(platform: :nico, name: 'AAA')
nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' })
version = nico_tag.nico_tag_versions.order(:version_no).last
expect(version.version_no).to eq(1)
@@ -376,148 +371,4 @@ RSpec.describe 'nico:sync' do
expect(versions.second.title).to eq('changed title')
expect(versions.second.tags).to eq(snapshot_tags(post.reload))
end
def create_external_tag!(name)
ExternalTag.create!(platform: :nico, name:)
end
def create_nico_sync_post!
post = Post.create!(
title: 't',
url: 'https://www.nicovideo.jp/watch/sm9',
uploaded_user: nil
)
PostTag.create!(post:, tag: Tag.no_deerjikist)
post
end
def run_nico_sync_with_tags! tags
stub_python([{
'code' => 'sm9',
'title' => 't',
'tags' => tags,
'user' => nil
}])
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
run_rake_task('nico:sync')
end
it '外部タグだけの変更では bot を付けず,投稿履歴を記録する' do
post = create_nico_sync_post!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
expect {
run_nico_sync_with_tags!(['raw tag[]', 'raw tag[]'])
}.to change(PostVersion, :count).by(1)
.and change(ExternalTag, :count).by(1)
.and change(PostExternalTag, :count).by(1)
.and change(NicoTagVersion, :count).by(1)
.and change(TagName, :count).by(0)
external = post.external_tags.sole
expect(external.name).to eq('raw tag[]')
expect(post.tags.map(&:name)).not_to include('bot操作')
expect(post.post_versions.order(:version_no).last.tags_json)
.to include('external_tag_id' => external.id)
expect {
run_nico_sync_with_tags!(['raw tag[]'])
}.to change(PostVersion, :count).by(0).and change(NicoTagVersion, :count).by(0)
expect {
run_nico_sync_with_tags!([])
}.to change(PostVersion, :count).by(1)
expect(post.reload.external_tags).to be_empty
expect(post.tags.map(&:name)).not_to include('bot操作')
end
it '外部タグが新規記載されたとき,その時点の連携タグを記載する' do
post = create_nico_sync_post!
external_tag = create_external_tag!('AAA')
linked_tag = create_tag!('spec_linked', category: :general)
link_nico_to_tag!(external_tag, linked_tag)
run_nico_sync_with_tags!(['AAA'])
expect(post.reload.external_tags).to include(external_tag)
expect(post.tags).to include(linked_tag)
end
it '外部タグに差分がない場合,内外マッピングが変はっても連携タグを再評価しない' do
post = create_nico_sync_post!
external_tag = create_external_tag!('AAA')
old_linked_tag = create_tag!('spec_old_linked', category: :general)
new_linked_tag = create_tag!('spec_new_linked', category: :general)
relation = link_nico_to_tag!(external_tag, old_linked_tag)
run_nico_sync_with_tags!(['AAA'])
expect(post.reload.tags).to include(old_linked_tag)
# 人手で連携タグを消除する.
PostTag.find_by!(post:, tag: old_linked_tag).destroy!
# 内外マッピングを変更する.
relation.destroy!
link_nico_to_tag!(external_tag, new_linked_tag)
# 外部タグ自体には差分が無い.
run_nico_sync_with_tags!(['AAA'])
post.reload
expect(post.external_tags).to include(external_tag)
expect(post.tags).not_to include(old_linked_tag)
expect(post.tags).not_to include(new_linked_tag)
end
it '外部タグの消除では連携タグを消除せず,再記載時にその時点の内外マッピングを適用する' do
post = create_nico_sync_post!
external_tag = create_external_tag!('AAA')
old_linked_tag = create_tag!('spec_old_linked', category: :general)
new_linked_tag = create_tag!('spec_new_linked', category: :general)
relation = link_nico_to_tag!(external_tag, old_linked_tag)
# 外部タグを新規記載する.
run_nico_sync_with_tags!(['AAA'])
post.reload
expect(post.external_tags).to include(external_tag)
expect(post.tags).to include(old_linked_tag)
# 外部タグを消除する.
run_nico_sync_with_tags!([])
post.reload
expect(post.external_tags).not_to include(external_tag)
# 外部タグの消除によって連携タグまでは消除されない.
expect(post.tags).to include(old_linked_tag)
# 外部タグが記載されてゐない間に内外マッピングを変更する.
relation.destroy!
link_nico_to_tag!(external_tag, new_linked_tag)
# 同じ外部タグを再記載する.
run_nico_sync_with_tags!(['AAA'])
post.reload
expect(post.external_tags).to include(external_tag)
# 旧連携タグは自動的には消除されない.
expect(post.tags).to include(old_linked_tag)
# 再記載時点の内外マッピングが新たに適用される.
expect(post.tags).to include(new_linked_tag)
end
end