| @@ -127,17 +127,20 @@ class PostsController < ApplicationController | |||||
| post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, | post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, | ||||
| original_created_from:, original_created_before:) | original_created_from:, original_created_before:) | ||||
| post.thumbnail.attach(thumbnail) | post.thumbnail.attach(thumbnail) | ||||
| if post.save | |||||
| post.resized_thumbnail! | |||||
| ActiveRecord::Base.transaction do | |||||
| post.save! | |||||
| tags = Tag.normalise_tags(tag_names) | tags = Tag.normalise_tags(tag_names) | ||||
| tags = Tag.expand_parent_tags(tags) | tags = Tag.expand_parent_tags(tags) | ||||
| sync_post_tags!(post, tags) | sync_post_tags!(post, tags) | ||||
| post.resized_thumbnail! | |||||
| PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user) | |||||
| end | |||||
| post.reload | post.reload | ||||
| render json: PostRepr.base(post), status: :created | render json: PostRepr.base(post), status: :created | ||||
| else | |||||
| rescue ActiveRecord::RecordInvalid | |||||
| render json: { errors: post.errors.full_messages }, status: :unprocessable_entity | render json: { errors: post.errors.full_messages }, status: :unprocessable_entity | ||||
| end | |||||
| rescue Tag::NicoTagNormalisationError | rescue Tag::NicoTagNormalisationError | ||||
| head :bad_request | head :bad_request | ||||
| end | end | ||||
| @@ -166,19 +169,22 @@ class PostsController < ApplicationController | |||||
| original_created_before = params[:original_created_before] | original_created_before = params[:original_created_before] | ||||
| post = Post.find(params[:id].to_i) | post = Post.find(params[:id].to_i) | ||||
| if post.update(title:, original_created_from:, original_created_before:) | |||||
| ActiveRecord::Base.transaction do | |||||
| post.update!(title:, original_created_from:, original_created_before:) | |||||
| tags = post.tags.where(category: 'nico').to_a + | tags = post.tags.where(category: 'nico').to_a + | ||||
| Tag.normalise_tags(tag_names, with_tagme: false) | Tag.normalise_tags(tag_names, with_tagme: false) | ||||
| tags = Tag.expand_parent_tags(tags) | tags = Tag.expand_parent_tags(tags) | ||||
| sync_post_tags!(post, tags) | sync_post_tags!(post, tags) | ||||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) | |||||
| end | |||||
| post.reload | post.reload | ||||
| json = post.as_json | json = post.as_json | ||||
| json['tags'] = build_tag_tree_for(post.tags) | json['tags'] = build_tag_tree_for(post.tags) | ||||
| render json:, status: :ok | render json:, status: :ok | ||||
| else | |||||
| rescue ActiveRecord::RecordInvalid | |||||
| render json: post.errors, status: :unprocessable_entity | render json: post.errors, status: :unprocessable_entity | ||||
| end | |||||
| rescue Tag::NicoTagNormalisationError | rescue Tag::NicoTagNormalisationError | ||||
| head :bad_request | head :bad_request | ||||
| end | end | ||||
| @@ -11,6 +11,7 @@ class Post < ApplicationRecord | |||||
| has_many :user_post_views, dependent: :delete_all | has_many :user_post_views, dependent: :delete_all | ||||
| has_many :post_similarities, dependent: :delete_all | has_many :post_similarities, dependent: :delete_all | ||||
| has_many :post_versions | |||||
| has_one_attached :thumbnail | has_one_attached :thumbnail | ||||
| @@ -30,6 +31,8 @@ class Post < ApplicationRecord | |||||
| super(options).merge(thumbnail: nil) | super(options).merge(thumbnail: nil) | ||||
| end | end | ||||
| def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') | |||||
| def related limit: nil | def related limit: nil | ||||
| ids = post_similarities.order(cos: :desc) | ids = post_similarities.order(cos: :desc) | ||||
| ids = ids.limit(limit) if limit | ids = ids.limit(limit) if limit | ||||
| @@ -0,0 +1,38 @@ | |||||
| class PostVersion < ApplicationRecord | |||||
| before_update do | |||||
| raise ActiveRecord::ReadOnlyRecord, '版は更新できません.' | |||||
| end | |||||
| before_destroy do | |||||
| raise ActiveRecord::ReadOnlyRecord, '版は削除できません.' | |||||
| end | |||||
| 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 | |||||
| f = original_created_from | |||||
| b = original_created_before | |||||
| return if f.blank? || b.blank? | |||||
| if f >= b | |||||
| errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.' | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -1,3 +1,6 @@ | |||||
| require 'set' | |||||
| class Tag < ApplicationRecord | class Tag < ApplicationRecord | ||||
| include MyDiscard | include MyDiscard | ||||
| @@ -150,6 +153,8 @@ class Tag < ApplicationRecord | |||||
| def self.merge_tags! target_tag, source_tags | def self.merge_tags! target_tag, source_tags | ||||
| target_tag => Tag | target_tag => Tag | ||||
| affected_post_ids = Set.new | |||||
| Tag.transaction do | Tag.transaction do | ||||
| Array(source_tags).compact.uniq.each do |source_tag| | Array(source_tags).compact.uniq.each do |source_tag| | ||||
| source_tag => Tag | source_tag => Tag | ||||
| @@ -158,6 +163,7 @@ class Tag < ApplicationRecord | |||||
| source_tag.post_tags.kept.find_each do |source_pt| | source_tag.post_tags.kept.find_each do |source_pt| | ||||
| post_id = source_pt.post_id | post_id = source_pt.post_id | ||||
| affected_post_ids << post_id | |||||
| source_pt.discard_by!(nil) | source_pt.discard_by!(nil) | ||||
| unless PostTag.kept.exists?(post_id:, tag: target_tag) | unless PostTag.kept.exists?(post_id:, tag: target_tag) | ||||
| PostTag.create!(post_id:, tag: target_tag) | PostTag.create!(post_id:, tag: target_tag) | ||||
| @@ -180,6 +186,10 @@ class Tag < ApplicationRecord | |||||
| end | end | ||||
| end | end | ||||
| Post.where(id: affected_post_ids.to_a).find_each do |post| | |||||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) | |||||
| end | |||||
| # 投稿件数を再集計 | # 投稿件数を再集計 | ||||
| target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count) | target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count) | ||||
| end | end | ||||
| @@ -0,0 +1,57 @@ | |||||
| class PostVersionRecorder | |||||
| 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 | |||||
| end | |||||
| def record! | |||||
| @post.with_lock do | |||||
| latest = @post.post_versions.order(version_no: :desc).first | |||||
| attrs = snapshot_attributes | |||||
| 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 | |||||
| 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 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] | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,203 @@ | |||||
| require 'set' | |||||
| class CreatePostVersions < ActiveRecord::Migration[8.0] | |||||
| class Post < ApplicationRecord | |||||
| self.table_name = 'posts' | |||||
| end | |||||
| class PostTag < ApplicationRecord | |||||
| self.table_name = 'post_tags' | |||||
| end | |||||
| class PostVersion < ApplicationRecord | |||||
| self.table_name = 'post_versions' | |||||
| end | |||||
| def up | |||||
| create_table :post_versions do |t| | |||||
| t.references :post, null: false, foreign_key: true | |||||
| t.integer :version_no, null: false | |||||
| t.string :event_type, null: false | |||||
| t.string :title | |||||
| t.string :url, limit: 768, null: false | |||||
| t.string :thumbnail_base, limit: 2000 | |||||
| t.text :tags, null: false | |||||
| t.references :parent, foreign_key: { to_table: :posts } | |||||
| t.datetime :original_created_from | |||||
| t.datetime :original_created_before | |||||
| t.datetime :created_at, null: false | |||||
| t.references :created_by_user, foreign_key: { to_table: :users } | |||||
| t.index [:post_id, :version_no], unique: true | |||||
| t.check_constraint 'version_no > 0', | |||||
| name: 'post_versions_version_no_positive' | |||||
| t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')", | |||||
| name: 'post_versions_event_type_valid' | |||||
| end | |||||
| PostVersion.reset_column_information | |||||
| say_with_time 'Backfilling post_versions' do | |||||
| Post.find_in_batches(batch_size: 500) do |posts| | |||||
| post_ids = posts.map(&:id) | |||||
| post_tag_rows_by_post_id = | |||||
| PostTag | |||||
| .joins('INNER JOIN tags ON tags.id = post_tags.tag_id') | |||||
| .joins('INNER JOIN tag_names ON tag_names.id = tags.tag_name_id') | |||||
| .where(post_id: post_ids) | |||||
| .pluck('post_tags.post_id', | |||||
| 'post_tags.created_at', | |||||
| 'post_tags.discarded_at', | |||||
| 'post_tags.created_user_id', | |||||
| 'post_tags.deleted_user_id', | |||||
| 'tag_names.name') | |||||
| .each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h| | |||||
| post_id, created_at, discarded_at, created_user_id, deleted_user_id, tag_name = row | |||||
| h[post_id] << { created_at:, | |||||
| discarded_at:, | |||||
| created_user_id:, | |||||
| deleted_user_id:, | |||||
| tag_name: } | |||||
| end | |||||
| rows = [] | |||||
| posts.each do |post| | |||||
| post_tag_rows = post_tag_rows_by_post_id[post.id] | |||||
| events = post_tag_rows.flat_map do |post_tag_row| | |||||
| ary = [[post_tag_row[:created_at], | |||||
| post_tag_row[:created_user_id], | |||||
| :add, | |||||
| post_tag_row[:tag_name]]] | |||||
| if post_tag_row[:discarded_at] | |||||
| ary << [post_tag_row[:discarded_at], | |||||
| post_tag_row[:deleted_user_id], | |||||
| :remove, | |||||
| post_tag_row[:tag_name]] | |||||
| end | |||||
| ary | |||||
| end | |||||
| kind_order = { add: 0, remove: 1 } | |||||
| events.sort_by! do |event_at, user_id, kind, tag_name| | |||||
| [event_at, user_id || 0, kind_order.fetch(kind), tag_name] | |||||
| end | |||||
| event_buckets = bucket_events(events) | |||||
| active_tags = Set.new | |||||
| version_no = 0 | |||||
| if event_buckets.empty? | |||||
| version_no += 1 | |||||
| rows << build_row(post:, | |||||
| version_no:, | |||||
| event_type: 'create', | |||||
| created_at: post.created_at, | |||||
| created_by_user_id: post.uploaded_user_id, | |||||
| tags: []) | |||||
| next | |||||
| end | |||||
| first_bucket = event_buckets.first | |||||
| merge_first_bucket_into_create = first_bucket[:first_at] <= post.created_at + 1.second | |||||
| if merge_first_bucket_into_create | |||||
| event_buckets.shift | |||||
| apply_bucket!(active_tags, first_bucket) | |||||
| version_no += 1 | |||||
| rows << build_row( | |||||
| post:, | |||||
| version_no:, | |||||
| event_type: 'create', | |||||
| created_at: post.created_at, | |||||
| created_by_user_id: post.uploaded_user_id || first_bucket[:user_ids].compact.first, | |||||
| tags: active_tags.to_a.sort) | |||||
| else | |||||
| version_no += 1 | |||||
| rows << build_row( | |||||
| post:, | |||||
| version_no:, | |||||
| event_type: 'create', | |||||
| created_at: post.created_at, | |||||
| created_by_user_id: post.uploaded_user_id, | |||||
| tags: []) | |||||
| end | |||||
| event_buckets.each do |bucket| | |||||
| apply_bucket!(active_tags, bucket) | |||||
| version_no += 1 | |||||
| rows << build_row( | |||||
| post:, | |||||
| version_no:, | |||||
| event_type: 'update', | |||||
| created_at: bucket[:first_at], | |||||
| created_by_user_id: bucket[:user_ids].compact.first, | |||||
| tags: active_tags.to_a.sort) | |||||
| end | |||||
| end | |||||
| PostVersion.insert_all!(rows) if rows.any? | |||||
| end | |||||
| end | |||||
| end | |||||
| def down | |||||
| drop_table :post_versions | |||||
| end | |||||
| private | |||||
| def bucket_events events | |||||
| buckets = [] | |||||
| events.each do |event_at, user_id, kind, tag_name| | |||||
| if buckets.empty? || event_at - buckets.last[:last_at] > 1.second | |||||
| buckets << { first_at: event_at, | |||||
| last_at: event_at, | |||||
| user_ids: [user_id], | |||||
| events: [[kind, tag_name]] } | |||||
| else | |||||
| bucket = buckets.last | |||||
| bucket[:last_at] = event_at | |||||
| bucket[:user_ids] << user_id | |||||
| bucket[:events] << [kind, tag_name] | |||||
| end | |||||
| end | |||||
| buckets | |||||
| end | |||||
| def apply_bucket! active_tags, bucket | |||||
| bucket[:events].each do |kind, tag_name| | |||||
| if kind == :add | |||||
| active_tags.add(tag_name) | |||||
| else | |||||
| active_tags.delete(tag_name) | |||||
| end | |||||
| end | |||||
| end | |||||
| def build_row post:, version_no:, event_type:, created_at:, created_by_user_id:, tags: | |||||
| { post_id: post.id, | |||||
| version_no:, | |||||
| event_type:, | |||||
| title: post.title, | |||||
| url: post.url, | |||||
| thumbnail_base: post.thumbnail_base, | |||||
| tags: tags.join(' '), | |||||
| parent_id: post.parent_id, | |||||
| original_created_from: post.original_created_from, | |||||
| original_created_before: post.original_created_before, | |||||
| created_at:, | |||||
| created_by_user_id: } | |||||
| end | |||||
| end | |||||
| @@ -10,7 +10,7 @@ | |||||
| # | # | ||||
| # It's strongly recommended that you check this file into your version control system. | # It's strongly recommended that you check this file into your version control system. | ||||
| ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do | |||||
| ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do | |||||
| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.string "name", null: false | t.string "name", null: false | ||||
| t.string "record_type", null: false | t.string "record_type", null: false | ||||
| @@ -132,6 +132,27 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do | |||||
| t.index ["tag_id"], name: "index_post_tags_on_tag_id" | t.index ["tag_id"], name: "index_post_tags_on_tag_id" | ||||
| end | end | ||||
| create_table "post_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||||
| t.bigint "post_id", null: false | |||||
| t.integer "version_no", null: false | |||||
| t.string "event_type", null: false | |||||
| t.string "title" | |||||
| t.string "url", limit: 768, null: false | |||||
| t.string "thumbnail_base", limit: 2000 | |||||
| t.text "tags", null: false | |||||
| t.bigint "parent_id" | |||||
| t.datetime "original_created_from" | |||||
| t.datetime "original_created_before" | |||||
| t.datetime "created_at", null: false | |||||
| t.bigint "created_by_user_id" | |||||
| t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id" | |||||
| t.index ["parent_id"], name: "index_post_versions_on_parent_id" | |||||
| t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true | |||||
| t.index ["post_id"], name: "index_post_versions_on_post_id" | |||||
| 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" | |||||
| end | |||||
| create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.string "title" | t.string "title" | ||||
| t.string "url", limit: 768, null: false | t.string "url", limit: 768, null: false | ||||
| @@ -362,6 +383,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do | |||||
| add_foreign_key "post_tags", "tags" | add_foreign_key "post_tags", "tags" | ||||
| add_foreign_key "post_tags", "users", column: "created_user_id" | add_foreign_key "post_tags", "users", column: "created_user_id" | ||||
| add_foreign_key "post_tags", "users", column: "deleted_user_id" | add_foreign_key "post_tags", "users", column: "deleted_user_id" | ||||
| add_foreign_key "post_versions", "posts" | |||||
| add_foreign_key "post_versions", "posts", column: "parent_id" | |||||
| add_foreign_key "post_versions", "users", column: "created_by_user_id" | |||||
| add_foreign_key "posts", "posts", column: "parent_id" | add_foreign_key "posts", "posts", column: "parent_id" | ||||
| add_foreign_key "posts", "users", column: "uploaded_user_id" | add_foreign_key "posts", "users", column: "uploaded_user_id" | ||||
| add_foreign_key "settings", "users" | add_foreign_key "settings", "users" | ||||
| @@ -61,6 +61,9 @@ namespace :nico do | |||||
| original_created_from = original_created_at&.change(sec: 0) | original_created_from = original_created_at&.change(sec: 0) | ||||
| original_created_before = original_created_from&.+(1.minute) | original_created_before = original_created_from&.+(1.minute) | ||||
| post_created = false | |||||
| post_changed = false | |||||
| if post | if post | ||||
| attrs = { title:, original_created_from:, original_created_before: } | attrs = { title:, original_created_from:, original_created_before: } | ||||
| @@ -76,11 +79,13 @@ namespace :nico do | |||||
| end | end | ||||
| post.assign_attributes(attrs) | post.assign_attributes(attrs) | ||||
| if post.changed? | |||||
| post_changed = post.changed? | |||||
| if post_changed | |||||
| post.save! | post.save! | ||||
| post.resized_thumbnail! if post.thumbnail.attached? | post.resized_thumbnail! if post.thumbnail.attached? | ||||
| end | end | ||||
| else | else | ||||
| post_created = true | |||||
| url = "https://www.nicovideo.jp/watch/#{ code }" | url = "https://www.nicovideo.jp/watch/#{ code }" | ||||
| thumbnail_base = fetch_thumbnail.(url) rescue nil | thumbnail_base = fetch_thumbnail.(url) rescue nil | ||||
| post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil, | post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil, | ||||
| @@ -140,6 +145,12 @@ namespace :nico do | |||||
| desired_all_tag_ids.uniq! | desired_all_tag_ids.uniq! | ||||
| sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids) | sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids) | ||||
| if post_created | |||||
| PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil) | |||||
| elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set | |||||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil) | |||||
| end | |||||
| end | end | ||||
| end | end | ||||
| end | end | ||||
| @@ -0,0 +1,41 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe PostVersion, type: :model do | |||||
| let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') } | |||||
| let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } | |||||
| let!(:post_record) do | |||||
| Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post| | |||||
| PostTag.create!(post: post, tag: tag) | |||||
| end | |||||
| end | |||||
| let!(:post_version) do | |||||
| PostVersion.create!( | |||||
| post: post_record, | |||||
| version_no: 1, | |||||
| event_type: 'create', | |||||
| title: post_record.title, | |||||
| url: post_record.url, | |||||
| thumbnail_base: post_record.thumbnail_base, | |||||
| tags: post_record.snapshot_tag_names.join(' '), | |||||
| parent: post_record.parent, | |||||
| original_created_from: post_record.original_created_from, | |||||
| original_created_before: post_record.original_created_before, | |||||
| created_at: Time.current, | |||||
| created_by_user: nil | |||||
| ) | |||||
| end | |||||
| it 'is read only after create' do | |||||
| expect do | |||||
| post_version.update!(title: 'changed') | |||||
| end.to raise_error(ActiveRecord::ReadOnlyRecord) | |||||
| end | |||||
| it 'cannot be destroyed' do | |||||
| expect do | |||||
| post_version.destroy! | |||||
| end.to raise_error(ActiveRecord::ReadOnlyRecord) | |||||
| end | |||||
| end | |||||
| @@ -795,4 +795,127 @@ RSpec.describe 'Posts API', type: :request do | |||||
| expect(user.reload.viewed?(post_record)).to be(false) | expect(user.reload.viewed?(post_record)).to be(false) | ||||
| end | end | ||||
| end | end | ||||
| describe 'post versioning' do | |||||
| let(:member) { create(:user, :member) } | |||||
| def snapshot_tags(post) | |||||
| post.snapshot_tag_names.join(' ') | |||||
| end | |||||
| def create_post_version_for!(post) | |||||
| PostVersion.create!( | |||||
| post: post, | |||||
| version_no: 1, | |||||
| event_type: 'create', | |||||
| title: post.title, | |||||
| url: post.url, | |||||
| thumbnail_base: post.thumbnail_base, | |||||
| tags: snapshot_tags(post), | |||||
| parent: post.parent, | |||||
| original_created_from: post.original_created_from, | |||||
| original_created_before: post.original_created_before, | |||||
| created_at: post.created_at, | |||||
| created_by_user: post.uploaded_user | |||||
| ) | |||||
| end | |||||
| it 'creates version 1 on POST /posts' do | |||||
| sign_in_as(member) | |||||
| expect do | |||||
| post '/posts', params: { | |||||
| title: 'versioned post', | |||||
| url: 'https://example.com/versioned-post', | |||||
| tags: 'spec_tag', | |||||
| thumbnail: dummy_upload | |||||
| } | |||||
| end.to change(PostVersion, :count).by(1) | |||||
| expect(response).to have_http_status(:created) | |||||
| created_post = Post.find(json.fetch('id')) | |||||
| version = PostVersion.find_by!(post: created_post, version_no: 1) | |||||
| expect(version.event_type).to eq('create') | |||||
| expect(version.title).to eq('versioned post') | |||||
| expect(version.url).to eq('https://example.com/versioned-post') | |||||
| expect(version.created_by_user_id).to eq(member.id) | |||||
| expect(version.tags).to eq(snapshot_tags(created_post)) | |||||
| end | |||||
| it 'creates next version on PUT /posts/:id when snapshot changes' do | |||||
| sign_in_as(member) | |||||
| create_post_version_for!(post_record) | |||||
| tag_name2 = TagName.create!(name: 'spec_tag_2') | |||||
| Tag.create!(tag_name: tag_name2, category: :general) | |||||
| expect do | |||||
| put "/posts/#{post_record.id}", params: { | |||||
| title: 'updated title', | |||||
| tags: 'spec_tag_2' | |||||
| } | |||||
| end.to change(PostVersion, :count).by(1) | |||||
| expect(response).to have_http_status(:ok) | |||||
| version = post_record.reload.post_versions.order(:version_no).last | |||||
| expect(version.version_no).to eq(2) | |||||
| expect(version.event_type).to eq('update') | |||||
| expect(version.title).to eq('updated title') | |||||
| expect(version.created_by_user_id).to eq(member.id) | |||||
| expect(version.tags).to eq(snapshot_tags(post_record.reload)) | |||||
| end | |||||
| 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 | |||||
| put "/posts/#{post_record.id}", params: { | |||||
| title: post_record.title, | |||||
| tags: 'spec_tag' | |||||
| } | |||||
| end.not_to change(PostVersion, :count) | |||||
| expect(response).to have_http_status(:ok) | |||||
| version = post_record.reload.post_versions.order(:version_no).last | |||||
| expect(version.version_no).to eq(1) | |||||
| expect(version.event_type).to eq('create') | |||||
| expect(version.tags).to eq(snapshot_tags(post_record)) | |||||
| end | |||||
| it 'does not create a version when POST /posts is invalid' do | |||||
| sign_in_as(member) | |||||
| expect do | |||||
| post '/posts', params: { | |||||
| title: 'invalid post', | |||||
| url: 'ぼざクリタグ広場', | |||||
| tags: 'spec_tag', | |||||
| thumbnail: dummy_upload | |||||
| } | |||||
| end.not_to change(PostVersion, :count) | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| end | |||||
| it 'does not create a version when PUT /posts/:id is invalid' do | |||||
| sign_in_as(member) | |||||
| create_post_version_for!(post_record) | |||||
| expect do | |||||
| put "/posts/#{post_record.id}", params: { | |||||
| title: 'updated title', | |||||
| tags: 'spec_tag', | |||||
| original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601, | |||||
| original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601 | |||||
| } | |||||
| end.not_to change(PostVersion, :count) | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| end | |||||
| end | |||||
| end | end | ||||