383 行
12 KiB
Ruby
383 行
12 KiB
Ruby
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 '#attach_thumbnail_if_needed!' do
|
|
it 'uses the common remote thumbnail attach path' do
|
|
post = create(:post, thumbnail_base: nil)
|
|
allow(sync).to receive(:attach_thumbnail_if_needed!).and_call_original
|
|
|
|
expect(post).to receive(:attach_thumbnail_from_url!)
|
|
.with('https://example.com/thumb.jpg')
|
|
|
|
sync.send(
|
|
:attach_thumbnail_if_needed!,
|
|
post,
|
|
'https://example.com/thumb.jpg')
|
|
end
|
|
|
|
it 'retries on a later sync when the previous remote attach failed' do
|
|
post = create(:post, thumbnail_base: nil)
|
|
allow(sync).to receive(:attach_thumbnail_if_needed!).and_call_original
|
|
expect(post).to receive(:attach_thumbnail_from_url!)
|
|
.with('https://example.com/thumb.jpg')
|
|
.twice
|
|
.and_raise(Post::RemoteThumbnailFetchFailed, 'failed')
|
|
|
|
2.times do
|
|
sync.send(
|
|
:attach_thumbnail_if_needed!,
|
|
post,
|
|
'https://example.com/thumb.jpg')
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#sync!' do
|
|
it 'groups created and updated post histories into one sync event' do
|
|
allow(PostVersionRecorder).to receive(:record!).and_call_original
|
|
allow(PostVersionRecorder).to receive(:ensure_snapshot!).and_call_original
|
|
existing = create(:post, url: 'https://youtu.be/video-1', title: 'old title')
|
|
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' } },
|
|
{ 'contentDetails' => { 'videoId' => 'video-2' } }])
|
|
allow(client).to receive(:videos).with(['video-1', 'video-2'])
|
|
.and_return('items' => [
|
|
youtube_video_item(id: 'video-1', title: 'updated', channel_id: 'UC_NONE'),
|
|
youtube_video_item(id: 'video-2', title: 'created', channel_id: 'UC_NONE')])
|
|
previous_post_ids = PostVersion.pluck(:id)
|
|
previous_tag_ids = TagVersion.pluck(:id)
|
|
|
|
expect { sync.sync! }.to change { event_rows.length }.by(1)
|
|
|
|
event = event_rows.last
|
|
expect(event).to include('event_type' => 'posts.sync', 'created_by_user_id' => nil)
|
|
versions = PostVersion.where.not(id: previous_post_ids)
|
|
created = Post.find_by!(url: 'https://www.youtube.com/watch?v=video-2')
|
|
expect(versions.pluck(:post_id).uniq).to contain_exactly(existing.id, created.id)
|
|
expect(versions.pluck(:event_type)).to include('create', 'update')
|
|
expect(versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
|
tag_versions = TagVersion.where.not(id: previous_tag_ids)
|
|
expect(tag_versions).not_to be_empty
|
|
expect(tag_versions.pluck(:event_id).uniq).to eq([event.fetch('id')])
|
|
|
|
events = event_rows
|
|
expect { sync.sync! }.not_to change(PostVersion, :count)
|
|
expect(event_rows).to eq(events)
|
|
end
|
|
|
|
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 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!(
|
|
Locale.nipponese, 'テスト投稿者', 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!(
|
|
Locale.nipponese, '後から判明した投稿者', 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(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
|
|
)
|
|
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
|