Reviewed-on: #413 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #413 でマージされました.
このコミットが含まれているのは:
@@ -1,4 +1,5 @@
|
||||
require 'rails_helper'
|
||||
require 'tempfile'
|
||||
|
||||
RSpec.describe Post, type: :model do
|
||||
before do
|
||||
@@ -65,4 +66,296 @@ RSpec.describe Post, type: :model do
|
||||
expect(post.errors.details.fetch(:url)).to include(error: :taken, value: post.url)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'thumbnail processing' do
|
||||
def image_blob(width:, height:, background:, draw: nil)
|
||||
Tempfile.create(['post-thumbnail', '.png']) do |file|
|
||||
MiniMagick::Tool::Convert.new do |convert|
|
||||
convert.size "#{ width }x#{ height }"
|
||||
convert.xc background
|
||||
draw&.call(convert)
|
||||
convert << file.path
|
||||
end
|
||||
File.binread(file.path)
|
||||
end
|
||||
end
|
||||
|
||||
def upload_for(blob)
|
||||
StringIO.new(blob).tap(&:rewind)
|
||||
end
|
||||
|
||||
def read_image(attachment)
|
||||
blob =
|
||||
attachment.is_a?(Hash) ? attachment.fetch(:io).read : attachment.download
|
||||
MiniMagick::Image.read(blob)
|
||||
end
|
||||
|
||||
def colour_at(image, x, y)
|
||||
image.get_pixels.fetch(y).fetch(x)
|
||||
end
|
||||
|
||||
def expect_green(pixel)
|
||||
expect(pixel[1]).to be > pixel[0] + 40
|
||||
expect(pixel[1]).to be > pixel[2] + 40
|
||||
end
|
||||
|
||||
def expect_red(pixel)
|
||||
expect(pixel[0]).to be > pixel[1] + 40
|
||||
expect(pixel[0]).to be > pixel[2] + 40
|
||||
end
|
||||
|
||||
def expect_blue(pixel)
|
||||
expect(pixel[2]).to be > pixel[0] + 40
|
||||
expect(pixel[2]).to be > pixel[1] + 40
|
||||
end
|
||||
|
||||
describe '.resized_thumbnail_attachment' do
|
||||
it 'fits a wide image within 180x180 without distorting it' do
|
||||
blob = image_blob(
|
||||
width: 360,
|
||||
height: 180,
|
||||
background: 'red',
|
||||
draw: -> convert {
|
||||
convert.fill 'green'
|
||||
convert.draw 'rectangle 90,0 269,179'
|
||||
})
|
||||
|
||||
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||
image = read_image(resized)
|
||||
|
||||
expect(image.dimensions).to eq([180, 90])
|
||||
expect_red(colour_at(image, 0, 45))
|
||||
expect_green(colour_at(image, 90, 45))
|
||||
expect_red(colour_at(image, 179, 45))
|
||||
end
|
||||
|
||||
it 'fits a tall image within 180x180 without distorting it' do
|
||||
blob = image_blob(
|
||||
width: 180,
|
||||
height: 360,
|
||||
background: 'red',
|
||||
draw: -> convert {
|
||||
convert.fill 'green'
|
||||
convert.draw 'rectangle 0,90 179,269'
|
||||
})
|
||||
|
||||
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||
image = read_image(resized)
|
||||
|
||||
expect(image.dimensions).to eq([90, 180])
|
||||
expect_red(colour_at(image, 45, 0))
|
||||
expect_green(colour_at(image, 45, 90))
|
||||
expect_red(colour_at(image, 45, 179))
|
||||
end
|
||||
|
||||
it 'keeps a square image square without distortion' do
|
||||
blob = image_blob(
|
||||
width: 180,
|
||||
height: 180,
|
||||
background: 'red',
|
||||
draw: -> convert {
|
||||
convert.fill 'blue'
|
||||
convert.draw 'rectangle 90,0 179,179'
|
||||
})
|
||||
|
||||
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||
image = read_image(resized)
|
||||
|
||||
expect(image.dimensions).to eq([180, 180])
|
||||
expect_red(colour_at(image, 20, 90))
|
||||
expect_blue(colour_at(image, 160, 90))
|
||||
end
|
||||
end
|
||||
|
||||
describe '#attach_thumbnail_from_url!' do
|
||||
it 'attaches a fetched remote image through the common resize path' do
|
||||
blob = image_blob(
|
||||
width: 240,
|
||||
height: 180,
|
||||
background: 'red',
|
||||
draw: -> convert {
|
||||
convert.fill 'green'
|
||||
convert.draw 'rectangle 30,0 209,179'
|
||||
})
|
||||
response = Preview::HttpFetcher::Response.new(
|
||||
blob,
|
||||
'image/png',
|
||||
'https://example.com/thumb.png')
|
||||
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
|
||||
.with('https://example.com/thumb.png')
|
||||
.and_return(response)
|
||||
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
|
||||
expect(post.thumbnail).to receive(:attach).once.and_call_original
|
||||
post.attach_thumbnail_from_url!('https://example.com/thumb.png')
|
||||
|
||||
expect(post.thumbnail).to be_attached
|
||||
image = read_image(post.thumbnail)
|
||||
expect(image.dimensions).to eq([180, 135])
|
||||
end
|
||||
|
||||
it 'does not attach anything when thumbnail conversion fails' do
|
||||
response = Preview::HttpFetcher::Response.new(
|
||||
'not-an-image',
|
||||
'image/png',
|
||||
'https://example.com/thumb.png')
|
||||
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
|
||||
.with('https://example.com/thumb.png')
|
||||
.and_return(response)
|
||||
allow(described_class).to receive(:resized_thumbnail_attachment)
|
||||
.and_raise(MiniMagick::Error, 'convert failed')
|
||||
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
|
||||
expect {
|
||||
post.attach_thumbnail_from_url!('https://example.com/thumb.png')
|
||||
}.to raise_error(Post::RemoteThumbnailFetchFailed, 'convert failed')
|
||||
|
||||
expect(post.thumbnail).not_to be_attached
|
||||
end
|
||||
|
||||
it 'keeps an existing thumbnail when remote conversion fails' do
|
||||
existing = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
existing.thumbnail.attach(
|
||||
io: StringIO.new('existing'),
|
||||
filename: 'existing.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
blob_id = existing.thumbnail.blob.id
|
||||
response = Preview::HttpFetcher::Response.new(
|
||||
'not-an-image',
|
||||
'image/png',
|
||||
'https://example.com/thumb.png')
|
||||
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
|
||||
.with('https://example.com/thumb.png')
|
||||
.and_return(response)
|
||||
allow(described_class).to receive(:resized_thumbnail_attachment)
|
||||
.and_raise(MiniMagick::Error, 'convert failed')
|
||||
|
||||
expect {
|
||||
existing.attach_thumbnail_from_url!('https://example.com/thumb.png')
|
||||
}.to raise_error(Post::RemoteThumbnailFetchFailed, 'convert failed')
|
||||
|
||||
existing.reload
|
||||
expect(existing.thumbnail).to be_attached
|
||||
expect(existing.thumbnail.blob.id).to eq(blob_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'original created datetime validation' do
|
||||
it 'allows unrelated updates on persisted posts with second-bearing datetimes' do
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
post.update_columns(
|
||||
original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
|
||||
original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
|
||||
)
|
||||
|
||||
post.title = 'updated title'
|
||||
|
||||
expect(post).to be_valid
|
||||
expect { post.save! }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'rejects second-bearing updates when the datetime field changes' do
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
|
||||
post.original_created_from = '2024-01-01T12:34:30Z'
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
|
||||
)
|
||||
end
|
||||
|
||||
it 'accepts fixing persisted datetimes to minute precision' do
|
||||
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||
post.update_columns(
|
||||
original_created_from: Time.zone.parse('2024-01-01T12:34:30Z'),
|
||||
original_created_before: Time.zone.parse('2024-01-01T12:35:30Z')
|
||||
)
|
||||
|
||||
post.original_created_from = '2024-01-01T12:34Z'
|
||||
post.original_created_before = '2024-01-01T12:35Z'
|
||||
|
||||
expect(post).to be_valid
|
||||
expect { post.save! }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'adds only the minute-precision error for second precision values' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34:30',
|
||||
original_created_before: '2024-01-01T12:35'
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
|
||||
)
|
||||
expect(post.errors[:original_created_at]).to be_empty
|
||||
end
|
||||
|
||||
it 'adds only the minute-precision error for fractional-second values' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34:00.123',
|
||||
original_created_before: '2024-01-01T12:35'
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE]
|
||||
)
|
||||
expect(post.errors[:original_created_at]).to be_empty
|
||||
end
|
||||
|
||||
it 'checks range rules only for valid minute-precision endpoints' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34',
|
||||
original_created_before: '2024-01-01T12:34'
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_at]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_ORDER_MESSAGE]
|
||||
)
|
||||
end
|
||||
|
||||
it 'accepts a one-minute range at minute precision' do
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: '2024-01-01T12:34',
|
||||
original_created_before: '2024-01-01T12:35'
|
||||
)
|
||||
|
||||
expect(post).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects invalid calendar dates and invalid hours' do
|
||||
invalid_dates = [
|
||||
'2024-02-31T12:00',
|
||||
'2023-02-29T12:00',
|
||||
'2024-02-29T24:00'
|
||||
]
|
||||
|
||||
invalid_dates.each do |value|
|
||||
post = described_class.new(
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
original_created_from: value
|
||||
)
|
||||
|
||||
expect(post).to be_invalid
|
||||
expect(post.errors[:original_created_from]).to eq(
|
||||
[described_class::ORIGINAL_CREATED_INVALID_MESSAGE]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,11 +9,11 @@ RSpec.describe 'Posts API', type: :request do
|
||||
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
|
||||
before do
|
||||
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
|
||||
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
|
||||
io: StringIO.new('dummy'),
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg'
|
||||
)
|
||||
allow(Post).to receive(:resized_thumbnail_attachment) do
|
||||
{ io: StringIO.new('dummy'),
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg' }
|
||||
end
|
||||
end
|
||||
|
||||
def create_nico_tag!(name)
|
||||
@@ -21,8 +21,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
|
||||
def dummy_upload
|
||||
# 中身は何でもいい(加工処理はスタブしてる)
|
||||
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
|
||||
real_thumbnail_upload
|
||||
end
|
||||
|
||||
def real_thumbnail_upload
|
||||
@@ -102,34 +101,34 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /posts" do
|
||||
describe 'GET /posts' do
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
let!(:tag_name) { TagName.create!(name: "spec_tag") }
|
||||
let!(:tag_name) { TagName.create!(name: 'spec_tag') }
|
||||
let!(:tag) { Tag.create!(tag_name:, category: :general) }
|
||||
let!(:tag_name2) { TagName.create!(name: 'unko') }
|
||||
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :deerjikist) }
|
||||
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
|
||||
|
||||
let!(:hit_post) do
|
||||
Post.create!(uploaded_user: user, title: "hello spec world",
|
||||
Post.create!(uploaded_user: user, title: 'hello spec world',
|
||||
url: 'https://example.com/spec2').tap do |p|
|
||||
PostTag.create!(post: p, tag:)
|
||||
end
|
||||
end
|
||||
|
||||
let!(:miss_post) do
|
||||
Post.create!(uploaded_user: user, title: "unrelated title",
|
||||
Post.create!(uploaded_user: user, title: 'unrelated title',
|
||||
url: 'https://example.com/spec3').tap do |p|
|
||||
PostTag.create!(post: p, tag: tag2)
|
||||
end
|
||||
end
|
||||
|
||||
it "returns posts with tag name in JSON" do
|
||||
get "/posts"
|
||||
it 'returns posts with tag name in JSON' do
|
||||
get '/posts'
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
posts = json.fetch("posts")
|
||||
posts = json.fetch('posts')
|
||||
|
||||
# 全postの全tagが name を含むこと
|
||||
expect(posts).not_to be_empty
|
||||
@@ -142,8 +141,8 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(json['count']).to be_an(Integer)
|
||||
|
||||
# spec_tag を含む投稿が存在すること
|
||||
all_tag_names = posts.flat_map { |p| p["tags"].map { |t| t["name"] } }
|
||||
expect(all_tag_names).to include("spec_tag")
|
||||
all_tag_names = posts.flat_map { |p| p['tags'].map { |t| t['name'] } }
|
||||
expect(all_tag_names).to include('spec_tag')
|
||||
end
|
||||
|
||||
it 'keeps children and sections keys in non-detail tag responses' do
|
||||
@@ -162,9 +161,9 @@ RSpec.describe 'Posts API', type: :request do
|
||||
])
|
||||
end
|
||||
|
||||
context "when q is provided" do
|
||||
it "filters posts by q (hit case)" do
|
||||
get "/posts", params: { tags: "spec_tag" }
|
||||
context 'when q is provided' do
|
||||
it 'filters posts by q (hit case)' do
|
||||
get '/posts', params: { tags: 'spec_tag' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
posts = json.fetch('posts')
|
||||
@@ -182,8 +181,8 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it "filters posts by q (hit case by alias)" do
|
||||
get "/posts", params: { tags: "manko" }
|
||||
it 'filters posts by q (hit case by alias)' do
|
||||
get '/posts', params: { tags: 'manko' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
posts = json.fetch('posts')
|
||||
@@ -201,11 +200,11 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it "returns empty posts when nothing matches" do
|
||||
get "/posts", params: { tags: "no_such_keyword_12345" }
|
||||
it 'returns empty posts when nothing matches' do
|
||||
get '/posts', params: { tags: 'no_such_keyword_12345' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch("posts")).to eq([])
|
||||
expect(json.fetch('posts')).to eq([])
|
||||
expect(json.fetch('count')).to eq(0)
|
||||
end
|
||||
end
|
||||
@@ -693,6 +692,73 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /posts/metadata' do
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
it 'returns compact existing post data without fetching external metadata' do
|
||||
sign_in_as(member)
|
||||
existing = create(
|
||||
:post,
|
||||
title: 'existing post',
|
||||
url: 'https://example.com/existing')
|
||||
existing.thumbnail.attach(
|
||||
io: StringIO.new('thumbnail'),
|
||||
filename: 'thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
expect(PostMetadataFetcher).not_to receive(:fetch)
|
||||
|
||||
get '/posts/metadata', params: { url: 'https://example.com/existing' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'url' => existing.url,
|
||||
'existing_post_id' => existing.id,
|
||||
'field_warnings' => { })
|
||||
expect(json.fetch('existing_post')).to include(
|
||||
'id' => existing.id,
|
||||
'title' => existing.title,
|
||||
'url' => existing.url,
|
||||
'thumbnail_base' => existing.thumbnail_base)
|
||||
expect(json.dig('existing_post', 'thumbnail'))
|
||||
.to include('/rails/active_storage/blobs/proxy/')
|
||||
end
|
||||
|
||||
it 'returns fetched metadata and structured display tags' do
|
||||
sign_in_as(member)
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
allow(PostMetadataFetcher).to receive(:fetch).and_return(
|
||||
title: 'fetched title',
|
||||
thumbnail_base: 'https://example.com/thumbnail.jpg',
|
||||
tags: 'character:虹夏',
|
||||
display_tags: [{ name: '虹夏', category: 'character' }],
|
||||
original_created_from: nil,
|
||||
original_created_before: nil,
|
||||
duration: '1:00',
|
||||
video_ms: 60_000)
|
||||
|
||||
get '/posts/metadata', params: { url: 'https://example.com/new' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'url' => 'https://example.com/new',
|
||||
'title' => 'fetched title',
|
||||
'duration' => '1:00',
|
||||
'video_ms' => 60_000,
|
||||
'field_warnings' => { })
|
||||
expect(json.fetch('display_tags')).to eq(
|
||||
[{ 'name' => '虹夏', 'category' => 'character' }])
|
||||
end
|
||||
|
||||
it 'returns URL validation errors as 422' do
|
||||
sign_in_as(member)
|
||||
|
||||
get '/posts/metadata', params: { url: 'file:///etc/passwd' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to have_key('url')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /posts' do
|
||||
let(:member) { create(:user, :member) }
|
||||
let!(:alias_tag_name) { TagName.create!(name: 'manko', canonical: tag_name) }
|
||||
@@ -712,6 +778,46 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(response).to have_http_status(:forbidden)
|
||||
end
|
||||
|
||||
it 'dry-runs without persisting posts or new tags' do
|
||||
sign_in_as(member)
|
||||
counts = [Post.count, Tag.count, TagName.count]
|
||||
|
||||
post '/posts?dry=1', params: post_write_params(
|
||||
title: 'dry-run post',
|
||||
url: 'https://example.com/dry-run',
|
||||
tags: 'character:new_dry_run_tag')
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include(
|
||||
'url' => 'https://example.com/dry-run',
|
||||
'tags' => 'new_dry_run_tag',
|
||||
'existing_post_id' => nil)
|
||||
expect(json.fetch('display_tags')).to eq(
|
||||
[{ 'name' => 'new_dry_run_tag',
|
||||
'category' => 'character',
|
||||
'section_literals' => [] }])
|
||||
expect([Post.count, Tag.count, TagName.count]).to eq(counts)
|
||||
end
|
||||
|
||||
it 'dry-runs an existing URL without validating its upload' do
|
||||
sign_in_as(member)
|
||||
existing = create(:post, url: 'https://example.com/dry-existing')
|
||||
invalid_upload = Rack::Test::UploadedFile.new(
|
||||
StringIO.new('<svg></svg>'),
|
||||
'image/png',
|
||||
original_filename: 'thumbnail.png')
|
||||
|
||||
post '/posts?dry=1', params: post_write_params(
|
||||
title: '',
|
||||
url: existing.url,
|
||||
tags: '',
|
||||
thumbnail: invalid_upload)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('existing_post_id' => existing.id)
|
||||
expect(json.fetch('field_warnings')).not_to have_key('thumbnail_base')
|
||||
end
|
||||
|
||||
it '201 and creates post + tags when member' do
|
||||
sign_in_as(member)
|
||||
|
||||
@@ -769,7 +875,8 @@ RSpec.describe 'Posts API', type: :request do
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(open_transactions).to eq([baseline_open_transactions])
|
||||
expect(open_transactions).to eq(
|
||||
[baseline_open_transactions, baseline_open_transactions])
|
||||
end
|
||||
|
||||
it 'returns 422 and does not create a post when thumbnail resize fails' do
|
||||
@@ -827,7 +934,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'tags' => ['廃止済みタグは付与できません.']
|
||||
'tags' => ['廃止済みタグがあります: deprecated_direct_tag']
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1054,7 +1161,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
context "when nico tag already exists in tags" do
|
||||
context 'when nico tag already exists in tags' do
|
||||
before do
|
||||
Tag.find_undiscard_or_create_by!(
|
||||
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
|
||||
@@ -1251,6 +1358,47 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /posts/bulk' do
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
it 'parses the manifest and indexed thumbnail parts' do
|
||||
sign_in_as(member)
|
||||
manifest = [{ 'url' => 'https://example.com/bulk',
|
||||
'title' => 'bulk post',
|
||||
'tags' => 'spec_tag',
|
||||
'parent_post_ids' => '' }]
|
||||
creator = instance_double(
|
||||
PostBulkCreator,
|
||||
run: { results: [{ status: 'created', post: { id: 123 } }] })
|
||||
allow(PostBulkCreator).to receive(:new).and_return(creator)
|
||||
|
||||
post '/posts/bulk', params: {
|
||||
posts: JSON.generate(manifest),
|
||||
thumbnails: { '0' => real_thumbnail_upload } }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('results')).to eq(
|
||||
[{ 'status' => 'created', 'post' => { 'id' => 123 } }])
|
||||
expect(PostBulkCreator).to have_received(:new) do |arguments|
|
||||
expect(arguments[:actor]).to eq(member)
|
||||
expect(arguments[:posts]).to eq(manifest)
|
||||
expect(arguments[:thumbnails].keys).to eq([0])
|
||||
expect(arguments[:host]).to eq('http://www.example.com')
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects malformed manifests as a request-level error' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts/bulk', params: {
|
||||
posts: '{',
|
||||
thumbnails: { '0' => real_thumbnail_upload } }
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
expect(json.fetch('message')).to eq('posts manifest の JSON が不正です.')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /posts/:id' do
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
@@ -1307,7 +1455,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
)
|
||||
end
|
||||
|
||||
context "when nico tag already exists in tags" do
|
||||
context 'when nico tag already exists in tags' do
|
||||
before do
|
||||
Tag.find_undiscard_or_create_by!(
|
||||
tag_name: TagName.find_undiscard_or_create_by!(name: 'nico:nico_tag'),
|
||||
@@ -1564,7 +1712,7 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(post_record.reload.title).to eq('updated by other user')
|
||||
end
|
||||
|
||||
it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do
|
||||
it 'returns mergeable 409 for stale non-conflicting tag changes without merge' do
|
||||
sign_in_as(member)
|
||||
|
||||
base_version = create_post_version_for!(post_record.reload)
|
||||
@@ -2206,6 +2354,91 @@ RSpec.describe 'Posts API', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'rejects second-bearing original created timestamps on POST /posts' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts', params: post_write_params(
|
||||
title: 'invalid original created from',
|
||||
url: 'https://example.com/invalid-original-created-from',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload,
|
||||
original_created_from: '2020-01-01T00:00:01Z')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'original_created_from' => ['オリジナルの作成日時は分単位で入力してください.']
|
||||
)
|
||||
expect(json.fetch('errors')).not_to have_key('original_created_at')
|
||||
end
|
||||
|
||||
it 'rejects fractional-second original created timestamps on POST /posts' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts', params: post_write_params(
|
||||
title: 'invalid original created before',
|
||||
url: 'https://example.com/invalid-original-created-before',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload,
|
||||
original_created_before: '2020-01-01T00:00:00.123Z')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'original_created_before' => ['オリジナルの作成日時は分単位で入力してください.']
|
||||
)
|
||||
expect(json.fetch('errors')).not_to have_key('original_created_at')
|
||||
end
|
||||
|
||||
it 'rejects non-increasing original created ranges on POST /posts' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts', params: post_write_params(
|
||||
title: 'non-increasing original created range',
|
||||
url: 'https://example.com/non-increasing-original-created-range',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload,
|
||||
original_created_from: '2020-01-01T00:00Z',
|
||||
original_created_before: '2020-01-01T00:00Z')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'original_created_at' => ['オリジナルの作成日時の順番がをかしぃです.']
|
||||
)
|
||||
end
|
||||
|
||||
it 'accepts original created ranges that are exactly one minute on POST /posts' do
|
||||
sign_in_as(member)
|
||||
|
||||
post '/posts', params: post_write_params(
|
||||
title: 'valid original created range',
|
||||
url: 'https://example.com/valid-original-created-range',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload,
|
||||
original_created_from: '2020-01-01T00:00Z',
|
||||
original_created_before: '2020-01-01T00:01Z')
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
expect(Time.iso8601(json.fetch('original_created_from')))
|
||||
.to eq(Time.utc(2020, 1, 1, 0, 0))
|
||||
expect(Time.iso8601(json.fetch('original_created_before')))
|
||||
.to eq(Time.utc(2020, 1, 1, 0, 1))
|
||||
end
|
||||
|
||||
it 'rejects unparseable original created timestamps on PUT /posts/:id' do
|
||||
sign_in_as(member)
|
||||
base_version = create_post_version_for!(post_record)
|
||||
|
||||
put "/posts/#{post_record.id}", params: post_write_params(
|
||||
base_version_no: base_version.version_no,
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag',
|
||||
original_created_from: 'not-a-time')
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json.fetch('errors')).to include(
|
||||
'original_created_from' => ['オリジナルの作成日時の形式が不正です.']
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'tag versioning from post write actions' do
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki body search', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
it 'searches wiki pages by body text' do
|
||||
pending '#336 で対応予定'
|
||||
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: TagName.create!(name: 'wiki_body_search_hit'),
|
||||
body: 'unique body keyword for wiki search',
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: TagName.create!(name: 'wiki_body_search_miss'),
|
||||
body: 'ordinary body',
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
|
||||
get '/wiki/search', params: { body: 'unique body keyword' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.map { |page| page['title'] }).to include('wiki_body_search_hit')
|
||||
expect(json.map { |page| page['title'] }).not_to include('wiki_body_search_miss')
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Wiki restore', type: :request do
|
||||
let!(:user) { create_member_user! }
|
||||
|
||||
def auth_headers user
|
||||
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||
end
|
||||
|
||||
it 'restores wiki page to previous version' do
|
||||
pending '#337 で対応予定'
|
||||
|
||||
page =
|
||||
Wiki::Commit.create_content!(
|
||||
tag_name: TagName.create!(name: 'wiki_restore_page'),
|
||||
body: 'v1',
|
||||
created_by_user: user,
|
||||
message: 'init')
|
||||
|
||||
v1 = page.wiki_versions.order(:version_no).last
|
||||
|
||||
Wiki::Commit.content!(
|
||||
page:,
|
||||
body: 'v2',
|
||||
created_user: user,
|
||||
message: 'edit',
|
||||
base_revision_id: page.current_revision.id)
|
||||
|
||||
post "/wiki/#{ page.id }/restore",
|
||||
params: { version_no: v1.version_no },
|
||||
headers: auth_headers(user)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(page.reload.body).to eq('v1')
|
||||
expect(page.wiki_versions.order(:version_no).last.event_type).to eq('restore')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PostBulkCreator do
|
||||
it 'limits workers to two and keeps failures in their request slots' do
|
||||
actor = instance_double(User, id: 123)
|
||||
allow(User).to receive(:find).with(123) {
|
||||
instance_double(User, id: 123)
|
||||
}
|
||||
mutex = Mutex.new
|
||||
active = 0
|
||||
maximum_active = 0
|
||||
|
||||
allow(PostCreatePreflight).to receive(:new) do |attributes:, **|
|
||||
preflight = instance_double(PostCreatePreflight)
|
||||
allow(preflight).to receive(:run) do
|
||||
mutex.synchronize do
|
||||
active += 1
|
||||
maximum_active = [maximum_active, active].max
|
||||
end
|
||||
sleep 0.02
|
||||
mutex.synchronize { active -= 1 }
|
||||
attributes.symbolize_keys.merge(
|
||||
existing_post_id: nil,
|
||||
field_warnings: { },
|
||||
base_warnings: [])
|
||||
end
|
||||
preflight
|
||||
end
|
||||
allow(PostCreator).to receive(:new) do |attributes:, **|
|
||||
creator = instance_double(PostCreator)
|
||||
if attributes[:title] == 'broken'
|
||||
allow(creator).to receive(:create!).and_raise(StandardError, 'broken')
|
||||
else
|
||||
post = instance_double(Post, id: attributes[:title].delete_prefix('post ').to_i)
|
||||
allow(creator).to receive(:create!).and_return(post)
|
||||
end
|
||||
creator
|
||||
end
|
||||
posts = [
|
||||
{ 'title' => 'post 1', 'url' => 'https://example.com/1' },
|
||||
{ 'title' => 'broken', 'url' => 'https://example.com/2' },
|
||||
{ 'title' => 'post 3', 'url' => 'https://example.com/3' },
|
||||
{ 'title' => 'post 4', 'url' => 'https://example.com/4' }]
|
||||
|
||||
results = described_class.new(
|
||||
actor:,
|
||||
posts:,
|
||||
thumbnails: { }).run.fetch(:results)
|
||||
|
||||
expect(maximum_active).to eq(2)
|
||||
expect(results.length).to eq(posts.length)
|
||||
expect(results.map { _1[:status] }).to eq(
|
||||
['created', 'failed', 'created', 'created'])
|
||||
expect(results[0].dig(:post, :id)).to eq(1)
|
||||
expect(results[1]).to include(status: 'failed', recoverable: false)
|
||||
expect(results[2].dig(:post, :id)).to eq(3)
|
||||
expect(results[3].dig(:post, :id)).to eq(4)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PostCreatePlan do
|
||||
def create_tag! name, category
|
||||
Tag.create!(name:, category:)
|
||||
end
|
||||
|
||||
before do
|
||||
create_tag!('タグ希望', :meta)
|
||||
create_tag!('ニジラー情報不詳', :meta)
|
||||
end
|
||||
|
||||
it 'plans direct and existing default tags without persisting records' do
|
||||
counts = [TagName.count, Tag.count]
|
||||
|
||||
plan = described_class.new(
|
||||
attributes: {
|
||||
url: 'https://example.com/post',
|
||||
title: 'title',
|
||||
tags: 'character:new_character',
|
||||
parent_post_ids: '' }).build!
|
||||
|
||||
expect(plan[:tags]).to eq('new_character')
|
||||
expect(plan[:direct_tag_specs]).to eq(
|
||||
[{ name: 'new_character', category: :character }])
|
||||
expect(plan[:default_tag_specs]).to include(
|
||||
{ name: 'タグ希望', category: :meta },
|
||||
{ name: 'ニジラー情報不詳', category: :meta })
|
||||
expect([TagName.count, Tag.count]).to eq(counts)
|
||||
end
|
||||
|
||||
it 'resolves aliases and keeps tag sections separate from canonical names' do
|
||||
canonical = create_tag!('虹夏', :character)
|
||||
TagName.create!(name: 'にじか', canonical: canonical.tag_name)
|
||||
create_tag!('動画', :meta)
|
||||
|
||||
plan = described_class.new(
|
||||
attributes: {
|
||||
url: 'https://example.com/video',
|
||||
title: 'video',
|
||||
tags: '動画 にじか[0:10-0:20]',
|
||||
duration: '1:00',
|
||||
parent_post_ids: '' }).build!
|
||||
|
||||
expect(plan[:tags].split).to include('動画', '虹夏[0:10-0:20]')
|
||||
expect(plan[:display_tags]).to include(
|
||||
{ name: '虹夏',
|
||||
category: 'character',
|
||||
section_literals: ['[0:10-0:20]'] })
|
||||
expect(plan[:video_ms]).to eq(60_000)
|
||||
end
|
||||
|
||||
it 'validates a new tag name without persisting it' do
|
||||
long_name = 'a' * 256
|
||||
counts = [TagName.count, Tag.count]
|
||||
|
||||
expect {
|
||||
described_class.new(
|
||||
attributes: {
|
||||
url: 'https://example.com/post',
|
||||
title: 'title',
|
||||
tags: long_name,
|
||||
parent_post_ids: '' }).build!
|
||||
}.to raise_error(ActiveRecord::RecordInvalid) { |error|
|
||||
expect(error.record.errors[:tags]).not_to be_empty
|
||||
}
|
||||
expect([TagName.count, Tag.count]).to eq(counts)
|
||||
end
|
||||
|
||||
it 'ignores duration when the planned tags do not include video' do
|
||||
plan = described_class.new(
|
||||
attributes: {
|
||||
url: 'https://example.com/post',
|
||||
title: 'title',
|
||||
tags: 'ordinary_tag',
|
||||
duration: 'invalid',
|
||||
parent_post_ids: '' }).build!
|
||||
|
||||
expect(plan[:duration]).to eq('invalid')
|
||||
expect(plan[:video_ms]).to be_nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
require 'rails_helper'
|
||||
require 'base64'
|
||||
|
||||
RSpec.describe PostCreator do
|
||||
let(:actor) { create(:user, :member) }
|
||||
|
||||
def real_thumbnail_upload
|
||||
gif = Base64.decode64('R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
|
||||
|
||||
Rack::Test::UploadedFile.new(
|
||||
StringIO.new(gif),
|
||||
'image/gif',
|
||||
original_filename: 'thumbnail.gif')
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Tag).to receive(:normalise_tags!).and_return({ tags: [], sections: {} })
|
||||
allow(TagVersioning).to receive(:record_tag_snapshots!)
|
||||
allow(Tag).to receive(:expand_parent_tags).and_return([])
|
||||
allow(PostVersionRecorder).to receive(:record!)
|
||||
end
|
||||
|
||||
it 'prefers thumbnail_base over an explicit upload' do
|
||||
expect(Post).not_to receive(:resized_thumbnail_attachment)
|
||||
allow(Post).to receive(:remote_thumbnail_attachment).and_return(
|
||||
io: StringIO.new('remote'),
|
||||
filename: 'resized_thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
|
||||
post = described_class.new(
|
||||
actor:,
|
||||
attributes: {
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
thumbnail: real_thumbnail_upload,
|
||||
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||
tags: '' }).create!
|
||||
|
||||
expect(post.thumbnail).to be_attached
|
||||
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
|
||||
end
|
||||
|
||||
it 'uses the common remote thumbnail attach path when thumbnail_base is given' do
|
||||
expect(Post).to receive(:remote_thumbnail_attachment)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.and_return(
|
||||
io: StringIO.new('thumbnail'),
|
||||
filename: 'thumbnail.jpg',
|
||||
content_type: 'image/jpeg')
|
||||
|
||||
post = described_class.new(
|
||||
actor:,
|
||||
attributes: {
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||
tags: '' }).create!
|
||||
|
||||
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
|
||||
expect(post.thumbnail).to be_attached
|
||||
end
|
||||
|
||||
it 'does not create a post when remote thumbnail fetch fails' do
|
||||
allow(Post).to receive(:remote_thumbnail_attachment)
|
||||
.and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.')
|
||||
creator = described_class.new(
|
||||
actor:,
|
||||
attributes: {
|
||||
title: 'title',
|
||||
url: 'https://example.com/post',
|
||||
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||
tags: '' })
|
||||
|
||||
post_count = Post.count
|
||||
expect { creator.create! }.to raise_error(Post::RemoteThumbnailFetchFailed)
|
||||
expect(Post.count).to eq(post_count)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,187 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PostImportPreviewer do
|
||||
before do
|
||||
allow(Preview::UrlSafety).to receive(:validate) do |url|
|
||||
[URI.parse(url), ['8.8.8.8']]
|
||||
end
|
||||
end
|
||||
|
||||
def row(source_row:, url:, attributes: { }, provenance: { }, **values)
|
||||
{
|
||||
source_row:,
|
||||
url:,
|
||||
attributes:,
|
||||
provenance:
|
||||
}.merge(values)
|
||||
end
|
||||
|
||||
describe '#preview_rows' do
|
||||
it 'marks an existing post for skipping without fetching metadata' do
|
||||
existing = create(:post, url: 'https://example.com/existing')
|
||||
expect(PostMetadataFetcher).not_to receive(:fetch)
|
||||
|
||||
result = described_class.new.preview_rows(rows: [
|
||||
row(source_row: 1, url: 'https://EXAMPLE.com/existing/')
|
||||
]).first
|
||||
|
||||
expect(result).to include(
|
||||
url: 'https://example.com/existing',
|
||||
skip_reason: 'existing',
|
||||
existing_post_id: existing.id,
|
||||
validation_errors: {}
|
||||
)
|
||||
expect(result.fetch(:field_warnings)).to eq({})
|
||||
end
|
||||
|
||||
it 'reports list duplicates as URL errors instead of existing skips' do
|
||||
create(:post, url: 'https://example.com/duplicate')
|
||||
expect(PostMetadataFetcher).not_to receive(:fetch)
|
||||
|
||||
results = described_class.new.preview_rows(rows: [
|
||||
row(source_row: 1, url: 'https://example.com/duplicate'),
|
||||
row(source_row: 2, url: 'https://EXAMPLE.com/duplicate/')
|
||||
])
|
||||
|
||||
expect(results.map { _1[:validation_errors] }).to all(
|
||||
include(url: ['URL が重複しています.'])
|
||||
)
|
||||
expect(results).to all(include(skip_reason: nil, existing_post_id: nil))
|
||||
end
|
||||
|
||||
it 'does not fetch metadata for an unsafe URL' do
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||
expect(PostMetadataFetcher).not_to receive(:fetch)
|
||||
|
||||
result = described_class.new.preview_rows(rows: [
|
||||
row(source_row: 1, url: 'https://unsafe.example/post')
|
||||
]).first
|
||||
|
||||
expect(result.fetch(:validation_errors)).to include(
|
||||
url: ['安全でない接続先は使用できません.']
|
||||
)
|
||||
end
|
||||
|
||||
it 'applies metadata to automatic fields and recognises metadata tags' do
|
||||
Tag.create!(name: 'known-tag', category: :general)
|
||||
allow(PostMetadataFetcher).to receive(:fetch).and_return(
|
||||
title: 'metadata title',
|
||||
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||
duration: '2',
|
||||
tags: 'known-tag'
|
||||
)
|
||||
|
||||
result = described_class.new.preview_rows(rows: [
|
||||
row(source_row: 1, url: 'https://example.com/new')
|
||||
]).first
|
||||
|
||||
expect(result.fetch(:attributes)).to include(
|
||||
'title' => 'metadata title',
|
||||
'thumbnail_base' => 'https://example.com/thumb.jpg',
|
||||
'duration' => '2',
|
||||
'tags' => 'known-tag'
|
||||
)
|
||||
expect(result.fetch(:field_warnings)).not_to have_key('tags')
|
||||
expect(PostMetadataFetcher).to have_received(:fetch).once
|
||||
end
|
||||
|
||||
it 'preserves manual empty values when metadata is available' do
|
||||
allow(PostMetadataFetcher).to receive(:fetch).and_return(
|
||||
title: 'metadata title',
|
||||
tags: 'metadata-tag'
|
||||
)
|
||||
|
||||
result = described_class.new.preview_rows(rows: [
|
||||
row(
|
||||
source_row: 1,
|
||||
url: 'https://example.com/manual',
|
||||
attributes: { title: '', tags: '' },
|
||||
provenance: { title: 'manual', tags: 'manual' },
|
||||
tag_sources: { automatic: 'old-tag', manual: '' }
|
||||
)
|
||||
]).first
|
||||
|
||||
expect(result.fetch(:attributes)).to include('title' => '', 'tags' => '')
|
||||
expect(result.fetch(:tag_sources)).to include('manual' => '')
|
||||
end
|
||||
|
||||
it 'clears warnings derived from an old metadata URL' do
|
||||
allow(PostMetadataFetcher).to receive(:fetch).and_return(
|
||||
title: 'new title',
|
||||
thumbnail_base: 'https://example.com/new-thumb.jpg',
|
||||
tags: ''
|
||||
)
|
||||
|
||||
result = described_class.new.preview_rows(rows: [
|
||||
row(
|
||||
source_row: 1,
|
||||
url: 'https://example.com/new-url',
|
||||
attributes: { title: 'old title', tags: 'old-tag' },
|
||||
provenance: { title: 'automatic', tags: 'automatic' },
|
||||
tag_sources: { automatic: 'old-tag', manual: '' },
|
||||
metadata_url: 'https://example.com/old-url',
|
||||
field_warnings: { tags: ['old tag warning'], url: ['old fetch warning'] },
|
||||
base_warnings: ['old base warning']
|
||||
)
|
||||
]).first
|
||||
|
||||
expect(result.fetch(:attributes)).to include('title' => 'new title', 'tags' => '')
|
||||
expect(result.fetch(:base_warnings)).to eq([])
|
||||
expect(result.fetch(:field_warnings).values.flatten)
|
||||
.not_to include('old tag warning', 'old fetch warning')
|
||||
end
|
||||
|
||||
it 'isolates an unexpected metadata failure to the affected URL' do
|
||||
allow(PostMetadataFetcher).to receive(:fetch) do |url|
|
||||
raise 'fetch failure' if url.include?('failure')
|
||||
|
||||
{ title: 'successful title', tags: '' }
|
||||
end
|
||||
allow(Rails.logger).to receive(:error)
|
||||
|
||||
results = described_class.new.preview_rows(rows: [
|
||||
row(source_row: 1, url: 'https://example.com/failure'),
|
||||
row(source_row: 2, url: 'https://example.com/success')
|
||||
])
|
||||
|
||||
expect(results[0].fetch(:field_warnings)).to include(
|
||||
'url' => [described_class::METADATA_FETCH_WARNING]
|
||||
)
|
||||
expect(results[1].fetch(:attributes)).to include('title' => 'successful title')
|
||||
end
|
||||
|
||||
it 'turns an unsafe URL detected during metadata fetching into a URL error' do
|
||||
allow(PostMetadataFetcher).to receive(:fetch)
|
||||
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先です.')
|
||||
allow(Rails.logger).to receive(:info)
|
||||
|
||||
result = described_class.new.preview_rows(rows: [
|
||||
row(source_row: 1, url: 'https://example.com/redirects-to-private')
|
||||
]).first
|
||||
|
||||
expect(result.fetch(:validation_errors)).to include(
|
||||
url: ['安全でない接続先です.']
|
||||
)
|
||||
end
|
||||
|
||||
it 'validates parent IDs from the preloaded set' do
|
||||
parent = create(:post)
|
||||
|
||||
results = described_class.new.preview_rows(
|
||||
rows: [
|
||||
row(source_row: 1, url: 'https://example.com/valid-parent',
|
||||
attributes: { parent_post_ids: parent.id.to_s }),
|
||||
row(source_row: 2, url: 'https://example.com/missing-parent',
|
||||
attributes: { parent_post_ids: '999999' })
|
||||
],
|
||||
fetch_metadata: false
|
||||
)
|
||||
|
||||
expect(results[0].fetch(:validation_errors)).not_to have_key(:parent_post_ids)
|
||||
expect(results[1].fetch(:validation_errors)).to include(
|
||||
parent_post_ids: ['存在しない親投稿 Id. があります.']
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PostMetadataFetcher do
|
||||
Response = Struct.new(:body)
|
||||
|
||||
def fetch_with_published_time(value, url: 'https://example.com/video')
|
||||
html = <<~HTML
|
||||
<html><head>
|
||||
<meta property="article:published_time" content="#{ value }">
|
||||
</head></html>
|
||||
HTML
|
||||
uri = URI.parse(url)
|
||||
allow(Preview::UrlSafety).to receive(:validate).with(url).and_return([uri, ['8.8.8.8']])
|
||||
allow(Preview::HttpFetcher).to receive(:fetch).and_return(Response.new(html))
|
||||
allow(Preview::HtmlMetadataExtractor).to receive(:extract)
|
||||
.and_return(title: 'title', image_url: nil)
|
||||
|
||||
described_class.fetch(url)
|
||||
end
|
||||
|
||||
it 'builds ranges explicitly for year, month, and day precision' do
|
||||
year = fetch_with_published_time('2024')
|
||||
month = fetch_with_published_time('2024-02')
|
||||
day = fetch_with_published_time('2024-02-03')
|
||||
|
||||
expect(year).to include(
|
||||
original_created_from: Time.zone.local(2024, 1, 1).iso8601,
|
||||
original_created_before: Time.zone.local(2025, 1, 1).iso8601
|
||||
)
|
||||
expect(month).to include(
|
||||
original_created_from: Time.zone.local(2024, 2, 1).iso8601,
|
||||
original_created_before: Time.zone.local(2024, 3, 1).iso8601
|
||||
)
|
||||
expect(day).to include(
|
||||
original_created_from: Time.zone.local(2024, 2, 3).iso8601,
|
||||
original_created_before: Time.zone.local(2024, 2, 4).iso8601
|
||||
)
|
||||
end
|
||||
|
||||
it 'rounds second and fractional-second timestamps down to one-minute ranges' do
|
||||
result = fetch_with_published_time('2024-02-03T12:34:56.123+02:30')
|
||||
|
||||
expect(Time.iso8601(result.fetch(:original_created_from)))
|
||||
.to eq(Time.iso8601('2024-02-03T12:34:00+02:30'))
|
||||
expect(
|
||||
Time.iso8601(result.fetch(:original_created_before)) -
|
||||
Time.iso8601(result.fetch(:original_created_from))
|
||||
).to eq(60)
|
||||
expect(result.fetch(:original_created_from)).not_to include('.')
|
||||
end
|
||||
|
||||
it 'treats hour-precision timestamps as one-hour ranges with and without offsets' do
|
||||
offset_result = fetch_with_published_time('2024-02-03T12+02:30')
|
||||
local_result = fetch_with_published_time('2024-02-03T12')
|
||||
|
||||
expect(Time.iso8601(offset_result.fetch(:original_created_from)))
|
||||
.to eq(Time.iso8601('2024-02-03T12:00:00+02:30'))
|
||||
expect(
|
||||
Time.iso8601(offset_result.fetch(:original_created_before)) -
|
||||
Time.iso8601(offset_result.fetch(:original_created_from))
|
||||
).to eq(3600)
|
||||
expect(local_result.fetch(:original_created_from)).to eq('2024-02-03T12:00:00Z')
|
||||
expect(local_result.fetch(:original_created_before)).to eq('2024-02-03T13:00:00Z')
|
||||
end
|
||||
|
||||
it 'adds platform tags for known video URLs' do
|
||||
result = fetch_with_published_time('2024', url: 'https://youtu.be/abc123')
|
||||
|
||||
expect(result.fetch(:tags)).to eq('動画 YouTube')
|
||||
end
|
||||
|
||||
it 'returns nil dates for an invalid timestamp' do
|
||||
result = fetch_with_published_time('not-a-time')
|
||||
|
||||
expect(result.fetch(:original_created_from)).to be_nil
|
||||
expect(result.fetch(:original_created_before)).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil dates for an invalid timestamp second value' do
|
||||
result = fetch_with_published_time('2024-02-03T12:34:99Z')
|
||||
|
||||
expect(result.fetch(:original_created_from)).to be_nil
|
||||
expect(result.fetch(:original_created_before)).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil dates for invalid calendar dates and invalid hour values' do
|
||||
invalid_day = fetch_with_published_time('2024-02-31T12:00Z')
|
||||
invalid_leap = fetch_with_published_time('2023-02-29T12:00Z')
|
||||
invalid_hour = fetch_with_published_time('2024-02-29T24:00Z')
|
||||
|
||||
[invalid_day, invalid_leap, invalid_hour].each do |result|
|
||||
expect(result.fetch(:original_created_from)).to be_nil
|
||||
expect(result.fetch(:original_created_before)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
it 'accepts a valid leap-day timestamp at minute precision' do
|
||||
result = fetch_with_published_time('2024-02-29T12:34Z')
|
||||
|
||||
expect(result.fetch(:original_created_from)).to eq('2024-02-29T12:34:00Z')
|
||||
expect(result.fetch(:original_created_before)).to eq('2024-02-29T12:35:00Z')
|
||||
end
|
||||
|
||||
it 'serialises metadata duration as the same seconds string contract used by forms' do
|
||||
html = <<~HTML
|
||||
<html><head>
|
||||
<meta property="article:published_time" content="2024-02-03T12:34:56.123+02:30">
|
||||
<meta property="og:video:duration" content="2.5">
|
||||
</head></html>
|
||||
HTML
|
||||
uri = URI.parse('https://example.com/video')
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.with('https://example.com/video')
|
||||
.and_return([uri, ['8.8.8.8']])
|
||||
allow(Preview::HttpFetcher).to receive(:fetch).and_return(Response.new(html))
|
||||
allow(Preview::HtmlMetadataExtractor).to receive(:extract)
|
||||
.and_return(title: 'title', image_url: nil)
|
||||
|
||||
result = described_class.fetch('https://example.com/video')
|
||||
|
||||
expect(result.fetch(:duration)).to eq('2.5')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
require 'rails_helper'
|
||||
require 'base64'
|
||||
require 'tempfile'
|
||||
|
||||
RSpec.describe PostThumbnailUploadValidator do
|
||||
def with_upload bytes, content_type:, filename:
|
||||
tempfile = Tempfile.new(['thumbnail-upload', File.extname(filename)])
|
||||
tempfile.binmode
|
||||
tempfile.write(bytes)
|
||||
tempfile.rewind
|
||||
upload = ActionDispatch::Http::UploadedFile.new(
|
||||
tempfile:,
|
||||
filename:,
|
||||
type: content_type)
|
||||
yield upload
|
||||
ensure
|
||||
tempfile&.close!
|
||||
end
|
||||
|
||||
it 'accepts a raster upload after decoding and rewinds it' do
|
||||
gif = Base64.decode64('R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
|
||||
|
||||
with_upload(gif, content_type: 'image/gif', filename: 'thumbnail.gif') do |upload|
|
||||
expect { described_class.validate!(upload) }.not_to raise_error
|
||||
expect(upload.read(6)).to eq('GIF87a')
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects SVG content disguised as a raster MIME type' do
|
||||
with_upload(
|
||||
'<svg width="10" height="10"></svg>',
|
||||
content_type: 'image/png',
|
||||
filename: 'thumbnail.png') do |upload|
|
||||
expect { described_class.validate!(upload) }
|
||||
.to raise_error(
|
||||
described_class::InvalidUpload,
|
||||
'サムネイル画像の形式が不正です.')
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects non-raster content disguised as an image' do
|
||||
with_upload(
|
||||
'%PDF-1.7',
|
||||
content_type: 'image/png',
|
||||
filename: 'thumbnail.png') do |upload|
|
||||
expect { described_class.validate!(upload) }
|
||||
.to raise_error(
|
||||
described_class::InvalidUpload,
|
||||
'サムネイル画像の形式が不正です.')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PostUrlNormaliser do
|
||||
before do
|
||||
PostUrlSanitisationRule.unscoped.delete_all
|
||||
end
|
||||
|
||||
describe '.normalise' do
|
||||
it 'normalises the host and trailing slash before applying sanitisation rules' do
|
||||
PostUrlSanitisationRule.create!(
|
||||
priority: 10,
|
||||
source_pattern: '\\Ahttps://example\\.com/source\\z',
|
||||
replacement: 'https://example.com/canonical'
|
||||
)
|
||||
|
||||
result = described_class.normalise(' https://EXAMPLE.com/source/ ')
|
||||
|
||||
expect(result).to eq('https://example.com/canonical')
|
||||
end
|
||||
|
||||
it 'returns nil for unsupported or malformed URLs' do
|
||||
expect(described_class.normalise('ftp://example.com/file')).to be_nil
|
||||
expect(described_class.normalise('https://[')).to be_nil
|
||||
expect(described_class.normalise('https:/path')).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Preview::ThumbnailFetcher do
|
||||
describe '.fetch' do
|
||||
it 'rejects svg thumbnails' do
|
||||
it 'accepts svg thumbnails for the common safe rasterisation path' do
|
||||
page = Preview::HttpFetcher::Response.new(
|
||||
'<meta property="og:image" content="https://example.com/thumb.svg">',
|
||||
'text/html',
|
||||
@@ -12,8 +12,9 @@ RSpec.describe Preview::ThumbnailFetcher do
|
||||
'image/svg+xml',
|
||||
'https://example.com/thumb.svg')
|
||||
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
|
||||
allow(Preview::UrlSafety).to receive(:validate) do |url|
|
||||
[URI.parse(url), ['203.0.113.10']]
|
||||
end
|
||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
|
||||
.and_return(page)
|
||||
@@ -21,9 +22,7 @@ RSpec.describe Preview::ThumbnailFetcher do
|
||||
.with('https://example.com/thumb.svg')
|
||||
.and_return(svg)
|
||||
|
||||
expect {
|
||||
described_class.fetch('https://example.com/page')
|
||||
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
|
||||
expect(described_class.fetch('https://example.com/page')).to eq('<svg></svg>')
|
||||
end
|
||||
|
||||
it 'accepts allowed image content type with parameters' do
|
||||
@@ -32,12 +31,13 @@ RSpec.describe Preview::ThumbnailFetcher do
|
||||
'text/html',
|
||||
'https://example.com/page')
|
||||
image = Preview::HttpFetcher::Response.new(
|
||||
'jpeg-bytes',
|
||||
"\xFF\xD8\xFFjpeg-bytes".b,
|
||||
'image/jpeg; charset=binary',
|
||||
'https://example.com/thumb.jpg')
|
||||
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
|
||||
allow(Preview::UrlSafety).to receive(:validate) do |url|
|
||||
[URI.parse(url), ['203.0.113.10']]
|
||||
end
|
||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
|
||||
.and_return(page)
|
||||
@@ -45,7 +45,60 @@ RSpec.describe Preview::ThumbnailFetcher do
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.and_return(image)
|
||||
|
||||
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
|
||||
expect(described_class.fetch('https://example.com/page'))
|
||||
.to eq("\xFF\xD8\xFFjpeg-bytes".b)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.fetch_image_response' do
|
||||
it 'rejects an unsafe input URL before HTTP fetch' do
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||
expect(Preview::HttpFetcher).not_to receive(:fetch)
|
||||
|
||||
expect {
|
||||
described_class.fetch_image_response('https://unsafe.example.com/thumb.jpg')
|
||||
}.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||
end
|
||||
|
||||
it 'rejects an unsafe redirect target' do
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
|
||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||
|
||||
expect {
|
||||
described_class.fetch_image_response('https://example.com/thumb.jpg')
|
||||
}.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||
end
|
||||
|
||||
it 'rejects an oversized image response' do
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
|
||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.and_raise(Preview::HttpFetcher::ResponseTooLarge, 'too large')
|
||||
|
||||
expect {
|
||||
described_class.fetch_image_response('https://example.com/thumb.jpg')
|
||||
}.to raise_error(Preview::HttpFetcher::ResponseTooLarge)
|
||||
end
|
||||
|
||||
it 'rejects a non-image content type' do
|
||||
allow(Preview::UrlSafety).to receive(:validate)
|
||||
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
|
||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
.and_return(
|
||||
Preview::HttpFetcher::Response.new(
|
||||
'<html></html>',
|
||||
'text/html',
|
||||
'https://example.com/thumb.jpg'))
|
||||
|
||||
expect {
|
||||
described_class.fetch_image_response('https://example.com/thumb.jpg')
|
||||
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,6 +10,37 @@ RSpec.describe Youtube::Sync do
|
||||
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 'returns without fetching video details when no video ids are discovered' do
|
||||
allow(sync).to receive(:query_terms).and_return([])
|
||||
@@ -76,7 +107,7 @@ RSpec.describe Youtube::Sync do
|
||||
sync.sync!
|
||||
end
|
||||
|
||||
it 'creates a YouTube post with default tags and no_deerjikist when no deerjikist mapping exists' do
|
||||
it 'creates a YouTube post with default tags when no deerjikist mapping exists' do
|
||||
Tag.tagme
|
||||
Tag.bot
|
||||
Tag.youtube
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
require "rails_helper"
|
||||
require 'rails_helper'
|
||||
|
||||
|
||||
RSpec.describe "nico:sync" do
|
||||
RSpec.describe 'nico:sync' do
|
||||
def stub_python(json_array)
|
||||
status = instance_double(Process::Status, success?: true)
|
||||
allow(Open3).to receive(:capture3).and_return([json_array.to_json, "", status])
|
||||
allow(Open3).to receive(:capture3).and_return([json_array.to_json, '', status])
|
||||
end
|
||||
|
||||
def create_tag!(name, category:)
|
||||
@@ -16,17 +16,21 @@ RSpec.describe "nico:sync" do
|
||||
NicoTagRelation.create!(nico_tag_id: nico_tag.id, tag_id: tag.id)
|
||||
end
|
||||
|
||||
it "既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける" do
|
||||
it '既存 post を見つけて、nico tag と linked tag を追加し、差分が出たら bot を付ける' do
|
||||
# 既存 post(正規表現で拾われるURL)
|
||||
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
|
||||
post = Post.create!(
|
||||
title: 'old',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil
|
||||
)
|
||||
|
||||
# 既存の非nicoタグ(kept_non_nico_ids)
|
||||
kept_general = create_tag!("spec_kept", category: "general")
|
||||
kept_general = create_tag!('spec_kept', category: 'general')
|
||||
PostTag.create!(post: post, tag: kept_general)
|
||||
|
||||
# 追加される linked tag を準備(nico tag に紐付く一般タグ)
|
||||
linked = create_tag!("spec_linked", category: "general")
|
||||
nico = create_tag!("nico:AAA", category: "nico")
|
||||
linked = create_tag!('spec_linked', category: 'general')
|
||||
nico = create_tag!('nico:AAA', category: 'nico')
|
||||
link_nico_to_tag!(nico, linked)
|
||||
|
||||
# bot / tagme は task 内で使うので作っておく(Tag.bot/tagme がある前提)
|
||||
@@ -42,43 +46,91 @@ RSpec.describe "nico:sync" do
|
||||
'deleted_at' => '2026-01-31 00:00:00' }])
|
||||
|
||||
# 外部HTTPは今回「既存 post なので呼ばれない」はずだが、念のため塞ぐ
|
||||
allow(URI).to receive(:open).and_return(StringIO.new("<html></html>"))
|
||||
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||
|
||||
run_rake_task("nico:sync")
|
||||
run_rake_task('nico:sync')
|
||||
|
||||
post.reload
|
||||
active_tag_names = post.tags.joins(:tag_name).pluck("tag_names.name")
|
||||
active_tag_names = post.tags.joins(:tag_name).pluck('tag_names.name')
|
||||
|
||||
expect(active_tag_names).to include("spec_kept")
|
||||
expect(active_tag_names).to include("nico:AAA")
|
||||
expect(active_tag_names).to include("spec_linked")
|
||||
expect(active_tag_names).to include('spec_kept')
|
||||
expect(active_tag_names).to include('nico:AAA')
|
||||
expect(active_tag_names).to include('spec_linked')
|
||||
|
||||
expect(post.original_created_from).to eq(Time.iso8601('2026-01-01T03:34:00Z'))
|
||||
expect(post.original_created_before).to eq(Time.iso8601('2026-01-01T03:35:00Z'))
|
||||
|
||||
# 差分が出るので bot が付く(kept_non_nico_ids != desired_non_nico_ids)
|
||||
expect(active_tag_names).to include("bot操作")
|
||||
expect(active_tag_names).to include('bot操作')
|
||||
end
|
||||
|
||||
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
|
||||
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
|
||||
it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
|
||||
post = Post.create!(
|
||||
title: 'old',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil)
|
||||
Tag.bot
|
||||
Tag.tagme
|
||||
|
||||
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
|
||||
allow(URI).to receive(:open) do
|
||||
StringIO.new(
|
||||
'<meta name="thumbnail" content="https://example.com/thumb.jpg">')
|
||||
end
|
||||
expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
|
||||
.with('https://example.com/thumb.jpg')
|
||||
|
||||
run_rake_task('nico:sync')
|
||||
end
|
||||
|
||||
it 'サムネール取得失敗後も次回同期で再試行できる' do
|
||||
post = Post.create!(
|
||||
title: 'old',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil)
|
||||
Tag.bot
|
||||
Tag.tagme
|
||||
|
||||
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
|
||||
allow(URI).to receive(:open) do
|
||||
StringIO.new(
|
||||
'<meta name="thumbnail" content="https://example.com/thumb.jpg">')
|
||||
end
|
||||
calls = 0
|
||||
allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!) do
|
||||
calls += 1
|
||||
raise Post::RemoteThumbnailFetchFailed, 'failed'
|
||||
end
|
||||
|
||||
2.times do
|
||||
run_rake_task('nico:sync')
|
||||
end
|
||||
expect(calls).to eq(2)
|
||||
end
|
||||
|
||||
it '既存 post にあった古い nico tag は active から外され、履歴として discard される' do
|
||||
post = Post.create!(
|
||||
title: 'old',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil
|
||||
)
|
||||
|
||||
# 旧nicoタグ(今回の同期結果に含まれない)
|
||||
old_nico = create_tag!("nico:OLD", category: "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
|
||||
|
||||
# 今回は NEW のみ欲しい
|
||||
new_nico = create_tag!("nico:NEW", category: "nico")
|
||||
new_nico = create_tag!('nico:NEW', category: 'nico')
|
||||
|
||||
# bot/tagme 念のため
|
||||
Tag.bot
|
||||
Tag.tagme
|
||||
|
||||
stub_python([{ "code" => "sm9", "title" => "t", "tags" => ["NEW"] }])
|
||||
allow(URI).to receive(:open).and_return(StringIO.new("<html></html>"))
|
||||
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => ['NEW'] }])
|
||||
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||
|
||||
run_rake_task("nico:sync")
|
||||
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
|
||||
@@ -86,9 +138,9 @@ RSpec.describe "nico:sync" do
|
||||
|
||||
# NEW は active にいる
|
||||
post.reload
|
||||
active_names = post.tags.joins(:tag_name).pluck("tag_names.name")
|
||||
expect(active_names).to include("nico:NEW")
|
||||
expect(active_names).not_to include("nico:OLD")
|
||||
active_names = post.tags.joins(:tag_name).pluck('tag_names.name')
|
||||
expect(active_names).to include('nico:NEW')
|
||||
expect(active_names).not_to include('nico:OLD')
|
||||
end
|
||||
|
||||
def snapshot_tags(post)
|
||||
|
||||
新しい課題から参照
ユーザをブロックする