add favicon and site icon generation from uploaded images
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:
jamey
2026-02-24 17:22:15 +00:00
parent 12d87998ee
commit f788108665
15 changed files with 837 additions and 4 deletions

View File

@@ -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

View 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