require 'rails_helper' require 'tempfile' RSpec.describe Post, type: :model do before do PostUrlSanitisationRule.unscoped.delete_all end describe 'URL normalisation' do it 'normalises the HTTP URL before applying sanitisation rules' do PostUrlSanitisationRule.create!( priority: 10, source_pattern: '\\Ahttps://example\\.com/videos/([^/]+)\\z', replacement: 'https://example.com/watch/\\1' ) post = described_class.create!( title: 'normalised URL', url: ' https://EXAMPLE.com/videos/123/ ' ) expect(post.url).to eq('https://example.com/watch/123') end it 'does not normalise an unchanged URL when another attribute changes' do post = create(:post) post.update_column(:url, 'https://EXAMPLE.com/unchanged/') post.update!(title: 'updated title') expect(post.reload.url).to eq('https://EXAMPLE.com/unchanged/') end it 'validates the sanitised URL length' do path = 'a' * 375 PostUrlSanitisationRule.create!( priority: 10, source_pattern: '\\Ahttps://example\\.com/(a+)\\z', replacement: 'https://example.com/\\1\\1' ) post = described_class.new( title: 'long URL', url: "https://example.com/#{ path }" ) expect(post).to be_invalid expect(post.errors.details.fetch(:url)).to include( error: :too_long, count: 768 ) end it 'validates uniqueness after sanitisation' do PostUrlSanitisationRule.create!( priority: 10, source_pattern: '\\Ahttps://example\\.com/alias\\z', replacement: 'https://example.com/canonical' ) create(:post, url: 'https://example.com/canonical') post = described_class.new(title: 'duplicate URL', url: 'https://example.com/alias') expect(post).to be_invalid 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 'centre-crops a wide image to 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, 180]) expect_green(colour_at(image, 0, 90)) expect_green(colour_at(image, 90, 90)) expect_green(colour_at(image, 179, 90)) end it 'centre-crops a tall image to 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([180, 180]) expect_green(colour_at(image, 90, 0)) expect_green(colour_at(image, 90, 90)) expect_green(colour_at(image, 90, 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, 180]) 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