consolidate image serving and clean up pipeline

Move all image URL logic into ProductImage.url/2 and thumbnail_url/1,
remove dead on-demand generation code from Optimizer, strip controller
routes down to SVG recolor only, fix mockup startup check to verify all
variant formats, and isolate test image cache directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-16 17:47:41 +00:00
parent 81e94d0d65
commit bb358f890b
21 changed files with 134 additions and 428 deletions

View File

@ -40,3 +40,6 @@ config :phoenix_live_view,
# Use inline testing mode for Oban
config :simpleshop_theme, Oban, testing: :inline
# Isolate image cache so test cleanup doesn't wipe the dev cache
config :simpleshop_theme, :image_cache_dir, Path.expand("../tmp/test_image_cache", __DIR__)

View File

@ -8,6 +8,7 @@ defmodule SimpleshopTheme.Cart do
"""
alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.ProductImage
@session_key "cart"
@ -148,17 +149,9 @@ defmodule SimpleshopTheme.Cart do
defp format_variant_options(_), do: nil
defp variant_image_url(product) do
# Get first image from preloaded images
case product.images do
[first | _] ->
if first.image_id do
"/images/#{first.image_id}/variant/400.webp"
else
first.src
end
_ ->
nil
[first | _] -> ProductImage.url(first, 400)
_ -> nil
end
end

View File

@ -9,16 +9,26 @@ defmodule SimpleshopTheme.Images.Optimizer do
alias SimpleshopTheme.Media.Image, as: ImageSchema
@all_widths [400, 800, 1200]
# JPEG is generated on-demand to save ~50% disk space
# Only affects <5% of users (legacy browsers without AVIF/WebP support)
@pregenerated_formats [:avif, :webp]
@pregenerated_formats [:avif, :webp, :jpg]
@thumb_size 200
@max_stored_width 2000
@storage_quality 90
def cache_dir, do: Application.app_dir(:simpleshop_theme, "priv/static/image_cache")
def cache_dir do
Application.get_env(:simpleshop_theme, :image_cache_dir) ||
Application.app_dir(:simpleshop_theme, "priv/static/image_cache")
end
def all_widths, do: @all_widths
@doc """
Returns the expected disk path for a variant file.
Used to check the cache without loading the BLOB from the database.
"""
def variant_path(image_id, width, format) do
Path.join(cache_dir(), "#{image_id}-#{width}.#{format_ext(format)}")
end
@doc """
Convert uploaded image to optimized WebP for storage.
Images larger than #{@max_stored_width}px are resized down.
@ -73,6 +83,10 @@ defmodule SimpleshopTheme.Images.Optimizer do
%{data: data, source_width: width} = image ->
File.mkdir_p!(cache_dir())
# Write source WebP to disk so it can be served by Plug.Static
source_path = Path.join(cache_dir(), "#{image_id}.webp")
unless File.exists?(source_path), do: File.write!(source_path, data)
with {:ok, vips_image} <- Image.from_binary(data) do
widths = applicable_widths(width)
@ -135,10 +149,10 @@ defmodule SimpleshopTheme.Images.Optimizer do
@doc """
Check if disk variants exist for an image.
Only checks pre-generated formats (AVIF, WebP). JPEG is generated on-demand.
"""
def disk_variants_exist?(image_id, source_width) do
widths = applicable_widths(source_width)
source = File.exists?(Path.join(cache_dir(), "#{image_id}.webp"))
thumb = File.exists?(Path.join(cache_dir(), "#{image_id}-thumb.jpg"))
variants =
@ -148,34 +162,7 @@ defmodule SimpleshopTheme.Images.Optimizer do
end)
end)
thumb and variants
end
@doc """
Generate a variant on-demand. Returns path to generated file.
Supports all formats: :avif, :webp, :jpg
Used as fallback if cache files are deleted, and for JPEG legacy browser support.
"""
def generate_variant_on_demand(image_data, image_id, width, format)
when is_binary(image_data) and format in [:avif, :webp, :jpg] do
path = Path.join(cache_dir(), "#{image_id}-#{width}.#{format_ext(format)}")
if File.exists?(path) do
{:ok, path}
else
File.mkdir_p!(cache_dir())
with {:ok, vips_image} <- Image.from_binary(image_data),
{:ok, resized} <- Image.thumbnail(vips_image, width),
{:ok, _} <- write_format(resized, path, format) do
{:ok, path}
end
end
end
# Backward compatibility alias
def generate_jpeg_on_demand(image_data, image_id, width) do
generate_variant_on_demand(image_data, image_id, width, :jpg)
source and thumb and variants
end
@doc """

View File

@ -51,37 +51,56 @@ defmodule SimpleshopTheme.Images.VariantCache do
end
defp ensure_database_image_variants do
incomplete =
# Only load IDs and source_width for the disk check — avoids loading BLOBs
incomplete_ids =
ImageSchema
|> where([i], i.variants_status != "complete" or is_nil(i.variants_status))
|> where([i], i.is_svg == false)
|> select([i], {i.id, i.source_width})
|> Repo.all()
complete_missing =
complete_missing_ids =
ImageSchema
|> where([i], i.variants_status == "complete")
|> where([i], i.is_svg == false)
|> where([i], not is_nil(i.source_width))
|> select([i], {i.id, i.source_width})
|> Repo.all()
|> Enum.reject(fn img ->
Optimizer.disk_variants_exist?(img.id, img.source_width)
|> Enum.reject(fn {id, source_width} ->
Optimizer.disk_variants_exist?(id, source_width)
end)
to_process = incomplete ++ complete_missing
to_process = incomplete_ids ++ complete_missing_ids
if to_process == [] do
Logger.info("[VariantCache] All database image variants up to date")
else
Logger.info(
"[VariantCache] Enqueueing #{length(to_process)} database images for processing"
"[VariantCache] Processing #{length(to_process)} images with missing variants..."
)
Enum.each(to_process, fn image ->
image
|> ImageSchema.changeset(%{variants_status: "pending"})
|> Repo.update!()
# Process directly instead of round-tripping through Oban — more reliable at startup
to_process
|> Task.async_stream(
fn {id, _source_width} ->
case Optimizer.process_for_image(id) do
{:ok, _} ->
:ok
OptimizeWorker.enqueue(image.id)
{:error, reason} ->
Logger.warning("[VariantCache] Failed to process #{id}: #{inspect(reason)}")
end
end,
max_concurrency: 4,
timeout: :timer.seconds(30),
on_timeout: :kill_task
)
|> Enum.count(fn
{:ok, :ok} -> true
_ -> false
end)
|> then(fn count ->
Logger.info("[VariantCache] Processed #{count}/#{length(to_process)} image variants")
end)
end
end
@ -111,7 +130,12 @@ defmodule SimpleshopTheme.Images.VariantCache do
defp mockup_variants_exist?(source_path) do
basename = Path.basename(source_path) |> Path.rootname()
dir = Path.dirname(source_path)
File.exists?(Path.join(dir, "#{basename}-800.webp"))
expected =
["#{basename}-thumb.jpg"] ++
for w <- [400, 800, 1200], ext <- ["avif", "webp", "jpg"], do: "#{basename}-#{w}.#{ext}"
Enum.all?(expected, &File.exists?(Path.join(dir, &1)))
end
defp ensure_product_image_downloads do

View File

@ -170,31 +170,4 @@ defmodule SimpleshopTheme.Media do
def list_images_by_type(type) do
Repo.all(from i in ImageSchema, where: i.image_type == ^type, order_by: [desc: i.inserted_at])
end
@doc """
Gets the path to a thumbnail on disk.
## Examples
iex> get_thumbnail_path("abc123-def456")
"priv/static/image_cache/abc123-def456-thumb.jpg"
"""
def get_thumbnail_path(image_id) do
Path.join(Optimizer.cache_dir(), "#{image_id}-thumb.jpg")
end
@doc """
Gets the path to a variant on disk.
## Examples
iex> get_variant_path("abc123-def456", 800, :webp)
"priv/static/image_cache/abc123-def456-800.webp"
"""
def get_variant_path(image_id, width, format) do
ext = Atom.to_string(format)
Path.join(Optimizer.cache_dir(), "#{image_id}-#{width}.#{ext}")
end
end

View File

@ -183,7 +183,7 @@ defmodule SimpleshopTheme.Products do
)
|> Repo.one()
|> case do
{id, _src} when not is_nil(id) -> "/images/#{id}/variant/400.webp"
{id, _src} when not is_nil(id) -> "/image_cache/#{id}-400.webp"
{_, src} when is_binary(src) -> src
_ -> nil
end

View File

@ -40,30 +40,28 @@ defmodule SimpleshopTheme.Products.ProductImage do
# ---------------------------------------------------------------------------
@doc """
Returns the display URL for a product image at the given size.
Prefers local image_id (responsive optimized), falls back to CDN src.
Returns the URL for a product image variant at the given width.
Prefers local image_id (static file), falls back to CDN src.
Handles mockup URL patterns that need size suffixes.
"""
def display_url(image, size \\ 800)
def url(image, width \\ 800)
def display_url(%{image_id: id}, size) when not is_nil(id),
do: "/images/#{id}/variant/#{size}.webp"
def url(%{image_id: id}, width) when not is_nil(id),
do: "/image_cache/#{id}-#{width}.webp"
def display_url(%{src: src}, _size) when is_binary(src), do: src
def display_url(_, _), do: nil
def url(%{src: "/mockups/" <> _ = src}, width), do: "#{src}-#{width}.webp"
def url(%{src: src}, _width) when is_binary(src), do: src
def url(_, _), do: nil
@doc """
Returns a fully resolved URL for an image at the given size.
Unlike `display_url/2`, handles mockup URL patterns that need size suffixes.
Use for `<img>` tags where the URL must resolve to an actual file.
Returns the URL for the pre-generated 200px thumbnail.
Used for small previews (admin lists, cart items).
"""
def direct_url(image, size \\ 800)
def thumbnail_url(%{image_id: id}) when not is_nil(id),
do: "/image_cache/#{id}-thumb.jpg"
def direct_url(%{image_id: id}, size) when not is_nil(id),
do: "/images/#{id}/variant/#{size}.webp"
def direct_url(%{src: "/mockups/" <> _ = src}, size), do: "#{src}-#{size}.webp"
def direct_url(%{src: src}, _size) when is_binary(src), do: src
def direct_url(_, _), do: nil
def thumbnail_url(%{src: src}) when is_binary(src), do: src
def thumbnail_url(_), do: nil
@doc """
Returns the source width from the linked Media.Image, if preloaded.

View File

@ -414,7 +414,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
end
defp cart_item_image(product) do
ProductImage.direct_url(Product.primary_image(product), 400)
ProductImage.url(Product.primary_image(product), 400)
end
# Shared delivery line used by both cart_drawer and order_summary.

View File

@ -1046,15 +1046,10 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
available = Optimizer.applicable_widths(assigns.source_width)
default_width = Enum.max(available)
# Database images end with / (e.g., /images/{id}/variant/)
# Mockups use - separator (e.g., /mockups/product-1)
separator = if String.ends_with?(assigns.src, "/"), do: "", else: "-"
assigns =
assigns
|> assign(:available_widths, available)
|> assign(:default_width, default_width)
|> assign(:separator, separator)
~H"""
<picture>
@ -1069,7 +1064,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
sizes={@sizes}
/>
<img
src={"#{@src}#{@separator}#{@default_width}.jpg"}
src={"#{@src}-#{@default_width}.jpg"}
srcset={build_srcset(@src, @available_widths, "jpg")}
sizes={@sizes}
alt={@alt}
@ -1163,12 +1158,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
end
defp build_srcset(base, widths, format) do
# Database images end with / (e.g., /images/{id}/variant/)
# Mockups use - separator (e.g., /mockups/product-1)
separator = if String.ends_with?(base, "/"), do: "", else: "-"
widths
|> Enum.sort()
|> Enum.map_join(", ", &"#{base}#{separator}#{&1}.#{format} #{&1}w")
|> Enum.map_join(", ", &"#{base}-#{&1}.#{format} #{&1}w")
end
end

View File

@ -382,7 +382,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|> Enum.with_index()
|> Enum.map(fn {product, idx} ->
image = Product.primary_image(product)
%{product: product, image_url: ProductImage.direct_url(image, 400), idx: idx}
%{product: product, image_url: ProductImage.url(image, 400), idx: idx}
end)
)
@ -880,7 +880,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
"/images/#{logo_image.id}/recolored/#{clean_color}"
end
defp logo_url(logo_image, _), do: "/images/#{logo_image.id}"
defp logo_url(logo_image, _), do: "/image_cache/#{logo_image.id}.webp"
# Logo content that links to home, except when already on home page.
# This follows accessibility best practices - current page should not be a link.
@ -970,7 +970,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
defp header_background_style(settings, header_image) do
"position: absolute; top: 0; left: 0; right: 0; bottom: 0; " <>
"background-image: url('/images/#{header_image.id}'); " <>
"background-image: url('/image_cache/#{header_image.id}.webp'); " <>
"background-size: #{settings.header_zoom}%; " <>
"background-position: #{settings.header_position_x}% #{settings.header_position_y}%; " <>
"background-repeat: no-repeat; z-index: 0;"

View File

@ -213,7 +213,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
{nil, nil}
Map.get(image, :image_id) ->
{"/images/#{image.image_id}/variant/", ProductImage.source_width(image)}
{"/image_cache/#{image.image_id}", ProductImage.source_width(image)}
Map.get(image, :src) ->
{Map.get(image, :src), ProductImage.source_width(image)}

View File

@ -4,168 +4,6 @@ defmodule SimpleshopThemeWeb.ImageController do
alias SimpleshopTheme.Media
alias SimpleshopTheme.Media.SVGRecolorer
@doc """
Serves an image from the database by ID.
Images are served with aggressive caching headers since they are
immutable once uploaded.
"""
def show(conn, %{"id" => id}) do
etag = ~s("#{id}")
if etag_match?(conn, etag) do
send_not_modified(conn, etag)
else
case Media.get_image(id) do
nil ->
send_resp(conn, 404, "Image not found")
image ->
conn
|> put_resp_content_type(image.content_type)
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_resp(200, image.data)
end
end
end
@doc """
Serves a thumbnail of an image from the disk cache.
Thumbnails are generated by the background image optimization pipeline.
If the thumbnail doesn't exist on disk yet (still processing), generates
it on-demand and saves it for future requests.
"""
def thumbnail(conn, %{"id" => id}) do
etag = ~s("#{id}-thumb")
if etag_match?(conn, etag) do
send_not_modified(conn, etag)
else
thumb_path = Media.get_thumbnail_path(id)
if File.exists?(thumb_path) do
conn
|> put_resp_content_type("image/jpeg")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_file(200, thumb_path)
else
case Media.get_image(id) do
nil ->
send_resp(conn, 404, "Image not found")
%{data: data} when is_binary(data) ->
case generate_thumbnail_on_demand(data, thumb_path) do
{:ok, binary} ->
conn
|> put_resp_content_type("image/jpeg")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_resp(200, binary)
{:error, _} ->
conn
|> put_resp_content_type("image/webp")
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> send_resp(200, data)
end
%{data: nil} ->
send_resp(conn, 404, "Image data not available")
end
end
end
end
defp generate_thumbnail_on_demand(image_data, thumb_path) do
with {:ok, image} <- Image.from_binary(image_data),
{:ok, thumb} <- Image.thumbnail(image, 200),
{:ok, binary} <- Image.write(thumb, :memory, suffix: ".jpg", quality: 80) do
# Ensure cache directory exists and save for future requests
File.mkdir_p!(Path.dirname(thumb_path))
File.write!(thumb_path, binary)
{:ok, binary}
end
rescue
_ -> {:error, :thumbnail_generation_failed}
end
@supported_formats %{"avif" => :avif, "webp" => :webp, "jpg" => :jpg}
@doc """
Serves an image variant at the specified width and format.
Supports AVIF, WebP, and JPEG formats. If the variant doesn't exist on disk
(e.g., cache was deleted), it will be generated on-demand and cached.
JPEG variants are never pre-generated (to save disk space), so they are
always generated on first request.
## URL format
/images/:id/variant/:width.:format
Examples:
- `/images/abc123/variant/800.avif`
- `/images/abc123/variant/400.webp`
- `/images/abc123/variant/1200.jpg`
"""
def variant(conn, %{"id" => id, "width" => width_with_ext}) do
alias SimpleshopTheme.Images.Optimizer
with {width, format} <- parse_width_and_format(width_with_ext),
true <- width in Optimizer.all_widths() do
etag = ~s("#{id}-#{width}.#{format}")
if etag_match?(conn, etag) do
send_not_modified(conn, etag)
else
case Media.get_image(id) do
%{data: data} when is_binary(data) ->
case Optimizer.generate_variant_on_demand(data, id, width, format) do
{:ok, path} ->
conn
|> put_resp_content_type(format_content_type(format))
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_file(200, path)
{:error, _reason} ->
send_resp(conn, 500, "Failed to generate variant")
end
nil ->
send_resp(conn, 404, "Image not found")
%{data: nil} ->
send_resp(conn, 404, "Image data not available")
end
end
else
:error -> send_resp(conn, 400, "Invalid width or format")
false -> send_resp(conn, 400, "Width not supported")
end
end
defp parse_width_and_format(width_with_ext) do
case String.split(width_with_ext, ".") do
[width_str, ext] when is_map_key(@supported_formats, ext) ->
case Integer.parse(width_str) do
{width, ""} -> {width, @supported_formats[ext]}
_ -> :error
end
_ ->
:error
end
end
defp format_content_type(:avif), do: "image/avif"
defp format_content_type(:webp), do: "image/webp"
defp format_content_type(:jpg), do: "image/jpeg"
@doc """
Serves an SVG image recolored with the specified color.
@ -208,18 +46,4 @@ defmodule SimpleshopThemeWeb.ImageController do
"#" <> color
end
end
defp etag_match?(conn, etag) do
case Plug.Conn.get_req_header(conn, "if-none-match") do
[^etag] -> true
_ -> false
end
end
defp send_not_modified(conn, etag) do
conn
|> put_resp_header("cache-control", "public, max-age=31536000, immutable")
|> put_resp_header("etag", etag)
|> send_resp(304, "")
end
end

