diff --git a/config/test.exs b/config/test.exs index 3e7e396..362cd0e 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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__) diff --git a/lib/simpleshop_theme/cart.ex b/lib/simpleshop_theme/cart.ex index a288520..a3288ec 100644 --- a/lib/simpleshop_theme/cart.ex +++ b/lib/simpleshop_theme/cart.ex @@ -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 diff --git a/lib/simpleshop_theme/images/optimizer.ex b/lib/simpleshop_theme/images/optimizer.ex index 8e34ad9..6d8424a 100644 --- a/lib/simpleshop_theme/images/optimizer.ex +++ b/lib/simpleshop_theme/images/optimizer.ex @@ -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 """ diff --git a/lib/simpleshop_theme/images/variant_cache.ex b/lib/simpleshop_theme/images/variant_cache.ex index 55d3b5a..55dd4be 100644 --- a/lib/simpleshop_theme/images/variant_cache.ex +++ b/lib/simpleshop_theme/images/variant_cache.ex @@ -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 diff --git a/lib/simpleshop_theme/media.ex b/lib/simpleshop_theme/media.ex index 16077b2..2513cc5 100644 --- a/lib/simpleshop_theme/media.ex +++ b/lib/simpleshop_theme/media.ex @@ -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 diff --git a/lib/simpleshop_theme/products.ex b/lib/simpleshop_theme/products.ex index 12c555e..4d251df 100644 --- a/lib/simpleshop_theme/products.ex +++ b/lib/simpleshop_theme/products.ex @@ -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 diff --git a/lib/simpleshop_theme/products/product_image.ex b/lib/simpleshop_theme/products/product_image.ex index f29dd84..6108567 100644 --- a/lib/simpleshop_theme/products/product_image.ex +++ b/lib/simpleshop_theme/products/product_image.ex @@ -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 `` 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. diff --git a/lib/simpleshop_theme_web/components/shop_components/cart.ex b/lib/simpleshop_theme_web/components/shop_components/cart.ex index 022f9a2..b946b74 100644 --- a/lib/simpleshop_theme_web/components/shop_components/cart.ex +++ b/lib/simpleshop_theme_web/components/shop_components/cart.ex @@ -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. diff --git a/lib/simpleshop_theme_web/components/shop_components/content.ex b/lib/simpleshop_theme_web/components/shop_components/content.ex index e8bc6c8..bdcf455 100644 --- a/lib/simpleshop_theme_web/components/shop_components/content.ex +++ b/lib/simpleshop_theme_web/components/shop_components/content.ex @@ -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""" @@ -1069,7 +1064,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do sizes={@sizes} /> {@alt} Enum.sort() - |> Enum.map_join(", ", &"#{base}#{separator}#{&1}.#{format} #{&1}w") + |> Enum.map_join(", ", &"#{base}-#{&1}.#{format} #{&1}w") end end diff --git a/lib/simpleshop_theme_web/components/shop_components/layout.ex b/lib/simpleshop_theme_web/components/shop_components/layout.ex index 5876819..ea3ae16 100644 --- a/lib/simpleshop_theme_web/components/shop_components/layout.ex +++ b/lib/simpleshop_theme_web/components/shop_components/layout.ex @@ -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;" diff --git a/lib/simpleshop_theme_web/components/shop_components/product.ex b/lib/simpleshop_theme_web/components/shop_components/product.ex index b6a3a5e..a1ba1a9 100644 --- a/lib/simpleshop_theme_web/components/shop_components/product.ex +++ b/lib/simpleshop_theme_web/components/shop_components/product.ex @@ -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)} diff --git a/lib/simpleshop_theme_web/controllers/image_controller.ex b/lib/simpleshop_theme_web/controllers/image_controller.ex index dcf61f4..17fb283 100644 --- a/lib/simpleshop_theme_web/controllers/image_controller.ex +++ b/lib/simpleshop_theme_web/controllers/image_controller.ex @@ -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 diff --git a/lib/simpleshop_theme_web/live/admin/product_show.ex b/lib/simpleshop_theme_web/live/admin/product_show.ex index 11663c4..02ee6ed 100644 --- a/lib/simpleshop_theme_web/live/admin/product_show.ex +++ b/lib/simpleshop_theme_web/live/admin/product_show.ex @@ -126,7 +126,7 @@ defmodule SimpleshopThemeWeb.Admin.ProductShow do class="aspect-square rounded bg-base-200 overflow-hidden" > {image.alt 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 [] -> [] diff --git a/lib/simpleshop_theme_web/live/admin/theme/index.html.heex b/lib/simpleshop_theme_web/live/admin/theme/index.html.heex index 3e40f76..2716ee6 100644 --- a/lib/simpleshop_theme_web/live/admin/theme/index.html.heex +++ b/lib/simpleshop_theme_web/live/admin/theme/index.html.heex @@ -159,7 +159,7 @@ <%= if @logo_image do %>
Current logo @@ -299,7 +299,7 @@ <%= if @header_image do %>
Current header background diff --git a/lib/simpleshop_theme_web/router.ex b/lib/simpleshop_theme_web/router.ex index 0ca0376..f54cf55 100644 --- a/lib/simpleshop_theme_web/router.ex +++ b/lib/simpleshop_theme_web/router.ex @@ -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 diff --git a/test/simpleshop_theme/images/optimize_worker_test.exs b/test/simpleshop_theme/images/optimize_worker_test.exs index e95da8c..3cdbbcc 100644 --- a/test/simpleshop_theme/images/optimize_worker_test.exs +++ b/test/simpleshop_theme/images/optimize_worker_test.exs @@ -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 diff --git a/test/simpleshop_theme/images/optimizer_test.exs b/test/simpleshop_theme/images/optimizer_test.exs index 725fb90..4af6b2c 100644 --- a/test/simpleshop_theme/images/optimizer_test.exs +++ b/test/simpleshop_theme/images/optimizer_test.exs @@ -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 diff --git a/test/simpleshop_theme/products/product_image_test.exs b/test/simpleshop_theme/products/product_image_test.exs index 8629928..1b07bdb 100644 --- a/test/simpleshop_theme/products/product_image_test.exs +++ b/test/simpleshop_theme/products/product_image_test.exs @@ -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 diff --git a/test/simpleshop_theme_web/controllers/image_controller_test.exs b/test/simpleshop_theme_web/controllers/image_controller_test.exs index 0894f88..739a6dd 100644 --- a/test/simpleshop_theme_web/controllers/image_controller_test.exs +++ b/test/simpleshop_theme_web/controllers/image_controller_test.exs @@ -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() @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")