firt tests forpost file thumbnail spec

This commit is contained in:
Dylan Knutson
2025-03-03 22:59:07 +00:00
parent 70debe9995
commit 0040bc45a2
19 changed files with 3299 additions and 76 deletions

View File

@@ -13,7 +13,8 @@ class Domain::PostFile < ReduxApplicationRecord
has_many :thumbnails,
class_name: "::Domain::PostFileThumbnail",
foreign_key: :post_file_id,
dependent: :destroy
dependent: :destroy,
inverse_of: :post_file
attr_json :state, :string
attr_json :url_str, :string

View File

@@ -5,7 +5,8 @@ class Domain::PostFileThumbnail < ReduxApplicationRecord
belongs_to :post_file,
foreign_key: :post_file_id,
class_name: "::Domain::PostFile"
class_name: "::Domain::PostFile",
inverse_of: :thumbnails
has_many :perceptual_hashes,
class_name: "::Domain::PerceptualHash",
@@ -18,30 +19,84 @@ class Domain::PostFileThumbnail < ReduxApplicationRecord
scope: :post_file_id,
},
inclusion: {
in: THUMBNAIL_TYPES.keys,
in: Domain::ThumbnailType.values.map(&:name),
}
# Thumbnail types for different uses
THUMBNAIL_TYPES =
TMP_DIR = T.let(File.join(BlobFile::ROOT_DIR, "tmp-files"), String)
THUMBNAIL_ROOT_DIR =
T.let(File.join(BlobFile::ROOT_DIR, "post_file_thumbnails"), String)
THUMBNAIL_CONTENT_TYPES =
T.let(
{
small: {
width: 128,
height: 128,
},
medium: {
width: 256,
height: 256,
},
large: {
width: 512,
height: 512,
},
phash: {
width: 64,
height: 64,
}, # Special size for perceptual hashing
},
T::Hash[Symbol, T::Hash[Symbol, Integer]],
[
%r{image/jpeg},
%r{image/jpg},
%r{image/png},
%r{image/gif},
%r{image/webp},
],
T::Array[Regexp],
)
sig { returns(T.nilable(String)) }
def absolute_file_path
return nil unless thumbnail_type = self.thumbnail_type
return nil unless post_file_id = self.post_file&.id
return nil unless sha256 = self.post_file&.blob_sha256
sha256_hex = HexUtil.bin2hex(sha256)
path_segments = [
THUMBNAIL_ROOT_DIR,
thumbnail_type,
*BlobFile.path_segments([2, 2, 1], sha256_hex),
]
path_segments[-1] = "#{path_segments[-1]}.jpeg"
path_segments.join("/")
end
sig do
params(
post_file: Domain::PostFile,
thumbnail_type: Domain::ThumbnailType,
).returns(T.nilable(Domain::PostFileThumbnail))
end
def self.find_or_create_from_post_file(post_file, thumbnail_type)
if t = find_by(post_file: post_file, thumbnail_type: thumbnail_type.name)
return t
end
return nil unless post_file.state_ok?
return nil unless log_entry = post_file.log_entry
unless THUMBNAIL_CONTENT_TYPES.any? { |regex|
regex.match?(log_entry.content_type)
}
return nil
end
file_path = post_file.blob&.absolute_file_path
return nil unless file_path
thumbnail =
Domain::PostFileThumbnail.new(
post_file: post_file,
thumbnail_type: thumbnail_type.name,
)
thumbnail_path = thumbnail.absolute_file_path.to_s
unless File.exist?(thumbnail_path)
FileUtils.mkdir_p(File.dirname(thumbnail_path))
tmp_file_path = File.join(TMP_DIR, "thumbnail-#{SecureRandom.uuid}.jpeg")
image_data =
Vips::Image.thumbnail(
file_path,
thumbnail_type.width,
height: thumbnail_type.height,
size: :force,
)
image_data.jpegsave(tmp_file_path, Q: thumbnail_type.quality, strip: true)
FileUtils.mv(tmp_file_path, thumbnail_path)
end
thumbnail.save!
thumbnail
end
end

View File

@@ -0,0 +1,50 @@
# typed: strict
# Thumbnail types for different uses
class Domain::ThumbnailType < T::Enum
extend T::Sig
enums do
Small = new
Medium = new
Large = new
PHash = new
end
sig { returns(String) }
def name
case self
when Small
"small"
when Medium
"medium"
when Large
"large"
when PHash
"phash"
end
end
sig { returns(Integer) }
def width
case self
when Small
128
when Medium
256
when Large
512
when PHash
64
end
end
sig { returns(Integer) }
def height
width
end
sig { returns(Integer) }
def quality
70
end
end

View File

@@ -24,6 +24,16 @@ class IpAddressRole < ReduxApplicationRecord
end
end
sig { returns(T::Boolean) }
def admin?
false
end
sig { returns(T::Boolean) }
def moderator?
false
end
private
# Custom validation to prevent overlapping IP ranges

View File

@@ -57,7 +57,7 @@ class State::IpAddressRolePolicy < ApplicationPolicy
sig { returns(T.untyped) }
def resolve
if @user&.admin? || @controller.current_ip_address_role&.admin?
if @user&.admin?
@scope
else
@scope.where(id: nil) # Returns empty relation

View File

@@ -1,14 +1,9 @@
test:
tmp/blob_files_test
test: tmp/blob_files_test
development:
/mnt/blob_files_development
development: /mnt/blob_files_development
staging:
/mnt/blob_files_production
staging: /mnt/blob_files_production
production:
/mnt/blob_files_production
production: /mnt/blob_files_production
worker:
/mnt/blob_files_production
worker: /mnt/blob_files_production

View File

@@ -2696,6 +2696,71 @@ CREATE SEQUENCE public.domain_inkbunny_users_id_seq
ALTER SEQUENCE public.domain_inkbunny_users_id_seq OWNED BY public.domain_inkbunny_users.id;
--
-- Name: domain_perceptual_hashes; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.domain_perceptual_hashes (
id bigint NOT NULL,
thumbnail_id bigint NOT NULL,
algorithm character varying NOT NULL,
hash_value public.vector,
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL
);
--
-- Name: domain_perceptual_hashes_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.domain_perceptual_hashes_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: domain_perceptual_hashes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.domain_perceptual_hashes_id_seq OWNED BY public.domain_perceptual_hashes.id;
--
-- Name: domain_post_file_thumbnails; Type: TABLE; Schema: public; Owner: -
--
CREATE TABLE public.domain_post_file_thumbnails (
id bigint NOT NULL,
post_file_id bigint NOT NULL,
thumbnail_type character varying NOT NULL,
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL
);
--
-- Name: domain_post_file_thumbnails_id_seq; Type: SEQUENCE; Schema: public; Owner: -
--
CREATE SEQUENCE public.domain_post_file_thumbnails_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
--
-- Name: domain_post_file_thumbnails_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
--
ALTER SEQUENCE public.domain_post_file_thumbnails_id_seq OWNED BY public.domain_post_file_thumbnails.id;
SET default_tablespace = mirai;
--
@@ -4588,6 +4653,20 @@ ALTER TABLE ONLY public.domain_inkbunny_tags ALTER COLUMN id SET DEFAULT nextval
ALTER TABLE ONLY public.domain_inkbunny_users ALTER COLUMN id SET DEFAULT nextval('public.domain_inkbunny_users_id_seq'::regclass);
--
-- Name: domain_perceptual_hashes id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_perceptual_hashes ALTER COLUMN id SET DEFAULT nextval('public.domain_perceptual_hashes_id_seq'::regclass);
--
-- Name: domain_post_file_thumbnails id; Type: DEFAULT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_post_file_thumbnails ALTER COLUMN id SET DEFAULT nextval('public.domain_post_file_thumbnails_id_seq'::regclass);
--
-- Name: domain_post_files id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -5370,6 +5449,22 @@ ALTER TABLE ONLY public.domain_inkbunny_users
ADD CONSTRAINT domain_inkbunny_users_pkey PRIMARY KEY (id);
--
-- Name: domain_perceptual_hashes domain_perceptual_hashes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_perceptual_hashes
ADD CONSTRAINT domain_perceptual_hashes_pkey PRIMARY KEY (id);
--
-- Name: domain_post_file_thumbnails domain_post_file_thumbnails_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_post_file_thumbnails
ADD CONSTRAINT domain_post_file_thumbnails_pkey PRIMARY KEY (id);
SET default_tablespace = mirai;
--
@@ -6971,6 +7066,34 @@ CREATE UNIQUE INDEX index_domain_inkbunny_users_on_ib_user_id ON public.domain_i
CREATE INDEX index_domain_inkbunny_users_on_shallow_update_log_entry_id ON public.domain_inkbunny_users USING btree (shallow_update_log_entry_id);
--
-- Name: index_domain_perceptual_hashes_on_algorithm; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_domain_perceptual_hashes_on_algorithm ON public.domain_perceptual_hashes USING btree (algorithm);
--
-- Name: index_domain_perceptual_hashes_on_thumbnail_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_domain_perceptual_hashes_on_thumbnail_id ON public.domain_perceptual_hashes USING btree (thumbnail_id);
--
-- Name: index_domain_post_file_thumbnails_on_post_file_id; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_domain_post_file_thumbnails_on_post_file_id ON public.domain_post_file_thumbnails USING btree (post_file_id);
--
-- Name: index_domain_post_file_thumbnails_on_thumbnail_type; Type: INDEX; Schema: public; Owner: -
--
CREATE INDEX index_domain_post_file_thumbnails_on_thumbnail_type ON public.domain_post_file_thumbnails USING btree (thumbnail_type);
SET default_tablespace = mirai;
--
@@ -8434,6 +8557,14 @@ ALTER TABLE ONLY public.domain_fa_follows
ADD CONSTRAINT fk_rails_175679b7a2 FOREIGN KEY (followed_id) REFERENCES public.domain_fa_users(id);
--
-- Name: domain_perceptual_hashes fk_rails_1ae1a89060; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_perceptual_hashes
ADD CONSTRAINT fk_rails_1ae1a89060 FOREIGN KEY (thumbnail_id) REFERENCES public.domain_post_file_thumbnails(id);
--
-- Name: domain_post_group_joins fk_rails_22154fb920; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -8722,6 +8853,14 @@ ALTER TABLE ONLY public.domain_e621_taggings
ADD CONSTRAINT fk_rails_da3a488297 FOREIGN KEY (post_id) REFERENCES public.domain_e621_posts(id);
--
-- Name: domain_post_file_thumbnails fk_rails_dde88b4af5; Type: FK CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.domain_post_file_thumbnails
ADD CONSTRAINT fk_rails_dde88b4af5 FOREIGN KEY (post_file_id) REFERENCES public.domain_post_files(id);
--
-- Name: domain_inkbunny_follows fk_rails_dffb743e89; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -8786,6 +8925,8 @@ SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
('20250302074924'),
('20250301000002'),
('20250301000001'),
('20250226003653'),
('20250222035939'),
('20250206224121'),

View File

@@ -65,7 +65,7 @@ class ApplicationController
sig { params(scope: T.untyped).returns(T.untyped) }
def pundit_policy_scope(scope); end
sig { returns(T.untyped) }
sig { returns(T.nilable(T.any(::IpAddressRole, ::User))) }
def pundit_user; end
end

