add favicon and site icon generation from uploaded images
All checks were successful
deploy / deploy (push) Successful in 1m26s
All checks were successful
deploy / deploy (push) Successful in 1m26s
Upload a source image (PNG, JPEG, or SVG) and get a complete favicon setup: PNG variants at 32, 180, 192, 512px served from DB via FaviconController with ETag caching, SVG favicon for vector sources, dynamic site.webmanifest, and theme-color meta tag. Theme editor gains a site icon section with "use logo as icon" toggle, dedicated icon upload, short name, and background colour picker. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,12 @@ defmodule Berrypod.MediaTest do
|
||||
assert "is invalid" in errors_on(changeset).image_type
|
||||
end
|
||||
|
||||
test "accepts icon image type" do
|
||||
attrs = Map.put(@valid_attrs, :image_type, "icon")
|
||||
assert {:ok, image} = Media.upload_image(attrs)
|
||||
assert image.image_type == "icon"
|
||||
end
|
||||
|
||||
test "validates file size" do
|
||||
attrs = Map.put(@valid_attrs, :file_size, 10_000_000)
|
||||
assert {:error, changeset} = Media.upload_image(attrs)
|
||||
@@ -110,4 +116,77 @@ defmodule Berrypod.MediaTest do
|
||||
assert Media.list_images_by_type("product") == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_icon/0" do
|
||||
test "returns the most recent icon image" do
|
||||
attrs = Map.put(@valid_attrs, :image_type, "icon")
|
||||
{:ok, icon} = Media.upload_image(attrs)
|
||||
|
||||
result = Media.get_icon()
|
||||
assert result.id == icon.id
|
||||
assert result.image_type == "icon"
|
||||
end
|
||||
|
||||
test "returns nil when no icons exist" do
|
||||
assert Media.get_icon() == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "favicon variants" do
|
||||
defp create_source_image do
|
||||
{:ok, image} = Media.upload_image(@valid_attrs)
|
||||
image
|
||||
end
|
||||
|
||||
test "stores and retrieves favicon variants" do
|
||||
image = create_source_image()
|
||||
|
||||
{:ok, variants} =
|
||||
Media.store_favicon_variants(%{
|
||||
source_image_id: image.id,
|
||||
png_32: <<1, 2, 3>>,
|
||||
png_180: <<4, 5, 6>>,
|
||||
png_192: <<7, 8, 9>>,
|
||||
png_512: <<10, 11, 12>>
|
||||
})
|
||||
|
||||
assert variants.png_32 == <<1, 2, 3>>
|
||||
|
||||
fetched = Media.get_favicon_variants()
|
||||
assert fetched.id == variants.id
|
||||
assert fetched.png_32 == <<1, 2, 3>>
|
||||
assert fetched.png_512 == <<10, 11, 12>>
|
||||
end
|
||||
|
||||
test "replaces existing variants on store" do
|
||||
image = create_source_image()
|
||||
|
||||
{:ok, first} =
|
||||
Media.store_favicon_variants(%{
|
||||
source_image_id: image.id,
|
||||
png_32: <<1>>,
|
||||
png_180: <<2>>,
|
||||
png_192: <<3>>,
|
||||
png_512: <<4>>
|
||||
})
|
||||
|
||||
{:ok, second} =
|
||||
Media.store_favicon_variants(%{
|
||||
source_image_id: image.id,
|
||||
png_32: <<5>>,
|
||||
png_180: <<6>>,
|
||||
png_192: <<7>>,
|
||||
png_512: <<8>>
|
||||
})
|
||||
|
||||
assert second.id != first.id
|
||||
fetched = Media.get_favicon_variants()
|
||||
assert fetched.id == second.id
|
||||
assert fetched.png_32 == <<5>>
|
||||
end
|
||||
|
||||
test "returns nil when no variants exist" do
|
||||
assert Media.get_favicon_variants() == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
88
test/berrypod/workers/favicon_generator_worker_test.exs
Normal file
88
test/berrypod/workers/favicon_generator_worker_test.exs
Normal file
@@ -0,0 +1,88 @@
|
||||
defmodule Berrypod.Workers.FaviconGeneratorWorkerTest do
|
||||
use Berrypod.DataCase, async: false
|
||||
|
||||
import Berrypod.ImageFixtures
|
||||
|
||||
alias Berrypod.Media
|
||||
alias Berrypod.Workers.FaviconGeneratorWorker
|
||||
|
||||
describe "perform/1" do
|
||||
test "generates PNG variants from a raster image" do
|
||||
image = image_fixture(%{image_type: "icon"})
|
||||
|
||||
assert :ok =
|
||||
FaviconGeneratorWorker.perform(%Oban.Job{
|
||||
args: %{"source_image_id" => image.id}
|
||||
})
|
||||
|
||||
variants = Media.get_favicon_variants()
|
||||
assert variants != nil
|
||||
assert variants.source_image_id == image.id
|
||||
assert is_binary(variants.png_32)
|
||||
assert is_binary(variants.png_180)
|
||||
assert is_binary(variants.png_192)
|
||||
assert is_binary(variants.png_512)
|
||||
assert variants.svg == nil
|
||||
assert variants.generated_at != nil
|
||||
end
|
||||
|
||||
test "generates correct PNG sizes" do
|
||||
image = image_fixture(%{image_type: "icon"})
|
||||
|
||||
:ok =
|
||||
FaviconGeneratorWorker.perform(%Oban.Job{
|
||||
args: %{"source_image_id" => image.id}
|
||||
})
|
||||
|
||||
variants = Media.get_favicon_variants()
|
||||
|
||||
# Verify PNG data starts with PNG magic bytes
|
||||
for field <- [:png_32, :png_180, :png_192, :png_512] do
|
||||
data = Map.get(variants, field)
|
||||
assert <<137, 80, 78, 71, _rest::binary>> = data, "#{field} should be valid PNG"
|
||||
end
|
||||
end
|
||||
|
||||
test "stores SVG as-is for SVG source" do
|
||||
image = svg_fixture()
|
||||
|
||||
:ok =
|
||||
FaviconGeneratorWorker.perform(%Oban.Job{
|
||||
args: %{"source_image_id" => image.id}
|
||||
})
|
||||
|
||||
variants = Media.get_favicon_variants()
|
||||
assert variants != nil
|
||||
assert is_binary(variants.svg)
|
||||
assert variants.svg == image.svg_content
|
||||
end
|
||||
|
||||
test "replaces existing variants on regeneration" do
|
||||
image = image_fixture(%{image_type: "icon"})
|
||||
|
||||
:ok =
|
||||
FaviconGeneratorWorker.perform(%Oban.Job{
|
||||
args: %{"source_image_id" => image.id}
|
||||
})
|
||||
|
||||
first_variants = Media.get_favicon_variants()
|
||||
first_id = first_variants.id
|
||||
|
||||
# Generate again
|
||||
:ok =
|
||||
FaviconGeneratorWorker.perform(%Oban.Job{
|
||||
args: %{"source_image_id" => image.id}
|
||||
})
|
||||
|
||||
second_variants = Media.get_favicon_variants()
|
||||
assert second_variants.id != first_id
|
||||
end
|
||||
|
||||
test "cancels when image not found" do
|
||||
assert {:cancel, :image_not_found} =
|
||||
FaviconGeneratorWorker.perform(%Oban.Job{
|
||||
args: %{"source_image_id" => Ecto.UUID.generate()}
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
137
test/berrypod_web/controllers/favicon_controller_test.exs
Normal file
137
test/berrypod_web/controllers/favicon_controller_test.exs
Normal file
@@ -0,0 +1,137 @@
|
||||
defmodule BerrypodWeb.FaviconControllerTest do
|
||||
use BerrypodWeb.ConnCase, async: false
|
||||
|
||||
alias Berrypod.Media
|
||||
|
||||
# Minimal valid PNG (1x1 transparent)
|
||||
@test_png <<137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0,
|
||||
1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 10, 73, 68, 65, 84, 120, 156, 98, 0, 0,
|
||||
0, 2, 0, 1, 226, 33, 188, 51, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130>>
|
||||
|
||||
@test_svg ~s(<svg xmlns="http://www.w3.org/2000/svg"><circle r="10"/></svg>)
|
||||
|
||||
defp create_source_image do
|
||||
{:ok, image} =
|
||||
Media.upload_image(%{
|
||||
image_type: "icon",
|
||||
filename: "icon.svg",
|
||||
content_type: "image/svg+xml",
|
||||
file_size: byte_size(@test_svg),
|
||||
data: @test_svg
|
||||
})
|
||||
|
||||
image
|
||||
end
|
||||
|
||||
defp create_favicon_variants(_context) do
|
||||
image = create_source_image()
|
||||
|
||||
{:ok, variants} =
|
||||
Media.store_favicon_variants(%{
|
||||
source_image_id: image.id,
|
||||
png_32: @test_png,
|
||||
png_180: @test_png,
|
||||
png_192: @test_png,
|
||||
png_512: @test_png,
|
||||
svg: @test_svg
|
||||
})
|
||||
|
||||
%{variants: variants}
|
||||
end
|
||||
|
||||
describe "favicon routes with variants" do
|
||||
setup [:create_favicon_variants]
|
||||
|
||||
test "serves favicon SVG", %{conn: conn} do
|
||||
conn = get(conn, ~p"/favicon.svg")
|
||||
assert response_content_type(conn, :xml) =~ "image/svg+xml"
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body =~ "<svg"
|
||||
assert get_resp_header(conn, "cache-control") == ["public, max-age=86400"]
|
||||
assert [etag] = get_resp_header(conn, "etag")
|
||||
assert etag =~ ~r/^"fav-\d+"$/
|
||||
end
|
||||
|
||||
test "serves 32x32 PNG favicon", %{conn: conn} do
|
||||
conn = get(conn, ~p"/favicon-32x32.png")
|
||||
assert conn.status == 200
|
||||
assert response_content_type(conn, :png) =~ "image/png"
|
||||
assert <<137, 80, 78, 71, _::binary>> = conn.resp_body
|
||||
end
|
||||
|
||||
test "serves apple touch icon", %{conn: conn} do
|
||||
conn = get(conn, ~p"/apple-touch-icon.png")
|
||||
assert conn.status == 200
|
||||
assert response_content_type(conn, :png) =~ "image/png"
|
||||
end
|
||||
|
||||
test "serves 192px icon", %{conn: conn} do
|
||||
conn = get(conn, ~p"/icon-192.png")
|
||||
assert conn.status == 200
|
||||
assert response_content_type(conn, :png) =~ "image/png"
|
||||
end
|
||||
|
||||
test "serves 512px icon", %{conn: conn} do
|
||||
conn = get(conn, ~p"/icon-512.png")
|
||||
assert conn.status == 200
|
||||
assert response_content_type(conn, :png) =~ "image/png"
|
||||
end
|
||||
|
||||
test "returns 304 for matching ETag", %{conn: conn} do
|
||||
conn1 = get(conn, ~p"/favicon-32x32.png")
|
||||
[etag] = get_resp_header(conn1, "etag")
|
||||
|
||||
conn2 =
|
||||
conn
|
||||
|> put_req_header("if-none-match", etag)
|
||||
|> get(~p"/favicon-32x32.png")
|
||||
|
||||
assert conn2.status == 304
|
||||
end
|
||||
end
|
||||
|
||||
describe "favicon routes without variants" do
|
||||
test "returns 404 for favicon SVG", %{conn: conn} do
|
||||
conn = get(conn, ~p"/favicon.svg")
|
||||
assert conn.status == 404
|
||||
end
|
||||
|
||||
test "returns 404 for PNG variants", %{conn: conn} do
|
||||
assert get(conn, ~p"/favicon-32x32.png").status == 404
|
||||
assert get(conn, ~p"/apple-touch-icon.png").status == 404
|
||||
assert get(conn, ~p"/icon-192.png").status == 404
|
||||
assert get(conn, ~p"/icon-512.png").status == 404
|
||||
end
|
||||
end
|
||||
|
||||
describe "webmanifest" do
|
||||
test "returns valid JSON manifest", %{conn: conn} do
|
||||
conn = get(conn, ~p"/site.webmanifest")
|
||||
assert conn.status == 200
|
||||
assert response_content_type(conn, :json) =~ "application/manifest+json"
|
||||
|
||||
manifest = json_response(conn, 200)
|
||||
assert manifest["name"] == "Store Name"
|
||||
assert manifest["display"] == "minimal-ui"
|
||||
assert manifest["start_url"] == "/"
|
||||
assert is_binary(manifest["theme_color"])
|
||||
assert is_binary(manifest["background_color"])
|
||||
assert length(manifest["icons"]) == 2
|
||||
end
|
||||
|
||||
test "manifest icons have correct sizes", %{conn: conn} do
|
||||
manifest = json_response(get(conn, ~p"/site.webmanifest"), 200)
|
||||
icons = manifest["icons"]
|
||||
|
||||
assert Enum.any?(icons, &(&1["sizes"] == "192x192"))
|
||||
assert Enum.any?(icons, &(&1["sizes"] == "512x512"))
|
||||
end
|
||||
|
||||
test "manifest uses short name from settings", %{conn: conn} do
|
||||
manifest = json_response(get(conn, ~p"/site.webmanifest"), 200)
|
||||
# Default: truncated site_name (no favicon_short_name set)
|
||||
assert is_binary(manifest["short_name"])
|
||||
assert String.length(manifest["short_name"]) <= 12
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user