View File

@ -126,7 +126,7 @@ defmodule SimpleshopThemeWeb.Admin.ProductShow do
class="aspect-square rounded bg-base-200 overflow-hidden"
>
<img
src={ProductImage.display_url(image, 400)}
src={ProductImage.url(image, 400)}
alt={image.alt || @product.title}
class="w-full h-full object-cover"
loading="lazy"

View File

@ -212,7 +212,7 @@ defmodule SimpleshopThemeWeb.Admin.Products do
url =
if image do
ProductImage.display_url(image, 80)
ProductImage.thumbnail_url(image)
end
alt = (image && image.alt) || assigns.product.title

View File

@ -472,7 +472,7 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
(Map.get(product, :images) || [])
|> Enum.sort_by(& &1.position)
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|> Enum.map(fn img -> ProductImage.url(img, 1200) end)
|> Enum.reject(&is_nil/1)
|> case do
[] -> []

View File

@ -159,7 +159,7 @@
<%= if @logo_image do %>
<div class="relative w-16 h-10 bg-base-100 border border-base-300 rounded-lg flex items-center justify-center overflow-hidden">
<img
src={"/images/#{@logo_image.id}"}
src={"/image_cache/#{@logo_image.id}.webp"}
alt="Current logo"
class="max-w-full max-h-full object-contain"
/>
@ -299,7 +299,7 @@
<%= if @header_image do %>
<div class="relative w-full h-[60px] bg-base-100 border border-base-300 rounded-lg mt-2 overflow-hidden">
<img
src={"/images/#{@header_image.id}"}
src={"/image_cache/#{@header_image.id}.webp"}
alt="Current header background"
class="w-full h-full object-cover"
/>

View File

@ -20,6 +20,11 @@ defmodule SimpleshopThemeWeb.Router do
plug :accepts, ["json"]
end
# Lightweight pipeline for SVG recoloring — no session, CSRF, auth, or layout
pipeline :image do
plug :put_secure_browser_headers
end
pipeline :printify_webhook do
plug SimpleshopThemeWeb.Plugs.VerifyPrintifyWebhook
end
@ -88,13 +93,10 @@ defmodule SimpleshopThemeWeb.Router do
post "/cart", CartController, :update
end
# Image serving routes (public, no auth required)
# SVG recoloring (dynamic — can't be pre-generated to disk)
scope "/images", SimpleshopThemeWeb do
pipe_through :browser
pipe_through :image
get "/:id", ImageController, :show
get "/:id/thumbnail", ImageController, :thumbnail
get "/:id/variant/:width", ImageController, :variant
get "/:id/recolored/:color", ImageController, :recolored_svg
end

View File

@ -17,13 +17,9 @@ defmodule SimpleshopTheme.Images.OptimizeWorkerTest do
assert :ok = perform_job(OptimizeWorker, %{image_id: image.id})
# Verify pre-generated variants were created (AVIF and WebP only, not JPEG)
for w <- [400, 800, 1200], fmt <- [:avif, :webp] do
for w <- [400, 800, 1200], fmt <- [:avif, :webp, :jpg] do
assert File.exists?(cache_path(image.id, w, fmt))
end
# JPEG is generated on-demand, not pre-generated
refute File.exists?(cache_path(image.id, 400, :jpg))
end
test "cancels for missing image" do

View File

@ -62,16 +62,14 @@ defmodule SimpleshopTheme.Images.OptimizerTest do
assert {:ok, [400, 800, 1200]} = Optimizer.process_for_image(image.id)
# Only AVIF and WebP are pre-generated (JPEG is on-demand)
for w <- [400, 800, 1200], fmt <- [:avif, :webp] do
# Source WebP for Plug.Static serving
assert File.exists?(Path.join(Optimizer.cache_dir(), "#{image.id}.webp"))
for w <- [400, 800, 1200], fmt <- [:avif, :webp, :jpg] do
assert File.exists?(cache_path(image.id, w, fmt)),
"Missing #{w}.#{fmt}"
end
# JPEG should NOT be pre-generated
refute File.exists?(cache_path(image.id, 400, :jpg))
# Thumbnail is still pre-generated as JPEG
assert File.exists?(cache_path(image.id, "thumb", :jpg))
end
@ -138,36 +136,4 @@ defmodule SimpleshopTheme.Images.OptimizerTest do
refute Optimizer.disk_variants_exist?(image.id, 1200)
end
end
describe "generate_jpeg_on_demand/3" do
test "generates JPEG variant and caches to disk" do
image = image_fixture(%{source_width: 1200, source_height: 800})
# JPEG shouldn't exist yet
refute File.exists?(cache_path(image.id, 400, :jpg))
# Generate on-demand
{:ok, path} = Optimizer.generate_jpeg_on_demand(image.data, image.id, 400)
assert File.exists?(path)
assert path == cache_path(image.id, 400, :jpg)
end
test "returns cached path if JPEG already exists" do
image = image_fixture(%{source_width: 1200, source_height: 800})
# Generate first time
{:ok, path1} = Optimizer.generate_jpeg_on_demand(image.data, image.id, 800)
{:ok, %{mtime: mtime1}} = File.stat(path1)
Process.sleep(1100)
# Generate second time - should return cached
{:ok, path2} = Optimizer.generate_jpeg_on_demand(image.data, image.id, 800)
{:ok, %{mtime: mtime2}} = File.stat(path2)
assert path1 == path2
assert mtime1 == mtime2, "File was regenerated instead of cached"
end
end
end

View File

@ -67,28 +67,49 @@ defmodule SimpleshopTheme.Products.ProductImageTest do
# Display helpers
# =============================================================================
describe "display_url/2" do
describe "url/2" do
test "prefers local image_id over src" do
image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"}
assert ProductImage.display_url(image) == "/images/abc-123/variant/800.webp"
assert ProductImage.url(image) == "/image_cache/abc-123-800.webp"
end
test "accepts custom size" do
test "accepts custom width" do
image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"}
assert ProductImage.display_url(image, 400) == "/images/abc-123/variant/400.webp"
assert ProductImage.url(image, 400) == "/image_cache/abc-123-400.webp"
end
test "handles mockup URLs with size suffix" do
image = %{image_id: nil, src: "/mockups/product-1"}
assert ProductImage.url(image, 800) == "/mockups/product-1-800.webp"
end
test "falls back to src when no image_id" do
image = %{image_id: nil, src: "https://cdn.example.com/img.jpg"}
assert ProductImage.display_url(image) == "https://cdn.example.com/img.jpg"
assert ProductImage.url(image) == "https://cdn.example.com/img.jpg"
end
test "returns nil when neither image_id nor src" do
assert ProductImage.display_url(%{image_id: nil, src: nil}) == nil
assert ProductImage.url(%{image_id: nil, src: nil}) == nil
end
test "returns nil for nil input" do
assert ProductImage.display_url(nil) == nil
assert ProductImage.url(nil) == nil
end
end
describe "thumbnail_url/1" do
test "returns thumb path for local image" do
image = %{image_id: "abc-123"}
assert ProductImage.thumbnail_url(image) == "/image_cache/abc-123-thumb.jpg"
end
test "falls back to src when no image_id" do
image = %{image_id: nil, src: "https://cdn.example.com/img.jpg"}
assert ProductImage.thumbnail_url(image) == "https://cdn.example.com/img.jpg"
end
test "returns nil for nil input" do
assert ProductImage.thumbnail_url(nil) == nil
end
end

View File

@ -3,81 +3,9 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do
alias SimpleshopTheme.Media
# Minimal valid PNG (1x1 transparent pixel)
@png_binary <<137, 80, 78, 71, 13, 10, 26, 10>>
@svg_content ~s(<svg xmlns="http://www.w3.org/2000/svg"><circle fill="#000000" r="10"/></svg>)
@sample_jpeg File.read!("test/fixtures/sample_1200x800.jpg")
describe "show/2" do
test "returns 404 for non-existent image", %{conn: conn} do
conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}")
assert response(conn, 404) =~ "Image not found"
end
test "serves image with proper content type and caching headers", %{conn: conn} do
# Upload a real JPEG which gets converted to WebP
{:ok, image} =
Media.upload_image(%{
image_type: "logo",
filename: "test.jpg",
content_type: "image/jpeg",
file_size: byte_size(@sample_jpeg),
data: @sample_jpeg
})
conn = get(conn, ~p"/images/#{image.id}")
assert response(conn, 200)
# Image is converted to WebP for storage
assert get_resp_header(conn, "content-type") == ["image/webp; charset=utf-8"]
assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"]
assert get_resp_header(conn, "etag") == [~s("#{image.id}")]
end
end
describe "thumbnail/2" do
test "returns 404 for non-existent image", %{conn: conn} do
conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}/thumbnail")
assert response(conn, 404) =~ "Image not found"
end
test "generates thumbnail on-demand and serves as JPEG", %{conn: conn} do
# Upload a real image to test thumbnail generation
{:ok, image} =
Media.upload_image(%{
image_type: "logo",
filename: "test.jpg",
content_type: "image/jpeg",
file_size: byte_size(@sample_jpeg),
data: @sample_jpeg
})
conn = get(conn, ~p"/images/#{image.id}/thumbnail")
assert response(conn, 200)
# Thumbnail is served as JPEG
assert get_resp_header(conn, "content-type") == ["image/jpeg; charset=utf-8"]
assert get_resp_header(conn, "cache-control") == ["public, max-age=31536000, immutable"]
end
test "falls back to full image when image data is invalid", %{conn: conn} do
# This uses an invalid PNG header that can't be processed
{:ok, image} =
Media.upload_image(%{
image_type: "logo",
filename: "test.png",
content_type: "image/png",
file_size: byte_size(@png_binary),
data: @png_binary
})
conn = get(conn, ~p"/images/#{image.id}/thumbnail")
# Falls back to WebP since that's what we tried to convert to
assert response(conn, 200)
end
end
describe "recolored_svg/2" do
test "returns 404 for non-existent image", %{conn: conn} do
conn = get(conn, ~p"/images/#{Ecto.UUID.generate()}/recolored/ff6600")
@ -88,10 +16,10 @@ defmodule SimpleshopThemeWeb.ImageControllerTest do
{:ok, image} =
Media.upload_image(%{
image_type: "logo",
filename: "test.png",
content_type: "image/png",
file_size: byte_size(@png_binary),
data: @png_binary
filename: "test.jpg",
content_type: "image/jpeg",
file_size: byte_size(@sample_jpeg),
data: @sample_jpeg
})
conn = get(conn, ~p"/images/#{image.id}/recolored/ff6600")