1407
sorbet/rbi/dsl/domain/perceptual_hash.rbi generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -533,6 +533,20 @@ class Domain::PostFile
sig { void }
def reset_post; end
sig { returns(T::Array[T.untyped]) }
def thumbnail_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def thumbnail_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::PostFile` class because it declared `has_many :thumbnails`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::PostFileThumbnail::PrivateCollectionProxy) }
def thumbnails; end
sig { params(value: T::Enumerable[::Domain::PostFileThumbnail]).void }
def thumbnails=(value); end
end
module GeneratedAssociationRelationMethods

View File

@@ -516,6 +516,20 @@ class Domain::PostFile::InkbunnyPostFile
sig { void }
def reset_post; end
sig { returns(T::Array[T.untyped]) }
def thumbnail_ids; end
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def thumbnail_ids=(ids); end
# This method is created by ActiveRecord on the `Domain::PostFile` class because it declared `has_many :thumbnails`.
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
sig { returns(::Domain::PostFileThumbnail::PrivateCollectionProxy) }
def thumbnails; end
sig { params(value: T::Enumerable[::Domain::PostFileThumbnail]).void }
def thumbnails=(value); end
end
module GeneratedAssociationRelationMethods

File diff suppressed because it is too large Load Diff

View File

@@ -213,6 +213,9 @@ module GeneratedPathHelpersModule
sig { params(args: T.untyped).returns(String) }
def stats_log_entries_path(*args); end
sig { params(args: T.untyped).returns(String) }
def toggle_state_ip_address_role_path(*args); end
sig { params(args: T.untyped).returns(String) }
def turbo_recede_historical_location_path(*args); end

View File

@@ -213,6 +213,9 @@ module GeneratedUrlHelpersModule
sig { params(args: T.untyped).returns(String) }
def stats_log_entries_url(*args); end
sig { params(args: T.untyped).returns(String) }
def toggle_state_ip_address_role_url(*args); end
sig { params(args: T.untyped).returns(String) }
def turbo_recede_historical_location_url(*args); end

View File

@@ -393,18 +393,6 @@ class IpAddressRole
end
module EnumMethodsModule
sig { void }
def admin!; end
sig { returns(T::Boolean) }
def admin?; end
sig { void }
def moderator!; end
sig { returns(T::Boolean) }
def moderator?; end
sig { void }
def user!; end
@@ -413,9 +401,6 @@ class IpAddressRole
end
module GeneratedAssociationRelationMethods
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateAssociationRelation) }
def admin(*args, &blk); end
sig { returns(PrivateAssociationRelation) }
def all; end
@@ -485,18 +470,9 @@ class IpAddressRole
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateAssociationRelation) }
def merge(*args, &blk); end
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateAssociationRelation) }
def moderator(*args, &blk); end
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateAssociationRelation) }
def none(*args, &blk); end
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateAssociationRelation) }
def not_admin(*args, &blk); end
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateAssociationRelation) }
def not_moderator(*args, &blk); end
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateAssociationRelation) }
def not_user(*args, &blk); end
@@ -1082,9 +1058,6 @@ class IpAddressRole
end
module GeneratedRelationMethods
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateRelation) }
def admin(*args, &blk); end
sig { returns(PrivateRelation) }
def all; end
@@ -1154,18 +1127,9 @@ class IpAddressRole
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateRelation) }
def merge(*args, &blk); end
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateRelation) }
def moderator(*args, &blk); end
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateRelation) }
def none(*args, &blk); end
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateRelation) }
def not_admin(*args, &blk); end
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateRelation) }
def not_moderator(*args, &blk); end
sig { params(args: T.untyped, blk: T.untyped).returns(PrivateRelation) }
def not_user(*args, &blk); end

View File

@@ -7,6 +7,27 @@ class Vips::Image
sig { params(name: String, n: T.nilable(Integer)).returns(Vips::Image) }
def self.gifload(name, n: nil)
end
sig do
params(
filename: String,
width: Integer,
height: T.nilable(Integer),
# :up, :down, :both, :force
size: T.nilable(Symbol),
).returns(Vips::Image)
end
def self.thumbnail(filename, width, height: nil, size: nil)
end
sig do
params(
width: Integer,
height: T.nilable(Integer),
size: T.nilable(Symbol),
).returns(Vips::Image)
end
def thumbnail_image(width, height: nil, size: nil)
end
sig { params(scale: T.untyped).returns(Vips::Image) }
def resize(scale)
@@ -16,9 +37,7 @@ class Vips::Image
def gifsave_buffer(**opts)
end
sig do
params(width: Integer, height: T.nilable(Integer)).returns(Vips::Image)
end
def thumbnail_image(width, height: nil)
sig { params(filename: String, opts: T.untyped).returns(String) }
def jpegsave(filename, **opts)
end
end

View File

@@ -0,0 +1,7 @@
# typed: false
FactoryBot.define do
factory :domain_post_file_thumbnail, class: "Domain::PostFileThumbnail" do
association :post_file, factory: :domain_post_file
thumbnail_type { Domain::ThumbnailType::Small.name }
end
end

View File

@@ -0,0 +1,171 @@
# typed: false
require "rails_helper"
RSpec.describe Domain::PostFileThumbnail do
describe ".find_or_create_from_post_file" do
let(:post) { create(:domain_post_fa_post) }
let(:blob_file) do
fixture_path =
Rails.root.join(
"test/fixtures/files/images/thumb-036aaab6-content-container.jpeg",
)
create(
:blob_file,
contents: File.binread(fixture_path),
content_type: "image/jpeg",
)
end
let(:log_entry) do
create(:http_log_entry, content_type: "image/jpeg", response: blob_file)
end
let(:post_file) do
create(:domain_post_file, post: post, state: "ok", log_entry: log_entry)
end
before do
# Ensure directories exist
FileUtils.mkdir_p(Domain::PostFileThumbnail::TMP_DIR)
end
it "creates a thumbnail for a valid post file" do
thumbnail =
described_class.find_or_create_from_post_file(
post_file,
Domain::ThumbnailType::Small,
)
expect(thumbnail).to be_a(Domain::PostFileThumbnail)
expect(thumbnail.post_file).to eq(post_file)
expect(thumbnail.thumbnail_type).to eq("small")
# Verify the thumbnail file was created on disk
thumbnail_path = thumbnail.absolute_file_path
expect(thumbnail_path).not_to be_nil
expect(File.exist?(thumbnail_path)).to be true
end
it "returns existing thumbnail if one already exists" do
# Create a thumbnail first
existing =
described_class.find_or_create_from_post_file(
post_file,
Domain::ThumbnailType::Small,
)
expect(existing).not_to be_nil
# Try to find or create again
thumbnail =
described_class.find_or_create_from_post_file(
post_file,
Domain::ThumbnailType::Small,
)
# Should return the existing one without creating a new one
expect(thumbnail).to eq(existing)
end
it "returns nil for a post file that's not in 'ok' state" do
post_file.update!(state: "pending")
thumbnail =
described_class.find_or_create_from_post_file(
post_file,
Domain::ThumbnailType::Small,
)
expect(thumbnail).to be_nil
end
it "returns nil for a post file without a log entry" do
post_file.update!(log_entry: nil)
thumbnail =
described_class.find_or_create_from_post_file(
post_file,
Domain::ThumbnailType::Small,
)
expect(thumbnail).to be_nil
end
it "returns nil for a post file with an unsupported content type" do
# Create a new log entry with PDF content type since HttpLogEntry is immutable
pdf_blob =
create(
:blob_file,
content_type: "application/pdf",
contents: "fake pdf data",
)
pdf_log_entry =
create(
:http_log_entry,
content_type: "application/pdf",
response: pdf_blob,
)
pdf_post_file =
create(
:domain_post_file,
post: post,
state: "ok",
log_entry: pdf_log_entry,
)
thumbnail =
described_class.find_or_create_from_post_file(
pdf_post_file,
Domain::ThumbnailType::Small,
)
expect(thumbnail).to be_nil
end
it "returns nil if the post file has no blob" do
allow(post_file).to receive(:blob).and_return(nil)
thumbnail =
described_class.find_or_create_from_post_file(
post_file,
Domain::ThumbnailType::Small,
)
expect(thumbnail).to be_nil
end
end
describe "#absolute_file_path" do
let(:post) { create(:domain_post_fa_post) }
let(:blob_file) do
create(
:blob_file,
content_type: "image/jpeg",
contents: "fake image data",
)
end
let(:post_file) do
create(:domain_post_file, post: post, state: "ok", blob: blob_file)
end
let(:thumbnail) do
create(
:domain_post_file_thumbnail,
post_file: post_file,
thumbnail_type: "small",
)
end
it "constructs the correct file path" do
expect(thumbnail.absolute_file_path).to start_with(
Domain::PostFileThumbnail::THUMBNAIL_ROOT_DIR,
)
end
it "returns nil if thumbnail_type is nil" do
thumbnail.thumbnail_type = nil
expect(thumbnail.absolute_file_path).to be_nil
end
it "returns nil if post_file is nil" do
thumbnail.post_file = nil
expect(thumbnail.absolute_file_path).to be_nil
end
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB