require 'rails_helper' RSpec.describe Youtube::Sync do let(:client) { instance_double(Youtube::ApiClient) } let(:sync) { described_class.new(client:) } before do allow(PostVersionRecorder).to receive(:record!) allow(PostVersionRecorder).to receive(:ensure_snapshot!) allow(sync).to receive(:attach_thumbnail_if_needed!) end describe '#sync!' do it 'returns without fetching video details when no video ids are discovered' do allow(sync).to receive(:query_terms).and_return([]) allow(sync).to receive(:playlist_ids).and_return([]) expect(client).not_to receive(:videos) sync.sync! end it 'discovers ids from search and all playlist pages' do allow(sync).to receive(:query_terms).and_return(['ぼざろクリーチャー']) allow(sync).to receive(:playlist_ids).and_return(['PL123']) allow(sync).to receive(:sync_since).and_return(Time.zone.parse('2026-05-01 00:00:00')) allow(client).to receive(:search_videos).with( q: 'ぼざろクリーチャー', published_after: Time.zone.parse('2026-05-01 00:00:00') ).and_return({ 'items' => [ { 'id' => { 'videoId' => 'search-video-1' } } ] }) allow(client).to receive(:playlist_items).with( playlist_id: 'PL123', page_token: nil ).and_return({ 'items' => [ { 'contentDetails' => { 'videoId' => 'playlist-video-1' } } ], 'nextPageToken' => 'NEXT' }) allow(client).to receive(:playlist_items).with( playlist_id: 'PL123', page_token: 'NEXT' ).and_return({ 'items' => [ { 'snippet' => { 'resourceId' => { 'videoId' => 'playlist-video-2' } } } ] }) expect(client).to receive(:videos).with( satisfy do |ids| ids.sort == ['playlist-video-1', 'playlist-video-2', 'search-video-1'] end ).and_return({ 'items' => [] }) sync.sync! end it 'creates a YouTube post with default tags and no_deerjikist when no deerjikist mapping exists' do Tag.tagme Tag.bot Tag.youtube Tag.video Tag.no_deerjikist allow(sync).to receive(:query_terms).and_return([]) allow(sync).to receive(:playlist_ids).and_return(['PL123']) allow(client).to receive(:playlist_items).with( playlist_id: 'PL123', page_token: nil ).and_return({ 'items' => [ { 'contentDetails' => { 'videoId' => 'video-1' } } ] }) allow(client).to receive(:videos).with(['video-1']).and_return({ 'items' => [ youtube_video_item( id: 'video-1', title: 'YouTube テスト動画', channel_id: 'UC_NO_MAPPING' ) ] }) expect do sync.sync! end.to change(Post, :count).by(1) post = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-1') tag_ids = post.tags.pluck(:id) expect(post.title).to eq('YouTube テスト動画') expect(post.uploaded_user_id).to be_nil expect(post.original_created_from).to eq(Time.zone.parse('2026-05-01 12:34:00')) expect(post.original_created_before).to eq(Time.zone.parse('2026-05-01 12:35:00')) expect(tag_ids).to include(Tag.tagme.id) expect(tag_ids).to include(Tag.bot.id) expect(tag_ids).to include(Tag.youtube.id) expect(tag_ids).to include(Tag.video.id) expect(tag_ids).to include(Tag.no_deerjikist.id) expect(PostVersionRecorder).to have_received(:record!).with( post:, event_type: :create, created_by_user: nil ) end it 'uses deerjikist tag when channel id is mapped' do Tag.tagme Tag.bot Tag.youtube Tag.video Tag.no_deerjikist deerjikist_tag = Tag.find_or_create_by_tag_name!('テスト投稿者', category: :deerjikist) Deerjikist.create!( platform: 'youtube', code: 'UC_MAPPED', tag: deerjikist_tag ) allow(sync).to receive(:query_terms).and_return([]) allow(sync).to receive(:playlist_ids).and_return(['PL123']) allow(client).to receive(:playlist_items).with( playlist_id: 'PL123', page_token: nil ).and_return({ 'items' => [ { 'contentDetails' => { 'videoId' => 'video-1' } } ] }) allow(client).to receive(:videos).with(['video-1']).and_return({ 'items' => [ youtube_video_item( id: 'video-1', title: 'YouTube テスト動画', channel_id: 'UC_MAPPED' ) ] }) sync.sync! post = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-1') tag_ids = post.tags.pluck(:id) expect(tag_ids).to include(deerjikist_tag.id) expect(tag_ids).not_to include(Tag.no_deerjikist.id) end it 'removes no_deerjikist when deerjikist mapping is added later' do Tag.no_deerjikist post = Post.create!( title: '旧タイトル', url: 'https://www.youtube.com/watch?v=video-1', uploaded_user_id: nil, original_created_from: Time.zone.parse('2026-05-01 00:00:00'), original_created_before: Time.zone.parse('2026-05-01 00:01:00') ) PostTag.create!(post:, tag: Tag.no_deerjikist) deerjikist_tag = Tag.find_or_create_by_tag_name!('後から判明した投稿者', category: :deerjikist) Deerjikist.create!( platform: 'youtube', code: 'UC_MAPPED_LATER', tag: deerjikist_tag ) allow(sync).to receive(:query_terms).and_return([]) allow(sync).to receive(:playlist_ids).and_return(['PL123']) allow(client).to receive(:playlist_items).with( playlist_id: 'PL123', page_token: nil ).and_return({ 'items' => [ { 'contentDetails' => { 'videoId' => 'video-1' } } ] }) allow(client).to receive(:videos).with(['video-1']).and_return({ 'items' => [ youtube_video_item( id: 'video-1', title: '新タイトル', channel_id: 'UC_MAPPED_LATER' ) ] }) sync.sync! post.reload tag_ids = post.tags.pluck(:id) expect(post.title).to eq('新タイトル') expect(tag_ids).to include(deerjikist_tag.id) expect(tag_ids).not_to include(Tag.no_deerjikist.id) expect(PostVersionRecorder).to have_received(:ensure_snapshot!).with( post, created_by_user: nil ) expect(PostVersionRecorder).to have_received(:record!).with( post:, event_type: :update, created_by_user: nil ) end it 'matches existing youtu.be URL and does not create duplicate post' do post = Post.create!( title: '旧タイトル', url: 'https://youtu.be/video-1', uploaded_user_id: nil, original_created_from: Time.zone.parse('2026-05-01 00:00:00'), original_created_before: Time.zone.parse('2026-05-01 00:01:00') ) allow(sync).to receive(:query_terms).and_return([]) allow(sync).to receive(:playlist_ids).and_return(['PL123']) allow(client).to receive(:playlist_items).with( playlist_id: 'PL123', page_token: nil ).and_return({ 'items' => [ { 'contentDetails' => { 'videoId' => 'video-1' } } ] }) allow(client).to receive(:videos).with(['video-1']).and_return({ 'items' => [ youtube_video_item( id: 'video-1', title: '新タイトル', channel_id: 'UC_NO_MAPPING' ) ] }) expect do sync.sync! end.not_to change(Post, :count) expect(post.reload.title).to eq('新タイトル') end end def youtube_video_item(id:, title:, channel_id:) { 'id' => id, 'snippet' => { 'title' => title, 'channelId' => channel_id, 'publishedAt' => '2026-05-01T12:34:56Z', 'thumbnails' => { 'high' => { 'url' => "https://img.youtube.com/#{id}.jpg" } }, 'tags' => ['tag-a', 'tag-b'] } } end end