このコミットが含まれているのは:
2026-09-21 04:55:02 +09:00
コミット b217a307fe
6個のファイルの変更264行の追加147行の削除
+4 -4
ファイルの表示
@@ -4,10 +4,10 @@ class PostTagSection < ApplicationRecord
belongs_to :post
belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
belongs_to :post_tag, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
+68
ファイルの表示
@@ -1,5 +1,73 @@
require 'rails_helper'
RSpec.describe PostTag, type: :model do
describe 'uniqueness' do
it 'rejects duplicate post and tag pairs but allows either to be reused' do
post_tag = create(:post_tag)
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect(duplicate).not_to be_valid
expect(duplicate.errors.of_kind?(:post_id, :taken)).to be(true)
expect(build(:post_tag, post: post_tag.post, tag: create(:tag))).to be_valid
expect(build(:post_tag, post: create(:post), tag: post_tag.tag)).to be_valid
end
it 'enforces uniqueness in the database when validation is bypassed' do
post_tag = create(:post_tag)
duplicate = build(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect { duplicate.save!(validate: false) }
.to raise_error(ActiveRecord::RecordNotUnique)
end
end
describe '#destroy!' do
it 'deletes only the selected pair and its sections and updates the counter' do
post_tag = create(:post_tag)
same_post = create(:post_tag, post: post_tag.post)
same_tag = create(:post_tag, tag: post_tag.tag)
sections = [post_tag, same_post, same_tag].map do |link|
create(:post_tag_section, post: link.post, tag: link.tag,
begin_ms: 1000, end_ms: 2000)
end
expect { post_tag.destroy! }.to change(described_class, :count).by(-1)
.and change(PostTagSection, :count).by(-1)
.and change { post_tag.tag.reload.post_count }.from(2).to(1)
expect(described_class.exists?(post: post_tag.post, tag: post_tag.tag)).to be(false)
expect(same_post.reload).to be_persisted
expect(same_tag.reload).to be_persisted
expect(PostTagSection.all).to contain_exactly(*sections.drop(1))
expect(post_tag.post.reload.tags).to contain_exactly(same_post.tag)
expect(post_tag.tag.reload.posts).to contain_exactly(same_tag.post)
end
it 'allows a removed tag to be added again without restoring old sections' do
post_tag = create(:post_tag)
create(:post_tag_section, post: post_tag.post, tag: post_tag.tag,
begin_ms: 1000, end_ms: 2000)
post_tag.destroy!
replacement = create(:post_tag, post: post_tag.post, tag: post_tag.tag)
expect(replacement.reload.sections).to be_empty
expect(replacement.tag.reload.post_count).to eq(1)
end
end
describe '#sections' do
it 'loads the owning post_tag from a section using both keys' do
post_tag = create(:post_tag)
create(:post_tag, post: post_tag.post)
create(:post_tag, tag: post_tag.tag)
section = create(:post_tag_section, post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000, end_ms: 2000)
expect(section.reload.post_tag).to eq(post_tag)
end
it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
+36 -27
ファイルの表示
@@ -185,15 +185,14 @@ RSpec.describe Tag, type: :model do
context 'when merging a simple source tag' do
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'discards the source post_tag, creates an active target post_tag, discards the source tag, and aliases the source tag_name' do
it 'deletes the source link, links the target, and aliases the discarded source tag' do
described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(source_tag.reload.post_count).to eq(0)
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
@@ -206,16 +205,22 @@ RSpec.describe Tag, type: :model do
let!(:target_post_tag) { PostTag.create!(post: post_record, tag: target_tag) }
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
it 'discards the source post_tag, keeps one active target post_tag, discards the source tag, and aliases the source tag_name' do
it 'deletes the source link and preserves the existing target link' do
create(:post_tag_section, post: post_record, tag: source_tag,
begin_ms: 1000, end_ms: 2000)
target_section = create(:post_tag_section, post: post_record,
tag: target_tag,
begin_ms: 3000, end_ms: nil)
described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active = PostTag.kept.where(post_id: post_record.id, tag_id: target_tag.id)
target_links = PostTag.where(post: post_record, tag: target_tag)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active.count).to eq(1)
expect(active.first.id).to eq(target_post_tag.id)
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_links).to contain_exactly(target_post_tag)
expect(source_tag.reload.post_count).to eq(0)
expect(PostTagSection.where(post: post_record, tag: source_tag)).to be_empty
expect(target_post_tag.reload.sections).to contain_exactly(target_section)
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
@@ -230,14 +235,12 @@ RSpec.describe Tag, type: :model do
it 'ignores the target in source_tags while still merging the source tag' do
described_class.merge_tags!(target_tag, [source_tag, target_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(Tag.find(target_tag.id)).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(source_tag_name.reload.canonical_id).to eq(target_tag.tag_name_id)
expect(target_tag.reload.post_count).to eq(1)
end
@@ -263,13 +266,10 @@ RSpec.describe Tag, type: :model do
it 'still merges, but discards the source tag_name instead of aliasing it' do
described_class.merge_tags!(target_tag, [source_tag])
source_pt = PostTag.with_discarded.find(source_post_tag.id)
active_target = PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)
discarded_source_tag_name = TagName.with_discarded.find(source_tag_name.id)
target_link = PostTag.find_by(post: post_record, tag: target_tag)
expect(source_pt.discarded_at).to be_present
expect(source_pt.tag_id).to eq(source_tag.id)
expect(active_target).to be_present
expect(PostTag.exists?(post: post_record, tag: source_tag)).to be(false)
expect(target_link).to be_present
expect(Tag.with_discarded.find(source_tag.id)).to be_discarded
expect(target_tag.reload.post_count).to eq(1)
@@ -289,14 +289,20 @@ RSpec.describe Tag, type: :model do
end
it 'rolls back the transaction' do
source_section = create(:post_tag_section, post: post_record,
tag: source_tag,
begin_ms: 1000, end_ms: 2000)
expect {
described_class.merge_tags!(target_tag, [source_tag])
}.to raise_error(ActiveRecord::RecordInvalid)
expect(Tag.with_discarded.find(source_tag.id)).not_to be_discarded
expect(TagName.with_discarded.find(source_tag_name.id)).not_to be_discarded
expect(PostTag.kept.find(source_post_tag.id).tag_id).to eq(source_tag.id)
expect(PostTag.kept.find_by(post_id: post_record.id, tag_id: target_tag.id)).to be_nil
expect(source_post_tag.reload.tag_id).to eq(source_tag.id)
expect(source_post_tag.sections).to contain_exactly(source_section)
expect(PostTag.find_by(post: post_record, tag: target_tag)).to be_nil
expect(source_tag.reload.post_count).to eq(1)
expect(source_tag_name.reload.canonical_id).to be_nil
expect(target_tag.reload.post_count).to eq(0)
end
@@ -365,12 +371,15 @@ 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') })
.to eq([source_tag.id])
expect(unaffected_post.reload.post_versions.count).to eq(1)
end
end
context 'when the source tag has no active post_tags' do
context 'when the source tag has no post_tags' do
let!(:another_post) do
Post.create!(url: 'https://example.com/posts/3', title: 'another post')
end
+138 -110
ファイルの表示
@@ -148,12 +148,15 @@ RSpec.describe 'Posts API', type: :request do
it 'keeps children and sections keys in non-detail tag responses' do
PostTagSection.create!(post: hit_post, tag:, begin_ms: 1_000, end_ms: nil)
deprecated_tag = create(:tag, deprecated_at: Time.current)
create(:post_tag, post: hit_post, tag: deprecated_tag)
get '/posts'
expect(response).to have_http_status(:ok)
hit_json = json.fetch('posts').find { |post| post['id'] == hit_post.id }
expect(hit_json.fetch('tags').map { |item| item.fetch('id') }).to eq([tag.id])
tag_json = hit_json.fetch('tags').find { |item| item['name'] == 'spec_tag' }
expect(tag_json.fetch('children')).to eq([])
@@ -162,6 +165,26 @@ RSpec.describe 'Posts API', type: :request do
])
end
it 'preloads tag details and sections as the number of posts grows' do
5.times do
link = create(:post_tag, post: create(:post, uploaded_user: user))
create(:post_tag_section, post: link.post, tag: link.tag,
begin_ms: 1000, end_ms: 2000)
end
get '/posts', params: { limit: 1 }
one_post_queries = count_sql_queries do
get '/posts', params: { limit: 1 }
end
many_post_queries = count_sql_queries do
get '/posts', params: { limit: 20 }
end
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').size).to eq(8)
expect(many_post_queries).to be <= one_post_queries
end
context 'when q is provided' do
it 'filters posts by q (hit case)' do
get '/posts', params: { tags: 'spec_tag' }
@@ -460,6 +483,71 @@ RSpec.describe 'Posts API', type: :request do
end
end
context 'when update times include version history' do
let(:t0) { Time.zone.parse('2020-01-01 12:00:00') }
let(:t1) { t0 + 1.day }
let(:t2) { t0 + 2.days }
let(:t3) { t0 + 3.days }
let!(:history_post) do
create(:post, url: 'https://example.com/version-time/history',
created_at: t0, updated_at: t0)
end
let!(:plain_post) do
create(:post, url: 'https://example.com/version-time/plain',
created_at: t1, updated_at: t1)
end
let!(:newer_post) do
create(:post, url: 'https://example.com/version-time/newer',
created_at: t0, updated_at: t3)
end
before do
link = create(:post_tag, post: history_post, tag:)
travel_to(t0) do
PostVersionRecorder.record!(post: history_post,
event_type: :create, created_by_user: nil)
PostVersionRecorder.record!(post: newer_post,
event_type: :create, created_by_user: nil)
end
travel_to(t2) do
link.destroy!
PostVersionRecorder.record!(post: history_post,
event_type: :update, created_by_user: nil)
end
create(:post_tag, post: plain_post, tag:, created_at: t3)
end
['asc', 'desc'].each do |direction|
it "sorts by the later of post update and latest version time (#{ direction })" do
get '/posts', params: { url: '/version-time/', order: "updated_at:#{ direction }" }
expect(response).to have_http_status(:ok)
expected_ids = [plain_post.id, history_post.id, newer_post.id]
expected_ids.reverse! if direction == 'desc'
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq(expected_ids)
expect(json.fetch('count')).to eq(3)
times = json.fetch('posts').to_h do |item|
[item.fetch('id'), Time.zone.parse(item.fetch('updated_at'))]
end
expect(times).to eq({ plain_post.id => t1,
history_post.id => t2,
newer_post.id => t3 })
expect(history_post.reload.updated_at).to eq(t0)
end
end
it 'filters inclusively by the latest version time after a tag is deleted' do
get '/posts', params: { url: '/version-time/',
updated_from: t2.iso8601,
updated_to: t2.iso8601 }
expect(response).to have_http_status(:ok)
expect(json.fetch('posts').map { |item| item.fetch('id') }).to eq([history_post.id])
expect(json.fetch('count')).to eq(1)
end
end
context 'when original_created_from/original_created_to are provided' do
# 注意: controller の現状ロジックに合わせてる
# original_created_from は `original_created_before > ?`
@@ -1419,9 +1507,11 @@ RSpec.describe 'Posts API', type: :request do
it '200 and updates title + resync tags when member' do
sign_in_as(member)
create(:post_tag_section, post: post_record, tag:,
begin_ms: 1000, end_ms: 2000)
tn2 = TagName.create!(name: 'spec_tag_2')
Tag.create!(tag_name: tn2, category: :general)
replacement_tag = Tag.create!(tag_name: tn2, category: :general)
put "/posts/#{post_record.id}", params: post_update_params(
post_record,
@@ -1434,6 +1524,38 @@ RSpec.describe 'Posts API', type: :request do
names = json['tags'].map { |n| n['name'] }
expect(names).to include('spec_tag_2')
expect(names).not_to include('spec_tag')
expect(PostTag.exists?(post: post_record, tag:)).to be(false)
expect(PostTagSection.exists?(post: post_record, tag:)).to be(false)
expect(tag.reload.post_count).to eq(0)
expect(replacement_tag.reload.post_count).to eq(1)
versions = post_record.post_versions.order(:version_no)
expect(versions.first.tags_json).to include(
a_hash_including('id' => tag.id,
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => 2000 }]))
expect(versions.last.tags_json.map { |item| item.fetch('id') })
.not_to include(tag.id)
end
it 'can add a removed tag again and records both changes' do
sign_in_as(member)
put "/posts/#{ post_record.id }", params: post_update_params(post_record, tags: '')
expect(response).to have_http_status(:ok)
expect(PostTag.exists?(post: post_record, tag:)).to be(false)
put "/posts/#{ post_record.id }", params: post_update_params(
post_record, tags: 'spec_tag')
expect(response).to have_http_status(:ok)
expect(PostTag.where(post: post_record, tag:).count).to eq(1)
expect(PostTag.find_by!(post: post_record, tag:).created_user).to eq(member)
expect(tag.reload.post_count).to eq(1)
snapshots = post_record.post_versions.order(:version_no).map do |version|
version.tags_json.map { |item| item.fetch('id') }
end
expect(snapshots.map { |ids| ids.include?(tag.id) }).to eq([true, false, true])
end
it 'rejects a deprecated tag specified directly' do
@@ -1879,123 +2001,29 @@ RSpec.describe 'Posts API', type: :request do
expect(response).to have_http_status(:not_found)
end
it '200 and returns viewed boolean' do
it 'returns viewed state and current tags with their sections' do
create(:post_tag_section, post: post_record, tag:,
begin_ms: 1000, end_ms: nil)
deprecated_tag = create(:tag, deprecated_at: Time.current)
create(:post_tag, post: post_record, tag: deprecated_tag)
get '/posts/random'
expect(response).to have_http_status(:ok)
expect(json).to have_key('viewed')
expect([true, false]).to include(json['viewed'])
expect(json.fetch('tags')).to contain_exactly(
a_hash_including('id' => tag.id,
'children' => [],
'sections' => [{ 'begin_ms' => 1000, 'end_ms' => nil }]))
end
end
describe 'GET /posts/changes' do
let(:member) { create(:user, :member) }
it 'returns 404 for the retired history endpoint' do
get '/posts/changes'
it 'returns add/remove events (history) for a post' do
# add
tn2 = TagName.create!(name: 'spec_tag2')
tag2 = Tag.create!(tag_name: tn2, category: :general)
pt = PostTag.create!(post: post_record, tag: tag2, created_user: member)
# remove (discard)
pt.discard_by!(member)
get '/posts/changes', params: { id: post_record.id }
expect(response).to have_http_status(:ok)
expect(json).to include('changes', 'count')
expect(json['changes']).to be_an(Array)
expect(json['count']).to be >= 2
types = json['changes'].map { |e| e['change_type'] }.uniq
expect(types).to include('add')
expect(types).to include('remove')
end
it 'filters history by tag' do
tn2 = TagName.create!(name: 'history_tag_hit')
tag2 = Tag.create!(tag_name: tn2, category: :general)
tn3 = TagName.create!(name: 'history_tag_miss')
tag3 = Tag.create!(tag_name: tn3, category: :general)
other_post = Post.create!(
title: 'other post',
url: 'https://example.com/history-other'
)
# hit: add
PostTag.create!(post: post_record, tag: tag2, created_user: member)
# hit: add + remove
pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member)
pt2.discard_by!(member)
# miss: add + remove
pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member)
pt3.discard_by!(member)
get '/posts/changes', params: { tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json).to include('changes', 'count')
expect(json['count']).to eq(3)
changes = json.fetch('changes')
expect(changes.map { |e| e.dig('tag', 'id') }.uniq).to eq([tag2.id])
expect(changes.map { |e| e['change_type'] }).to match_array(%w[add add remove])
expect(changes.map { |e| e.dig('post', 'id') }).to match_array([
post_record.id,
other_post.id,
other_post.id
])
end
it 'filters history by post and tag together' do
tn2 = TagName.create!(name: 'history_tag_combo_hit')
tag2 = Tag.create!(tag_name: tn2, category: :general)
tn3 = TagName.create!(name: 'history_tag_combo_miss')
tag3 = Tag.create!(tag_name: tn3, category: :general)
other_post = Post.create!(
title: 'other combo post',
url: 'https://example.com/history-combo-other'
)
# hit
PostTag.create!(post: post_record, tag: tag2, created_user: member)
# miss by post
pt2 = PostTag.create!(post: other_post, tag: tag2, created_user: member)
pt2.discard_by!(member)
# miss by tag
pt3 = PostTag.create!(post: post_record, tag: tag3, created_user: member)
pt3.discard_by!(member)
get '/posts/changes', params: { id: post_record.id, tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json).to include('changes', 'count')
expect(json['count']).to eq(1)
changes = json.fetch('changes')
expect(changes.size).to eq(1)
expect(changes[0]['change_type']).to eq('add')
expect(changes[0].dig('post', 'id')).to eq(post_record.id)
expect(changes[0].dig('tag', 'id')).to eq(tag2.id)
end
it 'returns empty history when tag does not match' do
tn2 = TagName.create!(name: 'history_tag_no_hit')
tag2 = Tag.create!(tag_name: tn2, category: :general)
get '/posts/changes', params: { tag: tag2.id }
expect(response).to have_http_status(:ok)
expect(json.fetch('changes')).to eq([])
expect(json.fetch('count')).to eq(0)
expect(response).to have_http_status(:not_found)
end
end
@@ -2047,7 +2075,7 @@ RSpec.describe 'Posts API', type: :request do
end
let!(:v2) do
post_record.post_tags.kept.find_by!(tag: tag).discard_by!(member)
post_record.post_tags.find_by!(tag: tag).destroy!
PostTag.create!(post: post_record, tag: tag2, created_user: member)
post_record.update!(
title: 'updated spec post',
+4
ファイルの表示
@@ -268,6 +268,10 @@ RSpec.describe Youtube::Sync do
expect(tag_ids).to include(deerjikist_tag.id)
expect(tag_ids).not_to include(Tag.no_deerjikist.id)
expect(PostTag.exists?(post:, tag: Tag.no_deerjikist)).to be(false)
expect(Tag.no_deerjikist.reload.post_count).to eq(0)
expect(deerjikist_tag.reload.post_count).to eq(1)
expect(PostVersionRecorder).to have_received(:ensure_snapshot!).with(
post,
created_by_user: nil
+14 -6
ファイルの表示
@@ -108,7 +108,7 @@ RSpec.describe 'nico:sync' do
expect(calls).to eq(2)
end
it '既存 post にあった古い nico tag は active から外され、履歴として discard される' do
it '古い nico tag の関連を物理削除し、変更前後の履歴を version に残す' do
post = Post.create!(
title: 'old',
url: 'https://www.nicovideo.jp/watch/sm9',
@@ -117,8 +117,8 @@ RSpec.describe 'nico:sync' do
# 旧nicoタグ(今回の同期結果に含まれない)
old_nico = create_tag!('nico:OLD', category: 'nico')
old_pt = PostTag.create!(post: post, tag: old_nico)
expect(old_pt.discarded_at).to be_nil
PostTag.create!(post:, tag: old_nico)
create_post_version_for!(post)
# 今回は NEW のみ欲しい
new_nico = create_tag!('nico:NEW', category: 'nico')
@@ -132,9 +132,17 @@ RSpec.describe 'nico:sync' do
run_rake_task('nico:sync')
# OLD は active から外れる(discarded_at が入る)
old_pts = PostTag.where(post_id: post.id, tag_id: old_nico.id).order(:id).to_a
expect(old_pts.last.discarded_at).to be_present
expect(PostTag.exists?(post:, 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') })
.to include(old_nico.id)
expect(versions.last.tags_json.map { |item| item.fetch('id') })
.to include(new_nico.id)
expect(versions.last.tags_json.map { |item| item.fetch('id') })
.not_to include(old_nico.id)
# NEW は active にいる
post.reload