コミットを比較
9
コミット
main
...
feature/419
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
d1bab299ab | ||
|
|
089727c153 | ||
|
|
894f9f9571 | ||
|
|
2490ed91c4 | ||
|
|
2dbd5260ea | ||
|
|
2e25c6e0ba | ||
|
|
a0c2788e00 | ||
|
|
31fc32b377 | ||
|
|
068355720d |
@@ -15,30 +15,35 @@ class NicoTagsController < ApplicationController
|
||||
limit = 1 if limit < 1
|
||||
|
||||
post_tag_max_sql =
|
||||
PostTag
|
||||
.select('tag_id, MAX(created_at) AS max_created_at')
|
||||
.group('tag_id')
|
||||
PostExternalTag
|
||||
.select('external_tag_id, MAX(created_at) AS max_created_at')
|
||||
.group('external_tag_id')
|
||||
.to_sql
|
||||
|
||||
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
|
||||
q =
|
||||
ExternalTag
|
||||
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
|
||||
'ON post_tag_max.external_tag_id = external_tags.id')
|
||||
.includes(linked_tags: { tag_name: :wiki_page })
|
||||
if name
|
||||
q = q.where(('external_tags.name LIKE ? ' +
|
||||
"OR CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?"),
|
||||
"%#{ name }%", "%#{ name }")
|
||||
end
|
||||
|
||||
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 = tags.id)'
|
||||
'WHERE nico_tag_relations.nico_tag_id = external_tags.id)'
|
||||
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
|
||||
end
|
||||
|
||||
@@ -46,21 +51,21 @@ class NicoTagsController < ApplicationController
|
||||
sort_sql =
|
||||
case order[0]
|
||||
when 'name'
|
||||
'tag_names.name'
|
||||
'external_tags.name'
|
||||
when 'updated_at'
|
||||
'post_tag_max.max_created_at'
|
||||
else
|
||||
"tags.#{ order[0] }"
|
||||
"external_tags.#{ order[0] }"
|
||||
end
|
||||
tags = q.reselect('tags.*',
|
||||
tags = q.reselect('external_tags.*',
|
||||
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
|
||||
.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
|
||||
.order(Arel.sql("#{ sort_sql } #{ order[1] }, external_tags.id #{ order[1] }"))
|
||||
.limit(limit)
|
||||
.offset((page - 1) * limit)
|
||||
.to_a
|
||||
|
||||
render json: { tags: tags.map { |tag|
|
||||
TagRepr.base(tag).merge(
|
||||
external_tag_json(tag).merge(
|
||||
recent_post_tag_created_at: tag.recent_post_tag_created_at,
|
||||
linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) })
|
||||
}, count: }
|
||||
@@ -72,8 +77,7 @@ class NicoTagsController < ApplicationController
|
||||
|
||||
id = params[:id].to_i
|
||||
|
||||
tag = Tag.find(id)
|
||||
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico?
|
||||
tag = ExternalTag.find(id)
|
||||
|
||||
linked_tag_names = params[:tags].to_s.split
|
||||
linked_tags = nil
|
||||
@@ -81,16 +85,15 @@ 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!(tag:, event_type: :update, created_by_user: current_user)
|
||||
NicoTagVersionRecorder.record!(external_tag: tag,
|
||||
event_type: :update,
|
||||
created_by_user: current_user)
|
||||
end
|
||||
|
||||
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
|
||||
@@ -102,6 +105,21 @@ 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: [],
|
||||
has_wiki: false,
|
||||
material_id: nil,
|
||||
has_deerjikists: false }
|
||||
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|
|
||||
|
||||
@@ -2,6 +2,9 @@ class PostVersionsController < ApplicationController
|
||||
def index
|
||||
post_id = params[:post].presence
|
||||
tag_id = params[:tag].presence&.to_i
|
||||
external_tag_id = params[:external_tag].presence&.to_i
|
||||
return head :bad_request if tag_id && external_tag_id
|
||||
|
||||
page = (params[:page].presence || 1).to_i
|
||||
limit = (params[:limit].presence || 20).to_i
|
||||
|
||||
@@ -26,9 +29,14 @@ class PostVersionsController < ApplicationController
|
||||
'prev.original_created_from AS prev_original_created_from',
|
||||
'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('id', #{ tag_id })) " +
|
||||
"OR JSON_CONTAINS(prev.tags_json, JSON_OBJECT('id', #{ tag_id }))")
|
||||
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
|
||||
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
|
||||
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id })) " +
|
||||
'OR JSON_CONTAINS(prev.tags_json,' +
|
||||
"JSON_OBJECT('external_tag_id', #{ external_tag_id || tag_id }))")
|
||||
elsif 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 }))")
|
||||
end
|
||||
|
||||
count = q.except(:select, :order, :limit, :offset).count
|
||||
@@ -43,50 +51,19 @@ 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 =
|
||||
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) }
|
||||
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)
|
||||
|
||||
{ post_id: row.post_id,
|
||||
version_no: row.version_no,
|
||||
@@ -113,21 +90,52 @@ class PostVersionsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def build_version_tags(cur_tags, prev_tags)
|
||||
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
|
||||
(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
|
||||
|
||||
@@ -49,7 +49,7 @@ class PostsController < ApplicationController
|
||||
filtered_posts
|
||||
.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,
|
||||
.preload(:external_tags, :uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_name: :wiki_page }] }])
|
||||
.with_attached_thumbnail
|
||||
@@ -102,12 +102,14 @@ class PostsController < ApplicationController
|
||||
end
|
||||
|
||||
def random
|
||||
post = filtered_posts.preload(:uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_name: :wiki_page }] }])
|
||||
.with_attached_thumbnail
|
||||
.order('RAND()')
|
||||
.first
|
||||
post =
|
||||
filtered_posts
|
||||
.preload(:uploaded_user, :parents, :children,
|
||||
post_tags: [:sections, { tag: [:deerjikists, :materials,
|
||||
{ tag_name: :wiki_page }] }])
|
||||
.with_attached_thumbnail
|
||||
.order('RAND()')
|
||||
.first
|
||||
return head :not_found unless post
|
||||
|
||||
render json: PostRepr.base(post, current_user)
|
||||
@@ -426,8 +428,20 @@ class PostsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def tagged_post_ids_for(name) =
|
||||
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
|
||||
def tagged_post_ids_for(name)
|
||||
posts_by_internal_tags =
|
||||
Post
|
||||
.joins(tags: :tag_name)
|
||||
.where(tag_names: { name: })
|
||||
.select(:id)
|
||||
|
||||
posts_by_external_tags =
|
||||
Post
|
||||
.joins(:external_tags)
|
||||
.where("CONCAT(external_tags.platform, ':', external_tags.name) = ?", name)
|
||||
|
||||
(posts_by_internal_tags + posts_by_external_tags).uniq(&:id)
|
||||
end
|
||||
|
||||
def sync_post_tags! post, desired_tags, sections
|
||||
desired_tags.each do |t|
|
||||
@@ -505,7 +519,10 @@ class PostsController < ApplicationController
|
||||
memo[tag_id] = TagRepr.inline(tag).merge(children:, sections:)
|
||||
end
|
||||
|
||||
root_ids.filter_map { |id| build_node.call(id, []) }
|
||||
internal_tags = root_ids.filter_map { |id| build_node.call(id, []) }
|
||||
external_tags =
|
||||
post.external_tags.map { ExternalTagRepr.inline(_1).merge(children: [], sections: []) }
|
||||
internal_tags + external_tags
|
||||
end
|
||||
|
||||
def sibling_posts_by_parent parent_post_ids
|
||||
@@ -653,7 +670,7 @@ class PostsController < ApplicationController
|
||||
|
||||
def editable_tag_names_from_version version
|
||||
version.tags_json
|
||||
.reject { _1.fetch('category') == 'nico' }
|
||||
.select { _1.key?('tag_id') }
|
||||
.map { Post.tag_snapshot_literal(_1) }
|
||||
.sort
|
||||
end
|
||||
@@ -671,7 +688,6 @@ class PostsController < ApplicationController
|
||||
post
|
||||
.post_tags
|
||||
.joins(tag: :tag_name)
|
||||
.merge(Tag.not_nico)
|
||||
.merge(Tag.where(deprecated_at: nil))
|
||||
.includes(:sections, tag: :tag_name)
|
||||
.order('tag_names.name')
|
||||
@@ -689,8 +705,7 @@ 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:)
|
||||
@@ -833,15 +848,12 @@ 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: editable_tags, sections: }
|
||||
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@@ -10,7 +10,6 @@ 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)
|
||||
@@ -33,7 +32,6 @@ 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)
|
||||
|
||||
@@ -34,49 +34,155 @@ class TagsController < ApplicationController
|
||||
|
||||
offset = (page - 1) * limit
|
||||
|
||||
q =
|
||||
tags =
|
||||
if post_id.present?
|
||||
Tag.joins(:posts, :tag_name)
|
||||
Tag.joins(:posts, :tag_name).where(posts: { id: post_id })
|
||||
else
|
||||
Tag.joins(:tag_name)
|
||||
end
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
q = q.where(posts: { id: post_id }) if post_id.present?
|
||||
|
||||
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
|
||||
q = q.where(category:) if category
|
||||
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
|
||||
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
|
||||
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
|
||||
q = q.where('tags.created_at <= ?', created_between[1]) if created_between[1]
|
||||
q = q.where('tags.updated_at >= ?', updated_between[0]) if updated_between[0]
|
||||
q = q.where('tags.updated_at <= ?', updated_between[1]) if updated_between[1]
|
||||
if deprecated_given
|
||||
q = deprecated ? q.where.not(deprecated_at: nil) : q.where(deprecated_at: nil)
|
||||
external_tags =
|
||||
if post_id.present?
|
||||
ExternalTag.joins(:posts).where(posts: { id: post_id })
|
||||
else
|
||||
ExternalTag.all
|
||||
end
|
||||
|
||||
if name
|
||||
tags = tags.where('tag_names.name LIKE ?', "%#{ name }%")
|
||||
external_tags =
|
||||
external_tags.where("CONCAT(external_tags.platform, ':', external_tags.name) LIKE ?",
|
||||
"%#{ name }%")
|
||||
end
|
||||
|
||||
if category == 'nico'
|
||||
tags = tags.none
|
||||
elsif category
|
||||
tags = tags.where(category:)
|
||||
external_tags = external_tags.none
|
||||
end
|
||||
|
||||
if post_count_between[0]
|
||||
tags = tags.where('tags.post_count >= ?', post_count_between[0])
|
||||
external_tags = external_tags.where('external_tags.post_count >= ?', post_count_between[0])
|
||||
end
|
||||
|
||||
if post_count_between[1]
|
||||
tags = tags.where('tags.post_count <= ?', post_count_between[1])
|
||||
external_tags = external_tags.where('external_tags.post_count <= ?', post_count_between[1])
|
||||
end
|
||||
|
||||
if created_between[0]
|
||||
tags = tags.where('tags.created_at >= ?', created_between[0])
|
||||
external_tags = external_tags.where('external_tags.created_at >= ?', created_between[0])
|
||||
end
|
||||
|
||||
if created_between[1]
|
||||
tags = tags.where('tags.created_at <= ?', created_between[1])
|
||||
external_tags = external_tags.where('external_tags.created_at <= ?', created_between[1])
|
||||
end
|
||||
|
||||
if updated_between[0]
|
||||
tags = tags.where('tags.updated_at >= ?', updated_between[0])
|
||||
external_tags = external_tags.where('external_tags.created_at >= ?', updated_between[0])
|
||||
end
|
||||
|
||||
if updated_between[1]
|
||||
tags = tags.where('tags.updated_at <= ?', updated_between[1])
|
||||
external_tags = external_tags.where('external_tags.created_at <= ?', updated_between[1])
|
||||
end
|
||||
|
||||
if deprecated_given
|
||||
if deprecated
|
||||
tags = tags.where.not(deprecated_at: nil)
|
||||
external_tags = external_tags.none
|
||||
else
|
||||
tags = tags.where(deprecated_at: nil)
|
||||
end
|
||||
end
|
||||
|
||||
tag_sql = tags.select("'tag' AS source",
|
||||
'tags.id',
|
||||
'tag_names.name',
|
||||
'tags.category',
|
||||
'tags.post_count',
|
||||
'tags.created_at',
|
||||
'tags.updated_at',
|
||||
'tags.deprecated_at').to_sql
|
||||
|
||||
external_tag_sql = external_tags.select(
|
||||
"'external_tag' AS source",
|
||||
'external_tags.id',
|
||||
"CONCAT(external_tags.platform, ':', external_tags.name) AS name",
|
||||
"'nico' AS category",
|
||||
'external_tags.post_count',
|
||||
'external_tags.created_at',
|
||||
'external_tags.created_at AS updated_at',
|
||||
'NULL AS deprecated_at').to_sql
|
||||
|
||||
union_sql = "#{ tag_sql } UNION ALL #{ external_tag_sql }"
|
||||
|
||||
sort_sql =
|
||||
case order[0]
|
||||
when 'name'
|
||||
'tag_names.name'
|
||||
when 'category'
|
||||
'CASE tags.category ' +
|
||||
if order[0] == 'category'
|
||||
'CASE category ' +
|
||||
"WHEN 'deerjikist' THEN 0 " +
|
||||
"WHEN 'meme' THEN 1 " +
|
||||
"WHEN 'character' THEN 2 " +
|
||||
"WHEN 'general' THEN 3 " +
|
||||
"WHEN 'material' THEN 4 " +
|
||||
"WHEN 'meta' THEN 5 " +
|
||||
"WHEN 'nico' THEN 6 END"
|
||||
"WHEN 'nico' THEN 6 " +
|
||||
'END'
|
||||
else
|
||||
"tags.#{ order[0] }"
|
||||
order[0]
|
||||
end
|
||||
tags = q.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.to_a
|
||||
|
||||
render json: { tags: TagRepr.many(tags), count: q.size }
|
||||
connection = ApplicationRecord.connection
|
||||
|
||||
count = connection.select_value(<<~SQL)
|
||||
SELECT
|
||||
COUNT(0)
|
||||
FROM
|
||||
(#{ union_sql }) legacy_tags
|
||||
SQL
|
||||
|
||||
rows = connection.select_all(<<~SQL).to_a
|
||||
SELECT
|
||||
source
|
||||
, id
|
||||
FROM
|
||||
(#{ union_sql }) legacy_tags
|
||||
ORDER BY
|
||||
#{ sort_sql } #{ order[1] }
|
||||
, id #{ order[1] }
|
||||
, source #{ order[1] }
|
||||
LIMIT
|
||||
#{ limit }
|
||||
OFFSET
|
||||
#{ offset }
|
||||
SQL
|
||||
|
||||
tag_ids = rows.filter { _1['source'] == 'tag' }.map { _1['id'] }
|
||||
external_tag_ids = rows.filter { _1['source'] == 'external_tag' }.map { _1['id'] }
|
||||
|
||||
tags_by_id =
|
||||
Tag
|
||||
.joins(:tag_name)
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
.where(id: tag_ids)
|
||||
.index_by(&:id)
|
||||
external_tags_by_id =
|
||||
ExternalTag
|
||||
.where(id: external_tag_ids)
|
||||
.index_by(&:id)
|
||||
|
||||
render json: { tags: rows.map { |row|
|
||||
if row['source'] == 'tag'
|
||||
TagRepr.base(tags_by_id.fetch(row['id']))
|
||||
else
|
||||
external_tag_json(external_tags_by_id.fetch(row['id']))
|
||||
end
|
||||
}, count: }
|
||||
end
|
||||
|
||||
def with_depth
|
||||
@@ -100,13 +206,14 @@ 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 ?', "#{ q }%")
|
||||
.where('name LIKE ?', prefix)
|
||||
.where.not(canonical_id: nil)
|
||||
.pluck(:canonical_id, :name)
|
||||
|
||||
@@ -118,29 +225,43 @@ 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(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') +
|
||||
'tag_names.name LIKE ?'),
|
||||
*(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%")
|
||||
canonical_hit = base.where('tag_names.name LIKE ?', prefix)
|
||||
|
||||
tags =
|
||||
if canonical_ids.present?
|
||||
canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
|
||||
else
|
||||
canonical_hit
|
||||
end
|
||||
internal_tags = canonical_hit.or(base.where(tag_name_id: canonical_ids.uniq))
|
||||
|
||||
tags = tags.order(Arel.sql('post_count DESC, tag_names.name')).limit(20).to_a
|
||||
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])
|
||||
}
|
||||
|
||||
render json: tags.map { |tag|
|
||||
TagRepr.base(tag).merge(matched_alias: matched_alias_by_tag_name_id[tag.tag_name_id])
|
||||
}
|
||||
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 { external_tag_json(_1) }
|
||||
|
||||
rows =
|
||||
(internal_rows + external_rows)
|
||||
.sort_by { |row| [-row['post_count'], row['name']] }
|
||||
.first(20)
|
||||
|
||||
render json: rows
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -158,14 +279,20 @@ class TagsController < ApplicationController
|
||||
name = params[:name].to_s.strip
|
||||
return render_bad_request('name は必須です.') if name.blank?
|
||||
|
||||
tag = Tag.joins(:tag_name)
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
.find_by(tag_names: { name: })
|
||||
if tag
|
||||
render json: TagRepr.base(tag)
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
tag =
|
||||
Tag
|
||||
.joins(:tag_name)
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
.find_by(tag_names: { name: })
|
||||
return render json: TagRepr.base(tag) if tag
|
||||
|
||||
platform, external_name = name.split(':', 2)
|
||||
return head :not_found unless external_name
|
||||
|
||||
external_tag = ExternalTag.find_by(platform:, name: external_name)
|
||||
return head :not_found unless external_tag
|
||||
|
||||
render json: ExternalTagRepr.base(external_tag)
|
||||
end
|
||||
|
||||
def deerjikists
|
||||
@@ -282,11 +409,7 @@ class TagsController < ApplicationController
|
||||
parent_names = params[:parent_tags].to_s.split.uniq
|
||||
deprecated = bool?(:deprecated)
|
||||
|
||||
if tag.nico? && deprecated
|
||||
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
|
||||
end
|
||||
|
||||
if tag.nico? || category == 'nico'
|
||||
if category == 'nico'
|
||||
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
||||
end
|
||||
|
||||
@@ -334,13 +457,9 @@ 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 tag.nico? || (category.present? && category == 'nico')
|
||||
if category.present? && category == 'nico'
|
||||
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
||||
end
|
||||
|
||||
@@ -547,11 +666,6 @@ 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
|
||||
@@ -661,8 +775,7 @@ class TagsController < ApplicationController
|
||||
|
||||
def update_parent_tags! tag, parent_names
|
||||
parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false,
|
||||
with_no_deerjikist: false,
|
||||
deny_nico: true)
|
||||
with_no_deerjikist: false)
|
||||
|
||||
old_parent_tags = tag.parents.to_a
|
||||
|
||||
@@ -790,4 +903,20 @@ class TagsController < ApplicationController
|
||||
|
||||
render_validation_error fields:
|
||||
end
|
||||
|
||||
def external_tag_json 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,
|
||||
'aliases' => [],
|
||||
'parents' => [],
|
||||
'has_wiki' => false,
|
||||
'material_id' => nil,
|
||||
'has_deerjikists' => false }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
@@ -1,24 +1,10 @@
|
||||
class NicoTagRelation < ApplicationRecord
|
||||
belongs_to :nico_tag, class_name: 'Tag'
|
||||
belongs_to :tag, class_name: 'Tag'
|
||||
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
|
||||
|
||||
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,7 +1,7 @@
|
||||
class NicoTagVersion < ApplicationRecord
|
||||
include VersionRecord
|
||||
|
||||
belongs_to :tag
|
||||
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
|
||||
|
||||
validates :name, presence: true
|
||||
end
|
||||
|
||||
+24
-13
@@ -92,6 +92,9 @@ 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
|
||||
@@ -150,19 +153,27 @@ class Post < ApplicationRecord
|
||||
end
|
||||
|
||||
def snapshot_tags_json
|
||||
post_tags
|
||||
.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
|
||||
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
|
||||
end
|
||||
|
||||
def self.section_literal section
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
class PostExternalTag < ApplicationRecord
|
||||
belongs_to :post
|
||||
belongs_to :external_tag, counter_cache: :post_count
|
||||
end
|
||||
+10
-29
@@ -28,11 +28,10 @@ class Tag < ApplicationRecord
|
||||
has_many :post_tags, inverse_of: :tag
|
||||
has_many :posts, through: :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
|
||||
@@ -50,7 +49,6 @@ 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
|
||||
@@ -65,17 +63,13 @@ class Tag < ApplicationRecord
|
||||
character: 'character',
|
||||
general: 'general',
|
||||
material: 'material',
|
||||
nico: 'nico',
|
||||
meta: 'meta'
|
||||
|
||||
validates :category, presence: true, inclusion: { in: Tag.categories.keys }
|
||||
|
||||
validate :nico_tag_name_must_start_with_nico
|
||||
validate :tag_name_mustnt_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,
|
||||
@@ -110,10 +104,9 @@ 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 deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
|
||||
if tag_names.any? { |n| n.downcase.start_with?('nico:') }
|
||||
raise NicoTagNormalisationError
|
||||
end
|
||||
|
||||
@@ -273,12 +266,8 @@ class Tag < ApplicationRecord
|
||||
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
|
||||
source_tag.destroy!
|
||||
|
||||
if source_tag.nico?
|
||||
source_tag_name.destroy!
|
||||
else
|
||||
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
|
||||
updated_at: Time.current)
|
||||
end
|
||||
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
|
||||
updated_at: Time.current)
|
||||
|
||||
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
|
||||
end
|
||||
@@ -305,11 +294,9 @@ class Tag < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
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, 'ニコニコ・タグの命名規則に反してゐます.'
|
||||
def tag_name_mustnt_start_with_nico
|
||||
if name.to_s.downcase.start_with?('nico:')
|
||||
errors.add :name, 'タグの命名規則に反してゐます.'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -350,10 +337,4 @@ 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
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
module ExternalTagRepr
|
||||
module_function
|
||||
|
||||
def base 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' => [],
|
||||
'has_wiki' => false,
|
||||
'material_id' => nil,
|
||||
'has_deerjikists' => false }
|
||||
end
|
||||
|
||||
def inline(tag) = base(tag)
|
||||
end
|
||||
@@ -87,15 +87,24 @@ module PostRepr
|
||||
end
|
||||
|
||||
def tag_json post
|
||||
post
|
||||
internal_tags =
|
||||
post
|
||||
.post_tags
|
||||
.reject { _1.tag.deprecated? }
|
||||
.sort_by { _1.tag.name }
|
||||
.map do |post_tag|
|
||||
TagRepr.inline(post_tag.tag).merge(
|
||||
'children' => [],
|
||||
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
|
||||
end
|
||||
.map { |post_tag|
|
||||
TagRepr.inline(post_tag.tag).merge(
|
||||
'children' => [],
|
||||
'sections' => post_tag.sections.as_json(only: [:begin_ms, :end_ms]))
|
||||
}
|
||||
|
||||
external_tags =
|
||||
post
|
||||
.external_tags
|
||||
.sort_by { _1.name }
|
||||
.map { ExternalTagRepr.base(_1).merge('children' => [], 'sections' => []) }
|
||||
|
||||
internal_tags + external_tags
|
||||
end
|
||||
|
||||
def thumbnail_url post, host: nil
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
class NicoTagVersionRecorder < VersionRecorder
|
||||
def self.record! tag:, event_type:, created_by_user:
|
||||
new(tag:, event_type:, created_by_user:).record!
|
||||
def self.record! external_tag:, event_type:, created_by_user:
|
||||
new(external_tag:, event_type:, created_by_user:).record!
|
||||
end
|
||||
|
||||
def initialize tag:, event_type:, created_by_user:
|
||||
super(record: tag, event_type:, created_by_user:)
|
||||
def initialize external_tag:, event_type:, created_by_user:
|
||||
super(record: external_tag, event_type:, created_by_user:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def version_class = NicoTagVersion
|
||||
def version_association = :nico_tag_versions
|
||||
def record_key = :tag
|
||||
def record_key = :external_tag
|
||||
|
||||
def snapshot_attributes
|
||||
{ name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') }
|
||||
{ name: "#{ @record.platform }:#{ @record.name }",
|
||||
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
|
||||
end
|
||||
|
||||
def tracks_version_no_on_record? = false
|
||||
end
|
||||
|
||||
@@ -20,23 +20,22 @@ 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
|
||||
@@ -50,7 +49,6 @@ 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 << {
|
||||
@@ -139,14 +137,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({ }) do |spec, merged|
|
||||
specs.each_with_object({ }) { |spec, merged|
|
||||
merged[spec[:name]] =
|
||||
if merged.key?(spec[:name]) && merged[spec[:name]][:category] != :general
|
||||
merged[spec[:name]]
|
||||
@@ -155,7 +153,7 @@ class PostCreatePlan
|
||||
name: spec[:name],
|
||||
category: spec[:category] }
|
||||
end
|
||||
end.values.sort_by { _1[:name] }
|
||||
}.values.sort_by { _1[:name] }
|
||||
end
|
||||
|
||||
def existing_tags_by_name
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
class TagVersioning
|
||||
def self.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
|
||||
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
||||
end
|
||||
|
||||
def self.ensure_snapshot! tag, created_by_user:
|
||||
if tag.nico?
|
||||
return if tag.nico_tag_versions.exists?
|
||||
return if tag.tag_versions.exists?
|
||||
|
||||
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
|
||||
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
|
||||
end
|
||||
|
||||
def self.record_tag_snapshot! tag, created_by_user:
|
||||
event_type =
|
||||
if tag.nico?
|
||||
tag.nico_tag_versions.exists? ? :update : :create
|
||||
else
|
||||
tag.tag_versions.exists? ? :update : :create
|
||||
end
|
||||
|
||||
event_type = tag.tag_versions.exists? ? :update : :create
|
||||
record!(tag, event_type:, created_by_user:)
|
||||
end
|
||||
|
||||
|
||||
@@ -47,10 +47,14 @@ 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"
|
||||
@@ -60,7 +64,7 @@ class VersionRecorder
|
||||
raise "#{ version_class.name } create event already exists"
|
||||
end
|
||||
|
||||
return unless latest
|
||||
return if !(latest) || !(tracks_version_no_on_record?)
|
||||
|
||||
if @record.version_no != latest.version_no
|
||||
raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " +
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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
|
||||
@@ -0,0 +1,29 @@
|
||||
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
|
||||
@@ -0,0 +1,6 @@
|
||||
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
|
||||
@@ -0,0 +1,199 @@
|
||||
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
|
||||
生成ファイル
+20
-3
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_09_21_230000) 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,6 +48,14 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) 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
|
||||
@@ -281,6 +289,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) 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
|
||||
@@ -352,7 +367,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) 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\":{\"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"
|
||||
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"
|
||||
end
|
||||
|
||||
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
@@ -700,9 +715,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_09_21_030000) do
|
||||
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", "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"
|
||||
|
||||
@@ -30,9 +30,28 @@ namespace :nico do
|
||||
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).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
|
||||
end
|
||||
|
||||
PostExternalTag
|
||||
.where(post_id: post.id, external_tag_id: to_remove.to_a)
|
||||
.find_each(&:destroy!)
|
||||
end
|
||||
|
||||
mysql_user = ENV['MYSQL_USER']
|
||||
@@ -111,58 +130,63 @@ 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 = tags.pluck(:id).to_set
|
||||
# うち内部タグ Id. 集合
|
||||
kept_non_nico_tag_ids = tags.not_nico.pluck(:id).to_set
|
||||
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 = []
|
||||
|
||||
# 記載すべき外部タグ Id. および連携される内部タグ Id. のリスト
|
||||
desired_nico_tag_based_ids = []
|
||||
# 記載すべき内部タグ Id. のリスト
|
||||
desired_non_nico_tag_ids = []
|
||||
desired_tag_ids = kept_tag_ids.to_a
|
||||
|
||||
datum['tags'].each do |raw|
|
||||
name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
|
||||
tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
|
||||
tag = ExternalTag.find_or_create_by!(platform: :nico, name: raw)
|
||||
|
||||
event_type = tag.nico_tag_versions.exists? ? :update : :create
|
||||
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil)
|
||||
unless tag.nico_tag_versions.exists?
|
||||
NicoTagVersionRecorder.record!(external_tag: tag,
|
||||
event_type: :create,
|
||||
created_by_user: nil)
|
||||
end
|
||||
|
||||
desired_nico_tag_based_ids << tag.id
|
||||
desired_external_tag_ids << tag.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)
|
||||
# 連携タグは記載すれども消除せず.
|
||||
unless tag.id.in?(kept_external_tag_ids)
|
||||
desired_tag_ids.concat(tag.linked_tags.pluck(:id))
|
||||
end
|
||||
end
|
||||
|
||||
deerjikist = Deerjikist.find_by(platform: :nico, code: datum['user'])
|
||||
if deerjikist
|
||||
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
|
||||
desired_tag_ids << deerjikist.tag_id
|
||||
elsif !(Tag.where(id: kept_tag_ids).where(category: :deerjikist).exists?)
|
||||
desired_tag_ids << Tag.no_deerjikist.id
|
||||
end
|
||||
|
||||
desired_nico_tag_based_ids.uniq!
|
||||
desired_external_tag_ids.uniq!
|
||||
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
|
||||
# 外部タグの記載に際しては “bot 操作” タグを記載しなぃ.
|
||||
if kept_tag_ids != desired_tag_ids.to_set
|
||||
desired_tag_ids << Tag.bot.id
|
||||
desired_tag_ids.uniq!
|
||||
end
|
||||
desired_all_tag_ids.uniq!
|
||||
|
||||
sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids)
|
||||
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)
|
||||
|
||||
if post_created
|
||||
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
|
||||
elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set
|
||||
elsif post_changed || tags_changed
|
||||
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
|
||||
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
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
|
||||
@@ -0,0 +1,6 @@
|
||||
FactoryBot.define do
|
||||
factory :external_tag do
|
||||
platform { :nico }
|
||||
sequence(:name) { |n| "external_tag_#{ n }" }
|
||||
end
|
||||
end
|
||||
@@ -11,12 +11,5 @@ 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
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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
|
||||
@@ -0,0 +1,27 @@
|
||||
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
|
||||
@@ -0,0 +1,25 @@
|
||||
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
|
||||
@@ -6,6 +6,25 @@ 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!(
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
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')
|
||||
@@ -159,20 +182,6 @@ RSpec.describe Tag, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
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).not_to be_valid
|
||||
expect(tag.errors[:deprecated_at]).to include('ニコタグは廃止できません.')
|
||||
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
|
||||
@@ -290,7 +299,7 @@ RSpec.describe Tag, type: :model do
|
||||
it 'deletes source relationships while preserving unrelated relationships' do
|
||||
parent = create(:tag)
|
||||
child = create(:tag)
|
||||
nico_tag = create(:tag, :nico)
|
||||
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)
|
||||
@@ -412,48 +421,6 @@ RSpec.describe Tag, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when merging a nico source tag' do
|
||||
let!(:target_tag) do
|
||||
create(:tag, category: :nico, tag_name: create(:tag_name, name: 'nico:foo'))
|
||||
end
|
||||
let!(:source_tag) do
|
||||
create(:tag, category: :nico, tag_name: create(:tag_name, name: 'nico:bar'))
|
||||
end
|
||||
let!(:source_tag_name_id) { source_tag.tag_name_id }
|
||||
|
||||
it 'deletes the source tag and name instead of keeping an alias' do
|
||||
described_class.merge_tags!(target_tag, [source_tag])
|
||||
|
||||
expect(Tag.unscoped.exists?(source_tag.id)).to be(false)
|
||||
expect(TagName.unscoped.exists?(source_tag_name_id)).to be(false)
|
||||
expect(target_tag.reload.post_count).to eq(0)
|
||||
end
|
||||
|
||||
it 'keeps nico history while deleting source links and allows recreating the name' do
|
||||
linked_tag = create(:tag)
|
||||
NicoTagRelation.create!(nico_tag: source_tag, tag: linked_tag)
|
||||
kept_relation = NicoTagRelation.create!(nico_tag: target_tag, tag: linked_tag)
|
||||
user = create_member_user!
|
||||
source_name = source_tag.name
|
||||
|
||||
described_class.merge_tags!(target_tag, [source_tag], created_by_user: user)
|
||||
|
||||
expect(NicoTagRelation.all).to contain_exactly(kept_relation)
|
||||
versions = NicoTagVersion.where(tag_id: source_tag.id).order(:version_no)
|
||||
expect(versions.pluck(:version_no, :event_type))
|
||||
.to eq([[1, 'create'], [2, 'discard']])
|
||||
expect(versions.last).to have_attributes(
|
||||
name: source_name, linked_tags: linked_tag.name, created_by_user: user)
|
||||
|
||||
recreated = described_class.find_or_create_by_tag_name!(source_name, category: :nico)
|
||||
|
||||
expect(recreated.id).not_to eq(source_tag.id)
|
||||
expect(recreated.tag_name_id).not_to eq(source_tag_name_id)
|
||||
expect(recreated.nico_tag_versions).to be_empty
|
||||
expect(versions.reload.size).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
def snapshot_tags(post)
|
||||
post.snapshot_tag_names.join(' ')
|
||||
end
|
||||
@@ -499,8 +466,8 @@ 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('id') }).to eq([target_tag.id])
|
||||
expect(affected_versions.first.tags_json.map { |item| item.fetch('id') })
|
||||
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)
|
||||
|
||||
@@ -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(:tag, :nico, name: 'nico:version_record_tag') }
|
||||
let!(:nico_tag) { create(:external_tag, name: '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!(
|
||||
tag: nico_tag,
|
||||
external_tag: nico_tag,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: nico_tag.name,
|
||||
name: "nico:#{ 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!(
|
||||
tag: nico_tag,
|
||||
external_tag: nico_tag,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: nico_tag.name,
|
||||
name: "nico:#{ nico_tag.name }",
|
||||
linked_tags: '',
|
||||
created_at: Time.current,
|
||||
created_by_user: nil
|
||||
|
||||
@@ -3,10 +3,34 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe 'NicoTags', type: :request do
|
||||
describe 'GET /tags/nico' do
|
||||
it 'returns paginated tags and total count' do
|
||||
create_list(:tag, 3, :nico)
|
||||
it 'returns the legacy Tag-compatible external fields' do
|
||||
external = create(:external_tag, name: 'legacy_external', post_count: 3)
|
||||
|
||||
get '/tags/nico', params: { page: 2, limit: 2 }
|
||||
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 }") }
|
||||
|
||||
get '/tags/nico', params: { page: 2, limit: 2, name: 'pagination_' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json['tags'].size).to eq(1)
|
||||
@@ -14,12 +38,12 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
end
|
||||
|
||||
it 'filters by nico tag name, linked tag name, and link status' do
|
||||
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')
|
||||
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')
|
||||
destination = create(:tag, :general)
|
||||
destination.tag_name.update!(name: 'destination_search')
|
||||
NicoTagRelation.create!(nico_tag: linked, tag: destination)
|
||||
@@ -41,27 +65,41 @@ 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(:tag, :nico)
|
||||
older.tag_name.update!(name: 'nico:a')
|
||||
older = create(:external_tag)
|
||||
older.update!(name: 'ordered_a')
|
||||
older.update_columns(created_at: 2.days.ago)
|
||||
newer = create(:tag, :nico)
|
||||
newer.tag_name.update!(name: 'nico:b')
|
||||
newer = create(:external_tag)
|
||||
newer.update!(name: 'ordered_b')
|
||||
newer.update_columns(created_at: 1.day.ago)
|
||||
older_post_tag =
|
||||
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older)
|
||||
PostExternalTag.create!(post: create(:post), external_tag: older)
|
||||
older_post_tag.update_columns(created_at: 1.hour.ago)
|
||||
newer_post_tag =
|
||||
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer)
|
||||
PostExternalTag.create!(post: create(:post), external_tag: newer)
|
||||
newer_post_tag.update_columns(created_at: 2.hours.ago)
|
||||
|
||||
get '/tags/nico', params: { order: 'name:desc' }
|
||||
get '/tags/nico', params: { order: 'name:desc', name: 'ordered_' }
|
||||
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id])
|
||||
|
||||
get '/tags/nico', params: { order: 'created_at:asc' }
|
||||
get '/tags/nico', params: { order: 'created_at:asc', name: 'ordered_' }
|
||||
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
|
||||
|
||||
get '/tags/nico', params: { order: 'updated_at:desc' }
|
||||
get '/tags/nico', params: { order: 'updated_at:desc', name: 'ordered_' }
|
||||
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)
|
||||
@@ -71,7 +109,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(:tag, :nico) }
|
||||
let(:nico_tag) { create(:external_tag) }
|
||||
|
||||
it '401 when not logged in' do
|
||||
sign_out
|
||||
@@ -85,18 +123,18 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
|
||||
it '400 when target is not nico category' do
|
||||
it '404 when only an internal tag exists for the target id' 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(:bad_request)
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it '200 and updates linked tags while recording tag versions' do
|
||||
sign_in_as(admin)
|
||||
|
||||
nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source')
|
||||
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
|
||||
nico_tag = create(:external_tag, name: 'nico_tags_spec_source')
|
||||
|
||||
linked_a_name = TagName.create!(name: 'nico_linked_a')
|
||||
linked_a = Tag.create!(tag_name: linked_a_name, category: :general)
|
||||
@@ -104,7 +142,8 @@ 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)
|
||||
|
||||
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin)
|
||||
NicoTagVersionRecorder.record!(external_tag: nico_tag,
|
||||
event_type: :create, created_by_user: admin)
|
||||
|
||||
expect {
|
||||
patch "/tags/nico/#{nico_tag.id}", params: {
|
||||
@@ -126,26 +165,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 'returns 422 when linked tag normalises to nico tag' do
|
||||
it 'clears existing links and records the empty mapping for a member' do
|
||||
sign_in_as(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)
|
||||
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)
|
||||
|
||||
expect {
|
||||
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
|
||||
}.not_to change(NicoTagVersion, :count)
|
||||
patch "/tags/nico/#{ nico_tag.id }", params: { tags: '' }
|
||||
}.to change(NicoTagVersion, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'tags' => ['ニコニコ・タグ同士は連携できません.'])
|
||||
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)
|
||||
end
|
||||
|
||||
it 'returns the tags field error when a nico tag is specified directly' do
|
||||
|
||||
@@ -17,7 +17,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
|
||||
def create_nico_tag!(name)
|
||||
Tag.find_or_create_by_tag_name!(name, category: :nico)
|
||||
ExternalTag.find_or_create_by!(platform: :nico, name: name.delete_prefix('nico:'))
|
||||
end
|
||||
|
||||
def dummy_upload
|
||||
@@ -93,6 +93,26 @@ 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) }
|
||||
|
||||
@@ -233,6 +253,93 @@ 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) }
|
||||
@@ -612,6 +719,30 @@ 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
|
||||
@@ -769,6 +900,25 @@ 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
|
||||
@@ -1250,9 +1400,9 @@ RSpec.describe 'Posts API', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
context 'when nico tag already exists in tags' do
|
||||
context 'when the external nico tag already exists' do
|
||||
before do
|
||||
Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico)
|
||||
create(:external_tag, name: 'nico_tag')
|
||||
end
|
||||
|
||||
it 'returns 422 with tag field errors' do
|
||||
@@ -1530,9 +1680,9 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
versions = post_record.post_versions.order(:version_no)
|
||||
expect(versions.first.tags_json).to include(
|
||||
a_hash_including('id' => tag.id,
|
||||
a_hash_including('tag_id' => tag.id,
|
||||
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => 2000 }]))
|
||||
expect(versions.last.tags_json.map { |item| item.fetch('id') })
|
||||
expect(versions.last.tags_json.map { |item| item.fetch('tag_id') })
|
||||
.not_to include(tag.id)
|
||||
end
|
||||
|
||||
@@ -1551,7 +1701,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
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('id') }
|
||||
version.tags_json.map { |item| item.fetch('tag_id') }
|
||||
end
|
||||
expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true])
|
||||
end
|
||||
@@ -1576,9 +1726,9 @@ RSpec.describe 'Posts API', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
context 'when nico tag already exists in tags' do
|
||||
context 'when the external nico tag already exists' do
|
||||
before do
|
||||
Tag.find_or_create_by_tag_name!('nico:nico_tag', category: :nico)
|
||||
create(:external_tag, name: 'nico_tag')
|
||||
end
|
||||
|
||||
it 'returns 422 with tag field errors' do
|
||||
@@ -1895,7 +2045,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')
|
||||
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
|
||||
PostExternalTag.create!(post: post_record, external_tag: nico_tag)
|
||||
|
||||
PostVersionRecorder.record!(
|
||||
post: post_record.reload,
|
||||
@@ -1915,14 +2065,14 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
expect(names).to include('spec_tag')
|
||||
expect(names).to include(Tag.no_deerjikist.name)
|
||||
expect(names).to include(nico_tag.name)
|
||||
expect(post_record.external_tags).to contain_exactly(nico_tag)
|
||||
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')
|
||||
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
|
||||
PostExternalTag.create!(post: post_record, external_tag: nico_tag)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
|
||||
@@ -1937,7 +2087,15 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
expect(names).to include('spec_tag')
|
||||
expect(names).to include(Tag.no_deerjikist.name)
|
||||
expect(names).to include(nico_tag.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)
|
||||
end
|
||||
|
||||
it 'allows non-nico tags linked from nico tags to be removed by normal post update' do
|
||||
@@ -1947,7 +2105,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)
|
||||
PostTag.create!(post: post_record, tag: nico_tag, created_user: member)
|
||||
PostExternalTag.create!(post: post_record, external_tag: nico_tag)
|
||||
PostTag.create!(post: post_record, tag: linked_tag, created_user: member)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
@@ -1961,7 +2119,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
names = post_record.reload.tags.map(&:name)
|
||||
|
||||
expect(names).to include(nico_tag.name)
|
||||
expect(post_record.external_tags).to contain_exactly(nico_tag)
|
||||
expect(names).to include('spec_tag')
|
||||
expect(names).to include(Tag.no_deerjikist.name)
|
||||
expect(names).not_to include(linked_tag.name)
|
||||
@@ -2180,6 +2338,88 @@ 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 }
|
||||
|
||||
|
||||
@@ -80,38 +80,6 @@ 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
|
||||
@@ -186,31 +154,5 @@ 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
|
||||
|
||||
@@ -30,6 +30,30 @@ 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'
|
||||
|
||||
@@ -164,20 +188,79 @@ 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)
|
||||
Tag.create!(tag_name: TagName.create!(name: 'nico:cat_nico'), category: :nico)
|
||||
create(:external_tag, name: 'cat_nico')
|
||||
|
||||
get '/tags', params: { name: 'cat_', order: 'category:asc', limit: 20 }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response_names).to eq(%w[
|
||||
cat_deerjikist
|
||||
cat_meme
|
||||
cat_character
|
||||
cat_general
|
||||
cat_material
|
||||
cat_meta
|
||||
nico:cat_nico
|
||||
])
|
||||
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) do
|
||||
create(:tag,
|
||||
tag_name: create(:tag_name, name: 'a_mixed_page'),
|
||||
category: :meme)
|
||||
end
|
||||
|
||||
let!(:middle_tag) do
|
||||
create(:tag,
|
||||
tag_name: create(:tag_name, name: 'm_mixed_page'),
|
||||
category: :meta)
|
||||
end
|
||||
|
||||
let!(:last_tag) do
|
||||
create(:tag,
|
||||
tag_name: create(:tag_name, name: 'z_mixed_page'),
|
||||
category: :general)
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
it 'paginates and keeps total count' do
|
||||
@@ -299,6 +382,89 @@ 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' }
|
||||
|
||||
@@ -340,6 +506,27 @@ 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') }"
|
||||
|
||||
@@ -487,18 +674,6 @@ 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' }
|
||||
@@ -508,32 +683,6 @@ 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!(
|
||||
@@ -1169,28 +1318,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
@@ -50,6 +50,19 @@ 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]
|
||||
|
||||
@@ -29,7 +29,7 @@ RSpec.describe 'nico:sync' do
|
||||
|
||||
# 追加される linked tag を準備(nico tag に紐付く一般タグ)
|
||||
linked = create_tag!('spec_linked', category: 'general')
|
||||
nico = create_tag!('nico:AAA', category: 'nico')
|
||||
nico = create_external_tag!('AAA')
|
||||
link_nico_to_tag!(nico, linked)
|
||||
|
||||
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
|
||||
@@ -53,7 +53,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(active_tag_names).to include('nico:AAA')
|
||||
expect(post.external_tags).to contain_exactly(nico)
|
||||
expect(active_tag_names).to include('spec_linked')
|
||||
|
||||
expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z'))
|
||||
@@ -115,12 +115,12 @@ RSpec.describe 'nico:sync' do
|
||||
)
|
||||
|
||||
# 旧nicoタグ(今回の同期結果に含まれない)
|
||||
old_nico = create_tag!('nico:OLD', category: 'nico')
|
||||
PostTag.create!(post:, tag: old_nico)
|
||||
old_nico = create_external_tag!('OLD')
|
||||
PostExternalTag.create!(post:, external_tag: old_nico)
|
||||
create_post_version_for!(post)
|
||||
|
||||
# 今回は NEW のみ欲しい
|
||||
new_nico = create_tag!('nico:NEW', category: 'nico')
|
||||
new_nico = create_external_tag!('NEW')
|
||||
|
||||
# bot/tagme 念のため
|
||||
Tag.bot
|
||||
@@ -131,23 +131,21 @@ RSpec.describe 'nico:sync' do
|
||||
|
||||
run_rake_task('nico:sync')
|
||||
|
||||
expect(PostTag.exists?(post:, tag: old_nico)).to be(false)
|
||||
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.map { |item| item.fetch('id') })
|
||||
expect(versions.first.tags_json.filter_map { |item| item['external_tag_id'] })
|
||||
.to include(old_nico.id)
|
||||
expect(versions.last.tags_json.map { |item| item.fetch('id') })
|
||||
expect(versions.last.tags_json.filter_map { |item| item['external_tag_id'] })
|
||||
.to include(new_nico.id)
|
||||
expect(versions.last.tags_json.map { |item| item.fetch('id') })
|
||||
expect(versions.last.tags_json.filter_map { |item| item['external_tag_id'] })
|
||||
.not_to include(old_nico.id)
|
||||
|
||||
# NEW は active にいる
|
||||
post.reload
|
||||
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')
|
||||
expect(post.external_tags).to contain_exactly(new_nico)
|
||||
end
|
||||
|
||||
def snapshot_tags(post)
|
||||
@@ -213,7 +211,7 @@ RSpec.describe 'nico:sync' do
|
||||
create_post_version_for!(post)
|
||||
|
||||
linked = create_tag!('spec_linked', category: 'general')
|
||||
nico = create_tag!('nico:AAA', category: 'nico')
|
||||
nico = create_external_tag!('AAA')
|
||||
link_nico_to_tag!(nico, linked)
|
||||
|
||||
Tag.bot
|
||||
@@ -241,7 +239,7 @@ RSpec.describe 'nico:sync' do
|
||||
end
|
||||
|
||||
it '既存 post に差分が無いときは新しい version を作らない' do
|
||||
nico = create_tag!('nico:AAA', category: 'nico')
|
||||
nico = create_external_tag!('AAA')
|
||||
no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta')
|
||||
|
||||
post = Post.create!(
|
||||
@@ -252,7 +250,7 @@ RSpec.describe 'nico:sync' do
|
||||
original_created_before: Time.iso8601('2026-01-01T03:35:00Z')
|
||||
)
|
||||
|
||||
PostTag.create!(post: post, tag: nico)
|
||||
PostExternalTag.create!(post:, external_tag: nico)
|
||||
PostTag.create!(post: post, tag: no_deerjikist)
|
||||
create_post_version_for!(post)
|
||||
|
||||
@@ -295,7 +293,7 @@ RSpec.describe 'nico:sync' do
|
||||
run_rake_task('nico:sync')
|
||||
}.to change(NicoTagVersion, :count).by(1)
|
||||
|
||||
nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' })
|
||||
nico_tag = ExternalTag.find_by!(platform: :nico, name: 'AAA')
|
||||
version = nico_tag.nico_tag_versions.order(:version_no).last
|
||||
|
||||
expect(version.version_no).to eq(1)
|
||||
@@ -378,4 +376,148 @@ 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
|
||||
|
||||
@@ -13,11 +13,14 @@ vi.mock ('@dnd-kit/core', () => dndKit)
|
||||
|
||||
const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 })
|
||||
|
||||
const renderRow = (activeDndId?: string) => {
|
||||
const renderRow = (
|
||||
activeDndId?: string,
|
||||
renderedTag = tag,
|
||||
) => {
|
||||
renderWithProviders (
|
||||
<DraggableDroppableTagRow
|
||||
activeDndId={activeDndId}
|
||||
tag={tag}
|
||||
tag={renderedTag}
|
||||
nestLevel={2}
|
||||
pathKey="cat-general-7"
|
||||
suppressClickRef={{ current: false }}/>,
|
||||
@@ -72,4 +75,26 @@ describe ('DraggableDroppableTagRow', () => {
|
||||
renderRow ('tag-node:other')
|
||||
expect (tagBody ()).toHaveStyle ({ visibility: 'visible' })
|
||||
})
|
||||
|
||||
it ('disables drag and drop for external tags', () => {
|
||||
const external = buildTag ({
|
||||
id: 7,
|
||||
name: 'nico:external',
|
||||
category: 'nico',
|
||||
})
|
||||
|
||||
renderRow (undefined, external)
|
||||
|
||||
expect (dndKit.useDraggable).toHaveBeenCalledWith (
|
||||
expect.objectContaining ({
|
||||
disabled: true,
|
||||
}),
|
||||
)
|
||||
|
||||
expect (dndKit.useDroppable).toHaveBeenCalledWith (
|
||||
expect.objectContaining ({
|
||||
disabled: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import TagDetailSidebar from '@/components/TagDetailSidebar'
|
||||
import { setClientTagRelationDisplayMode } from '@/lib/settings'
|
||||
import { buildPost, buildTag } from '@/test/factories'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
vi.mock ('@/components/TagSearch', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock ('@/components/DraggableDroppableTagRow', () => ({
|
||||
default: ({ tag }: { tag: { name: string } }) => (
|
||||
<span>{tag.name}</span>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock ('@dnd-kit/core', () => ({
|
||||
DndContext: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DragOverlay: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
MeasuringStrategy: { Always: 'always' },
|
||||
MouseSensor: vi.fn (),
|
||||
TouchSensor: vi.fn (),
|
||||
pointerWithin: vi.fn (),
|
||||
useDroppable: vi.fn (() => ({
|
||||
setNodeRef: vi.fn (),
|
||||
isOver: false,
|
||||
})),
|
||||
useSensor: vi.fn (() => ({ })),
|
||||
useSensors: vi.fn (() => []),
|
||||
}))
|
||||
|
||||
describe ('TagDetailSidebar', () => {
|
||||
beforeEach (() => {
|
||||
localStorage.clear ()
|
||||
vi.clearAllMocks ()
|
||||
})
|
||||
|
||||
it ('keeps internal and external tags with the same numeric id in flat mode', () => {
|
||||
setClientTagRelationDisplayMode ('flat')
|
||||
|
||||
const internal = buildTag ({
|
||||
id: 7,
|
||||
name: 'internal_collision',
|
||||
category: 'general',
|
||||
})
|
||||
const external = buildTag ({
|
||||
id: 7,
|
||||
name: 'nico:external_collision',
|
||||
category: 'nico',
|
||||
})
|
||||
|
||||
renderWithProviders (
|
||||
<TagDetailSidebar
|
||||
post={buildPost ({
|
||||
tags: [internal, external],
|
||||
})}/>,
|
||||
)
|
||||
|
||||
expect (screen.getByText ('internal_collision')).toBeInTheDocument ()
|
||||
expect (screen.getByText ('nico:external_collision')).toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
@@ -57,4 +57,31 @@ describe ('TagLink', () => {
|
||||
expect (screen.getByText ('正式名')).toBeInTheDocument ()
|
||||
expect (screen.queryByRole ('link')).not.toBeInTheDocument ()
|
||||
})
|
||||
|
||||
it ('does not show a missing-information marker for external tags', () => {
|
||||
renderWithProviders (
|
||||
<TagLink
|
||||
tag={buildTag ({
|
||||
id: 7,
|
||||
name: 'nico:external',
|
||||
category: 'nico',
|
||||
hasWiki: false,
|
||||
materialId: null,
|
||||
hasDeerjikists: false,
|
||||
})}
|
||||
withCount={false}/>,
|
||||
)
|
||||
|
||||
expect (
|
||||
screen.getByRole ('link', { name: 'nico:external' }),
|
||||
).toBeInTheDocument ()
|
||||
|
||||
expect (
|
||||
screen.queryByRole ('link', { name: '!' }),
|
||||
).not.toBeInTheDocument ()
|
||||
|
||||
expect (
|
||||
screen.queryByTitle ('nico:external Wiki が存在しません.'),
|
||||
).not.toBeInTheDocument ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,4 +116,25 @@ describe ('posts API functions', () => {
|
||||
{ params: { page: 2, limit: 50 } },
|
||||
)
|
||||
})
|
||||
|
||||
it ('maps an explicit external tag history filter to external_tag', async () => {
|
||||
api.apiGet.mockResolvedValueOnce ({ versions: [], count: 0 })
|
||||
|
||||
await fetchPostChanges ({
|
||||
externalTag: '7',
|
||||
page: 2,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
expect (api.apiGet).toHaveBeenCalledWith (
|
||||
'/posts/versions',
|
||||
{
|
||||
params: {
|
||||
external_tag: '7',
|
||||
page: 2,
|
||||
limit: 50,
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -137,4 +137,19 @@ describe ('prefetchForURL', () => {
|
||||
expect (tagsApi.fetchTags).not.toHaveBeenCalled ()
|
||||
expect (wikiApi.fetchWikiPages).not.toHaveBeenCalled ()
|
||||
})
|
||||
|
||||
it ('prefetches external tag post history without treating it as an internal tag', async () => {
|
||||
await prefetchForURL (
|
||||
qc (),
|
||||
'http://localhost/posts/changes?external_tag=12&page=2&limit=50',
|
||||
)
|
||||
|
||||
expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({
|
||||
externalTag: '12',
|
||||
page: 2,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||
import { renderWithProviders } from '@/test/render'
|
||||
|
||||
const postsApi = vi.hoisted (() => ({
|
||||
fetchPostChanges: vi.fn (),
|
||||
updatePost: vi.fn (),
|
||||
}))
|
||||
|
||||
const tagsApi = vi.hoisted (() => ({
|
||||
fetchTag: vi.fn (),
|
||||
}))
|
||||
|
||||
vi.mock ('@/lib/posts', () => postsApi)
|
||||
vi.mock ('@/lib/tags', () => tagsApi)
|
||||
|
||||
describe ('PostHistoryPage', () => {
|
||||
beforeEach (() => {
|
||||
vi.clearAllMocks ()
|
||||
postsApi.fetchPostChanges.mockResolvedValue ({
|
||||
versions: [],
|
||||
count: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it ('filters by external_tag without resolving the id as an internal tag', async () => {
|
||||
renderWithProviders (
|
||||
<PostHistoryPage/>,
|
||||
{ route: '/posts/changes?external_tag=7' },
|
||||
)
|
||||
|
||||
await waitFor (() => {
|
||||
expect (postsApi.fetchPostChanges).toHaveBeenCalledWith ({
|
||||
externalTag: '7',
|
||||
page: 1,
|
||||
limit: 20,
|
||||
})
|
||||
})
|
||||
|
||||
expect (tagsApi.fetchTag).not.toHaveBeenCalled ()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import TagListPage from '@/pages/tags/TagListPage'
|
||||
@@ -127,4 +127,49 @@ describe ('TagListPage', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it ('keeps colliding internal and external tags on their own routes', async () => {
|
||||
const internal = buildTag ({
|
||||
id: 7,
|
||||
name: 'internal_collision',
|
||||
category: 'general',
|
||||
})
|
||||
const external = buildTag ({
|
||||
id: 7,
|
||||
name: 'nico:external_collision',
|
||||
category: 'nico',
|
||||
})
|
||||
|
||||
tagsApi.fetchTags.mockResolvedValueOnce ({
|
||||
tags: [internal, external],
|
||||
count: 2,
|
||||
})
|
||||
|
||||
renderWithProviders (<TagListPage/>, { route: '/tags' })
|
||||
|
||||
const internalLink =
|
||||
await screen.findByRole ('link', { name: 'internal_collision' })
|
||||
const externalLink =
|
||||
screen.getByRole ('link', { name: 'nico:external_collision' })
|
||||
|
||||
expect (internalLink).toHaveAttribute ('href', '/tags/7')
|
||||
expect (externalLink).toHaveAttribute (
|
||||
'href',
|
||||
'/tags/nico?name=nico%3Aexternal_collision',
|
||||
)
|
||||
|
||||
const internalRow = internalLink.closest ('tr')
|
||||
const externalRow = externalLink.closest ('tr')
|
||||
|
||||
expect (internalRow).not.toBeNull ()
|
||||
expect (externalRow).not.toBeNull ()
|
||||
|
||||
expect (
|
||||
within (internalRow!).getByRole ('link', { name: '耕作履歴' }),
|
||||
).toHaveAttribute ('href', '/posts/changes?tag=7')
|
||||
|
||||
expect (
|
||||
within (externalRow!).getByRole ('link', { name: '耕作履歴' }),
|
||||
).toHaveAttribute ('href', '/posts/changes?external_tag=7')
|
||||
})
|
||||
})
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする