#309 #309 #309 #309 #309 Merge remote-tracking branch 'origin/main' into feature/309 #309 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/319pull/325/head
| @@ -30,15 +30,21 @@ class NicoTagsController < ApplicationController | |||
| id = params[:id].to_i | |||
| tag = Tag.find(id) | |||
| return head :bad_request if tag.category != 'nico' | |||
| return head :bad_request unless tag.nico? | |||
| linked_tag_names = params[:tags].to_s.split(' ') | |||
| linked_tag_names = params[:tags].to_s.split | |||
| linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false, | |||
| with_no_deerjikist: false) | |||
| return head :bad_request if linked_tags.any? { |t| t.category == 'nico' } | |||
| return head :bad_request if linked_tags.any? { |t| t.nico? } | |||
| tag.linked_tags = linked_tags | |||
| tag.save! | |||
| ApplicationRecord.transaction do | |||
| 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) | |||
| end | |||
| render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok | |||
| end | |||
| @@ -128,9 +128,11 @@ class PostsController < ApplicationController | |||
| original_created_from:, original_created_before:) | |||
| post.thumbnail.attach(thumbnail) | |||
| ActiveRecord::Base.transaction do | |||
| ApplicationRecord.transaction do | |||
| post.save! | |||
| tags = Tag.normalise_tags(tag_names) | |||
| TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) | |||
| tags = Tag.expand_parent_tags(tags) | |||
| sync_post_tags!(post, tags) | |||
| post.resized_thumbnail! | |||
| @@ -170,10 +172,15 @@ class PostsController < ApplicationController | |||
| post = Post.find(params[:id].to_i) | |||
| ActiveRecord::Base.transaction do | |||
| ApplicationRecord.transaction do | |||
| PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) | |||
| post.update!(title:, original_created_from:, original_created_before:) | |||
| tags = post.tags.where(category: 'nico').to_a + | |||
| Tag.normalise_tags(tag_names, with_tagme: false) | |||
| normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false) | |||
| TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user) | |||
| tags = post.tags.nico.to_a + normalised_tags | |||
| tags = Tag.expand_parent_tags(tags) | |||
| sync_post_tags!(post, tags) | |||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) | |||
| @@ -7,7 +7,16 @@ class TagChildrenController < ApplicationController | |||
| child_id = params[:child_id] | |||
| return head :bad_request if parent_id.blank? || child_id.blank? | |||
| Tag.find(parent_id).children << Tag.find(child_id) rescue nil | |||
| parent = Tag.find(parent_id) | |||
| child = Tag.find(child_id) | |||
| return head :bad_request if parent.nico? || child.nico? | |||
| ApplicationRecord.transaction do | |||
| TagVersioning.ensure_snapshot!(child, created_by_user: current_user) | |||
| TagImplication.find_or_create_by!(parent_tag: parent, tag: child) | |||
| TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user) | |||
| end | |||
| head :no_content | |||
| end | |||
| @@ -20,7 +29,16 @@ class TagChildrenController < ApplicationController | |||
| child_id = params[:child_id] | |||
| return head :bad_request if parent_id.blank? || child_id.blank? | |||
| Tag.find(parent_id).children.delete(Tag.find(child_id)) rescue nil | |||
| parent = Tag.find(parent_id) | |||
| child = Tag.find(child_id) | |||
| return head :bad_request if parent.nico? || child.nico? | |||
| ApplicationRecord.transaction do | |||
| TagVersioning.ensure_snapshot!(child, created_by_user: current_user) | |||
| TagImplication.find_by(parent_tag: parent, tag: child)&.destroy! | |||
| TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user) | |||
| end | |||
| head :no_content | |||
| end | |||
| @@ -218,15 +218,21 @@ class TagsController < ApplicationController | |||
| tag = Tag.find(params[:id]) | |||
| if name.present? | |||
| tag.tag_name.update!(name:) | |||
| if category.present? && tag.nico? != (category == 'nico') | |||
| return render json: { error: 'ニコタグのカテゴリ変更はできません.' }, | |||
| status: :unprocessable_entity | |||
| end | |||
| if category.present? | |||
| tag.update!(category:) | |||
| ApplicationRecord.transaction do | |||
| TagVersioning.ensure_snapshot!(tag, created_by_user: current_user) | |||
| tag.tag_name.update!(name:) if name.present? | |||
| tag.update!(category:) if category.present? | |||
| record_tag_version!(tag, event_type: :update, created_by_user: current_user) | |||
| end | |||
| render json: TagRepr.base(tag) | |||
| render json: TagRepr.base(tag.reload) | |||
| end | |||
| private | |||
| @@ -244,4 +250,12 @@ class TagsController < ApplicationController | |||
| children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) }, | |||
| material: material.as_json&.merge(file:, content_type:)) | |||
| end | |||
| def record_tag_version!(tag, event_type:, created_by_user:) | |||
| if tag.nico? | |||
| NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:) | |||
| else | |||
| TagVersionRecorder.record!(tag:, event_type:, created_by_user:) | |||
| end | |||
| end | |||
| end | |||
| @@ -1,7 +1,11 @@ | |||
| module MyDiscard | |||
| extend ActiveSupport::Concern | |||
| included { include Discard::Model } | |||
| included do | |||
| include Discard::Model | |||
| default_scope -> { kept } | |||
| end | |||
| class_methods do | |||
| def find_undiscard_or_create_by! attrs, &block | |||
| @@ -0,0 +1,7 @@ | |||
| class NicoTagVersion < ApplicationRecord | |||
| include VersionRecord | |||
| belongs_to :tag | |||
| validates :name, presence: true | |||
| end | |||
| @@ -1,29 +1,13 @@ | |||
| class PostVersion < ApplicationRecord | |||
| before_update do | |||
| raise ActiveRecord::ReadOnlyRecord, '版は更新できません.' | |||
| end | |||
| before_destroy do | |||
| raise ActiveRecord::ReadOnlyRecord, '版は削除できません.' | |||
| end | |||
| include VersionRecord | |||
| belongs_to :post | |||
| belongs_to :parent, class_name: 'Post', optional: true | |||
| belongs_to :created_by_user, class_name: 'User', optional: true | |||
| enum :event_type, { create: 'create', | |||
| update: 'update', | |||
| discard: 'discard', | |||
| restore: 'restore' }, prefix: true, validate: true | |||
| validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 } | |||
| validates :event_type, presence: true, inclusion: { in: event_types.keys } | |||
| validates :url, presence: true | |||
| validate :validate_original_created_range | |||
| scope :chronological, -> { order(:version_no, :id) } | |||
| private | |||
| def validate_original_created_range | |||
| @@ -8,8 +8,6 @@ class Tag < ApplicationRecord | |||
| ; | |||
| end | |||
| default_scope -> { kept } | |||
| has_many :post_tags, inverse_of: :tag | |||
| has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag | |||
| has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag' | |||
| @@ -36,6 +34,9 @@ class Tag < ApplicationRecord | |||
| has_many :deerjikists, dependent: :delete_all | |||
| has_many :materials | |||
| has_many :tag_versions | |||
| has_many :nico_tag_versions | |||
| belongs_to :tag_name | |||
| delegate :wiki_page, to: :tag_name | |||
| @@ -78,25 +79,11 @@ class Tag < ApplicationRecord | |||
| def material_id = materials.first&.id | |||
| def self.tagme | |||
| @tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta) | |||
| end | |||
| def self.bot | |||
| @bot ||= find_or_create_by_tag_name!('bot操作', category: :meta) | |||
| end | |||
| def self.no_deerjikist | |||
| @no_deerjikist ||= find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) | |||
| end | |||
| def self.video | |||
| @video ||= find_or_create_by_tag_name!('動画', category: :meta) | |||
| end | |||
| def self.niconico | |||
| @niconico ||= find_or_create_by_tag_name!('ニコニコ', category: :meta) | |||
| end | |||
| def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta) | |||
| def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta) | |||
| def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta) | |||
| def self.video = find_or_create_by_tag_name!('動画', category: :meta) | |||
| def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) | |||
| def self.normalise_tags tag_names, with_tagme: true, | |||
| with_no_deerjikist: true, | |||
| @@ -152,21 +139,25 @@ class Tag < ApplicationRecord | |||
| retry | |||
| end | |||
| def self.merge_tags! target_tag, source_tags | |||
| def self.merge_tags! target_tag, source_tags, created_by_user: nil | |||
| target_tag => Tag | |||
| affected_post_ids = Set.new | |||
| Tag.transaction do | |||
| TagVersioning.ensure_snapshot!(target_tag, created_by_user:) | |||
| Array(source_tags).compact.uniq.each do |source_tag| | |||
| source_tag => Tag | |||
| next if source_tag == target_tag | |||
| TagVersioning.ensure_snapshot!(source_tag, created_by_user:) | |||
| source_tag.post_tags.kept.find_each do |source_pt| | |||
| post_id = source_pt.post_id | |||
| affected_post_ids << post_id | |||
| source_pt.discard_by!(nil) | |||
| source_pt.discard_by!(created_by_user) | |||
| unless PostTag.kept.exists?(post_id:, tag: target_tag) | |||
| PostTag.create!(post_id:, tag: target_tag) | |||
| end | |||
| @@ -178,6 +169,7 @@ class Tag < ApplicationRecord | |||
| raise ActiveRecord::RecordInvalid.new(source_tag_name) | |||
| end | |||
| TagVersioning.record!(source_tag, event_type: :discard, created_by_user:) | |||
| source_tag.discard! | |||
| if source_tag.nico? | |||
| @@ -186,10 +178,13 @@ class Tag < ApplicationRecord | |||
| source_tag_name.update_columns(canonical_id: target_tag.tag_name_id, | |||
| updated_at: Time.current) | |||
| end | |||
| TagVersioning.record!(target_tag, event_type: :update, created_by_user:) | |||
| end | |||
| Post.where(id: affected_post_ids.to_a).find_each do |post| | |||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) | |||
| PostVersionRecorder.ensure_snapshot!(post, created_by_user:) | |||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user:) | |||
| end | |||
| # 投稿件数を再集計 | |||
| @@ -199,6 +194,14 @@ class Tag < ApplicationRecord | |||
| target_tag.reload | |||
| end | |||
| def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name) | |||
| def snapshot_parent_tag_ids = parents.order(:id).pluck(:id) | |||
| def snapshot_linked_tag_names | |||
| linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') | |||
| end | |||
| private | |||
| def nico_tag_name_must_start_with_nico | |||
| @@ -1,8 +1,6 @@ | |||
| class TagName < ApplicationRecord | |||
| include MyDiscard | |||
| default_scope -> { kept } | |||
| has_one :tag | |||
| has_one :wiki_page | |||
| @@ -0,0 +1,15 @@ | |||
| class TagVersion < ApplicationRecord | |||
| include VersionRecord | |||
| belongs_to :tag | |||
| enum :category, { deerjikist: 'deerjikist', | |||
| meme: 'meme', | |||
| character: 'character', | |||
| general: 'general', | |||
| material: 'material', | |||
| meta: 'meta' }, validate: true | |||
| validates :name, presence: true | |||
| validates :category, presence: true | |||
| end | |||
| @@ -0,0 +1,19 @@ | |||
| module VersionRecord | |||
| extend ActiveSupport::Concern | |||
| def readonly? = persisted? | |||
| included do | |||
| belongs_to :created_by_user, class_name: 'User', optional: true | |||
| enum :event_type, { create: 'create', | |||
| update: 'update', | |||
| discard: 'discard', | |||
| restore: 'restore' }, prefix: true, validate: true | |||
| validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 } | |||
| validates :event_type, presence: true | |||
| scope :chronological, -> { order(:version_no, :id) } | |||
| end | |||
| end | |||
| @@ -4,8 +4,6 @@ require 'set' | |||
| class WikiPage < ApplicationRecord | |||
| include MyDiscard | |||
| default_scope -> { kept } | |||
| has_many :wiki_revisions, dependent: :destroy | |||
| belongs_to :created_user, class_name: 'User' | |||
| belongs_to :updated_user, class_name: 'User' | |||
| @@ -0,0 +1,19 @@ | |||
| class NicoTagVersionRecorder < VersionRecorder | |||
| def self.record! tag:, event_type:, created_by_user: | |||
| new(tag:, event_type:, created_by_user:).record! | |||
| end | |||
| def initialize tag:, event_type:, created_by_user: | |||
| super(record: tag, event_type:, created_by_user:) | |||
| end | |||
| private | |||
| def version_class = NicoTagVersion | |||
| def version_association = :nico_tag_versions | |||
| def record_key = :tag | |||
| def snapshot_attributes | |||
| { name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') } | |||
| end | |||
| end | |||
| @@ -1,57 +1,31 @@ | |||
| class PostVersionRecorder | |||
| class PostVersionRecorder < VersionRecorder | |||
| def self.record! post:, event_type:, created_by_user: | |||
| new(post:, event_type:, created_by_user:).record! | |||
| end | |||
| def initialize post:, event_type:, created_by_user: | |||
| @post = post | |||
| @event_type = event_type | |||
| @created_by_user = created_by_user | |||
| super(record: post, event_type:, created_by_user:) | |||
| end | |||
| def record! | |||
| @post.with_lock do | |||
| latest = @post.post_versions.order(version_no: :desc).first | |||
| attrs = snapshot_attributes | |||
| def self.ensure_snapshot! post, created_by_user: | |||
| return if post.post_versions.exists? | |||
| return latest if @event_type == :update && latest && same_snapshot?(latest, attrs) | |||
| PostVersion.create!( | |||
| post: @post, | |||
| version_no: (latest&.version_no || 0) + 1, | |||
| event_type: @event_type, | |||
| title: attrs[:title], | |||
| url: attrs[:url], | |||
| thumbnail_base: attrs[:thumbnail_base], | |||
| tags: attrs[:tags], | |||
| parent: attrs[:parent], | |||
| original_created_from: attrs[:original_created_from], | |||
| original_created_before: attrs[:original_created_before], | |||
| created_at: Time.current, | |||
| created_by_user: @created_by_user) | |||
| end | |||
| record!(post:, event_type: :create, created_by_user:) | |||
| end | |||
| private | |||
| def snapshot_attributes | |||
| { title: @post.title, | |||
| url: @post.url, | |||
| thumbnail_base: @post.thumbnail_base, | |||
| tags: @post.snapshot_tag_names.join(' '), | |||
| parent: @post.parent, | |||
| original_created_from: @post.original_created_from, | |||
| original_created_before: @post.original_created_before } | |||
| end | |||
| def version_class = PostVersion | |||
| def version_association = :post_versions | |||
| def record_key = :post | |||
| def same_snapshot? version, attrs | |||
| true && | |||
| version.title == attrs[:title] && | |||
| version.url == attrs[:url] && | |||
| version.thumbnail_base == attrs[:thumbnail_base] && | |||
| version.tags == attrs[:tags] && | |||
| version.parent_id == attrs[:parent]&.id && | |||
| version.original_created_from == attrs[:original_created_from] && | |||
| version.original_created_before == attrs[:original_created_before] | |||
| def snapshot_attributes | |||
| { title: @record.title, | |||
| url: @record.url, | |||
| thumbnail_base: @record.thumbnail_base, | |||
| tags: @record.snapshot_tag_names.join(' '), | |||
| parent_id: @record.parent_id, | |||
| original_created_from: @record.original_created_from, | |||
| original_created_before: @record.original_created_before } | |||
| end | |||
| end | |||
| @@ -0,0 +1,22 @@ | |||
| class TagVersionRecorder < VersionRecorder | |||
| def self.record! tag:, event_type:, created_by_user: | |||
| new(tag:, event_type:, created_by_user:).record! | |||
| end | |||
| def initialize tag:, event_type:, created_by_user: | |||
| super(record: tag, event_type:, created_by_user:) | |||
| end | |||
| private | |||
| def version_class = TagVersion | |||
| def version_association = :tag_versions | |||
| def record_key = :tag | |||
| def snapshot_attributes | |||
| { name: @record.name, | |||
| category: @record.category, | |||
| aliases: @record.snapshot_aliases.join(' '), | |||
| parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') } | |||
| end | |||
| end | |||
| @@ -0,0 +1,38 @@ | |||
| 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 | |||
| end | |||
| def self.ensure_snapshot! tag, created_by_user: | |||
| if tag.nico? | |||
| return if tag.nico_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 | |||
| 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 | |||
| record!(tag, event_type:, created_by_user:) | |||
| end | |||
| def self.record_tag_snapshots! tags, created_by_user: | |||
| tags.each do |tag| | |||
| record_tag_snapshot!(tag, created_by_user:) | |||
| end | |||
| end | |||
| end | |||
| @@ -0,0 +1,62 @@ | |||
| class VersionRecorder | |||
| EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze | |||
| def initialize record:, event_type:, created_by_user: | |||
| @record = record | |||
| @event_type = event_type.to_s | |||
| @created_by_user = created_by_user | |||
| validate_event_type! | |||
| end | |||
| def record! | |||
| raise "#{ record_class.name } must be persisted" unless @record.persisted? | |||
| ApplicationRecord.transaction do | |||
| @record = record_class.unscoped.lock.find(@record.id) | |||
| latest = latest_version | |||
| if !(latest) && @event_type != 'create' | |||
| raise "#{ version_class.name } first event must be create" | |||
| end | |||
| if @event_type == 'create' && latest | |||
| raise "#{ version_class.name } create event already exists" | |||
| end | |||
| attrs = snapshot_attributes | |||
| return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs) | |||
| version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs)) | |||
| end | |||
| end | |||
| private | |||
| def latest_version = versions.order(version_no: :desc).first | |||
| def versions = @record.public_send(version_association) | |||
| def base_attributes latest | |||
| { version_no: (latest&.version_no || 0) + 1, | |||
| event_type: @event_type, | |||
| created_at: Time.current, | |||
| created_by_user: @created_by_user } | |||
| end | |||
| def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v } | |||
| def validate_event_type! | |||
| return if EVENT_TYPES.include?(@event_type) | |||
| raise ArgumentError, "Invalid event_type: #{ @event_type }" | |||
| end | |||
| def version_class = raise NotImplementedError | |||
| def version_association = raise NotImplementedError | |||
| def record_key = raise NotImplementedError | |||
| def snapshot_attributes = raise NotImplementedError | |||
| def record_class = @record.class | |||
| end | |||
| @@ -2,15 +2,15 @@ require 'set' | |||
| class CreatePostVersions < ActiveRecord::Migration[8.0] | |||
| class Post < ApplicationRecord | |||
| class Post < ActiveRecord::Base | |||
| self.table_name = 'posts' | |||
| end | |||
| class PostTag < ApplicationRecord | |||
| class PostTag < ActiveRecord::Base | |||
| self.table_name = 'post_tags' | |||
| end | |||
| class PostVersion < ApplicationRecord | |||
| class PostVersion < ActiveRecord::Base | |||
| self.table_name = 'post_versions' | |||
| end | |||
| @@ -0,0 +1,156 @@ | |||
| class CreateTagVersions < ActiveRecord::Migration[8.0] | |||
| class Tag < ActiveRecord::Base | |||
| self.table_name = 'tags' | |||
| end | |||
| class TagName < ActiveRecord::Base | |||
| self.table_name = 'tag_names' | |||
| end | |||
| class TagImplication < ActiveRecord::Base | |||
| self.table_name = 'tag_implications' | |||
| end | |||
| class TagVersion < ActiveRecord::Base | |||
| self.table_name = 'tag_versions' | |||
| end | |||
| class NicoTagVersion < ActiveRecord::Base | |||
| self.table_name = 'nico_tag_versions' | |||
| end | |||
| class NicoTagRelation < ActiveRecord::Base | |||
| self.table_name = 'nico_tag_relations' | |||
| end | |||
| def up | |||
| create_table :tag_versions do |t| | |||
| t.references :tag, null: false, foreign_key: true, index: false | |||
| t.integer :version_no, null: false | |||
| t.string :event_type, null: false | |||
| t.string :name, null: false | |||
| t.string :category, null: false | |||
| t.text :aliases, null: false | |||
| t.text :parent_tag_ids, null: false | |||
| t.datetime :created_at, null: false | |||
| t.references :created_by_user, foreign_key: { to_table: :users }, index: false | |||
| t.index [:tag_id, :version_no], unique: true | |||
| t.index :created_at | |||
| t.index [:tag_id, :created_at], order: { created_at: :desc } | |||
| t.index [:created_by_user_id, :created_at], order: { created_at: :desc } | |||
| t.check_constraint 'version_no > 0', | |||
| name: 'tag_versions_version_no_positive' | |||
| end | |||
| create_table :nico_tag_versions do |t| | |||
| t.references :tag, null: false, foreign_key: true, index: false | |||
| t.integer :version_no, null: false | |||
| t.string :event_type, null: false | |||
| t.string :name, null: false | |||
| t.text :linked_tags, null: false | |||
| t.datetime :created_at, null: false | |||
| t.references :created_by_user, foreign_key: { to_table: :users }, index: false | |||
| t.index [:tag_id, :version_no], unique: true | |||
| t.index :created_at | |||
| t.index [:tag_id, :created_at], order: { created_at: :desc } | |||
| t.index [:created_by_user_id, :created_at], order: { created_at: :desc } | |||
| t.check_constraint 'version_no > 0', | |||
| name: 'nico_tag_versions_version_no_positive' | |||
| end | |||
| TagVersion.reset_column_information | |||
| say_with_time 'Backfilling tag_versions' do | |||
| Tag.where(discarded_at: nil) | |||
| .where.not(category: 'nico') | |||
| .find_in_batches(batch_size: 500) do |tags| | |||
| tag_ids = tags.map(&:id) | |||
| tag_implication_rows_by_tag_id = | |||
| TagImplication | |||
| .where(tag_id: tag_ids) | |||
| .pluck(:tag_id, :parent_tag_id) | |||
| .each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h| | |||
| h[row[0]] << row[1] | |||
| end | |||
| tag_name_rows_by_tag_id = | |||
| TagName | |||
| .joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id') | |||
| .where(tags: { id: tag_ids }) | |||
| .pluck('tags.id', 'tag_names.name') | |||
| .each_with_object({ }) do |row, h| | |||
| h[row[0]] = row[1] | |||
| end | |||
| tag_alias_rows_by_tag_id = | |||
| TagName | |||
| .joins('INNER JOIN tags ON tags.tag_name_id = tag_names.canonical_id') | |||
| .where(tags: { id: tag_ids }) | |||
| .where(tag_names: { discarded_at: nil }) | |||
| .pluck('tags.id', 'tag_names.name') | |||
| .each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h| | |||
| h[row[0]] << row[1] | |||
| end | |||
| TagVersion.insert_all(tags.map { |tag| | |||
| { tag_id: tag.id, | |||
| version_no: 1, | |||
| event_type: 'create', | |||
| name: tag_name_rows_by_tag_id[tag.id], | |||
| category: tag.category, | |||
| aliases: tag_alias_rows_by_tag_id[tag.id].sort.join(' '), | |||
| parent_tag_ids: tag_implication_rows_by_tag_id[tag.id].sort.join(' '), | |||
| created_at: tag.created_at, | |||
| created_by_user_id: nil } | |||
| }) | |||
| end | |||
| end | |||
| NicoTagVersion.reset_column_information | |||
| say_with_time 'Backfilling nico_tag_versions' do | |||
| Tag.where(discarded_at: nil, category: 'nico') | |||
| .find_in_batches(batch_size: 500) do |tags| | |||
| tag_ids = tags.map(&:id) | |||
| tag_name_rows_by_tag_id = | |||
| TagName | |||
| .joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id') | |||
| .where(tags: { id: tag_ids }) | |||
| .pluck('tags.id', 'tag_names.name') | |||
| .each_with_object({ }) do |row, h| | |||
| h[row[0]] = row[1] | |||
| end | |||
| nico_tag_relation_rows_by_tag_id = | |||
| NicoTagRelation | |||
| .joins('INNER JOIN tags nico_tags ON nico_tags.id = nico_tag_relations.nico_tag_id') | |||
| .joins('INNER JOIN tags linked_tags ON linked_tags.id = nico_tag_relations.tag_id') | |||
| .joins('INNER JOIN tag_names ON tag_names.id = linked_tags.tag_name_id') | |||
| .where(nico_tags: { id: tag_ids }) | |||
| .where(linked_tags: { discarded_at: nil }) | |||
| .where(tag_names: { discarded_at: nil }) | |||
| .pluck('nico_tags.id', 'tag_names.name') | |||
| .each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h| | |||
| h[row[0]] << row[1] | |||
| end | |||
| NicoTagVersion.insert_all(tags.map { |tag| | |||
| { tag_id: tag.id, | |||
| version_no: 1, | |||
| event_type: 'create', | |||
| name: tag_name_rows_by_tag_id[tag.id], | |||
| linked_tags: nico_tag_relation_rows_by_tag_id[tag.id].sort.join(' '), | |||
| created_at: tag.created_at, | |||
| created_by_user_id: nil } | |||
| }) | |||
| end | |||
| end | |||
| end | |||
| def down | |||
| drop_table :nico_tag_versions | |||
| drop_table :tag_versions | |||
| end | |||
| end | |||
| @@ -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_04_09_123700) do | |||
| ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) 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 | |||
| @@ -104,6 +104,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do | |||
| t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id" | |||
| end | |||
| create_table "nico_tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "tag_id", null: false | |||
| t.integer "version_no", null: false | |||
| t.string "event_type", null: false | |||
| t.string "name", null: false | |||
| t.text "linked_tags", null: false | |||
| t.datetime "created_at", null: false | |||
| t.bigint "created_by_user_id" | |||
| t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" | |||
| t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } | |||
| t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } | |||
| t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true | |||
| t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" | |||
| end | |||
| create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "post_id", null: false | |||
| t.bigint "target_post_id", null: false | |||
| @@ -216,6 +231,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do | |||
| t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" | |||
| end | |||
| create_table "tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "tag_id", null: false | |||
| t.integer "version_no", null: false | |||
| t.string "event_type", null: false | |||
| t.string "name", null: false | |||
| t.string "category", null: false | |||
| t.text "aliases", null: false | |||
| t.text "parent_tag_ids", null: false | |||
| t.datetime "created_at", null: false | |||
| t.bigint "created_by_user_id" | |||
| t.index ["created_at"], name: "index_tag_versions_on_created_at" | |||
| t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } | |||
| t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } | |||
| t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true | |||
| t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive" | |||
| end | |||
| create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "tag_name_id", null: false | |||
| t.string "category", default: "general", null: false | |||
| @@ -377,6 +409,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do | |||
| add_foreign_key "materials", "users", column: "updated_by_user_id" | |||
| add_foreign_key "nico_tag_relations", "tags" | |||
| add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" | |||
| add_foreign_key "nico_tag_versions", "tags" | |||
| add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id" | |||
| add_foreign_key "post_similarities", "posts" | |||
| add_foreign_key "post_similarities", "posts", column: "target_post_id" | |||
| add_foreign_key "post_tags", "posts" | |||
| @@ -394,6 +428,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do | |||
| add_foreign_key "tag_names", "tag_names", column: "canonical_id" | |||
| add_foreign_key "tag_similarities", "tags" | |||
| add_foreign_key "tag_similarities", "tags", column: "target_tag_id" | |||
| add_foreign_key "tag_versions", "tags" | |||
| add_foreign_key "tag_versions", "users", column: "created_by_user_id" | |||
| add_foreign_key "tags", "tag_names" | |||
| add_foreign_key "theatre_comments", "theatres" | |||
| add_foreign_key "theatre_comments", "users" | |||
| @@ -115,6 +115,10 @@ namespace :nico do | |||
| datum['tags'].each do |raw| | |||
| name = TagNameSanitisationRule.sanitise("nico:#{ raw }") | |||
| tag = Tag.find_or_create_by_tag_name!(name, category: :nico) | |||
| event_type = tag.nico_tag_versions.exists? ? :update : :create | |||
| NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil) | |||
| desired_nico_tag_based_ids << tag.id | |||
| # 新たに記載される外部タグと連携される内部タグを記載 | |||
| @@ -149,6 +153,7 @@ namespace :nico do | |||
| 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 | |||
| PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil) | |||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) | |||
| end | |||
| end | |||
| @@ -0,0 +1,74 @@ | |||
| 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') } | |||
| it 'makes TagVersion read only after create' do | |||
| version = TagVersion.create!( | |||
| tag: tag, | |||
| version_no: 1, | |||
| event_type: 'create', | |||
| name: tag.name, | |||
| category: tag.category, | |||
| aliases: '', | |||
| parent_tag_ids: '', | |||
| created_at: Time.current, | |||
| created_by_user: nil | |||
| ) | |||
| expect { | |||
| version.update!(name: 'changed') | |||
| }.to raise_error(ActiveRecord::ReadOnlyRecord) | |||
| end | |||
| it 'prevents TagVersion destroy' do | |||
| version = TagVersion.create!( | |||
| tag: tag, | |||
| version_no: 1, | |||
| event_type: 'create', | |||
| name: tag.name, | |||
| category: tag.category, | |||
| aliases: '', | |||
| parent_tag_ids: '', | |||
| created_at: Time.current, | |||
| created_by_user: nil | |||
| ) | |||
| expect { | |||
| version.destroy! | |||
| }.to raise_error(ActiveRecord::ReadOnlyRecord) | |||
| end | |||
| it 'makes NicoTagVersion read only after create' do | |||
| version = NicoTagVersion.create!( | |||
| tag: nico_tag, | |||
| version_no: 1, | |||
| event_type: 'create', | |||
| name: nico_tag.name, | |||
| linked_tags: '', | |||
| created_at: Time.current, | |||
| created_by_user: nil | |||
| ) | |||
| expect { | |||
| version.update!(name: 'nico:changed') | |||
| }.to raise_error(ActiveRecord::ReadOnlyRecord) | |||
| end | |||
| it 'prevents NicoTagVersion destroy' do | |||
| version = NicoTagVersion.create!( | |||
| tag: nico_tag, | |||
| version_no: 1, | |||
| event_type: 'create', | |||
| name: nico_tag.name, | |||
| linked_tags: '', | |||
| created_at: Time.current, | |||
| created_by_user: nil | |||
| ) | |||
| expect { | |||
| version.destroy! | |||
| }.to raise_error(ActiveRecord::ReadOnlyRecord) | |||
| end | |||
| end | |||
| @@ -14,6 +14,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) } | |||
| it '401 when not logged in' do | |||
| @@ -34,5 +35,59 @@ RSpec.describe 'NicoTags', type: :request do | |||
| patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' } | |||
| expect(response).to have_http_status(:bad_request) | |||
| end | |||
| it '200 and updates linked tags while recording tag versions' do | |||
| sign_in_as(admin) | |||
| nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source') | |||
| nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico) | |||
| linked_a_name = TagName.create!(name: 'nico_linked_a') | |||
| linked_a = Tag.create!(tag_name: linked_a_name, category: :general) | |||
| 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) | |||
| expect { | |||
| patch "/tags/nico/#{nico_tag.id}", params: { | |||
| tags: " #{linked_a.name}\n#{linked_b.name} " | |||
| } | |||
| }.to change(TagVersion, :count).by(2) | |||
| .and change(NicoTagVersion, :count).by(1) | |||
| expect(response).to have_http_status(:ok) | |||
| names = json.map { |t| t['name'] } | |||
| expect(names).to match_array(['nico_linked_a', 'nico_linked_b']) | |||
| linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id) | |||
| expect(linked_versions.map(&:event_type)).to eq(['create', 'create']) | |||
| expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id)) | |||
| versions = nico_tag.reload.nico_tag_versions.order(:version_no) | |||
| expect(versions.map(&:event_type)).to eq(['create', 'update']) | |||
| expect(versions.last.linked_tags.split).to match_array([ | |||
| 'nico_linked_a', | |||
| 'nico_linked_b' | |||
| ]) | |||
| expect(versions.last.created_by_user_id).to eq(admin.id) | |||
| end | |||
| it '400 when linked tag normalises to nico tag' 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) | |||
| expect { | |||
| patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' } | |||
| }.not_to change(NicoTagVersion, :count) | |||
| expect(response).to have_http_status(:bad_request) | |||
| end | |||
| end | |||
| end | |||
| @@ -1,8 +1,8 @@ | |||
| include ActiveSupport::Testing::TimeHelpers | |||
| require 'rails_helper' | |||
| require 'set' | |||
| include ActiveSupport::Testing::TimeHelpers | |||
| RSpec.describe 'Posts API', type: :request do | |||
| # create / update で thumbnail.attach は走るが、 | |||
| # resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。 | |||
| @@ -1082,15 +1082,16 @@ RSpec.describe 'Posts API', type: :request do | |||
| it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do | |||
| sign_in_as(member) | |||
| create_post_version_for!(post_record) | |||
| expect do | |||
| PostTag.create!(post: post_record, tag: Tag.no_deerjikist) | |||
| create_post_version_for!(post_record.reload) | |||
| expect { | |||
| put "/posts/#{post_record.id}", params: { | |||
| title: post_record.title, | |||
| tags: 'spec_tag' | |||
| } | |||
| end.not_to change(PostVersion, :count) | |||
| }.not_to change(PostVersion, :count) | |||
| expect(response).to have_http_status(:ok) | |||
| version = post_record.reload.post_versions.order(:version_no).last | |||
| @@ -1130,4 +1131,52 @@ RSpec.describe 'Posts API', type: :request do | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| end | |||
| end | |||
| describe 'tag versioning from post write actions' do | |||
| let(:member) { create(:user, :member) } | |||
| it 'creates tag snapshot for normalised tags on POST /posts' do | |||
| sign_in_as(member) | |||
| expect { | |||
| post '/posts', params: { | |||
| title: 'tag versioned post', | |||
| url: 'https://example.com/tag-versioned-post', | |||
| tags: 'spec_tag', | |||
| thumbnail: dummy_upload | |||
| } | |||
| }.to change { tag.reload.tag_versions.count }.by(1) | |||
| expect(response).to have_http_status(:created) | |||
| version = tag.reload.tag_versions.order(:version_no).last | |||
| expect(version.version_no).to eq(1) | |||
| expect(version.event_type).to eq('create') | |||
| expect(version.name).to eq('spec_tag') | |||
| expect(version.category).to eq('general') | |||
| expect(version.created_by_user_id).to eq(member.id) | |||
| end | |||
| it 'creates tag snapshot for normalised tags on PUT /posts/:id' do | |||
| sign_in_as(member) | |||
| tag_name2 = TagName.create!(name: 'spec_tag_2') | |||
| tag2 = Tag.create!(tag_name: tag_name2, category: :general) | |||
| expect { | |||
| put "/posts/#{post_record.id}", params: { | |||
| title: 'updated title', | |||
| tags: 'spec_tag_2' | |||
| } | |||
| }.to change { tag2.reload.tag_versions.count }.by(1) | |||
| expect(response).to have_http_status(:ok) | |||
| version = tag2.reload.tag_versions.order(:version_no).last | |||
| expect(version.version_no).to eq(1) | |||
| expect(version.event_type).to eq('create') | |||
| expect(version.name).to eq('spec_tag_2') | |||
| expect(version.created_by_user_id).to eq(member.id) | |||
| end | |||
| end | |||
| end | |||
| @@ -58,15 +58,47 @@ RSpec.describe "TagChildren", type: :request do | |||
| end | |||
| end | |||
| context "when Tag.find raises (invalid ids) it still returns 204" do | |||
| context "when Tag.find raises (invalid ids)" do | |||
| before { stub_current_user(admin) } | |||
| let(:parent_id) { -1 } | |||
| let(:child_id) { -1 } | |||
| it "returns 204 (rescue nil)" do | |||
| it "returns 404" do | |||
| do_request | |||
| expect(response).to have_http_status(:no_content) | |||
| 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 | |||
| @@ -116,17 +148,57 @@ RSpec.describe "TagChildren", type: :request do | |||
| expect(response).to have_http_status(:no_content) | |||
| end | |||
| it 'records create and update versions for child tag' do | |||
| expect { | |||
| do_request | |||
| }.to change(TagVersion, :count).by(2) | |||
| expect(response).to have_http_status(:no_content) | |||
| versions = child.reload.tag_versions.order(:version_no) | |||
| expect(versions.map(&:event_type)).to eq(['create', 'update']) | |||
| expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s) | |||
| expect(versions.second.parent_tag_ids).to eq('') | |||
| expect(versions.second.created_by_user_id).to eq(admin.id) | |||
| end | |||
| end | |||
| context "when Tag.find raises (invalid ids) it still returns 204" do | |||
| context "when Tag.find raises (invalid ids)" do | |||
| before { stub_current_user(admin) } | |||
| let(:parent_id) { -1 } | |||
| let(:child_id) { -1 } | |||
| it "returns 204 (rescue nil)" do | |||
| it "returns 404" do | |||
| do_request | |||
| expect(response).to have_http_status(:no_content) | |||
| 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 | |||
| @@ -364,9 +364,70 @@ RSpec.describe 'Tags API', type: :request do | |||
| expect(response.status).to be_in([404, 500]) | |||
| end | |||
| it 'バリデーションで update! が失敗したら(通常は 422 か 500)' do | |||
| patch "/tags/#{ tag.id }", params: { name: 'new', category: 'nico' } | |||
| expect(response.status).to be_in([422, 500]) | |||
| it 'nico category への変更は 422 を返す' do | |||
| patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' } | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| expect(tag.reload.name).to eq('spec_tag') | |||
| expect(tag.category).to eq('general') | |||
| end | |||
| it 'creates initial and update tag versions when name and category change' do | |||
| expect { | |||
| patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' } | |||
| }.to change(TagVersion, :count).by(2) | |||
| expect(response).to have_http_status(:ok) | |||
| versions = tag.reload.tag_versions.order(:version_no) | |||
| expect(versions.map(&:event_type)).to eq(['create', 'update']) | |||
| expect(versions.first.name).to eq('spec_tag') | |||
| expect(versions.first.category).to eq('general') | |||
| expect(versions.first.aliases.split).to include('unko') | |||
| expect(versions.second.name).to eq('new_tag_name') | |||
| expect(versions.second.category).to eq('meme') | |||
| expect(versions.second.created_by_user_id).to eq(member_user.id) | |||
| end | |||
| it 'returns 422 when changing normal tag category to nico' do | |||
| expect { | |||
| patch "/tags/#{tag.id}", params: { category: 'nico' } | |||
| }.not_to change(TagVersion, :count) | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| expect(tag.reload.category).to eq('general') | |||
| end | |||
| it 'creates nico tag versions 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' } | |||
| }.to change(NicoTagVersion, :count).by(2) | |||
| expect(response).to have_http_status(:ok) | |||
| versions = nico_tag.reload.nico_tag_versions.order(:version_no) | |||
| expect(versions.map(&:event_type)).to eq(['create', 'update']) | |||
| expect(versions.first.name).to eq('nico:tags_spec_source') | |||
| expect(versions.second.name).to eq('nico:tags_spec_renamed') | |||
| expect(versions.second.created_by_user_id).to eq(member_user.id) | |||
| 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 | |||
| end | |||
| end | |||
| @@ -214,4 +214,108 @@ RSpec.describe "nico:sync" do | |||
| expect(version.event_type).to eq('create') | |||
| expect(version.tags).to eq(snapshot_tags(post.reload)) | |||
| end | |||
| it '新規 nico tag に nico tag version を作る' do | |||
| Tag.bot | |||
| Tag.tagme | |||
| Tag.niconico | |||
| Tag.video | |||
| Tag.no_deerjikist | |||
| stub_python([{ | |||
| 'code' => 'sm9', | |||
| 'title' => 't', | |||
| 'tags' => ['AAA'], | |||
| 'uploaded_at' => '2026-01-01 12:34:56' | |||
| }]) | |||
| allow(URI).to receive(:open).and_return(StringIO.new('<html></html>')) | |||
| expect { | |||
| run_rake_task('nico:sync') | |||
| }.to change(NicoTagVersion, :count).by(1) | |||
| nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' }) | |||
| version = nico_tag.nico_tag_versions.order(:version_no).last | |||
| expect(version.version_no).to eq(1) | |||
| expect(version.event_type).to eq('create') | |||
| expect(version.name).to eq('nico:AAA') | |||
| expect(version.created_by_user).to be_nil | |||
| end | |||
| it '既存 post に version が無い場合は create snapshot を補う' do | |||
| post = Post.create!( | |||
| title: 'old', | |||
| url: 'https://www.nicovideo.jp/watch/sm9', | |||
| uploaded_user: nil | |||
| ) | |||
| kept_general = create_tag!('spec_kept_without_version', category: 'general') | |||
| PostTag.create!(post: post, tag: kept_general) | |||
| Tag.bot | |||
| Tag.tagme | |||
| Tag.no_deerjikist | |||
| stub_python([{ | |||
| 'code' => 'sm9', | |||
| 'title' => 'changed title', | |||
| 'tags' => ['AAA'], | |||
| 'uploaded_at' => '2026-01-01 12:34:56' | |||
| }]) | |||
| allow(URI).to receive(:open).and_return(StringIO.new('<html></html>')) | |||
| expect { | |||
| run_rake_task('nico:sync') | |||
| }.to change { post.reload.post_versions.count }.by(1) | |||
| versions = post.reload.post_versions.order(:version_no) | |||
| expect(versions.map(&:event_type)).to eq(['create']) | |||
| expect(versions.first.title).to eq('changed title') | |||
| expect(versions.first.tags).to eq(snapshot_tags(post.reload)) | |||
| end | |||
| it '既存 version がある post には update version を作る' do | |||
| post = Post.create!( | |||
| title: 'old', | |||
| url: 'https://www.nicovideo.jp/watch/sm9', | |||
| uploaded_user: nil | |||
| ) | |||
| kept_general = create_tag!('spec_kept_with_version', category: 'general') | |||
| PostTag.create!(post: post, tag: kept_general) | |||
| PostVersionRecorder.record!( | |||
| post: post, | |||
| event_type: :create, | |||
| created_by_user: nil | |||
| ) | |||
| Tag.bot | |||
| Tag.tagme | |||
| Tag.no_deerjikist | |||
| stub_python([{ | |||
| 'code' => 'sm9', | |||
| 'title' => 'changed title', | |||
| 'tags' => ['AAA'], | |||
| 'uploaded_at' => '2026-01-01 12:34:56' | |||
| }]) | |||
| allow(URI).to receive(:open).and_return(StringIO.new('<html></html>')) | |||
| expect { | |||
| run_rake_task('nico:sync') | |||
| }.to change { post.reload.post_versions.count }.by(1) | |||
| versions = post.reload.post_versions.order(:version_no) | |||
| expect(versions.map(&:event_type)).to eq(['create', 'update']) | |||
| expect(versions.first.title).to eq('old') | |||
| expect(versions.second.title).to eq('changed title') | |||
| expect(versions.second.tags).to eq(snapshot_tags(post.reload)) | |||
| end | |||
| end | |||