rename project from SimpleshopTheme to Berrypod

All modules, configs, paths, and references updated.
836 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-18 21:23:15 +00:00
parent c65e777832
commit 9528700862
300 changed files with 23932 additions and 1349 deletions

View File

@@ -0,0 +1,413 @@
defmodule Berrypod.AccountsTest do
use Berrypod.DataCase
alias Berrypod.Accounts
import Berrypod.AccountsFixtures
alias Berrypod.Accounts.{User, UserToken}
describe "has_admin?/0" do
test "returns false when no users exist" do
refute Accounts.has_admin?()
end
test "returns true when a user exists" do
user_fixture()
assert Accounts.has_admin?()
end
test "returns true with an unconfirmed user" do
unconfirmed_user_fixture()
assert Accounts.has_admin?()
end
end
describe "get_user_by_email/1" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email("unknown@example.com")
end
test "returns the user if the email exists" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user_by_email(user.email)
end
end
describe "get_user_by_email_and_password/2" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
end
test "does not return the user if the password is not valid" do
user = user_fixture() |> set_password()
refute Accounts.get_user_by_email_and_password(user.email, "invalid")
end
test "returns the user if the email and password are valid" do
%{id: id} = user = user_fixture() |> set_password()
assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
end
end
describe "get_user!/1" do
test "raises if id is invalid" do
assert_raise Ecto.NoResultsError, fn ->
Accounts.get_user!("11111111-1111-1111-1111-111111111111")
end
end
test "returns the user with the given id" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user!(user.id)
end
end
describe "register_user/1" do
test "requires email to be set" do
{:error, changeset} = Accounts.register_user(%{})
assert %{email: ["can't be blank"]} = errors_on(changeset)
end
test "validates email when given" do
{:error, changeset} = Accounts.register_user(%{email: "not valid"})
assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
end
test "validates maximum values for email for security" do
too_long = String.duplicate("db", 100)
{:error, changeset} = Accounts.register_user(%{email: too_long})
assert "should be at most 160 character(s)" in errors_on(changeset).email
end
test "validates email uniqueness" do
%{email: email} = user_fixture()
{:error, changeset} = Accounts.register_user(%{email: email})
assert "has already been taken" in errors_on(changeset).email
# Now try with the uppercased email too, to check that email case is ignored.
{:error, changeset} = Accounts.register_user(%{email: String.upcase(email)})
assert "has already been taken" in errors_on(changeset).email
end
test "registers users without password" do
email = unique_user_email()
{:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
assert user.email == email
assert is_nil(user.hashed_password)
assert is_nil(user.confirmed_at)
assert is_nil(user.password)
end
end
describe "sudo_mode?/2" do
test "validates the authenticated_at time" do
now = DateTime.utc_now()
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.utc_now()})
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -19, :minute)})
refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -21, :minute)})
# minute override
refute Accounts.sudo_mode?(
%User{authenticated_at: DateTime.add(now, -11, :minute)},
-10
)
# not authenticated
refute Accounts.sudo_mode?(%User{})
end
end
describe "change_user_email/3" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{})
assert changeset.required == [:email]
end
end
describe "deliver_user_update_email_instructions/3" do
setup do
%{user: user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(user, "current@example.com", url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
assert user_token.user_id == user.id
assert user_token.sent_to == user.email
assert user_token.context == "change:current@example.com"
end
end
describe "update_user_email/2" do
setup do
user = unconfirmed_user_fixture()
email = unique_user_email()
token =
extract_user_token(fn url ->
Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
end)
%{user: user, token: token, email: email}
end
test "updates the email with a valid token", %{user: user, token: token, email: email} do
assert {:ok, %{email: ^email}} = Accounts.update_user_email(user, token)
changed_user = Repo.get!(User, user.id)
assert changed_user.email != user.email
assert changed_user.email == email
refute Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email with invalid token", %{user: user} do
assert Accounts.update_user_email(user, "oops") ==
{:error, :transaction_aborted}
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email if user email changed", %{user: user, token: token} do
assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) ==
{:error, :transaction_aborted}
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
test "does not update email if token expired", %{user: user, token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
assert Accounts.update_user_email(user, token) ==
{:error, :transaction_aborted}
assert Repo.get!(User, user.id).email == user.email
assert Repo.get_by(UserToken, user_id: user.id)
end
end
describe "change_user_password/3" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{})
assert changeset.required == [:password]
end
test "allows fields to be set" do
changeset =
Accounts.change_user_password(
%User{},
%{
"password" => "new valid password"
},
hash_password: false
)
assert changeset.valid?
assert get_change(changeset, :password) == "new valid password"
assert is_nil(get_change(changeset, :hashed_password))
end
end
describe "update_user_password/2" do
setup do
%{user: user_fixture()}
end
test "validates password", %{user: user} do
{:error, changeset} =
Accounts.update_user_password(user, %{
password: "not valid",
password_confirmation: "another"
})
assert %{
password: ["should be at least 12 character(s)"],
password_confirmation: ["does not match password"]
} = errors_on(changeset)
end
test "validates maximum values for password for security", %{user: user} do
too_long = String.duplicate("db", 100)
{:error, changeset} =
Accounts.update_user_password(user, %{password: too_long})
assert "should be at most 72 character(s)" in errors_on(changeset).password
end
test "updates the password", %{user: user} do
{:ok, {user, expired_tokens}} =
Accounts.update_user_password(user, %{
password: "new valid password"
})
assert expired_tokens == []
assert is_nil(user.password)
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "deletes all tokens for the given user", %{user: user} do
_ = Accounts.generate_user_session_token(user)
{:ok, {_, _}} =
Accounts.update_user_password(user, %{
password: "new valid password"
})
refute Repo.get_by(UserToken, user_id: user.id)
end
end
describe "generate_user_session_token/1" do
setup do
%{user: user_fixture()}
end
test "generates a token", %{user: user} do
token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.context == "session"
assert user_token.authenticated_at != nil
# Creating the same token for another user should fail
assert_raise Ecto.ConstraintError, fn ->
Repo.insert!(%UserToken{
token: user_token.token,
user_id: user_fixture().id,
context: "session"
})
end
end
test "duplicates the authenticated_at of given user in new token", %{user: user} do
user = %{user | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)}
token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.authenticated_at == user.authenticated_at
assert DateTime.compare(user_token.inserted_at, user.authenticated_at) == :gt
end
end
describe "get_user_by_session_token/1" do
setup do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
%{user: user, token: token}
end
test "returns user by token", %{user: user, token: token} do
assert {session_user, token_inserted_at} = Accounts.get_user_by_session_token(token)
assert session_user.id == user.id
assert session_user.authenticated_at != nil
assert token_inserted_at != nil
end
test "does not return user for invalid token" do
refute Accounts.get_user_by_session_token("oops")
end
test "does not return user for expired token", %{token: token} do
dt = ~N[2020-01-01 00:00:00]
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: dt, authenticated_at: dt])
refute Accounts.get_user_by_session_token(token)
end
end
describe "get_user_by_magic_link_token/1" do
setup do
user = user_fixture()
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
%{user: user, token: encoded_token}
end
test "returns user by token", %{user: user, token: token} do
assert session_user = Accounts.get_user_by_magic_link_token(token)
assert session_user.id == user.id
end
test "does not return user for invalid token" do
refute Accounts.get_user_by_magic_link_token("oops")
end
test "does not return user for expired token", %{token: token} do
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
refute Accounts.get_user_by_magic_link_token(token)
end
end
describe "login_user_by_magic_link/1" do
test "confirms user and expires tokens" do
user = unconfirmed_user_fixture()
refute user.confirmed_at
{encoded_token, hashed_token} = generate_user_magic_link_token(user)
assert {:ok, {user, [%{token: ^hashed_token}]}} =
Accounts.login_user_by_magic_link(encoded_token)
assert user.confirmed_at
end
test "returns user and (deleted) token for confirmed user" do
user = user_fixture()
assert user.confirmed_at
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
assert {:ok, {^user, []}} = Accounts.login_user_by_magic_link(encoded_token)
# one time use only
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
end
test "raises when unconfirmed user has password set" do
user = unconfirmed_user_fixture()
{1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"])
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn ->
Accounts.login_user_by_magic_link(encoded_token)
end
end
end
describe "delete_user_session_token/1" do
test "deletes the token" do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
assert Accounts.delete_user_session_token(token) == :ok
refute Accounts.get_user_by_session_token(token)
end
end
describe "deliver_login_instructions/2" do
setup do
%{user: unconfirmed_user_fixture()}
end
test "sends token through notification", %{user: user} do
token =
extract_user_token(fn url ->
Accounts.deliver_login_instructions(user, url)
end)
{:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
assert user_token.user_id == user.id
assert user_token.sent_to == user.email
assert user_token.context == "login"
end
end
describe "inspect/2 for the User module" do
test "does not include password" do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end
end
end

173
test/berrypod/cart_test.exs Normal file
View File

@@ -0,0 +1,173 @@
defmodule Berrypod.CartTest do
use ExUnit.Case, async: true
alias Berrypod.Cart
describe "add_item/3" do
test "adds a new item to an empty cart" do
cart = Cart.add_item([], "variant-1")
assert cart == [{"variant-1", 1}]
end
test "adds a new item with custom quantity" do
cart = Cart.add_item([], "variant-1", 3)
assert cart == [{"variant-1", 3}]
end
test "increments quantity for existing item" do
cart = [{"variant-1", 2}]
cart = Cart.add_item(cart, "variant-1", 1)
assert cart == [{"variant-1", 3}]
end
test "adds different items separately" do
cart =
[]
|> Cart.add_item("variant-1", 1)
|> Cart.add_item("variant-2", 2)
assert cart == [{"variant-1", 1}, {"variant-2", 2}]
end
end
describe "update_quantity/3" do
test "updates the quantity of an existing item" do
cart = [{"variant-1", 2}]
cart = Cart.update_quantity(cart, "variant-1", 5)
assert cart == [{"variant-1", 5}]
end
test "removes item when quantity is zero" do
cart = [{"variant-1", 2}]
cart = Cart.update_quantity(cart, "variant-1", 0)
assert cart == []
end
test "removes item when quantity is negative" do
cart = [{"variant-1", 2}]
cart = Cart.update_quantity(cart, "variant-1", -1)
assert cart == []
end
test "does nothing for non-existent item" do
cart = [{"variant-1", 2}]
cart = Cart.update_quantity(cart, "variant-999", 5)
assert cart == [{"variant-1", 2}]
end
end
describe "remove_item/2" do
test "removes an item from the cart" do
cart = [{"variant-1", 2}, {"variant-2", 1}]
cart = Cart.remove_item(cart, "variant-1")
assert cart == [{"variant-2", 1}]
end
test "does nothing for non-existent item" do
cart = [{"variant-1", 2}]
cart = Cart.remove_item(cart, "variant-999")
assert cart == [{"variant-1", 2}]
end
test "returns empty list when removing last item" do
cart = Cart.remove_item([{"variant-1", 1}], "variant-1")
assert cart == []
end
end
describe "get_quantity/2" do
test "returns quantity for existing item" do
cart = [{"variant-1", 3}]
assert Cart.get_quantity(cart, "variant-1") == 3
end
test "returns 0 for non-existent item" do
assert Cart.get_quantity([], "variant-1") == 0
end
end
describe "item_count/1" do
test "sums all quantities" do
cart = [{"variant-1", 2}, {"variant-2", 3}]
assert Cart.item_count(cart) == 5
end
test "returns 0 for empty cart" do
assert Cart.item_count([]) == 0
end
end
describe "format_price/1" do
test "formats pence as GBP string" do
assert Cart.format_price(2400) == "£24.00"
end
test "formats with pence correctly" do
assert Cart.format_price(1499) == "£14.99"
end
test "formats zero" do
assert Cart.format_price(0) == "£0.00"
end
test "formats single digit pence with padding" do
assert Cart.format_price(105) == "£1.05"
end
test "returns fallback for non-integer" do
assert Cart.format_price(nil) == "£0.00"
assert Cart.format_price("foo") == "£0.00"
end
end
describe "calculate_subtotal/1" do
test "sums price * quantity for all items" do
items = [
%{price: 2400, quantity: 1},
%{price: 1499, quantity: 2}
]
assert Cart.calculate_subtotal(items) == 5398
end
test "returns 0 for empty list" do
assert Cart.calculate_subtotal([]) == 0
end
end
describe "serialize/1 and deserialize/1" do
test "round-trips cart data" do
cart = [{"variant-1", 2}, {"variant-2", 1}]
assert cart == cart |> Cart.serialize() |> Cart.deserialize()
end
test "serialize converts tuples to lists" do
assert Cart.serialize([{"v1", 3}]) == [["v1", 3]]
end
test "deserialize drops malformed entries" do
assert Cart.deserialize([[1, 2]]) == []
assert Cart.deserialize([["valid", 1], [nil, 2]]) == [{"valid", 1}]
end
test "deserialize handles non-list input" do
assert Cart.deserialize(nil) == []
assert Cart.deserialize("garbage") == []
end
end
describe "get_from_session/1" do
test "returns cart items from session" do
session = %{"cart" => [{"v1", 2}]}
assert Cart.get_from_session(session) == [{"v1", 2}]
end
test "returns empty list when no cart in session" do
assert Cart.get_from_session(%{}) == []
end
test "returns empty list for invalid cart data" do
assert Cart.get_from_session(%{"cart" => "garbage"}) == []
end
end
end

View File

@@ -0,0 +1,48 @@
defmodule Berrypod.Clients.PrintfulTest do
use ExUnit.Case, async: true
alias Berrypod.Clients.Printful
describe "api_token/0" do
test "reads from process dictionary when set" do
Process.put(:printful_api_key, "test_token_123")
assert Printful.api_token() == "test_token_123"
after
Process.delete(:printful_api_key)
end
test "raises when no token available" do
Process.delete(:printful_api_key)
_original = System.get_env("PRINTFUL_API_TOKEN")
System.delete_env("PRINTFUL_API_TOKEN")
assert_raise RuntimeError, ~r/PRINTFUL_API_TOKEN/, fn ->
Printful.api_token()
end
after
Process.delete(:printful_api_key)
# Restore env if it was set
case System.get_env("PRINTFUL_API_TOKEN") do
nil -> :ok
_ -> :ok
end
end
end
describe "store_id/0" do
test "reads from process dictionary" do
Process.put(:printful_store_id, 12345)
assert Printful.store_id() == 12345
after
Process.delete(:printful_store_id)
end
test "returns nil when not set" do
Process.delete(:printful_store_id)
assert Printful.store_id() == nil
end
end
end

View File

@@ -0,0 +1,47 @@
defmodule Berrypod.Images.OptimizeWorkerTest do
use Berrypod.DataCase, async: false
use Oban.Testing, repo: Berrypod.Repo
alias Berrypod.Images.OptimizeWorker
import Berrypod.ImageFixtures
setup do
cleanup_cache()
on_exit(&cleanup_cache/0)
:ok
end
describe "perform/1" do
test "processes image and generates variants" do
image = image_fixture(%{source_width: 1200, source_height: 800})
assert :ok = perform_job(OptimizeWorker, %{image_id: image.id})
for w <- [400, 800, 1200], fmt <- [:avif, :webp, :jpg] do
assert File.exists?(cache_path(image.id, w, fmt))
end
end
test "cancels for missing image" do
assert {:cancel, :image_not_found} =
perform_job(OptimizeWorker, %{image_id: Ecto.UUID.generate()})
end
test "skips SVG images" do
image = svg_fixture()
assert :ok = perform_job(OptimizeWorker, %{image_id: image.id})
end
end
describe "enqueue/1" do
test "inserts and executes job (inline mode)" do
image = image_fixture(%{source_width: 1200, source_height: 800})
# In inline test mode, job executes immediately
assert {:ok, %Oban.Job{state: "completed"}} = OptimizeWorker.enqueue(image.id)
# Verify variants were created (job ran inline)
assert File.exists?(cache_path(image.id, 400, :avif))
end
end
end

View File

@@ -0,0 +1,139 @@
defmodule Berrypod.Images.OptimizerTest do
use Berrypod.DataCase, async: false
alias Berrypod.Images.Optimizer
import Berrypod.ImageFixtures
setup do
cleanup_cache()
on_exit(&cleanup_cache/0)
:ok
end
describe "applicable_widths/1" do
test "returns all widths for large source" do
assert [400, 800, 1200] = Optimizer.applicable_widths(1500)
end
test "excludes widths larger than source" do
assert [400, 800] = Optimizer.applicable_widths(900)
assert [400] = Optimizer.applicable_widths(500)
end
test "returns source width when smaller than minimum" do
assert [300] = Optimizer.applicable_widths(300)
assert [50] = Optimizer.applicable_widths(50)
end
end
describe "to_optimized_webp/1" do
test "converts image and returns dimensions" do
{:ok, webp, width, height} = Optimizer.to_optimized_webp(sample_jpeg())
assert is_binary(webp)
assert width == 1200
assert height == 800
# WebP magic bytes: RIFF....WEBP
assert <<"RIFF", _size::binary-size(4), "WEBP", _::binary>> = webp
end
test "resizes images larger than 2000px" do
# Create a large test image by scaling up
{:ok, image} = Image.open("test/fixtures/sample_1200x800.jpg")
{:ok, large} = Image.thumbnail(image, 3000)
{:ok, large_data} = Image.write(large, :memory, suffix: ".jpg")
{:ok, webp, width, height} = Optimizer.to_optimized_webp(large_data)
assert is_binary(webp)
assert width == 2000
# Height should be proportionally scaled
assert height <= 2000
end
test "returns error for invalid data" do
assert {:error, _} = Optimizer.to_optimized_webp("not an image")
end
end
describe "process_for_image/1" do
test "generates AVIF and WebP variants for 1200px image" do
image = image_fixture(%{source_width: 1200, source_height: 800})
assert {:ok, [400, 800, 1200]} = Optimizer.process_for_image(image.id)
# 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
assert File.exists?(cache_path(image.id, "thumb", :jpg))
end
test "generates only applicable widths for smaller image" do
# Create fixture with smaller source width
{:ok, webp, _w, _h} = Optimizer.to_optimized_webp(sample_jpeg())
image =
%Berrypod.Media.Image{}
|> Berrypod.Media.Image.changeset(%{
image_type: "product",
filename: "small.jpg",
content_type: "image/webp",
file_size: byte_size(webp),
data: webp,
source_width: 600,
source_height: 400,
variants_status: "pending",
is_svg: false
})
|> Repo.insert!()
assert {:ok, [400]} = Optimizer.process_for_image(image.id)
assert File.exists?(cache_path(image.id, 400, :avif))
refute File.exists?(cache_path(image.id, 800, :avif))
end
test "skips SVG images" do
image = svg_fixture()
assert {:ok, :svg_skipped} = Optimizer.process_for_image(image.id)
end
test "returns error for missing image" do
assert {:error, :not_found} = Optimizer.process_for_image(Ecto.UUID.generate())
end
test "is idempotent - skips existing files" do
image = image_fixture(%{source_width: 1200, source_height: 800})
{:ok, _} = Optimizer.process_for_image(image.id)
path = cache_path(image.id, 400, :avif)
{:ok, %{mtime: mtime1}} = File.stat(path)
Process.sleep(1100)
{:ok, _} = Optimizer.process_for_image(image.id)
{:ok, %{mtime: mtime2}} = File.stat(path)
assert mtime1 == mtime2, "File was regenerated"
end
end
describe "disk_variants_exist?/2" do
test "returns true when all pre-generated variants exist" do
image = image_fixture(%{source_width: 1200, source_height: 800})
{:ok, _} = Optimizer.process_for_image(image.id)
# Should return true even without JPEG (only checks AVIF/WebP)
assert Optimizer.disk_variants_exist?(image.id, 1200)
end
test "returns false when variants missing" do
image = image_fixture(%{source_width: 1200, source_height: 800})
refute Optimizer.disk_variants_exist?(image.id, 1200)
end
end
end

View File

@@ -0,0 +1,148 @@
defmodule Berrypod.Media.SVGRecolorerTest do
use ExUnit.Case, async: true
alias Berrypod.Media.SVGRecolorer
describe "recolor/2" do
test "replaces fill attributes" do
svg = ~s(<svg><path fill="#000000" d="M0 0"/></svg>)
result = SVGRecolorer.recolor(svg, "#ff6600")
assert result =~ ~s(fill="#ff6600")
refute result =~ "#000000"
end
test "replaces stroke attributes" do
svg = ~s(<svg><path stroke="#000000" d="M0 0"/></svg>)
result = SVGRecolorer.recolor(svg, "#ff6600")
assert result =~ ~s(stroke="#ff6600")
refute result =~ "#000000"
end
test "preserves fill=none" do
svg = ~s(<svg><path fill="none" d="M0 0"/></svg>)
result = SVGRecolorer.recolor(svg, "#ff6600")
assert result =~ ~s(fill="none")
end
test "preserves stroke=none" do
svg = ~s(<svg><path stroke="none" d="M0 0"/></svg>)
result = SVGRecolorer.recolor(svg, "#ff6600")
assert result =~ ~s(stroke="none")
end
test "replaces currentColor" do
svg = ~s(<svg><path fill="currentColor" d="M0 0"/></svg>)
result = SVGRecolorer.recolor(svg, "#ff6600")
assert result =~ "#ff6600"
refute result =~ "currentColor"
end
test "handles multiple elements" do
svg = ~s(<svg><circle fill="#000" r="10"/><rect fill="#fff" width="20"/></svg>)
result = SVGRecolorer.recolor(svg, "#ff6600")
assert result =~ ~s(fill="#ff6600")
refute result =~ "#000"
refute result =~ "#fff"
end
test "handles RGB color names" do
svg = ~s(<svg><path fill="black" d="M0 0"/></svg>)
result = SVGRecolorer.recolor(svg, "#ff6600")
assert result =~ ~s(fill="#ff6600")
refute result =~ "black"
end
test "handles inline styles with fill" do
svg = ~s(<svg><path style="fill: #000000; opacity: 1" d="M0 0"/></svg>)
result = SVGRecolorer.recolor(svg, "#ff6600")
assert result =~ "fill:#ff6600"
refute result =~ "#000000"
end
test "handles both fill and stroke in same element" do
svg = ~s(<svg><path fill="#000" stroke="#fff" d="M0 0"/></svg>)
result = SVGRecolorer.recolor(svg, "#ff6600")
assert String.contains?(result, ~s(fill="#ff6600"))
assert String.contains?(result, ~s(stroke="#ff6600"))
end
test "handles CSS style blocks with fill" do
svg = """
<svg><style>.st0{fill:#FFFFFF;}.st1{fill:#EF1D1D;stroke:#000000;}</style><path class="st0"/></svg>
"""
result = SVGRecolorer.recolor(svg, "#ff6600")
assert result =~ "fill:#ff6600"
refute result =~ "#FFFFFF"
refute result =~ "#EF1D1D"
end
test "handles CSS style blocks with stroke" do
svg = """
<svg><style>.st1{stroke:#000000;stroke-miterlimit:10;}</style><path class="st1"/></svg>
"""
result = SVGRecolorer.recolor(svg, "#ff6600")
assert result =~ "stroke:#ff6600"
refute result =~ "#000000"
end
test "preserves fill:none in CSS" do
svg = """
<svg><style>.st0{fill:none;stroke:#000;}</style><path class="st0"/></svg>
"""
result = SVGRecolorer.recolor(svg, "#ff6600")
assert result =~ "fill:none"
assert result =~ "stroke:#ff6600"
end
test "handles CSS with color names" do
svg = """
<svg><style>.icon{fill:black;stroke:white;}</style><path class="icon"/></svg>
"""
result = SVGRecolorer.recolor(svg, "#ff6600")
assert result =~ "fill:#ff6600"
assert result =~ "stroke:#ff6600"
refute result =~ "black"
refute result =~ "white"
end
end
describe "valid_hex_color?/1" do
test "accepts 6-digit hex colors" do
assert SVGRecolorer.valid_hex_color?("#ff6600")
assert SVGRecolorer.valid_hex_color?("#FFFFFF")
assert SVGRecolorer.valid_hex_color?("#000000")
end
test "accepts 3-digit hex colors" do
assert SVGRecolorer.valid_hex_color?("#f60")
assert SVGRecolorer.valid_hex_color?("#FFF")
assert SVGRecolorer.valid_hex_color?("#000")
end
test "rejects invalid colors" do
refute SVGRecolorer.valid_hex_color?("ff6600")
refute SVGRecolorer.valid_hex_color?("#gg0000")
refute SVGRecolorer.valid_hex_color?("#ff")
refute SVGRecolorer.valid_hex_color?("red")
refute SVGRecolorer.valid_hex_color?(nil)
refute SVGRecolorer.valid_hex_color?(123)
end
end
describe "normalize_hex_color/1" do
test "expands 3-digit hex to 6-digit" do
assert SVGRecolorer.normalize_hex_color("#f60") == "#ff6600"
assert SVGRecolorer.normalize_hex_color("#FFF") == "#FFFFFF"
assert SVGRecolorer.normalize_hex_color("#000") == "#000000"
end
test "leaves 6-digit hex unchanged" do
assert SVGRecolorer.normalize_hex_color("#ff6600") == "#ff6600"
assert SVGRecolorer.normalize_hex_color("#FFFFFF") == "#FFFFFF"
end
end
end

View File

@@ -0,0 +1,113 @@
defmodule Berrypod.MediaTest do
use Berrypod.DataCase, async: true
alias Berrypod.Media
@valid_attrs %{
image_type: "logo",
filename: "logo.png",
content_type: "image/png",
file_size: 1024,
data: <<137, 80, 78, 71>>
}
@svg_attrs %{
image_type: "logo",
filename: "logo.svg",
content_type: "image/svg+xml",
file_size: 512,
data: "<svg xmlns=\"http://www.w3.org/2000/svg\"><circle r=\"10\"/></svg>"
}
describe "upload_image/1" do
test "uploads an image with valid attributes" do
assert {:ok, image} = Media.upload_image(@valid_attrs)
assert image.image_type == "logo"
assert image.filename == "logo.png"
assert image.content_type == "image/png"
assert image.is_svg == false
end
test "detects and stores SVG content" do
assert {:ok, image} = Media.upload_image(@svg_attrs)
assert image.is_svg == true
assert image.svg_content == @svg_attrs.data
end
test "validates required fields" do
assert {:error, changeset} = Media.upload_image(%{})
assert "can't be blank" in errors_on(changeset).image_type
assert "can't be blank" in errors_on(changeset).filename
end
test "validates image_type inclusion" do
attrs = Map.put(@valid_attrs, :image_type, "invalid")
assert {:error, changeset} = Media.upload_image(attrs)
assert "is invalid" in errors_on(changeset).image_type
end
test "validates file size" do
attrs = Map.put(@valid_attrs, :file_size, 10_000_000)
assert {:error, changeset} = Media.upload_image(attrs)
assert "must be less than 5000000" in errors_on(changeset).file_size
end
end
describe "get_image/1" do
test "returns image by id" do
{:ok, image} = Media.upload_image(@valid_attrs)
assert ^image = Media.get_image(image.id)
end
test "returns nil for nonexistent id" do
assert Media.get_image(Ecto.UUID.generate()) == nil
end
end
describe "get_logo/0 and get_header/0" do
test "get_logo returns a logo" do
{:ok, logo} = Media.upload_image(@valid_attrs)
result = Media.get_logo()
assert result.id == logo.id
assert result.image_type == "logo"
end
test "get_header returns most recent header" do
attrs = Map.put(@valid_attrs, :image_type, "header")
{:ok, header} = Media.upload_image(attrs)
result = Media.get_header()
assert result.id == header.id
end
test "get_logo returns nil when no logos exist" do
assert Media.get_logo() == nil
end
end
describe "delete_image/1" do
test "deletes an image" do
{:ok, image} = Media.upload_image(@valid_attrs)
assert {:ok, _} = Media.delete_image(image)
assert Media.get_image(image.id) == nil
end
end
describe "list_images_by_type/1" do
test "lists all images of a specific type" do
{:ok, logo1} = Media.upload_image(@valid_attrs)
{:ok, logo2} = Media.upload_image(@valid_attrs)
{:ok, _header} = Media.upload_image(Map.put(@valid_attrs, :image_type, "header"))
logos = Media.list_images_by_type("logo")
assert length(logos) == 2
assert Enum.any?(logos, &(&1.id == logo1.id))
assert Enum.any?(logos, &(&1.id == logo2.id))
end
test "returns empty list when no images of type exist" do
assert Media.list_images_by_type("product") == []
end
end
end

View File

@@ -0,0 +1,84 @@
defmodule Berrypod.Orders.FulfilmentStatusWorkerTest do
use Berrypod.DataCase, async: false
import Mox
import Berrypod.OrdersFixtures
alias Berrypod.Orders
alias Berrypod.Orders.FulfilmentStatusWorker
alias Berrypod.Providers.MockProvider
setup :verify_on_exit!
setup do
Application.put_env(:berrypod, :provider_modules, %{
"printify" => MockProvider
})
on_exit(fn -> Application.delete_env(:berrypod, :provider_modules) end)
:ok
end
describe "perform/1" do
test "no-op when no submitted orders" do
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
end
test "updates status from submitted to processing" do
{order, _variant, _product, _conn} = submitted_order_fixture()
expect(MockProvider, :get_order_status, fn _conn, provider_order_id ->
assert provider_order_id == order.provider_order_id
{:ok,
%{
status: "processing",
provider_status: "in-production",
tracking_number: nil,
tracking_url: nil,
shipments: []
}}
end)
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "processing"
assert updated.provider_status == "in-production"
end
test "sets tracking info when shipped" do
{order, _variant, _product, _conn} = submitted_order_fixture()
expect(MockProvider, :get_order_status, fn _conn, _pid ->
{:ok,
%{
status: "shipped",
provider_status: "shipped",
tracking_number: "1Z999AA10123456784",
tracking_url: "https://tracking.example.com/1Z999AA10123456784",
shipments: []
}}
end)
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "shipped"
assert updated.tracking_number == "1Z999AA10123456784"
assert updated.tracking_url == "https://tracking.example.com/1Z999AA10123456784"
assert updated.shipped_at != nil
end
test "handles provider error gracefully" do
{_order, _variant, _product, _conn} = submitted_order_fixture()
expect(MockProvider, :get_order_status, fn _conn, _pid ->
{:error, {500, %{"message" => "Internal error"}}}
end)
# Should not raise, just log the error
assert :ok = FulfilmentStatusWorker.perform(%Oban.Job{args: %{}})
end
end
end

View File

@@ -0,0 +1,161 @@
defmodule Berrypod.Orders.OrderNotifierTest do
use Berrypod.DataCase, async: true
import Swoosh.TestAssertions
import Berrypod.OrdersFixtures
alias Berrypod.Orders
alias Berrypod.Orders.OrderNotifier
describe "deliver_order_confirmation/1" do
test "sends confirmation with order details" do
order =
order_fixture(%{
customer_email: "buyer@example.com",
payment_status: "paid",
shipping_address: %{
"name" => "Jane Doe",
"line1" => "42 Test Street",
"city" => "London",
"postal_code" => "SW1A 1AA",
"country" => "GB"
}
})
assert {:ok, _email} = OrderNotifier.deliver_order_confirmation(order)
assert_email_sent(fn email ->
assert email.to == [{"", "buyer@example.com"}]
assert email.subject =~ "Order confirmed"
assert email.subject =~ order.order_number
assert email.text_body =~ order.order_number
assert email.text_body =~ "Test product"
assert email.text_body =~ "Jane Doe"
assert email.text_body =~ "42 Test Street"
assert email.text_body =~ "London"
end)
end
test "includes item quantities and prices" do
order =
order_fixture(%{
customer_email: "buyer@example.com",
payment_status: "paid",
quantity: 2,
unit_price: 1500
})
assert {:ok, _email} = OrderNotifier.deliver_order_confirmation(order)
assert_email_sent(fn email ->
assert email.text_body =~ "2x Test product"
end)
end
test "includes order total" do
order =
order_fixture(%{
customer_email: "buyer@example.com",
payment_status: "paid",
unit_price: 2500
})
assert {:ok, _email} = OrderNotifier.deliver_order_confirmation(order)
assert_email_sent(fn email ->
assert email.text_body =~ "Total:"
end)
end
test "skips when customer_email is nil" do
order = order_fixture(%{customer_email: nil})
# Override to nil since fixture sets a default
{:ok, order} = Orders.update_order(order, %{customer_email: nil})
assert {:ok, :no_email} = OrderNotifier.deliver_order_confirmation(order)
assert_no_email_sent()
end
test "skips when customer_email is empty string" do
order = order_fixture()
{:ok, order} = Orders.update_order(order, %{customer_email: ""})
assert {:ok, :no_email} = OrderNotifier.deliver_order_confirmation(order)
assert_no_email_sent()
end
test "handles missing shipping address" do
order = order_fixture(%{customer_email: "buyer@example.com", payment_status: "paid"})
assert {:ok, _email} = OrderNotifier.deliver_order_confirmation(order)
assert_email_sent(subject: "Order confirmed - #{order.order_number}")
end
end
describe "deliver_shipping_notification/1" do
test "sends notification with tracking info" do
{order, _v, _p, _c} = submitted_order_fixture(%{customer_email: "buyer@example.com"})
{:ok, order} =
Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
tracking_number: "1Z999AA1",
tracking_url: "https://ups.com/track/1Z999AA1",
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert {:ok, _email} = OrderNotifier.deliver_shipping_notification(order)
assert_email_sent(fn email ->
assert email.to == [{"", "buyer@example.com"}]
assert email.subject =~ "Your order has shipped"
assert email.subject =~ order.order_number
assert email.text_body =~ "1Z999AA1"
assert email.text_body =~ "https://ups.com/track/1Z999AA1"
end)
end
test "handles missing tracking info gracefully" do
{order, _v, _p, _c} = submitted_order_fixture(%{customer_email: "buyer@example.com"})
{:ok, order} =
Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert {:ok, _email} = OrderNotifier.deliver_shipping_notification(order)
assert_email_sent(fn email ->
assert email.text_body =~ "Tracking details will follow"
end)
end
test "includes tracking number without URL" do
{order, _v, _p, _c} = submitted_order_fixture(%{customer_email: "buyer@example.com"})
{:ok, order} =
Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
tracking_number: "RM123456789GB",
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert {:ok, _email} = OrderNotifier.deliver_shipping_notification(order)
assert_email_sent(fn email ->
assert email.text_body =~ "RM123456789GB"
assert not (email.text_body =~ "Tracking details will follow")
end)
end
test "skips when customer_email is nil" do
{order, _v, _p, _c} = submitted_order_fixture()
{:ok, order} = Orders.update_order(order, %{customer_email: nil})
assert {:ok, :no_email} = OrderNotifier.deliver_shipping_notification(order)
assert_no_email_sent()
end
end
end

View File

@@ -0,0 +1,111 @@
defmodule Berrypod.Orders.OrderSubmissionWorkerTest do
use Berrypod.DataCase, async: false
import Mox
import Berrypod.OrdersFixtures
alias Berrypod.Orders
alias Berrypod.Orders.OrderSubmissionWorker
alias Berrypod.Providers.MockProvider
setup :verify_on_exit!
setup do
Application.put_env(:berrypod, :provider_modules, %{
"printify" => MockProvider
})
on_exit(fn -> Application.delete_env(:berrypod, :provider_modules) end)
:ok
end
describe "perform/1" do
test "cancels if order not found" do
fake_id = Ecto.UUID.generate()
assert {:cancel, :order_not_found} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => fake_id}})
end
test "cancels if order is not paid" do
order = order_fixture()
assert order.payment_status == "pending"
assert {:cancel, :not_paid} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
end
test "returns :ok if already submitted" do
{order, _variant, _product, _conn} = submitted_order_fixture()
assert :ok =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
end
test "errors if no shipping address (will retry)" do
order = order_fixture(payment_status: "paid")
assert order.shipping_address == %{}
assert {:error, :no_shipping_address} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
end
test "submits to provider successfully" do
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
expect(MockProvider, :submit_order, fn _conn, order_data ->
assert order_data.order_number == order.order_number
assert length(order_data.line_items) == 1
{:ok, %{provider_order_id: "printify_order_123"}}
end)
assert :ok =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "submitted"
assert updated.provider_order_id == "printify_order_123"
assert updated.submitted_at != nil
end
test "sets failed status when provider returns error" do
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
expect(MockProvider, :submit_order, fn _conn, _order_data ->
{:error, {422, %{"message" => "Invalid address"}}}
end)
assert {:error, {422, _}} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "failed"
assert updated.fulfilment_error =~ "Provider API error (422)"
end
test "sets failed status when variant not found in local DB" do
# Create order with a variant_id that doesn't exist in product variants.
# Provider connection lookup now goes through the variant's product,
# so a missing variant causes :variant_not_found at routing time.
order =
order_fixture(%{
variant_id: Ecto.UUID.generate(),
payment_status: "paid",
shipping_address: %{
"name" => "Test",
"line1" => "1 Street",
"city" => "London",
"postal_code" => "SW1",
"country" => "GB"
}
})
assert {:error, :variant_not_found} =
OrderSubmissionWorker.perform(%Oban.Job{args: %{"order_id" => order.id}})
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "failed"
assert updated.fulfilment_error =~ "no longer exists"
end
end
end

View File

@@ -0,0 +1,219 @@
defmodule Berrypod.OrdersTest do
use Berrypod.DataCase, async: false
import Mox
import Berrypod.OrdersFixtures
alias Berrypod.Orders
alias Berrypod.Providers.MockProvider
setup :verify_on_exit!
describe "list_orders/1" do
test "returns all orders" do
order1 = order_fixture()
order2 = order_fixture()
orders = Orders.list_orders()
order_ids = Enum.map(orders, & &1.id)
assert order1.id in order_ids
assert order2.id in order_ids
assert length(orders) == 2
end
test "filters by payment status" do
_pending = order_fixture()
paid = order_fixture(payment_status: "paid")
_failed = order_fixture(payment_status: "failed")
orders = Orders.list_orders(status: "paid")
assert length(orders) == 1
assert hd(orders).id == paid.id
end
test "returns all when status is 'all'" do
order_fixture()
order_fixture(payment_status: "paid")
orders = Orders.list_orders(status: "all")
assert length(orders) == 2
end
test "preloads items" do
order_fixture()
[order] = Orders.list_orders()
assert Ecto.assoc_loaded?(order.items)
assert length(order.items) == 1
end
end
describe "count_orders_by_status/0" do
test "returns empty map when no orders" do
assert Orders.count_orders_by_status() == %{}
end
test "counts orders by status" do
order_fixture()
order_fixture()
order_fixture(payment_status: "paid")
order_fixture(payment_status: "failed")
counts = Orders.count_orders_by_status()
assert counts["pending"] == 2
assert counts["paid"] == 1
assert counts["failed"] == 1
end
end
describe "submit_to_provider/1" do
setup do
Application.put_env(:berrypod, :provider_modules, %{
"printify" => MockProvider
})
on_exit(fn -> Application.delete_env(:berrypod, :provider_modules) end)
:ok
end
test "submits order and sets fulfilment status" do
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
expect(MockProvider, :submit_order, fn _conn, order_data ->
assert order_data.order_number == order.order_number
assert is_list(order_data.line_items)
assert hd(order_data.line_items).quantity == 1
{:ok, %{provider_order_id: "pfy_123"}}
end)
assert {:ok, updated} = Orders.submit_to_provider(order)
assert updated.fulfilment_status == "submitted"
assert updated.provider_order_id == "pfy_123"
assert updated.submitted_at != nil
assert updated.fulfilment_error == nil
end
test "is idempotent when already submitted" do
{order, _variant, _product, _conn} = submitted_order_fixture()
# No mock expectations — provider should not be called
assert {:ok, ^order} = Orders.submit_to_provider(order)
end
test "sets failed status on provider error" do
{order, _variant, _product, _conn} = paid_order_with_products_fixture()
expect(MockProvider, :submit_order, fn _conn, _data ->
{:error, {500, %{"message" => "Server error"}}}
end)
assert {:error, {500, _}} = Orders.submit_to_provider(order)
updated = Orders.get_order(order.id)
assert updated.fulfilment_status == "failed"
assert updated.fulfilment_error =~ "Provider API error (500)"
end
end
describe "refresh_fulfilment_status/1" do
setup do
Application.put_env(:berrypod, :provider_modules, %{
"printify" => MockProvider
})
on_exit(fn -> Application.delete_env(:berrypod, :provider_modules) end)
:ok
end
test "updates tracking info from provider" do
{order, _variant, _product, _conn} = submitted_order_fixture()
expect(MockProvider, :get_order_status, fn _conn, pid ->
assert pid == order.provider_order_id
{:ok,
%{
status: "shipped",
provider_status: "shipped",
tracking_number: "TRACK123",
tracking_url: "https://track.example.com/TRACK123"
}}
end)
assert {:ok, updated} = Orders.refresh_fulfilment_status(order)
assert updated.fulfilment_status == "shipped"
assert updated.tracking_number == "TRACK123"
assert updated.tracking_url == "https://track.example.com/TRACK123"
assert updated.shipped_at != nil
end
test "no-op when no provider_order_id" do
order = order_fixture(payment_status: "paid")
assert is_nil(order.provider_order_id)
assert {:ok, ^order} = Orders.refresh_fulfilment_status(order)
end
end
describe "update_fulfilment/2" do
test "updates fulfilment fields" do
order = order_fixture()
assert {:ok, updated} =
Orders.update_fulfilment(order, %{
fulfilment_status: "submitted",
provider_order_id: "test_123"
})
assert updated.fulfilment_status == "submitted"
assert updated.provider_order_id == "test_123"
end
test "validates fulfilment status inclusion" do
order = order_fixture()
assert {:error, changeset} =
Orders.update_fulfilment(order, %{fulfilment_status: "bogus"})
assert errors_on(changeset).fulfilment_status != []
end
end
describe "list_submitted_orders/0" do
test "returns orders with submitted or processing status" do
{submitted, _v, _p, _c} = submitted_order_fixture()
{processing, _v2, _p2, _c2} = submitted_order_fixture()
{:ok, processing} =
Orders.update_fulfilment(processing, %{fulfilment_status: "processing"})
_unfulfilled = order_fixture(payment_status: "paid")
orders = Orders.list_submitted_orders()
ids = Enum.map(orders, & &1.id)
assert submitted.id in ids
assert processing.id in ids
assert length(orders) == 2
end
test "returns empty list when no submitted orders" do
order_fixture()
assert Orders.list_submitted_orders() == []
end
end
describe "get_order_by_number/1" do
test "finds order by order number" do
order = order_fixture()
found = Orders.get_order_by_number(order.order_number)
assert found.id == order.id
end
test "returns nil for unknown order number" do
assert is_nil(Orders.get_order_by_number("SS-000000-XXXX"))
end
end
end

View File

@@ -0,0 +1,130 @@
defmodule Berrypod.Products.ProductImageTest do
use Berrypod.DataCase, async: false
alias Berrypod.Products.ProductImage
import Berrypod.ProductsFixtures
describe "changeset/2" do
setup do
product = product_fixture()
{:ok, product: product}
end
test "valid attributes create a valid changeset", %{product: product} do
attrs = valid_product_image_attrs(%{product_id: product.id})
changeset = ProductImage.changeset(%ProductImage{}, attrs)
assert changeset.valid?
end
test "requires product_id", %{product: _product} do
attrs = valid_product_image_attrs() |> Map.delete(:product_id)
changeset = ProductImage.changeset(%ProductImage{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).product_id
end
test "requires src", %{product: product} do
attrs =
valid_product_image_attrs(%{product_id: product.id})
|> Map.delete(:src)
changeset = ProductImage.changeset(%ProductImage{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).src
end
test "defaults position to 0", %{product: product} do
attrs =
valid_product_image_attrs(%{product_id: product.id})
|> Map.delete(:position)
changeset = ProductImage.changeset(%ProductImage{}, attrs)
assert changeset.valid?
end
test "accepts optional alt text", %{product: product} do
attrs = valid_product_image_attrs(%{product_id: product.id, alt: "Product image alt text"})
changeset = ProductImage.changeset(%ProductImage{}, attrs)
assert changeset.valid?
assert changeset.changes.alt == "Product image alt text"
end
test "allows nil alt text", %{product: product} do
attrs = valid_product_image_attrs(%{product_id: product.id, alt: nil})
changeset = ProductImage.changeset(%ProductImage{}, attrs)
assert changeset.valid?
end
end
# =============================================================================
# Display helpers
# =============================================================================
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.url(image) == "/image_cache/abc-123-800.webp"
end
test "accepts custom width" do
image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"}
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.url(image) == "https://cdn.example.com/img.jpg"
end
test "returns nil when neither image_id nor src" do
assert ProductImage.url(%{image_id: nil, src: nil}) == nil
end
test "returns nil for nil input" do
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
describe "source_width/1" do
test "returns source_width from preloaded image" do
image = %{image: %{source_width: 2400}}
assert ProductImage.source_width(image) == 2400
end
test "returns nil when image not preloaded" do
assert ProductImage.source_width(%{}) == nil
end
test "returns nil when source_width is nil" do
assert ProductImage.source_width(%{image: %{source_width: nil}}) == nil
end
end
end

View File

@@ -0,0 +1,323 @@
defmodule Berrypod.Products.ProductTest do
use Berrypod.DataCase, async: true
alias Berrypod.Products.Product
import Berrypod.ProductsFixtures
describe "changeset/2" do
setup do
conn = provider_connection_fixture()
{:ok, conn: conn}
end
test "valid attributes create a valid changeset", %{conn: conn} do
attrs = valid_product_attrs(%{provider_connection_id: conn.id})
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
end
test "requires provider_connection_id", %{conn: _conn} do
attrs = valid_product_attrs() |> Map.put(:provider_connection_id, nil)
changeset = Product.changeset(%Product{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).provider_connection_id
end
test "requires provider_product_id", %{conn: conn} do
attrs =
valid_product_attrs(%{provider_connection_id: conn.id})
|> Map.delete(:provider_product_id)
changeset = Product.changeset(%Product{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).provider_product_id
end
test "requires title", %{conn: conn} do
attrs =
valid_product_attrs(%{provider_connection_id: conn.id})
|> Map.delete(:title)
changeset = Product.changeset(%Product{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).title
end
test "requires slug", %{conn: conn} do
attrs =
valid_product_attrs(%{provider_connection_id: conn.id})
|> Map.delete(:slug)
|> Map.delete(:title)
changeset = Product.changeset(%Product{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).slug
end
test "validates status is in allowed list", %{conn: conn} do
attrs = valid_product_attrs(%{provider_connection_id: conn.id, status: "invalid"})
changeset = Product.changeset(%Product{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).status
end
test "accepts all valid statuses", %{conn: conn} do
for status <- Product.statuses() do
attrs = valid_product_attrs(%{provider_connection_id: conn.id, status: status})
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?, "Expected #{status} to be valid"
end
end
test "defaults status to active", %{conn: conn} do
attrs =
valid_product_attrs(%{provider_connection_id: conn.id})
|> Map.delete(:status)
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
end
test "defaults visible to true", %{conn: conn} do
attrs =
valid_product_attrs(%{provider_connection_id: conn.id})
|> Map.delete(:visible)
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
end
test "stores provider_data as map", %{conn: conn} do
provider_data = %{"blueprint_id" => 145, "print_provider_id" => 29, "extra" => "value"}
attrs =
valid_product_attrs(%{provider_connection_id: conn.id, provider_data: provider_data})
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
assert changeset.changes.provider_data == provider_data
end
end
describe "slug generation" do
setup do
conn = provider_connection_fixture()
{:ok, conn: conn}
end
test "generates slug from title when slug not provided", %{conn: conn} do
attrs =
valid_product_attrs(%{
provider_connection_id: conn.id,
title: "My Awesome Product"
})
|> Map.delete(:slug)
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
assert changeset.changes.slug == "my-awesome-product"
end
test "uses provided slug over generated one", %{conn: conn} do
attrs =
valid_product_attrs(%{
provider_connection_id: conn.id,
title: "My Awesome Product",
slug: "custom-slug"
})
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
assert changeset.changes.slug == "custom-slug"
end
test "handles special characters in title for slug generation", %{conn: conn} do
attrs =
valid_product_attrs(%{
provider_connection_id: conn.id,
title: "Product (Special) & More!"
})
|> Map.delete(:slug)
changeset = Product.changeset(%Product{}, attrs)
assert changeset.valid?
# Special chars removed, spaces become dashes, consecutive dashes collapsed
assert changeset.changes.slug == "product-special-more"
end
end
describe "compute_checksum/1" do
test "generates consistent checksum for same data" do
data = %{"title" => "Test", "price" => 100}
checksum1 = Product.compute_checksum(data)
checksum2 = Product.compute_checksum(data)
assert checksum1 == checksum2
end
test "generates different checksum for different data" do
data1 = %{"title" => "Test", "price" => 100}
data2 = %{"title" => "Test", "price" => 200}
checksum1 = Product.compute_checksum(data1)
checksum2 = Product.compute_checksum(data2)
assert checksum1 != checksum2
end
test "returns 16-character hex string" do
data = %{"key" => "value"}
checksum = Product.compute_checksum(data)
assert is_binary(checksum)
assert String.length(checksum) == 16
assert Regex.match?(~r/^[a-f0-9]+$/, checksum)
end
test "returns nil for non-map input" do
assert Product.compute_checksum(nil) == nil
assert Product.compute_checksum("string") == nil
assert Product.compute_checksum(123) == nil
end
test "handles nested maps" do
data = %{
"title" => "Test",
"options" => [
%{"name" => "Size", "values" => ["S", "M", "L"]}
]
}
checksum = Product.compute_checksum(data)
assert is_binary(checksum)
end
end
describe "unique constraints" do
test "enforces unique slug" do
_product1 = product_fixture(%{slug: "unique-product"})
conn = provider_connection_fixture(%{provider_type: "gelato"})
assert {:error, changeset} =
Berrypod.Products.create_product(
valid_product_attrs(%{
provider_connection_id: conn.id,
slug: "unique-product"
})
)
assert "has already been taken" in errors_on(changeset).slug
end
test "enforces unique provider_connection_id + provider_product_id" do
conn = provider_connection_fixture()
_product1 = product_fixture(%{provider_connection: conn, provider_product_id: "ext_123"})
assert {:error, changeset} =
Berrypod.Products.create_product(
valid_product_attrs(%{
provider_connection_id: conn.id,
provider_product_id: "ext_123"
})
)
assert "has already been taken" in errors_on(changeset).provider_connection_id
end
end
describe "statuses/0" do
test "returns list of valid statuses" do
statuses = Product.statuses()
assert is_list(statuses)
assert "active" in statuses
assert "draft" in statuses
assert "archived" in statuses
end
end
# =============================================================================
# Display helpers
# =============================================================================
describe "primary_image/1" do
test "returns image with lowest position" do
product = %{images: [%{position: 2, src: "b.jpg"}, %{position: 0, src: "a.jpg"}]}
assert Product.primary_image(product).src == "a.jpg"
end
test "returns nil with no images" do
assert Product.primary_image(%{images: []}) == nil
end
test "returns nil when images not present" do
assert Product.primary_image(%{}) == nil
end
end
describe "hover_image/1" do
test "returns second image by position" do
product = %{images: [%{position: 0, src: "a.jpg"}, %{position: 1, src: "b.jpg"}]}
assert Product.hover_image(product).src == "b.jpg"
end
test "returns nil with fewer than 2 images" do
assert Product.hover_image(%{images: [%{position: 0, src: "a.jpg"}]}) == nil
end
test "returns nil with no images" do
assert Product.hover_image(%{images: []}) == nil
end
end
describe "option_types/1" do
test "extracts from provider_data" do
product = %{
provider_data: %{
"options" => [
%{
"name" => "Size",
"type" => "size",
"values" => [%{"title" => "S"}, %{"title" => "M"}]
},
%{
"name" => "Color",
"type" => "color",
"values" => [%{"title" => "Red", "colors" => ["#FF0000"]}]
}
]
}
}
types = Product.option_types(product)
assert length(types) == 2
assert hd(types) == %{name: "Size", type: :size, values: [%{title: "S"}, %{title: "M"}]}
color_type = Enum.at(types, 1)
assert color_type.type == :color
assert hd(color_type.values) == %{title: "Red", hex: "#FF0000"}
end
test "returns empty list when no provider_data" do
assert Product.option_types(%{}) == []
end
test "falls back to option_types field on mock data" do
mock = %{option_types: [%{name: "Size", values: ["S", "M"]}]}
assert Product.option_types(mock) == [%{name: "Size", values: ["S", "M"]}]
end
end
end

View File

@@ -0,0 +1,201 @@
defmodule Berrypod.Products.ProductVariantTest do
use Berrypod.DataCase, async: false
alias Berrypod.Products.ProductVariant
import Berrypod.ProductsFixtures
describe "changeset/2" do
setup do
product = product_fixture()
{:ok, product: product}
end
test "valid attributes create a valid changeset", %{product: product} do
attrs = valid_product_variant_attrs(%{product_id: product.id})
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
assert changeset.valid?
end
test "requires product_id", %{product: _product} do
attrs = valid_product_variant_attrs() |> Map.delete(:product_id)
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).product_id
end
test "requires provider_variant_id", %{product: product} do
attrs =
valid_product_variant_attrs(%{product_id: product.id})
|> Map.delete(:provider_variant_id)
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).provider_variant_id
end
test "requires title", %{product: product} do
attrs =
valid_product_variant_attrs(%{product_id: product.id})
|> Map.delete(:title)
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).title
end
test "requires price", %{product: product} do
attrs =
valid_product_variant_attrs(%{product_id: product.id})
|> Map.delete(:price)
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).price
end
test "validates price is non-negative", %{product: product} do
attrs = valid_product_variant_attrs(%{product_id: product.id, price: -100})
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "must be greater than or equal to 0" in errors_on(changeset).price
end
test "validates compare_at_price is non-negative when provided", %{product: product} do
attrs = valid_product_variant_attrs(%{product_id: product.id, compare_at_price: -100})
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "must be greater than or equal to 0" in errors_on(changeset).compare_at_price
end
test "validates cost is non-negative when provided", %{product: product} do
attrs = valid_product_variant_attrs(%{product_id: product.id, cost: -100})
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
refute changeset.valid?
assert "must be greater than or equal to 0" in errors_on(changeset).cost
end
test "stores options as map", %{product: product} do
options = %{"Size" => "Large", "Color" => "Red"}
attrs = valid_product_variant_attrs(%{product_id: product.id, options: options})
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
assert changeset.valid?
assert changeset.changes.options == options
end
test "defaults is_enabled to true", %{product: product} do
attrs =
valid_product_variant_attrs(%{product_id: product.id})
|> Map.delete(:is_enabled)
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
assert changeset.valid?
end
test "defaults is_available to true", %{product: product} do
attrs =
valid_product_variant_attrs(%{product_id: product.id})
|> Map.delete(:is_available)
changeset = ProductVariant.changeset(%ProductVariant{}, attrs)
assert changeset.valid?
end
end
describe "profit/1" do
test "calculates profit correctly" do
variant = %ProductVariant{price: 2500, cost: 1200}
assert ProductVariant.profit(variant) == 1300
end
test "returns nil when cost is nil" do
variant = %ProductVariant{price: 2500, cost: nil}
assert ProductVariant.profit(variant) == nil
end
test "returns nil when price is nil" do
variant = %ProductVariant{price: nil, cost: 1200}
assert ProductVariant.profit(variant) == nil
end
test "handles zero values" do
variant = %ProductVariant{price: 0, cost: 0}
assert ProductVariant.profit(variant) == 0
end
end
describe "on_sale?/1" do
test "returns true when compare_at_price is higher than price" do
variant = %ProductVariant{price: 2000, compare_at_price: 2500}
assert ProductVariant.on_sale?(variant) == true
end
test "returns false when compare_at_price equals price" do
variant = %ProductVariant{price: 2500, compare_at_price: 2500}
assert ProductVariant.on_sale?(variant) == false
end
test "returns false when compare_at_price is lower than price" do
variant = %ProductVariant{price: 2500, compare_at_price: 2000}
assert ProductVariant.on_sale?(variant) == false
end
test "returns false when compare_at_price is nil" do
variant = %ProductVariant{price: 2500, compare_at_price: nil}
assert ProductVariant.on_sale?(variant) == false
end
end
describe "options_title/1" do
test "formats options as slash-separated string" do
variant = %ProductVariant{options: %{"Size" => "Large", "Color" => "Blue"}}
title = ProductVariant.options_title(variant)
# Map iteration order isn't guaranteed, so check both options are present
assert String.contains?(title, "Large")
assert String.contains?(title, "Blue")
assert String.contains?(title, " / ")
end
test "returns nil for empty options" do
variant = %ProductVariant{options: %{}}
assert ProductVariant.options_title(variant) == nil
end
test "returns nil for nil options" do
variant = %ProductVariant{options: nil}
assert ProductVariant.options_title(variant) == nil
end
test "handles single option" do
variant = %ProductVariant{options: %{"Size" => "Medium"}}
assert ProductVariant.options_title(variant) == "Medium"
end
end
describe "unique constraint" do
test "enforces unique product_id + provider_variant_id" do
product = product_fixture()
_variant1 = product_variant_fixture(%{product: product, provider_variant_id: "var_123"})
assert {:error, changeset} =
Berrypod.Products.create_product_variant(
valid_product_variant_attrs(%{
product_id: product.id,
provider_variant_id: "var_123"
})
)
assert "has already been taken" in errors_on(changeset).product_id
end
end
end

View File

@@ -0,0 +1,169 @@
defmodule Berrypod.Products.ProviderConnectionTest do
use Berrypod.DataCase, async: false
alias Berrypod.Products.ProviderConnection
alias Berrypod.Vault
import Berrypod.ProductsFixtures
describe "changeset/2" do
test "valid attributes create a valid changeset" do
attrs = valid_provider_connection_attrs()
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
assert changeset.valid?
end
test "requires provider_type" do
attrs = valid_provider_connection_attrs() |> Map.delete(:provider_type)
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).provider_type
end
test "requires name" do
attrs = valid_provider_connection_attrs() |> Map.delete(:name)
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end
test "validates provider_type is in allowed list" do
attrs = valid_provider_connection_attrs(%{provider_type: "invalid_provider"})
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).provider_type
end
test "accepts all valid provider types" do
for type <- ProviderConnection.provider_types() do
attrs = valid_provider_connection_attrs(%{provider_type: type})
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
assert changeset.valid?, "Expected #{type} to be valid"
end
end
test "encrypts api_key when provided" do
attrs = valid_provider_connection_attrs(%{api_key: "my_secret_key"})
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
assert changeset.valid?
# api_key should be removed from changes
refute Map.has_key?(changeset.changes, :api_key)
# api_key_encrypted should be set
assert encrypted = changeset.changes.api_key_encrypted
assert is_binary(encrypted)
# Should decrypt to original
assert {:ok, "my_secret_key"} = Vault.decrypt(encrypted)
end
test "does not set api_key_encrypted when api_key is nil" do
attrs = valid_provider_connection_attrs() |> Map.delete(:api_key)
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
assert changeset.valid?
refute Map.has_key?(changeset.changes, :api_key_encrypted)
end
test "defaults enabled to true" do
attrs = valid_provider_connection_attrs() |> Map.delete(:enabled)
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
# enabled should use schema default
assert changeset.valid?
end
test "stores config as map" do
config = %{"shop_id" => "123", "extra" => "value"}
attrs = valid_provider_connection_attrs(%{config: config})
changeset = ProviderConnection.changeset(%ProviderConnection{}, attrs)
assert changeset.valid?
assert changeset.changes.config == config
end
end
describe "sync_changeset/2" do
test "updates sync_status" do
conn = provider_connection_fixture()
changeset = ProviderConnection.sync_changeset(conn, %{sync_status: "syncing"})
assert changeset.valid?
assert changeset.changes.sync_status == "syncing"
end
test "validates sync_status is in allowed list" do
conn = provider_connection_fixture()
changeset = ProviderConnection.sync_changeset(conn, %{sync_status: "invalid"})
refute changeset.valid?
assert "is invalid" in errors_on(changeset).sync_status
end
test "accepts all valid sync statuses" do
conn = provider_connection_fixture()
for status <- ProviderConnection.sync_statuses() do
changeset = ProviderConnection.sync_changeset(conn, %{sync_status: status})
assert changeset.valid?, "Expected #{status} to be valid"
end
end
test "updates last_synced_at" do
conn = provider_connection_fixture()
now = DateTime.utc_now() |> DateTime.truncate(:second)
changeset = ProviderConnection.sync_changeset(conn, %{last_synced_at: now})
assert changeset.valid?
assert changeset.changes.last_synced_at == now
end
end
describe "get_api_key/1" do
test "returns decrypted api_key" do
conn = provider_connection_fixture(%{api_key: "secret_key_123"})
assert ProviderConnection.get_api_key(conn) == "secret_key_123"
end
test "returns nil when api_key_encrypted is nil" do
conn = %ProviderConnection{api_key_encrypted: nil}
assert ProviderConnection.get_api_key(conn) == nil
end
test "returns nil on decryption failure" do
conn = %ProviderConnection{api_key_encrypted: "invalid_encrypted_data"}
assert ProviderConnection.get_api_key(conn) == nil
end
end
describe "provider_types/0" do
test "returns list of supported providers" do
types = ProviderConnection.provider_types()
assert is_list(types)
assert "printify" in types
assert "gelato" in types
assert "prodigi" in types
assert "printful" in types
end
end
describe "unique constraint" do
test "enforces unique provider_type" do
_first = provider_connection_fixture(%{provider_type: "printify"})
assert {:error, changeset} =
Berrypod.Products.create_provider_connection(
valid_provider_connection_attrs(%{provider_type: "printify"})
)
assert "has already been taken" in errors_on(changeset).provider_type
end
end
end

View File

@@ -0,0 +1,754 @@
defmodule Berrypod.ProductsTest do
use Berrypod.DataCase, async: false
alias Berrypod.Products
alias Berrypod.Products.{ProviderConnection, Product, ProductImage, ProductVariant}
import Berrypod.ProductsFixtures
# =============================================================================
# Provider Connections
# =============================================================================
describe "list_provider_connections/0" do
test "returns empty list when no connections exist" do
assert Products.list_provider_connections() == []
end
test "returns all provider connections" do
conn1 = provider_connection_fixture(%{provider_type: "printify"})
conn2 = provider_connection_fixture(%{provider_type: "gelato"})
connections = Products.list_provider_connections()
assert length(connections) == 2
assert Enum.any?(connections, &(&1.id == conn1.id))
assert Enum.any?(connections, &(&1.id == conn2.id))
end
end
describe "get_provider_connection/1" do
test "returns the connection with given id" do
conn = provider_connection_fixture()
assert Products.get_provider_connection(conn.id).id == conn.id
end
test "returns nil for non-existent id" do
assert Products.get_provider_connection(Ecto.UUID.generate()) == nil
end
end
describe "get_provider_connection_by_type/1" do
test "returns the connection with given provider_type" do
conn = provider_connection_fixture(%{provider_type: "printify"})
assert Products.get_provider_connection_by_type("printify").id == conn.id
end
test "returns nil for non-existent type" do
assert Products.get_provider_connection_by_type("nonexistent") == nil
end
end
describe "create_provider_connection/1" do
test "creates a provider connection with valid attrs" do
attrs = valid_provider_connection_attrs()
assert {:ok, %ProviderConnection{} = conn} = Products.create_provider_connection(attrs)
assert conn.provider_type == attrs.provider_type
assert conn.name == attrs.name
assert conn.enabled == true
end
test "returns error changeset with invalid attrs" do
assert {:error, %Ecto.Changeset{}} = Products.create_provider_connection(%{})
end
end
describe "update_provider_connection/2" do
test "updates the connection with valid attrs" do
conn = provider_connection_fixture()
assert {:ok, updated} = Products.update_provider_connection(conn, %{name: "Updated Name"})
assert updated.name == "Updated Name"
end
test "returns error changeset with invalid attrs" do
conn = provider_connection_fixture()
assert {:error, %Ecto.Changeset{}} =
Products.update_provider_connection(conn, %{provider_type: "invalid"})
end
end
describe "delete_provider_connection/1" do
test "deletes the connection" do
conn = provider_connection_fixture()
assert {:ok, %ProviderConnection{}} = Products.delete_provider_connection(conn)
assert Products.get_provider_connection(conn.id) == nil
end
end
describe "update_sync_status/3" do
test "updates sync status" do
conn = provider_connection_fixture()
assert {:ok, updated} = Products.update_sync_status(conn, "syncing")
assert updated.sync_status == "syncing"
end
test "updates sync status with timestamp" do
conn = provider_connection_fixture()
now = DateTime.utc_now() |> DateTime.truncate(:second)
assert {:ok, updated} = Products.update_sync_status(conn, "completed", now)
assert updated.sync_status == "completed"
assert updated.last_synced_at == now
end
end
# =============================================================================
# Products
# =============================================================================
describe "list_products/1" do
test "returns empty list when no products exist" do
assert Products.list_products() == []
end
test "returns all products" do
product1 = product_fixture()
product2 = product_fixture()
products = Products.list_products()
assert length(products) == 2
ids = Enum.map(products, & &1.id)
assert product1.id in ids
assert product2.id in ids
end
test "filters by visible" do
_visible = product_fixture(%{visible: true})
_hidden = product_fixture(%{visible: false})
visible_products = Products.list_products(visible: true)
assert length(visible_products) == 1
assert hd(visible_products).visible == true
end
test "filters by status" do
_active = product_fixture(%{status: "active"})
_draft = product_fixture(%{status: "draft"})
active_products = Products.list_products(status: "active")
assert length(active_products) == 1
assert hd(active_products).status == "active"
end
test "filters by category" do
_apparel = product_fixture(%{category: "Apparel"})
_homewares = product_fixture(%{category: "Homewares"})
apparel_products = Products.list_products(category: "Apparel")
assert length(apparel_products) == 1
assert hd(apparel_products).category == "Apparel"
end
test "filters by provider_connection_id" do
conn1 = provider_connection_fixture(%{provider_type: "printify"})
conn2 = provider_connection_fixture(%{provider_type: "gelato"})
product1 = product_fixture(%{provider_connection: conn1})
_product2 = product_fixture(%{provider_connection: conn2})
products = Products.list_products(provider_connection_id: conn1.id)
assert length(products) == 1
assert hd(products).id == product1.id
end
test "preloads associations" do
product = product_fixture()
_image = product_image_fixture(%{product: product})
_variant = product_variant_fixture(%{product: product})
[loaded] = Products.list_products(preload: [:images, :variants])
assert length(loaded.images) == 1
assert length(loaded.variants) == 1
end
end
describe "get_product/2" do
test "returns the product with given id" do
product = product_fixture()
assert Products.get_product(product.id).id == product.id
end
test "returns nil for non-existent id" do
assert Products.get_product(Ecto.UUID.generate()) == nil
end
test "preloads associations when requested" do
product = product_fixture()
_image = product_image_fixture(%{product: product})
loaded = Products.get_product(product.id, preload: [:images])
assert length(loaded.images) == 1
end
end
describe "get_product_by_slug/2" do
test "returns the product with given slug" do
product = product_fixture(%{slug: "my-product"})
assert Products.get_product_by_slug("my-product").id == product.id
end
test "returns nil for non-existent slug" do
assert Products.get_product_by_slug("nonexistent") == nil
end
end
describe "get_product_by_provider/2" do
test "returns the product by provider connection and product id" do
conn = provider_connection_fixture()
product = product_fixture(%{provider_connection: conn, provider_product_id: "ext_123"})
assert Products.get_product_by_provider(conn.id, "ext_123").id == product.id
end
test "returns nil when not found" do
conn = provider_connection_fixture()
assert Products.get_product_by_provider(conn.id, "nonexistent") == nil
end
end
describe "create_product/1" do
test "creates a product with valid attrs" do
conn = provider_connection_fixture()
attrs = valid_product_attrs(%{provider_connection_id: conn.id})
assert {:ok, %Product{} = product} = Products.create_product(attrs)
assert product.title == attrs.title
assert product.provider_product_id == attrs.provider_product_id
end
test "returns error changeset with invalid attrs" do
assert {:error, %Ecto.Changeset{}} = Products.create_product(%{})
end
end
describe "update_product/2" do
test "updates the product with valid attrs" do
product = product_fixture()
assert {:ok, updated} = Products.update_product(product, %{title: "Updated Title"})
assert updated.title == "Updated Title"
end
end
describe "delete_product/1" do
test "deletes the product" do
product = product_fixture()
assert {:ok, %Product{}} = Products.delete_product(product)
assert Products.get_product(product.id) == nil
end
end
describe "upsert_product/2" do
test "creates new product when not exists" do
conn = provider_connection_fixture()
attrs = %{
provider_product_id: "new_ext_123",
title: "New Product",
slug: "new-product",
provider_data: %{"key" => "value"}
}
assert {:ok, product, :created} = Products.upsert_product(conn, attrs)
assert product.title == "New Product"
assert product.provider_connection_id == conn.id
end
test "updates existing product when checksum differs" do
conn = provider_connection_fixture()
existing = product_fixture(%{provider_connection: conn, provider_product_id: "ext_123"})
attrs = %{
provider_product_id: "ext_123",
title: "Updated Title",
slug: existing.slug,
provider_data: %{"different" => "data"}
}
assert {:ok, product, :updated} = Products.upsert_product(conn, attrs)
assert product.id == existing.id
assert product.title == "Updated Title"
end
test "returns unchanged when checksum matches" do
conn = provider_connection_fixture()
provider_data = %{"key" => "value"}
existing =
product_fixture(%{
provider_connection: conn,
provider_product_id: "ext_123",
provider_data: provider_data,
checksum: Product.compute_checksum(provider_data)
})
attrs = %{
provider_product_id: "ext_123",
title: "Different Title",
slug: existing.slug,
provider_data: provider_data
}
assert {:ok, product, :unchanged} = Products.upsert_product(conn, attrs)
assert product.id == existing.id
# Title should NOT be updated since checksum matched
assert product.title == existing.title
end
end
# =============================================================================
# Product Images
# =============================================================================
describe "create_product_image/1" do
test "creates a product image" do
product = product_fixture()
attrs = valid_product_image_attrs(%{product_id: product.id})
assert {:ok, %ProductImage{} = image} = Products.create_product_image(attrs)
assert image.product_id == product.id
end
end
describe "delete_product_images/1" do
test "deletes all images for a product" do
product = product_fixture()
_image1 = product_image_fixture(%{product: product})
_image2 = product_image_fixture(%{product: product})
assert {2, nil} = Products.delete_product_images(product)
loaded = Products.get_product(product.id, preload: [:images])
assert loaded.images == []
end
end
describe "sync_product_images/2" do
test "replaces all images" do
product = product_fixture()
_old_image = product_image_fixture(%{product: product})
new_images = [
%{src: "https://new.com/1.jpg"},
%{src: "https://new.com/2.jpg"}
]
results = Products.sync_product_images(product, new_images)
assert length(results) == 2
assert Enum.all?(results, &match?({:ok, _}, &1))
loaded = Products.get_product(product.id, preload: [:images])
assert length(loaded.images) == 2
end
test "assigns positions based on list order" do
product = product_fixture()
images = [
%{src: "https://new.com/first.jpg"},
%{src: "https://new.com/second.jpg"}
]
Products.sync_product_images(product, images)
loaded = Products.get_product(product.id, preload: [:images])
sorted = Enum.sort_by(loaded.images, & &1.position)
assert Enum.at(sorted, 0).position == 0
assert Enum.at(sorted, 1).position == 1
end
end
# =============================================================================
# Product Variants
# =============================================================================
describe "create_product_variant/1" do
test "creates a product variant" do
product = product_fixture()
attrs = valid_product_variant_attrs(%{product_id: product.id})
assert {:ok, %ProductVariant{} = variant} = Products.create_product_variant(attrs)
assert variant.product_id == product.id
end
end
describe "update_product_variant/2" do
test "updates the variant" do
variant = product_variant_fixture()
assert {:ok, updated} = Products.update_product_variant(variant, %{price: 3000})
assert updated.price == 3000
end
end
describe "delete_product_variants/1" do
test "deletes all variants for a product" do
product = product_fixture()
_variant1 = product_variant_fixture(%{product: product})
_variant2 = product_variant_fixture(%{product: product})
assert {2, nil} = Products.delete_product_variants(product)
loaded = Products.get_product(product.id, preload: [:variants])
assert loaded.variants == []
end
end
describe "get_variant_by_provider/2" do
test "returns variant by product and provider variant id" do
product = product_fixture()
variant = product_variant_fixture(%{product: product, provider_variant_id: "var_123"})
assert Products.get_variant_by_provider(product.id, "var_123").id == variant.id
end
test "returns nil when not found" do
product = product_fixture()
assert Products.get_variant_by_provider(product.id, "nonexistent") == nil
end
end
# =============================================================================
# Storefront queries
# =============================================================================
describe "recompute_cached_fields/1" do
test "computes cheapest price from available variants" do
product = product_fixture()
product_variant_fixture(%{
product: product,
price: 3000,
is_enabled: true,
is_available: true
})
product_variant_fixture(%{
product: product,
price: 2000,
is_enabled: true,
is_available: true
})
product_variant_fixture(%{
product: product,
price: 1500,
is_enabled: false,
is_available: true
})
assert {:ok, updated} = Products.recompute_cached_fields(product)
assert updated.cheapest_price == 2000
end
test "sets cheapest_price to 0 when no available variants" do
product = product_fixture()
product_variant_fixture(%{
product: product,
price: 2000,
is_enabled: true,
is_available: false
})
assert {:ok, updated} = Products.recompute_cached_fields(product)
assert updated.cheapest_price == 0
end
test "sets in_stock based on available variants" do
product = product_fixture()
product_variant_fixture(%{product: product, is_enabled: true, is_available: true})
assert {:ok, updated} = Products.recompute_cached_fields(product)
assert updated.in_stock == true
end
test "sets in_stock false when no available variants" do
product = product_fixture()
product_variant_fixture(%{product: product, is_enabled: true, is_available: false})
assert {:ok, updated} = Products.recompute_cached_fields(product)
assert updated.in_stock == false
end
test "sets on_sale when any variant has compare_at_price > price" do
product = product_fixture()
product_variant_fixture(%{product: product, price: 2000, compare_at_price: 3000})
assert {:ok, updated} = Products.recompute_cached_fields(product)
assert updated.on_sale == true
end
test "sets on_sale false when no sale variants" do
product = product_fixture()
product_variant_fixture(%{product: product, price: 2000, compare_at_price: nil})
assert {:ok, updated} = Products.recompute_cached_fields(product)
assert updated.on_sale == false
end
test "stores compare_at_price from cheapest available variant" do
product = product_fixture()
product_variant_fixture(%{
product: product,
price: 2000,
compare_at_price: 3000,
is_enabled: true,
is_available: true
})
product_variant_fixture(%{
product: product,
price: 1500,
compare_at_price: 2500,
is_enabled: true,
is_available: true
})
assert {:ok, updated} = Products.recompute_cached_fields(product)
assert updated.cheapest_price == 1500
assert updated.compare_at_price == 2500
end
end
describe "get_visible_product/1" do
test "returns visible active product by slug" do
product = product_fixture(%{slug: "test-product", visible: true, status: "active"})
found = Products.get_visible_product("test-product")
assert found.id == product.id
end
test "returns nil for hidden product" do
_product = product_fixture(%{slug: "hidden", visible: false, status: "active"})
assert Products.get_visible_product("hidden") == nil
end
test "returns nil for draft product" do
_product = product_fixture(%{slug: "draft", visible: true, status: "draft"})
assert Products.get_visible_product("draft") == nil
end
test "preloads images and variants" do
product = product_fixture(%{slug: "preloaded"})
product_image_fixture(%{product: product})
product_variant_fixture(%{product: product})
found = Products.get_visible_product("preloaded")
assert length(found.images) == 1
assert length(found.variants) == 1
end
end
describe "list_visible_products/1" do
test "returns only visible active products" do
_visible = product_fixture(%{visible: true, status: "active"})
_hidden = product_fixture(%{visible: false, status: "active"})
_draft = product_fixture(%{visible: true, status: "draft"})
products = Products.list_visible_products()
assert length(products) == 1
end
test "filters by category" do
_apparel = product_fixture(%{category: "Apparel"})
_home = product_fixture(%{category: "Homewares"})
products = Products.list_visible_products(category: "Apparel")
assert length(products) == 1
assert hd(products).category == "Apparel"
end
test "filters by on_sale" do
sale = product_fixture()
_regular = product_fixture()
product_variant_fixture(%{product: sale, price: 2000, compare_at_price: 3000})
Products.recompute_cached_fields(sale)
products = Products.list_visible_products(on_sale: true)
assert length(products) == 1
assert hd(products).id == sale.id
end
test "filters by in_stock" do
in_stock = product_fixture()
out_of_stock = product_fixture()
product_variant_fixture(%{product: in_stock, is_enabled: true, is_available: true})
product_variant_fixture(%{product: out_of_stock, is_enabled: true, is_available: false})
Products.recompute_cached_fields(in_stock)
Products.recompute_cached_fields(out_of_stock)
products = Products.list_visible_products(in_stock: true)
assert length(products) == 1
assert hd(products).id == in_stock.id
end
test "sorts by price ascending" do
cheap = product_fixture()
expensive = product_fixture()
product_variant_fixture(%{
product: cheap,
price: 1000,
is_enabled: true,
is_available: true
})
product_variant_fixture(%{
product: expensive,
price: 5000,
is_enabled: true,
is_available: true
})
Products.recompute_cached_fields(cheap)
Products.recompute_cached_fields(expensive)
products = Products.list_visible_products(sort: "price_asc")
assert Enum.map(products, & &1.id) == [cheap.id, expensive.id]
end
test "sorts by price descending" do
cheap = product_fixture()
expensive = product_fixture()
product_variant_fixture(%{
product: cheap,
price: 1000,
is_enabled: true,
is_available: true
})
product_variant_fixture(%{
product: expensive,
price: 5000,
is_enabled: true,
is_available: true
})
Products.recompute_cached_fields(cheap)
Products.recompute_cached_fields(expensive)
products = Products.list_visible_products(sort: "price_desc")
assert Enum.map(products, & &1.id) == [expensive.id, cheap.id]
end
test "sorts by name ascending" do
b = product_fixture(%{title: "Banana"})
a = product_fixture(%{title: "Apple"})
products = Products.list_visible_products(sort: "name_asc")
assert Enum.map(products, & &1.id) == [a.id, b.id]
end
test "limits results" do
for _ <- 1..5, do: product_fixture()
products = Products.list_visible_products(limit: 3)
assert length(products) == 3
end
test "excludes a product by ID" do
p1 = product_fixture()
p2 = product_fixture()
products = Products.list_visible_products(exclude: p1.id)
assert length(products) == 1
assert hd(products).id == p2.id
end
test "preloads images but not variants" do
product = product_fixture()
product_image_fixture(%{product: product})
product_variant_fixture(%{product: product})
[loaded] = Products.list_visible_products()
assert length(loaded.images) == 1
assert %Ecto.Association.NotLoaded{} = loaded.variants
end
end
describe "list_categories/0" do
test "returns distinct categories from visible products" do
product_fixture(%{category: "Apparel"})
product_fixture(%{category: "Homewares"})
product_fixture(%{category: "Apparel"})
product_fixture(%{category: nil})
categories = Products.list_categories()
assert length(categories) == 2
assert Enum.map(categories, & &1.name) == ["Apparel", "Homewares"]
assert Enum.map(categories, & &1.slug) == ["apparel", "homewares"]
end
test "excludes categories from hidden products" do
product_fixture(%{category: "Visible", visible: true})
product_fixture(%{category: "Hidden", visible: false})
categories = Products.list_categories()
assert length(categories) == 1
assert hd(categories).name == "Visible"
end
end
describe "sync_product_variants/2" do
test "creates new variants" do
product = product_fixture()
variants = [
%{provider_variant_id: "v1", title: "Small", price: 2000},
%{provider_variant_id: "v2", title: "Large", price: 2500}
]
results = Products.sync_product_variants(product, variants)
assert length(results) == 2
assert Enum.all?(results, &match?({:ok, _}, &1))
loaded = Products.get_product(product.id, preload: [:variants])
assert length(loaded.variants) == 2
end
test "updates existing variants" do
product = product_fixture()
existing =
product_variant_fixture(%{product: product, provider_variant_id: "v1", price: 2000})
variants = [
%{provider_variant_id: "v1", title: "Small Updated", price: 2200}
]
Products.sync_product_variants(product, variants)
updated = Repo.get!(ProductVariant, existing.id)
assert updated.title == "Small Updated"
assert updated.price == 2200
end
test "removes variants not in incoming list" do
product = product_fixture()
_keep = product_variant_fixture(%{product: product, provider_variant_id: "keep"})
_remove = product_variant_fixture(%{product: product, provider_variant_id: "remove"})
variants = [
%{provider_variant_id: "keep", title: "Keep", price: 2000}
]
Products.sync_product_variants(product, variants)
loaded = Products.get_product(product.id, preload: [:variants])
assert length(loaded.variants) == 1
assert hd(loaded.variants).provider_variant_id == "keep"
end
end
end

View File

@@ -0,0 +1,152 @@
defmodule Berrypod.ProductsUpsertTest do
use Berrypod.DataCase, async: false
alias Berrypod.Products
import Berrypod.ProductsFixtures
describe "upsert_product/2" do
test "creates a new product when it doesn't exist" do
conn = provider_connection_fixture()
attrs = %{
provider_product_id: "new-product-123",
title: "New Product",
description: "A new product",
provider_data: %{"blueprint_id" => 145}
}
assert {:ok, product, :created} = Products.upsert_product(conn, attrs)
assert product.title == "New Product"
assert product.provider_product_id == "new-product-123"
end
test "returns unchanged when checksum matches" do
conn = provider_connection_fixture()
provider_data = %{"blueprint_id" => 145, "data" => "same"}
{:ok, original, :created} =
Products.upsert_product(conn, %{
provider_product_id: "product-123",
title: "Product",
provider_data: provider_data
})
# Same provider_data = same checksum
{:ok, product, :unchanged} =
Products.upsert_product(conn, %{
provider_product_id: "product-123",
title: "Product Updated",
provider_data: provider_data
})
assert product.id == original.id
# Title should NOT be updated since checksum matched
assert product.title == "Product"
end
test "updates product when checksum differs" do
conn = provider_connection_fixture()
{:ok, original, :created} =
Products.upsert_product(conn, %{
provider_product_id: "product-123",
title: "Product",
provider_data: %{"version" => 1}
})
# Different provider_data = different checksum
{:ok, product, :updated} =
Products.upsert_product(conn, %{
provider_product_id: "product-123",
title: "Product Updated",
provider_data: %{"version" => 2}
})
assert product.id == original.id
assert product.title == "Product Updated"
end
test "handles race condition when two processes try to insert same product" do
conn = provider_connection_fixture()
provider_product_id = "race-condition-test-#{System.unique_integer()}"
attrs = %{
provider_product_id: provider_product_id,
title: "Race Condition Product",
provider_data: %{"test" => true}
}
# Simulate race condition by running concurrent inserts
tasks =
for _ <- 1..5 do
Task.async(fn ->
Products.upsert_product(conn, attrs)
end)
end
results = Task.await_many(tasks, 5000)
# All should succeed (no crashes)
assert Enum.all?(results, fn
{:ok, _product, status} when status in [:created, :unchanged] -> true
_ -> false
end)
# Only one product should exist
assert Products.count_products_for_connection(conn.id) >= 1
# Verify we can fetch the product
product = Products.get_product_by_provider(conn.id, provider_product_id)
assert product.title == "Race Condition Product"
end
test "matches by slug when provider_product_id changes" do
conn = provider_connection_fixture()
# Create first product
{:ok, product1, :created} =
Products.upsert_product(conn, %{
provider_product_id: "old-product-id",
title: "Same Title Product",
provider_data: %{"id" => 1}
})
assert product1.provider_product_id == "old-product-id"
# Same title but different provider_product_id - matches by slug and updates
{:ok, product2, :updated} =
Products.upsert_product(conn, %{
provider_product_id: "new-product-id",
title: "Same Title Product",
provider_data: %{"id" => 2}
})
# Should be the same product, with updated provider_product_id
assert product2.id == product1.id
assert product2.provider_product_id == "new-product-id"
assert product2.slug == "same-title-product"
end
test "different titles create different slugs successfully" do
conn = provider_connection_fixture()
{:ok, product1, :created} =
Products.upsert_product(conn, %{
provider_product_id: "product-1",
title: "First Product",
provider_data: %{"id" => 1}
})
{:ok, product2, :created} =
Products.upsert_product(conn, %{
provider_product_id: "product-2",
title: "Second Product",
provider_data: %{"id" => 2}
})
assert product1.slug == "first-product"
assert product2.slug == "second-product"
end
end
end

View File

@@ -0,0 +1,487 @@
defmodule Berrypod.Providers.PrintfulTest do
use Berrypod.DataCase, async: true
alias Berrypod.Providers.Printful
describe "provider_type/0" do
test "returns printful" do
assert Printful.provider_type() == "printful"
end
end
describe "test_connection/1" do
test "returns error when no API key" do
conn = %Berrypod.Products.ProviderConnection{
provider_type: "printful",
api_key_encrypted: nil
}
assert {:error, :no_api_key} = Printful.test_connection(conn)
end
end
describe "fetch_products/1" do
test "returns error when no store_id in config" do
conn = %Berrypod.Products.ProviderConnection{
provider_type: "printful",
api_key_encrypted: nil,
config: %{}
}
assert {:error, :no_store_id} = Printful.fetch_products(conn)
end
test "returns error when no API key" do
conn = %Berrypod.Products.ProviderConnection{
provider_type: "printful",
api_key_encrypted: nil,
config: %{"store_id" => "12345"}
}
assert {:error, :no_api_key} = Printful.fetch_products(conn)
end
end
describe "submit_order/2" do
test "returns error when no store_id in config" do
conn = %Berrypod.Products.ProviderConnection{
provider_type: "printful",
api_key_encrypted: nil,
config: %{}
}
assert {:error, :no_store_id} = Printful.submit_order(conn, %{})
end
test "returns error when no API key" do
conn = %Berrypod.Products.ProviderConnection{
provider_type: "printful",
api_key_encrypted: nil,
config: %{"store_id" => "12345"}
}
assert {:error, :no_api_key} = Printful.submit_order(conn, %{})
end
end
describe "get_order_status/2" do
test "returns error when no store_id in config" do
conn = %Berrypod.Products.ProviderConnection{
provider_type: "printful",
api_key_encrypted: nil,
config: %{}
}
assert {:error, :no_store_id} = Printful.get_order_status(conn, "12345")
end
test "returns error when no API key" do
conn = %Berrypod.Products.ProviderConnection{
provider_type: "printful",
api_key_encrypted: nil,
config: %{"store_id" => "12345"}
}
assert {:error, :no_api_key} = Printful.get_order_status(conn, "12345")
end
end
describe "fetch_shipping_rates/2" do
test "returns error when no API key" do
conn = %Berrypod.Products.ProviderConnection{
provider_type: "printful",
api_key_encrypted: nil,
config: %{"store_id" => "12345"}
}
assert {:error, :no_api_key} = Printful.fetch_shipping_rates(conn, [])
end
end
describe "extract_option_types/1" do
test "extracts color and size options" do
provider_data = %{
"options" => [
%{
"name" => "Color",
"type" => "color",
"values" => [
%{"title" => "Black"},
%{"title" => "Natural", "hex" => "#F5F5DC"}
]
},
%{
"name" => "Size",
"type" => "size",
"values" => [
%{"title" => "S"},
%{"title" => "M"},
%{"title" => "L"}
]
}
]
}
result = Printful.extract_option_types(provider_data)
assert length(result) == 2
[color_opt, size_opt] = result
assert color_opt.name == "Color"
assert color_opt.type == :color
assert length(color_opt.values) == 2
assert hd(color_opt.values).title == "Black"
assert Enum.at(color_opt.values, 1).hex == "#F5F5DC"
assert size_opt.name == "Size"
assert size_opt.type == :size
assert length(size_opt.values) == 3
end
test "returns empty list for nil provider_data" do
assert Printful.extract_option_types(nil) == []
end
test "returns empty list for provider_data without options" do
assert Printful.extract_option_types(%{}) == []
end
end
describe "product normalization" do
test "normalizes sync product response correctly" do
{sync_product, sync_variants} = printful_sync_product_response()
normalized = normalize_product(sync_product, sync_variants)
assert normalized.provider_product_id == "456789"
assert normalized.title == "PC Man T-Shirt"
assert normalized.description == ""
assert normalized.category == "Apparel"
# Images — one per unique colour from preview files
assert length(normalized.images) == 2
[img1, img2] = normalized.images
assert img1.position == 0
assert img2.position == 1
assert img1.alt == "Black"
assert img2.alt == "Natural"
# Variants
assert length(normalized.variants) == 4
[v1 | _] = normalized.variants
assert v1.provider_variant_id == "5001"
assert v1.title == "Black / S"
assert v1.price == 1350
assert v1.sku == "PCM-BK-S"
assert v1.is_enabled == true
assert v1.is_available == true
assert v1.options == %{"Color" => "Black", "Size" => "S"}
# Provider data
assert normalized.provider_data.catalog_product_id == 71
assert normalized.provider_data.blueprint_id == 71
assert normalized.provider_data.print_provider_id == 0
assert is_list(normalized.provider_data.options)
assert length(normalized.provider_data.catalog_variant_ids) == 4
end
test "handles variant with missing colour or size" do
sync_product = %{"id" => 1, "name" => "Test", "thumbnail_url" => nil}
sync_variants = [
%{
"id" => 100,
"color" => nil,
"size" => "M",
"retail_price" => "10.00",
"sku" => "T-M",
"synced" => true,
"availability_status" => "active",
"files" => [],
"product" => %{"product_id" => 1, "name" => "Test"},
"variant_id" => 200
}
]
normalized = normalize_product(sync_product, sync_variants)
assert length(normalized.variants) == 1
[v] = normalized.variants
assert v.title == "M"
assert v.options == %{"Size" => "M"}
end
test "parses price strings correctly" do
assert parse_price("13.50") == 1350
assert parse_price("0.99") == 99
assert parse_price("100.00") == 10000
assert parse_price(nil) == 0
assert parse_price(13.5) == 1350
end
end
describe "order status mapping" do
test "maps Printful statuses to internal statuses" do
assert map_order_status("draft") == "submitted"
assert map_order_status("pending") == "submitted"
assert map_order_status("inprocess") == "processing"
assert map_order_status("fulfilled") == "shipped"
assert map_order_status("shipped") == "shipped"
assert map_order_status("delivered") == "delivered"
assert map_order_status("canceled") == "cancelled"
assert map_order_status("failed") == "submitted"
assert map_order_status("onhold") == "submitted"
assert map_order_status("unknown_status") == "submitted"
end
end
# =============================================================================
# Test fixtures — replicate Printful API responses
# =============================================================================
defp printful_sync_product_response do
sync_product = %{
"id" => 456_789,
"name" => "PC Man T-Shirt",
"thumbnail_url" => "https://files.cdn.printful.com/thumb.png",
"synced" => 4
}
sync_variants = [
%{
"id" => 5001,
"color" => "Black",
"size" => "S",
"retail_price" => "13.50",
"sku" => "PCM-BK-S",
"synced" => true,
"availability_status" => "active",
"variant_id" => 4011,
"product" => %{
"product_id" => 71,
"variant_id" => 4011,
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
},
"files" => [
%{
"type" => "preview",
"preview_url" => "https://files.cdn.printful.com/preview-black.png",
"thumbnail_url" => "https://files.cdn.printful.com/thumb-black.png"
}
]
},
%{
"id" => 5002,
"color" => "Black",
"size" => "M",
"retail_price" => "13.50",
"sku" => "PCM-BK-M",
"synced" => true,
"availability_status" => "active",
"variant_id" => 4012,
"product" => %{
"product_id" => 71,
"variant_id" => 4012,
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
},
"files" => [
%{
"type" => "preview",
"preview_url" => "https://files.cdn.printful.com/preview-black.png",
"thumbnail_url" => "https://files.cdn.printful.com/thumb-black.png"
}
]
},
%{
"id" => 5003,
"color" => "Natural",
"size" => "S",
"retail_price" => "13.50",
"sku" => "PCM-NT-S",
"synced" => true,
"availability_status" => "active",
"variant_id" => 4013,
"product" => %{
"product_id" => 71,
"variant_id" => 4013,
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
},
"files" => [
%{
"type" => "preview",
"preview_url" => "https://files.cdn.printful.com/preview-natural.png",
"thumbnail_url" => "https://files.cdn.printful.com/thumb-natural.png"
}
]
},
%{
"id" => 5004,
"color" => "Natural",
"size" => "M",
"retail_price" => "13.50",
"sku" => "PCM-NT-M",
"synced" => true,
"availability_status" => "active",
"variant_id" => 4014,
"product" => %{
"product_id" => 71,
"variant_id" => 4014,
"name" => "Bella+Canvas 3001 Unisex Short Sleeve Jersey T-Shirt"
},
"files" => [
%{
"type" => "preview",
"preview_url" => "https://files.cdn.printful.com/preview-natural.png",
"thumbnail_url" => "https://files.cdn.printful.com/thumb-natural.png"
}
]
}
]
{sync_product, sync_variants}
end
# =============================================================================
# Test helpers — replicate private normalization functions
# =============================================================================
defp normalize_product(sync_product, sync_variants) do
images = extract_preview_images(sync_variants)
catalog_product_id = extract_catalog_product_id(sync_variants)
catalog_variant_ids = Enum.map(sync_variants, & &1["variant_id"]) |> Enum.reject(&is_nil/1)
%{
provider_product_id: to_string(sync_product["id"]),
title: sync_product["name"],
description: "",
category: extract_category(sync_variants),
images: images,
variants: Enum.map(sync_variants, &normalize_variant/1),
provider_data: %{
catalog_product_id: catalog_product_id,
catalog_variant_ids: catalog_variant_ids,
blueprint_id: catalog_product_id,
print_provider_id: 0,
thumbnail_url: sync_product["thumbnail_url"],
options: build_option_types(sync_variants),
raw: %{sync_product: sync_product}
}
}
end
defp normalize_variant(sv) do
%{
provider_variant_id: to_string(sv["id"]),
title: build_variant_title(sv),
sku: sv["sku"],
price: parse_price(sv["retail_price"]),
cost: nil,
options: build_variant_options(sv),
is_enabled: sv["synced"] == true,
is_available: sv["availability_status"] == "active"
}
end
defp build_variant_title(sv) do
[sv["color"], sv["size"]] |> Enum.reject(&is_nil/1) |> Enum.join(" / ")
end
defp build_variant_options(sv) do
opts = %{}
opts = if sv["color"], do: Map.put(opts, "Color", sv["color"]), else: opts
if sv["size"], do: Map.put(opts, "Size", sv["size"]), else: opts
end
defp extract_preview_images(sync_variants) do
sync_variants
|> Enum.flat_map(fn sv ->
(sv["files"] || [])
|> Enum.filter(&(&1["type"] == "preview"))
|> Enum.map(fn file ->
%{src: file["preview_url"] || file["thumbnail_url"], color: sv["color"]}
end)
end)
|> Enum.uniq_by(& &1.color)
|> Enum.with_index()
|> Enum.map(fn {img, index} ->
%{src: img.src, position: index, alt: img.color}
end)
end
defp extract_catalog_product_id(sync_variants) do
Enum.find_value(sync_variants, 0, fn sv -> get_in(sv, ["product", "product_id"]) end)
end
defp build_option_types(sync_variants) do
colors =
sync_variants
|> Enum.map(& &1["color"])
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> Enum.map(fn c -> %{"title" => c} end)
sizes =
sync_variants
|> Enum.map(& &1["size"])
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> Enum.map(fn s -> %{"title" => s} end)
opts = []
opts =
if colors != [],
do: opts ++ [%{"name" => "Color", "type" => "color", "values" => colors}],
else: opts
if sizes != [],
do: opts ++ [%{"name" => "Size", "type" => "size", "values" => sizes}],
else: opts
end
defp extract_category(sync_variants) do
case sync_variants do
[sv | _] ->
product_name = get_in(sv, ["product", "name"]) || ""
categorize_from_name(product_name)
[] ->
nil
end
end
defp categorize_from_name(name) do
name_lower = String.downcase(name)
cond do
has_keyword?(name_lower, ~w[t-shirt tshirt shirt hoodie sweatshirt jogger]) -> "Apparel"
has_keyword?(name_lower, ~w[canvas poster print frame]) -> "Canvas Prints"
has_keyword?(name_lower, ~w[mug cup blanket pillow cushion]) -> "Homewares"
has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery"
has_keyword?(name_lower, ~w[phone case bag tote hat cap]) -> "Accessories"
true -> "Apparel"
end
end
defp has_keyword?(text, keywords), do: Enum.any?(keywords, &String.contains?(text, &1))
defp parse_price(price) when is_binary(price) do
case Float.parse(price) do
{float, _} -> round(float * 100)
:error -> 0
end
end
defp parse_price(price) when is_number(price), do: round(price * 100)
defp parse_price(_), do: 0
defp map_order_status("draft"), do: "submitted"
defp map_order_status("pending"), do: "submitted"
defp map_order_status("inprocess"), do: "processing"
defp map_order_status("fulfilled"), do: "shipped"
defp map_order_status("shipped"), do: "shipped"
defp map_order_status("delivered"), do: "delivered"
defp map_order_status("canceled"), do: "cancelled"
defp map_order_status("failed"), do: "submitted"
defp map_order_status("onhold"), do: "submitted"
defp map_order_status(_), do: "submitted"
end

View File

@@ -0,0 +1,151 @@
defmodule Berrypod.Providers.PrintifyTest do
use Berrypod.DataCase, async: true
alias Berrypod.Providers.Printify
import Berrypod.ProductsFixtures
describe "provider_type/0" do
test "returns printify" do
assert Printify.provider_type() == "printify"
end
end
describe "test_connection/1" do
test "returns error when no API key" do
conn = %Berrypod.Products.ProviderConnection{
provider_type: "printify",
api_key_encrypted: nil
}
assert {:error, :no_api_key} = Printify.test_connection(conn)
end
end
describe "fetch_products/1" do
test "returns error when no shop_id in config" do
conn = %Berrypod.Products.ProviderConnection{
provider_type: "printify",
api_key_encrypted: nil,
config: %{}
}
assert {:error, :no_shop_id} = Printify.fetch_products(conn)
end
test "returns error when no API key" do
conn = %Berrypod.Products.ProviderConnection{
provider_type: "printify",
api_key_encrypted: nil,
config: %{"shop_id" => "12345"}
}
assert {:error, :no_api_key} = Printify.fetch_products(conn)
end
end
describe "product normalization" do
test "normalizes Printify product response correctly" do
# Use the fixture response
raw = printify_product_response()
# Call the private normalize function via the module
# We test this indirectly through the public API, but we can also
# verify the expected structure
normalized = normalize_product(raw)
assert normalized[:provider_product_id] == "12345"
assert normalized[:title] == "Classic T-Shirt"
assert normalized[:description] == "A comfortable cotton t-shirt"
assert normalized[:category] == "Apparel"
# Check images
assert length(normalized[:images]) == 2
[img1, img2] = normalized[:images]
assert img1[:src] == "https://printify.com/img1.jpg"
assert img1[:position] == 0
assert img2[:position] == 1
# Check variants
assert length(normalized[:variants]) == 2
[var1, _var2] = normalized[:variants]
assert var1[:provider_variant_id] == "100"
assert var1[:title] == "Solid White / S"
assert var1[:price] == 2500
assert var1[:is_enabled] == true
end
end
# Helper to call private function for testing
# In production, we'd test this through the public API
defp normalize_product(raw) do
# Replicate the normalization logic for testing
%{
provider_product_id: to_string(raw["id"]),
title: raw["title"],
description: raw["description"],
category: extract_category(raw),
images: normalize_images(raw["images"] || []),
variants: normalize_variants(raw["variants"] || []),
provider_data: %{
blueprint_id: raw["blueprint_id"],
print_provider_id: raw["print_provider_id"],
tags: raw["tags"] || [],
options: raw["options"] || [],
raw: raw
}
}
end
defp normalize_images(images) do
images
|> Enum.with_index()
|> Enum.map(fn {img, index} ->
%{
src: img["src"],
position: img["position"] || index,
alt: nil
}
end)
end
defp normalize_variants(variants) do
Enum.map(variants, fn var ->
%{
provider_variant_id: to_string(var["id"]),
title: var["title"],
sku: var["sku"],
price: var["price"],
cost: var["cost"],
options: normalize_variant_options(var),
is_enabled: var["is_enabled"] == true,
is_available: var["is_available"] == true
}
end)
end
defp normalize_variant_options(variant) do
title = variant["title"] || ""
parts = String.split(title, " / ")
option_names = ["Size", "Color", "Style"]
parts
|> Enum.with_index()
|> Enum.reduce(%{}, fn {value, index}, acc ->
key = Enum.at(option_names, index) || "Option #{index + 1}"
Map.put(acc, key, value)
end)
end
defp extract_category(raw) do
tags = raw["tags"] || []
cond do
"apparel" in tags or "clothing" in tags -> "Apparel"
"homeware" in tags or "home" in tags -> "Homewares"
"accessories" in tags -> "Accessories"
"art" in tags or "print" in tags -> "Art Prints"
true -> nil
end
end
end

View File

@@ -0,0 +1,247 @@
defmodule Berrypod.SearchTest do
use Berrypod.DataCase, async: false
alias Berrypod.Search
import Berrypod.ProductsFixtures
setup do
conn = provider_connection_fixture()
mountain =
product_fixture(%{
provider_connection: conn,
title: "Mountain Sunrise Art Print",
description: "<p>A beautiful <strong>mountain</strong> landscape at dawn.</p>",
category: "Art Prints"
})
product_variant_fixture(%{
product: mountain,
title: "Small / Navy",
options: %{"Size" => "Small", "Color" => "Navy"},
price: 1999
})
ocean =
product_fixture(%{
provider_connection: conn,
title: "Ocean Waves Notebook",
description: "A spiral-bound notebook with ocean wave cover art.",
category: "Stationery"
})
product_variant_fixture(%{
product: ocean,
title: "A5",
options: %{"Size" => "A5"},
price: 1299
})
forest =
product_fixture(%{
provider_connection: conn,
title: "Forest Silhouette T-Shirt",
description: "Cotton t-shirt with forest silhouette print.",
category: "Apparel"
})
product_variant_fixture(%{
product: forest,
title: "Large / Black",
options: %{"Size" => "Large", "Color" => "Black"},
price: 2999
})
product_variant_fixture(%{
product: forest,
title: "Medium / Navy Blue",
options: %{"Size" => "Medium", "Color" => "Navy Blue"},
price: 2999
})
# Hidden product — should not appear in search
_hidden =
product_fixture(%{
provider_connection: conn,
title: "Hidden Mountain Poster",
description: "This should not be searchable.",
category: "Art Prints",
visible: false
})
Search.rebuild_index()
%{mountain: mountain, ocean: ocean, forest: forest}
end
describe "search/1" do
test "finds products by title", %{mountain: mountain} do
results = Search.search("mountain")
assert length(results) >= 1
assert Enum.any?(results, &(&1.id == mountain.id))
end
test "finds products by category", %{mountain: mountain, ocean: ocean} do
results = Search.search("art prints")
assert Enum.any?(results, &(&1.id == mountain.id))
refute Enum.any?(results, &(&1.id == ocean.id))
end
test "finds products by variant attributes", %{forest: forest} do
results = Search.search("navy")
assert Enum.any?(results, &(&1.id == forest.id))
end
test "finds products by description", %{mountain: mountain} do
results = Search.search("landscape")
assert Enum.any?(results, &(&1.id == mountain.id))
end
test "prefix matching on last token", %{mountain: mountain} do
results = Search.search("mou")
assert Enum.any?(results, &(&1.id == mountain.id))
end
test "multi-word query matches all tokens", %{mountain: mountain} do
results = Search.search("mountain sunrise")
assert Enum.any?(results, &(&1.id == mountain.id))
end
test "returns empty list for blank query" do
assert Search.search("") == []
assert Search.search(" ") == []
end
test "returns empty list for single character" do
assert Search.search("a") == []
end
test "returns empty list for nil" do
assert Search.search(nil) == []
end
test "returns empty list for no matches" do
assert Search.search("xyznonexistent") == []
end
test "special characters don't crash" do
assert Search.search("mountain's") == Search.search("mountains")
assert Search.search("test & foo") == []
assert Search.search("(brackets)") == []
end
test "hidden products are excluded" do
results = Search.search("hidden")
assert results == []
end
test "strips HTML from descriptions", %{mountain: mountain} do
results = Search.search("landscape dawn")
assert Enum.any?(results, &(&1.id == mountain.id))
end
test "results include listing preloads", %{mountain: mountain} do
[result | _] = Search.search("mountain sunrise")
assert result.id == mountain.id
assert Ecto.assoc_loaded?(result.images)
end
test "title matches rank higher than description matches" do
results = Search.search("mountain")
# Mountain Sunrise Art Print (title match) should rank above
# Forest T-Shirt if it happened to mention "mountain" in description
first = List.first(results)
assert first.title =~ "Mountain"
end
end
describe "rebuild_index/0" do
test "rebuilds from scratch" do
# Verify search works before rebuild
assert length(Search.search("mountain")) >= 1
# Rebuild and verify still works
Search.rebuild_index()
assert length(Search.search("mountain")) >= 1
end
end
describe "index_product/1" do
test "indexes a single product", %{ocean: ocean} do
# Clear FTS index
Berrypod.Repo.query!("DELETE FROM products_search_map")
Berrypod.Repo.query!("DELETE FROM products_search")
# Verify FTS index is empty
%{rows: rows} = Berrypod.Repo.query!("SELECT COUNT(*) FROM products_search_map")
assert rows == [[0]]
# Index just ocean
ocean = Berrypod.Repo.preload(ocean, [:variants])
Search.index_product(ocean)
# Verify ocean is now in the FTS index
%{rows: [[count]]} = Berrypod.Repo.query!("SELECT COUNT(*) FROM products_search_map")
assert count == 1
results = Search.search("ocean")
assert length(results) >= 1
assert hd(results).id == ocean.id
end
test "reindexing updates existing entry", %{mountain: mountain} do
# Change title and reindex
mountain =
mountain
|> Ecto.Changeset.change(title: "Alpine Sunrise Art Print")
|> Berrypod.Repo.update!()
Search.index_product(mountain)
assert Search.search("alpine") != []
end
end
describe "LIKE fallback" do
test "finds substring matches that FTS5 prefix misses", %{ocean: ocean} do
# "ebook" is in the middle of "Notebook" — FTS5 prefix won't match
results = Search.search("ebook")
assert Enum.any?(results, &(&1.id == ocean.id))
end
test "falls back to category substring match", %{forest: forest} do
# "ppar" is a substring of "Apparel" — FTS5 prefix won't match
results = Search.search("pparel")
assert Enum.any?(results, &(&1.id == forest.id))
end
end
describe "remove_product/1" do
test "removes a product from the index", %{ocean: ocean} do
# Verify ocean is in the FTS index
%{rows: [[rowid]]} =
Berrypod.Repo.query!(
"SELECT rowid FROM products_search_map WHERE product_id = ?1",
[ocean.id]
)
assert rowid
Search.remove_product(ocean.id)
# Verify it's gone from the FTS index
%{rows: rows} =
Berrypod.Repo.query!(
"SELECT rowid FROM products_search_map WHERE product_id = ?1",
[ocean.id]
)
assert rows == []
end
test "is a no-op for unindexed product" do
assert Search.remove_product(-1) == :ok
end
end
end

View File

@@ -0,0 +1,227 @@
defmodule Berrypod.SettingsTest do
use Berrypod.DataCase, async: false
alias Berrypod.Settings
alias Berrypod.Settings.ThemeSettings
describe "get_setting/2 and put_setting/3" do
test "stores and retrieves string settings" do
assert {:ok, _} = Settings.put_setting("site_name", "My Shop")
assert Settings.get_setting("site_name") == "My Shop"
end
test "stores and retrieves json settings" do
data = %{"foo" => "bar", "nested" => %{"key" => "value"}}
assert {:ok, _} = Settings.put_setting("custom_data", data, "json")
assert Settings.get_setting("custom_data") == data
end
test "stores and retrieves integer settings" do
assert {:ok, _} = Settings.put_setting("max_products", 100, "integer")
assert Settings.get_setting("max_products") == 100
end
test "stores and retrieves boolean settings" do
assert {:ok, _} = Settings.put_setting("feature_enabled", true, "boolean")
assert Settings.get_setting("feature_enabled") == true
end
test "returns default when setting doesn't exist" do
assert Settings.get_setting("nonexistent", "default") == "default"
assert Settings.get_setting("nonexistent") == nil
end
test "updates existing setting" do
assert {:ok, _} = Settings.put_setting("site_name", "Old Name")
assert {:ok, _} = Settings.put_setting("site_name", "New Name")
assert Settings.get_setting("site_name") == "New Name"
end
end
describe "get_theme_settings/0" do
test "returns default theme settings when none exist" do
settings = Settings.get_theme_settings()
assert %ThemeSettings{} = settings
assert settings.mood == "neutral"
assert settings.typography == "clean"
assert settings.shape == "soft"
assert settings.density == "balanced"
end
test "returns stored theme settings" do
{:ok, _} = Settings.update_theme_settings(%{mood: "dark", typography: "modern"})
settings = Settings.get_theme_settings()
assert settings.mood == "dark"
assert settings.typography == "modern"
end
end
describe "update_theme_settings/1" do
test "updates theme settings successfully" do
{:ok, settings} = Settings.update_theme_settings(%{mood: "warm", typography: "editorial"})
assert settings.mood == "warm"
assert settings.typography == "editorial"
end
test "regenerates CSS cache when settings change" do
alias Berrypod.Theme.CSSCache
# Get initial cached CSS
{:ok, initial_css} = CSSCache.get()
assert initial_css =~ ".themed {"
# Update to a different accent color
{:ok, _settings} = Settings.update_theme_settings(%{accent_color: "#ff0000"})
# Cache should now contain new CSS with the red accent color
{:ok, updated_css} = CSSCache.get()
assert updated_css =~ ".themed {"
# Red = hue 0
assert updated_css =~ "--t-accent-h: 0"
# Change to blue
{:ok, _settings} = Settings.update_theme_settings(%{accent_color: "#0000ff"})
{:ok, blue_css} = CSSCache.get()
# Blue = hue 240
assert blue_css =~ "--t-accent-h: 240"
# Restore default
CSSCache.warm()
end
test "validates mood values" do
{:error, changeset} = Settings.update_theme_settings(%{mood: "invalid"})
assert "is invalid" in errors_on(changeset).mood
end
test "validates typography values" do
{:error, changeset} = Settings.update_theme_settings(%{typography: "invalid"})
assert "is invalid" in errors_on(changeset).typography
end
test "validates logo size range" do
{:error, changeset} = Settings.update_theme_settings(%{logo_size: 10})
assert "must be greater than or equal to 24" in errors_on(changeset).logo_size
end
test "preserves existing settings when updating subset" do
{:ok, _} = Settings.update_theme_settings(%{mood: "warm"})
{:ok, settings} = Settings.update_theme_settings(%{typography: "modern"})
assert settings.mood == "warm"
assert settings.typography == "modern"
end
end
describe "apply_preset/1" do
test "applies gallery preset successfully" do
{:ok, settings} = Settings.apply_preset(:gallery)
assert settings.mood == "warm"
assert settings.typography == "editorial"
assert settings.shape == "soft"
assert settings.accent_color == "#e85d04"
end
test "applies studio preset successfully" do
{:ok, settings} = Settings.apply_preset(:studio)
assert settings.mood == "neutral"
assert settings.typography == "clean"
assert settings.accent_color == "#2563eb"
end
test "returns error for invalid preset" do
assert {:error, :preset_not_found} = Settings.apply_preset(:nonexistent)
end
end
describe "put_secret/2 and get_secret/2" do
test "encrypts, stores, and retrieves a secret" do
assert {:ok, _} = Settings.put_secret("test_key", "super_secret_value")
assert Settings.get_secret("test_key") == "super_secret_value"
end
test "returns default when secret doesn't exist" do
assert Settings.get_secret("nonexistent") == nil
assert Settings.get_secret("nonexistent", "fallback") == "fallback"
end
test "upserts existing secret" do
assert {:ok, _} = Settings.put_secret("test_key", "first_value")
assert {:ok, _} = Settings.put_secret("test_key", "second_value")
assert Settings.get_secret("test_key") == "second_value"
end
test "stores encrypted_value as binary, not plaintext" do
{:ok, _} = Settings.put_secret("test_key", "plaintext_here")
setting = Repo.get_by(Berrypod.Settings.Setting, key: "test_key")
assert setting.value_type == "encrypted"
assert setting.value == "[encrypted]"
assert is_binary(setting.encrypted_value)
refute setting.encrypted_value == "plaintext_here"
end
end
describe "has_secret?/1" do
test "returns false when secret doesn't exist" do
refute Settings.has_secret?("nonexistent")
end
test "returns true when secret exists" do
{:ok, _} = Settings.put_secret("test_key", "value")
assert Settings.has_secret?("test_key")
end
end
describe "secret_hint/1" do
test "returns nil when secret doesn't exist" do
assert Settings.secret_hint("nonexistent") == nil
end
test "returns masked hint for long secrets" do
{:ok, _} = Settings.put_secret("test_key", "sk_test_abc123xyz789")
hint = Settings.secret_hint("test_key")
assert hint =~ "sk_test_"
assert hint =~ "•••"
assert hint =~ "789"
end
test "returns masked hint for short secrets" do
{:ok, _} = Settings.put_secret("test_key", "short")
assert Settings.secret_hint("test_key") == "•••"
end
end
describe "site_live?/0 and set_site_live/1" do
test "defaults to false when no setting exists" do
refute Settings.site_live?()
end
test "returns true after setting site live" do
assert {:ok, _} = Settings.set_site_live(true)
assert Settings.site_live?()
end
test "returns false after setting site offline" do
assert {:ok, _} = Settings.set_site_live(true)
assert Settings.site_live?()
assert {:ok, _} = Settings.set_site_live(false)
refute Settings.site_live?()
end
end
describe "delete_setting/1" do
test "deletes an existing setting" do
{:ok, _} = Settings.put_setting("to_delete", "value")
assert Settings.get_setting("to_delete") == "value"
assert {:ok, _} = Settings.delete_setting("to_delete")
assert Settings.get_setting("to_delete") == nil
end
test "returns :ok when setting doesn't exist" do
assert :ok = Settings.delete_setting("nonexistent")
end
end
end

View File

@@ -0,0 +1,94 @@
defmodule Berrypod.SetupTest do
use Berrypod.DataCase, async: false
alias Berrypod.{Setup, Settings, Products}
import Berrypod.AccountsFixtures
describe "setup_status/0" do
test "returns all false on fresh install" do
status = Setup.setup_status()
refute status.admin_created
refute status.printify_connected
refute status.products_synced
assert status.product_count == 0
refute status.stripe_connected
refute status.site_live
refute status.can_go_live
end
test "detects admin created" do
user_fixture()
status = Setup.setup_status()
assert status.admin_created
end
test "detects stripe connected" do
{:ok, _} = Settings.put_secret("stripe_api_key", "sk_test_abc123")
status = Setup.setup_status()
assert status.stripe_connected
end
test "detects site live" do
{:ok, _} = Settings.set_site_live(true)
status = Setup.setup_status()
assert status.site_live
end
test "detects printify connected with products" do
{:ok, conn} =
Products.create_provider_connection(%{
name: "Test",
provider_type: "printify",
api_key: "test_api_key"
})
status = Setup.setup_status()
assert status.printify_connected
refute status.products_synced
assert status.product_count == 0
# Add a product
{:ok, _product} =
Products.create_product(%{
title: "Test product",
provider_product_id: "ext-1",
provider_connection_id: conn.id,
status: "active"
})
status = Setup.setup_status()
assert status.products_synced
assert status.product_count == 1
end
test "can_go_live requires printify, products, and stripe" do
{:ok, conn} =
Products.create_provider_connection(%{
name: "Test",
provider_type: "printify",
api_key: "test_api_key"
})
{:ok, _product} =
Products.create_product(%{
title: "Test product",
provider_product_id: "ext-1",
provider_connection_id: conn.id,
status: "active"
})
# Still missing stripe
refute Setup.setup_status().can_go_live
# Add stripe
{:ok, _} = Settings.put_secret("stripe_api_key", "sk_test_abc123")
assert Setup.setup_status().can_go_live
end
end
end

View File

@@ -0,0 +1,310 @@
defmodule Berrypod.ShippingTest do
use Berrypod.DataCase, async: false
alias Berrypod.Shipping
alias Berrypod.Shipping.ShippingRate
import Berrypod.ProductsFixtures
describe "upsert_rates/2" do
test "inserts new rates" do
conn = provider_connection_fixture()
rates = [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
},
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "US",
first_item_cost: 650,
additional_item_cost: 150,
currency: "USD"
}
]
assert {:ok, 2} = Shipping.upsert_rates(conn.id, rates)
assert Repo.aggregate(ShippingRate, :count) == 2
end
test "replaces existing rates on conflict" do
conn = provider_connection_fixture()
rates = [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
}
]
{:ok, 1} = Shipping.upsert_rates(conn.id, rates)
updated_rates = [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 550,
additional_item_cost: 200,
currency: "GBP"
}
]
{:ok, 1} = Shipping.upsert_rates(conn.id, updated_rates)
assert Repo.aggregate(ShippingRate, :count) == 1
rate = Repo.one(ShippingRate)
assert rate.first_item_cost == 550
assert rate.additional_item_cost == 200
end
end
describe "get_rate/3" do
test "returns rate when it exists" do
conn = provider_connection_fixture()
{:ok, _} =
Shipping.upsert_rates(conn.id, [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
}
])
rate = Shipping.get_rate(100, 200, "GB")
assert rate.first_item_cost == 450
assert rate.country_code == "GB"
end
test "returns nil when no rate exists" do
assert Shipping.get_rate(999, 999, "XX") == nil
end
end
describe "calculate_for_cart/2" do
test "returns {:ok, 0} for empty cart" do
assert {:ok, 0} = Shipping.calculate_for_cart([], "GB")
end
test "calculates shipping for single item" do
conn = provider_connection_fixture()
product =
product_fixture(%{
provider_connection: conn,
provider_data: %{"blueprint_id" => 100, "print_provider_id" => 200}
})
variant = product_variant_fixture(%{product: product})
{:ok, _} =
Shipping.upsert_rates(conn.id, [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
}
])
cart_items = [%{variant_id: variant.id, quantity: 1}]
assert {:ok, 450} = Shipping.calculate_for_cart(cart_items, "GB")
end
test "calculates shipping for multiple quantities" do
conn = provider_connection_fixture()
product =
product_fixture(%{
provider_connection: conn,
provider_data: %{"blueprint_id" => 100, "print_provider_id" => 200}
})
variant = product_variant_fixture(%{product: product})
{:ok, _} =
Shipping.upsert_rates(conn.id, [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
}
])
cart_items = [%{variant_id: variant.id, quantity: 3}]
# first_item_cost + 2 * additional_item_cost = 450 + 200 = 650
assert {:ok, 650} = Shipping.calculate_for_cart(cart_items, "GB")
end
test "returns error when no rates found" do
conn = provider_connection_fixture()
product =
product_fixture(%{
provider_connection: conn,
provider_data: %{"blueprint_id" => 100, "print_provider_id" => 200}
})
variant = product_variant_fixture(%{product: product})
cart_items = [%{variant_id: variant.id, quantity: 1}]
assert {:error, :rates_not_found} = Shipping.calculate_for_cart(cart_items, "XX")
end
test "groups items by print provider" do
conn = provider_connection_fixture()
product1 =
product_fixture(%{
provider_connection: conn,
provider_data: %{"blueprint_id" => 100, "print_provider_id" => 200}
})
product2 =
product_fixture(%{
provider_connection: conn,
provider_data: %{"blueprint_id" => 101, "print_provider_id" => 200}
})
variant1 = product_variant_fixture(%{product: product1})
variant2 = product_variant_fixture(%{product: product2})
{:ok, _} =
Shipping.upsert_rates(conn.id, [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
},
%{
blueprint_id: 101,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 500,
additional_item_cost: 150,
currency: "GBP"
}
])
cart_items = [
%{variant_id: variant1.id, quantity: 1},
%{variant_id: variant2.id, quantity: 1}
]
# Same provider: highest first_item_cost (500) + 1 additional item cost
assert {:ok, cost} = Shipping.calculate_for_cart(cart_items, "GB")
# 500 (highest first item) + one additional item cost (100 or 150)
assert cost in [600, 650]
end
end
describe "upsert_rates/3 with exchange rates" do
test "converts rates to GBP at sync time with buffer" do
conn = provider_connection_fixture()
exchange_rates = %{"USD" => 0.80}
rates = [
%{
blueprint_id: 100,
print_provider_id: 1,
country_code: "US",
first_item_cost: 1000,
additional_item_cost: 500,
currency: "USD"
}
]
{:ok, 1} = Shipping.upsert_rates(conn.id, rates, exchange_rates)
rate = Shipping.get_rate(100, 1, "US")
assert rate.currency == "GBP"
# 1000 USD cents * 0.80 rate * 1.05 buffer, ceil'd
assert rate.first_item_cost == 841
assert rate.additional_item_cost == 421
end
test "leaves GBP rates unconverted" do
conn = provider_connection_fixture()
exchange_rates = %{"USD" => 0.80}
rates = [
%{
blueprint_id: 100,
print_provider_id: 1,
country_code: "GB",
first_item_cost: 500,
additional_item_cost: 200,
currency: "GBP"
}
]
{:ok, 1} = Shipping.upsert_rates(conn.id, rates, exchange_rates)
rate = Shipping.get_rate(100, 1, "GB")
assert rate.currency == "GBP"
assert rate.first_item_cost == 500
assert rate.additional_item_cost == 200
end
end
describe "list_available_countries/0" do
test "returns distinct country codes" do
conn = provider_connection_fixture()
{:ok, _} =
Shipping.upsert_rates(conn.id, [
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 450,
additional_item_cost: 100,
currency: "GBP"
},
%{
blueprint_id: 100,
print_provider_id: 200,
country_code: "US",
first_item_cost: 650,
additional_item_cost: 150,
currency: "USD"
},
%{
blueprint_id: 101,
print_provider_id: 200,
country_code: "GB",
first_item_cost: 500,
additional_item_cost: 100,
currency: "GBP"
}
])
countries = Shipping.list_available_countries()
assert "GB" in countries
assert "US" in countries
assert length(countries) == 2
end
end
end

View File

@@ -0,0 +1,55 @@
defmodule Berrypod.Stripe.SetupTest do
use Berrypod.DataCase, async: false
alias Berrypod.Settings
alias Berrypod.Stripe.Setup
describe "localhost?/0" do
test "returns true for localhost endpoint" do
# In test env, endpoint URL is localhost
assert Setup.localhost?()
end
end
describe "webhook_url/0" do
test "returns the webhook endpoint URL" do
url = Setup.webhook_url()
assert url =~ "/webhooks/stripe"
end
end
describe "save_signing_secret/1" do
test "stores signing secret and loads into Application env" do
Setup.save_signing_secret("whsec_test_secret_123")
assert Settings.get_secret("stripe_signing_secret") == "whsec_test_secret_123"
assert Application.get_env(:stripity_stripe, :signing_secret) == "whsec_test_secret_123"
end
end
describe "disconnect/0" do
test "removes all Stripe settings from DB and Application env" do
# Set up some Stripe config
Settings.put_secret("stripe_api_key", "sk_test_123")
Settings.put_secret("stripe_signing_secret", "whsec_test_456")
Settings.put_setting("stripe_webhook_endpoint_id", "we_test_789")
Application.put_env(:stripity_stripe, :api_key, "sk_test_123")
Application.put_env(:stripity_stripe, :signing_secret, "whsec_test_456")
assert :ok = Setup.disconnect()
# DB cleared
refute Settings.has_secret?("stripe_api_key")
refute Settings.has_secret?("stripe_signing_secret")
assert Settings.get_setting("stripe_webhook_endpoint_id") == nil
# Application env cleared
assert Application.get_env(:stripity_stripe, :api_key) == nil
assert Application.get_env(:stripity_stripe, :signing_secret) == nil
end
test "handles disconnect when nothing is configured" do
assert :ok = Setup.disconnect()
end
end
end

View File

@@ -0,0 +1,86 @@
defmodule Berrypod.Sync.ProductSyncWorkerTest do
use Berrypod.DataCase, async: false
use Oban.Testing, repo: Berrypod.Repo
alias Berrypod.Products
alias Berrypod.Sync.ProductSyncWorker
import Berrypod.ProductsFixtures
describe "perform/1" do
test "cancels for missing connection" do
fake_id = Ecto.UUID.generate()
assert {:cancel, :connection_not_found} =
perform_job(ProductSyncWorker, %{provider_connection_id: fake_id})
end
test "cancels for disabled connection" do
conn = provider_connection_fixture(%{enabled: false})
assert {:cancel, :connection_disabled} =
perform_job(ProductSyncWorker, %{provider_connection_id: conn.id})
end
test "sets status to syncing then updates on completion or failure" do
conn = provider_connection_fixture(%{enabled: true})
# The job will fail because we don't have a real API connection
# but it should still update the status properly
_result = perform_job(ProductSyncWorker, %{provider_connection_id: conn.id})
# Reload the connection
updated_conn = Products.get_provider_connection!(conn.id)
# Status should be either "completed" or "failed", not stuck at "syncing"
assert updated_conn.sync_status in ["completed", "failed"]
end
end
describe "enqueue/1" do
test "creates a job with correct args" do
# Temporarily switch to manual mode to avoid inline execution
Oban.Testing.with_testing_mode(:manual, fn ->
conn = provider_connection_fixture()
assert {:ok, %Oban.Job{} = job} = ProductSyncWorker.enqueue(conn.id)
# In manual mode, args use atom keys
assert job.args == %{provider_connection_id: conn.id}
assert job.queue == "sync"
end)
end
end
describe "enqueue/2 with delay" do
test "schedules a job for later" do
Oban.Testing.with_testing_mode(:manual, fn ->
conn = provider_connection_fixture()
assert {:ok, %Oban.Job{} = job} = ProductSyncWorker.enqueue(conn.id, 60)
# In manual mode, args use atom keys
assert job.args == %{provider_connection_id: conn.id}
# Job should be scheduled in the future
assert DateTime.compare(job.scheduled_at, DateTime.utc_now()) == :gt
end)
end
end
describe "job creation" do
test "new/1 creates job changeset with provider_connection_id" do
changeset = ProductSyncWorker.new(%{provider_connection_id: "test-id"})
assert changeset.changes.args == %{provider_connection_id: "test-id"}
assert changeset.changes.queue == "sync"
end
test "new/2 with scheduled_at creates scheduled job" do
future = DateTime.add(DateTime.utc_now(), 60, :second)
changeset =
ProductSyncWorker.new(%{provider_connection_id: "test-id"}, scheduled_at: future)
assert changeset.changes.scheduled_at == future
end
end
end

View File

@@ -0,0 +1,40 @@
defmodule Berrypod.Sync.ScheduledSyncWorkerTest do
use Berrypod.DataCase, async: false
use Oban.Testing, repo: Berrypod.Repo
alias Berrypod.Sync.ScheduledSyncWorker
alias Berrypod.Sync.ProductSyncWorker
import Berrypod.ProductsFixtures
describe "perform/1" do
test "enqueues sync for enabled connections" do
conn = provider_connection_fixture(%{enabled: true})
Oban.Testing.with_testing_mode(:manual, fn ->
assert :ok = perform_job(ScheduledSyncWorker, %{})
assert_enqueued(
worker: ProductSyncWorker,
args: %{provider_connection_id: conn.id}
)
end)
end
test "skips disabled connections" do
_conn = provider_connection_fixture(%{enabled: false})
Oban.Testing.with_testing_mode(:manual, fn ->
assert :ok = perform_job(ScheduledSyncWorker, %{})
refute_enqueued(worker: ProductSyncWorker)
end)
end
test "handles no connections gracefully" do
Oban.Testing.with_testing_mode(:manual, fn ->
assert :ok = perform_job(ScheduledSyncWorker, %{})
refute_enqueued(worker: ProductSyncWorker)
end)
end
end
end

View File

@@ -0,0 +1,109 @@
defmodule Berrypod.Theme.CSSCacheTest do
use Berrypod.DataCase
alias Berrypod.Theme.CSSCache
alias Berrypod.Theme.CSSGenerator
alias Berrypod.Settings.ThemeSettings
describe "get/0" do
test "returns cached CSS after warm" do
# Cache should be pre-warmed on startup
assert {:ok, css} = CSSCache.get()
assert is_binary(css)
assert css =~ ".themed {"
end
end
describe "put/1 and get/0" do
test "stores and retrieves CSS" do
test_css = "/* test css */"
:ok = CSSCache.put(test_css)
assert {:ok, ^test_css} = CSSCache.get()
# Restore original cache
CSSCache.warm()
end
end
describe "invalidate/0" do
test "clears cached CSS" do
# Ensure cache has something
CSSCache.warm()
assert {:ok, _} = CSSCache.get()
# Invalidate
:ok = CSSCache.invalidate()
assert :miss = CSSCache.get()
# Restore
CSSCache.warm()
end
end
describe "warm/0" do
test "populates cache from current settings" do
CSSCache.invalidate()
assert :miss = CSSCache.get()
:ok = CSSCache.warm()
assert {:ok, css} = CSSCache.get()
assert is_binary(css)
end
end
describe "performance" do
@tag :benchmark
test "cache hit is faster than generation" do
# Ensure cache is warm
CSSCache.warm()
settings = %ThemeSettings{}
# Benchmark cache hit (should be microseconds)
{cache_time, {:ok, _cached_css}} =
:timer.tc(fn ->
CSSCache.get()
end)
# Benchmark CSS generation (should be milliseconds)
{gen_time, _generated_css} =
:timer.tc(fn ->
CSSGenerator.generate(settings)
end)
# Log results for visibility
IO.puts("\n")
IO.puts(" Cache hit: #{cache_time} µs")
IO.puts(" CSS generate: #{gen_time} µs")
IO.puts(" Speedup: #{Float.round(gen_time / max(cache_time, 1), 1)}x faster")
# Cache should be faster than generation
assert cache_time < gen_time,
"Cache (#{cache_time}µs) should be faster than generation (#{gen_time}µs)"
end
@tag :benchmark
test "multiple cache hits are consistent" do
CSSCache.warm()
times =
for _ <- 1..100 do
{time, {:ok, _}} = :timer.tc(fn -> CSSCache.get() end)
time
end
avg = Enum.sum(times) / length(times)
max_time = Enum.max(times)
min_time = Enum.min(times)
IO.puts("\n")
IO.puts(" 100 cache hits:")
IO.puts(" Avg: #{Float.round(avg, 1)} µs")
IO.puts(" Min: #{min_time} µs")
IO.puts(" Max: #{max_time} µs")
# Average should be under 100 microseconds (ETS is fast)
assert avg < 100, "Average cache hit (#{avg}µs) should be under 100µs"
end
end
end

View File

@@ -0,0 +1,165 @@
defmodule Berrypod.Theme.CSSGeneratorTest do
use ExUnit.Case, async: true
alias Berrypod.Theme.CSSGenerator
alias Berrypod.Settings.ThemeSettings
describe "generate/1" do
test "generates CSS for default theme settings" do
settings = %ThemeSettings{}
css = CSSGenerator.generate(settings)
assert is_binary(css)
# CSS targets .themed (used by both shop and preview)
assert css =~ ".themed {"
assert css =~ "--t-accent-h:"
assert css =~ "--t-accent-s:"
assert css =~ "--t-accent-l:"
# Should include all theme token categories
assert css =~ "--t-surface-base:"
assert css =~ "--t-font-heading:"
assert css =~ "--t-radius-sm:"
assert css =~ "--t-density:"
end
test "converts hex colors to HSL" do
settings = %ThemeSettings{accent_color: "#ff0000"}
css = CSSGenerator.generate(settings)
# Red should be H=0, S=100%, L=50%
assert css =~ "--t-accent-h: 0"
assert css =~ "--t-accent-s: 100%"
assert css =~ "--t-accent-l: 50%"
end
test "handles blue accent color" do
settings = %ThemeSettings{accent_color: "#0000ff"}
css = CSSGenerator.generate(settings)
# Blue should be H=240, S=100%, L=50%
assert css =~ "--t-accent-h: 240"
assert css =~ "--t-accent-s: 100%"
assert css =~ "--t-accent-l: 50%"
end
test "handles green accent color" do
settings = %ThemeSettings{accent_color: "#00ff00"}
css = CSSGenerator.generate(settings)
# Green should be H=120, S=100%, L=50%
assert css =~ "--t-accent-h: 120"
assert css =~ "--t-accent-s: 100%"
assert css =~ "--t-accent-l: 50%"
end
test "includes secondary colors" do
settings = %ThemeSettings{
secondary_accent_color: "#ea580c",
sale_color: "#dc2626"
}
css = CSSGenerator.generate(settings)
assert css =~ "--t-secondary-accent: #ea580c"
assert css =~ "--t-sale-color: #dc2626"
end
test "generates font size scale for small" do
settings = %ThemeSettings{font_size: "small"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-font-size-scale: 1.125"
end
test "generates font size scale for medium" do
settings = %ThemeSettings{font_size: "medium"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-font-size-scale: 1.1875"
end
test "generates font size scale for large" do
settings = %ThemeSettings{font_size: "large"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-font-size-scale: 1.25"
end
test "generates heading weight override for regular" do
settings = %ThemeSettings{heading_weight: "regular"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-heading-weight-override: 400"
end
test "generates heading weight override for bold" do
settings = %ThemeSettings{heading_weight: "bold"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-heading-weight-override: 700"
end
test "generates layout width for contained" do
settings = %ThemeSettings{layout_width: "contained"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-layout-max-width: 1100px"
end
test "generates layout width for wide" do
settings = %ThemeSettings{layout_width: "wide"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-layout-max-width: 1400px"
end
test "generates layout width for full" do
settings = %ThemeSettings{layout_width: "full"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-layout-max-width: 100%"
end
test "generates button style for filled" do
settings = %ThemeSettings{button_style: "filled"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-button-style: filled"
end
test "generates button style for outline" do
settings = %ThemeSettings{button_style: "outline"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-button-style: outline"
end
test "generates product text alignment" do
settings = %ThemeSettings{product_text_align: "center"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-product-text-align: center"
end
test "generates image aspect ratio for square" do
settings = %ThemeSettings{image_aspect_ratio: "square"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-image-aspect-ratio: 1 / 1"
end
test "generates image aspect ratio for portrait" do
settings = %ThemeSettings{image_aspect_ratio: "portrait"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-image-aspect-ratio: 3 / 4"
end
test "generates image aspect ratio for landscape" do
settings = %ThemeSettings{image_aspect_ratio: "landscape"}
css = CSSGenerator.generate(settings)
assert css =~ "--t-image-aspect-ratio: 4 / 3"
end
end
end

View File

@@ -0,0 +1,119 @@
defmodule Berrypod.Theme.PresetsTest do
use ExUnit.Case, async: true
alias Berrypod.Theme.Presets
describe "all/0" do
test "returns all 8 presets" do
presets = Presets.all()
assert is_map(presets)
assert map_size(presets) == 8
assert Map.has_key?(presets, :gallery)
assert Map.has_key?(presets, :studio)
assert Map.has_key?(presets, :boutique)
assert Map.has_key?(presets, :bold)
assert Map.has_key?(presets, :playful)
assert Map.has_key?(presets, :minimal)
assert Map.has_key?(presets, :night)
assert Map.has_key?(presets, :classic)
end
end
describe "get/1" do
test "returns gallery preset with correct settings" do
preset = Presets.get(:gallery)
assert preset.mood == "warm"
assert preset.typography == "editorial"
assert preset.shape == "soft"
assert preset.density == "spacious"
assert preset.grid_columns == "3"
assert preset.header_layout == "centered"
assert preset.accent_color == "#e85d04"
end
test "returns studio preset with correct settings" do
preset = Presets.get(:studio)
assert preset.mood == "neutral"
assert preset.typography == "clean"
assert preset.shape == "soft"
assert preset.density == "balanced"
assert preset.grid_columns == "4"
assert preset.header_layout == "standard"
assert preset.accent_color == "#2563eb"
end
test "returns boutique preset" do
preset = Presets.get(:boutique)
assert preset.mood == "warm"
assert preset.typography == "classic"
assert preset.accent_color == "#b45309"
end
test "returns bold preset" do
preset = Presets.get(:bold)
assert preset.mood == "neutral"
assert preset.typography == "modern"
assert preset.shape == "sharp"
assert preset.accent_color == "#dc2626"
end
test "returns playful preset" do
preset = Presets.get(:playful)
assert preset.typography == "friendly"
assert preset.shape == "pill"
assert preset.accent_color == "#8b5cf6"
end
test "returns minimal preset" do
preset = Presets.get(:minimal)
assert preset.mood == "neutral"
assert preset.typography == "impulse"
assert preset.shape == "sharp"
assert preset.accent_color == "#171717"
end
test "returns night preset" do
preset = Presets.get(:night)
assert preset.mood == "dark"
assert preset.typography == "modern"
assert preset.accent_color == "#f97316"
end
test "returns classic preset" do
preset = Presets.get(:classic)
assert preset.mood == "warm"
assert preset.typography == "classic"
assert preset.accent_color == "#166534"
end
test "returns nil for nonexistent preset" do
assert Presets.get(:nonexistent) == nil
end
end
describe "list_names/0" do
test "returns list of all preset names" do
names = Presets.list_names()
assert is_list(names)
assert length(names) == 8
assert :gallery in names
assert :studio in names
assert :boutique in names
assert :bold in names
assert :playful in names
assert :minimal in names
assert :night in names
assert :classic in names
end
end
end

View File

@@ -0,0 +1,275 @@
defmodule Berrypod.Theme.PreviewDataTest do
use ExUnit.Case, async: true
alias Berrypod.Theme.PreviewData
describe "products/0" do
test "returns a list of products" do
products = PreviewData.products()
assert is_list(products)
assert products != []
end
test "each product has required fields" do
products = PreviewData.products()
product = List.first(products)
assert is_map(product)
assert Map.has_key?(product, :id)
assert Map.has_key?(product, :title)
assert Map.has_key?(product, :description)
assert Map.has_key?(product, :cheapest_price)
assert Map.has_key?(product, :images)
assert Map.has_key?(product, :category)
assert Map.has_key?(product, :in_stock)
assert Map.has_key?(product, :on_sale)
end
test "products have valid prices" do
products = PreviewData.products()
for product <- products do
assert is_integer(product.cheapest_price)
assert product.cheapest_price > 0
if product.compare_at_price do
assert is_integer(product.compare_at_price)
assert product.compare_at_price > product.cheapest_price
end
end
end
test "products have images" do
products = PreviewData.products()
for product <- products do
assert is_list(product.images)
assert length(product.images) >= 1
for image <- product.images do
assert is_integer(image.position)
assert is_binary(image.src) or not is_nil(image.image_id)
end
end
end
test "some products are on sale" do
products = PreviewData.products()
on_sale_products = Enum.filter(products, & &1.on_sale)
assert on_sale_products != []
end
test "on-sale products have compare_at_price" do
products = PreviewData.products()
on_sale_products = Enum.filter(products, & &1.on_sale)
for product <- on_sale_products do
assert product.compare_at_price != nil
end
end
end
describe "cart_items/0" do
test "returns a list of cart items" do
cart_items = PreviewData.cart_items()
assert is_list(cart_items)
assert cart_items != []
end
test "each cart item has required fields" do
cart_items = PreviewData.cart_items()
item = List.first(cart_items)
assert is_map(item)
assert Map.has_key?(item, :product)
assert Map.has_key?(item, :quantity)
assert Map.has_key?(item, :variant)
end
test "cart items have valid quantities" do
cart_items = PreviewData.cart_items()
for item <- cart_items do
assert is_integer(item.quantity)
assert item.quantity > 0
end
end
test "cart items reference valid products" do
cart_items = PreviewData.cart_items()
for item <- cart_items do
product = item.product
assert is_map(product)
assert Map.has_key?(product, :id)
assert Map.has_key?(product, :title)
assert Map.has_key?(product, :cheapest_price)
end
end
end
describe "testimonials/0" do
test "returns a list of testimonials" do
testimonials = PreviewData.testimonials()
assert is_list(testimonials)
assert testimonials != []
end
test "each testimonial has required fields" do
testimonials = PreviewData.testimonials()
testimonial = List.first(testimonials)
assert is_map(testimonial)
assert Map.has_key?(testimonial, :id)
assert Map.has_key?(testimonial, :author)
assert Map.has_key?(testimonial, :content)
assert Map.has_key?(testimonial, :rating)
assert Map.has_key?(testimonial, :date)
end
test "testimonials have valid ratings" do
testimonials = PreviewData.testimonials()
for testimonial <- testimonials do
assert is_integer(testimonial.rating)
assert testimonial.rating >= 1
assert testimonial.rating <= 5
end
end
test "testimonials have content" do
testimonials = PreviewData.testimonials()
for testimonial <- testimonials do
assert is_binary(testimonial.content)
assert String.length(testimonial.content) > 0
end
end
end
describe "categories/0" do
test "returns a list of categories" do
categories = PreviewData.categories()
assert is_list(categories)
assert categories != []
end
test "each category has required fields" do
categories = PreviewData.categories()
category = List.first(categories)
assert is_map(category)
assert Map.has_key?(category, :id)
assert Map.has_key?(category, :name)
assert Map.has_key?(category, :slug)
assert Map.has_key?(category, :product_count)
assert Map.has_key?(category, :image_url)
end
test "categories have valid slugs" do
categories = PreviewData.categories()
for category <- categories do
assert is_binary(category.slug)
assert String.match?(category.slug, ~r/^[a-z0-9-]+$/)
end
end
test "categories have product counts" do
categories = PreviewData.categories()
for category <- categories do
assert is_integer(category.product_count)
assert category.product_count >= 0
end
end
end
describe "has_real_products?/0" do
test "returns false when no real products exist" do
assert PreviewData.has_real_products?() == false
end
end
describe "category_by_slug/1" do
test "returns category when slug exists" do
category = PreviewData.category_by_slug("art-prints")
assert category != nil
assert category.slug == "art-prints"
assert is_binary(category.name)
end
test "returns nil when slug does not exist" do
assert PreviewData.category_by_slug("nonexistent") == nil
end
test "finds all known categories by slug" do
categories = PreviewData.categories()
for category <- categories do
found = PreviewData.category_by_slug(category.slug)
assert found != nil
assert found.id == category.id
end
end
end
describe "products_by_category/1" do
test "returns all products for nil" do
all_products = PreviewData.products()
filtered = PreviewData.products_by_category(nil)
assert filtered == all_products
end
test "returns all products for 'all'" do
all_products = PreviewData.products()
filtered = PreviewData.products_by_category("all")
assert filtered == all_products
end
test "returns empty list for nonexistent category" do
assert PreviewData.products_by_category("nonexistent") == []
end
test "returns only products matching the category" do
category = List.first(PreviewData.categories())
products = PreviewData.products_by_category(category.slug)
assert is_list(products)
for product <- products do
assert product.category == category.name
end
end
test "products are filtered correctly for each category" do
categories = PreviewData.categories()
for category <- categories do
products = PreviewData.products_by_category(category.slug)
for product <- products do
assert product.category == category.name
end
end
end
test "all products belong to at least one category" do
all_products = PreviewData.products()
categories = PreviewData.categories()
category_names = Enum.map(categories, & &1.name)
for product <- all_products do
assert product.category in category_names
end
end
end
end

View File

@@ -0,0 +1,106 @@
defmodule Berrypod.VaultTest do
use Berrypod.DataCase, async: true
alias Berrypod.Vault
describe "encrypt/1 and decrypt/1" do
test "round-trips a string successfully" do
plaintext = "my_secret_api_key_12345"
assert {:ok, ciphertext} = Vault.encrypt(plaintext)
assert is_binary(ciphertext)
assert ciphertext != plaintext
assert {:ok, decrypted} = Vault.decrypt(ciphertext)
assert decrypted == plaintext
end
test "produces different ciphertext for same plaintext (random IV)" do
plaintext = "same_secret"
{:ok, ciphertext1} = Vault.encrypt(plaintext)
{:ok, ciphertext2} = Vault.encrypt(plaintext)
assert ciphertext1 != ciphertext2
# Both should decrypt to same value
assert {:ok, ^plaintext} = Vault.decrypt(ciphertext1)
assert {:ok, ^plaintext} = Vault.decrypt(ciphertext2)
end
test "handles empty string" do
assert {:ok, ciphertext} = Vault.encrypt("")
assert {:ok, ""} = Vault.decrypt(ciphertext)
end
test "handles unicode characters" do
plaintext = "héllo wörld 你好 🎉"
{:ok, ciphertext} = Vault.encrypt(plaintext)
{:ok, decrypted} = Vault.decrypt(ciphertext)
assert decrypted == plaintext
end
test "handles long strings" do
plaintext = String.duplicate("a", 10_000)
{:ok, ciphertext} = Vault.encrypt(plaintext)
{:ok, decrypted} = Vault.decrypt(ciphertext)
assert decrypted == plaintext
end
test "encrypt/1 returns {:ok, nil} for nil input" do
assert {:ok, nil} = Vault.encrypt(nil)
end
test "decrypt/1 returns {:ok, nil} for nil input" do
assert {:ok, nil} = Vault.decrypt(nil)
end
test "decrypt/1 returns {:ok, empty} for empty string" do
assert {:ok, ""} = Vault.decrypt("")
end
test "decrypt/1 returns error for invalid ciphertext" do
assert {:error, :invalid_ciphertext} = Vault.decrypt("not_valid")
assert {:error, :invalid_ciphertext} = Vault.decrypt(<<1, 2, 3>>)
end
test "decrypt/1 returns error for tampered ciphertext" do
{:ok, ciphertext} = Vault.encrypt("secret")
# Tamper with the ciphertext (flip a bit in the middle)
tampered = :binary.bin_to_list(ciphertext)
middle = div(length(tampered), 2)
tampered = List.update_at(tampered, middle, &Bitwise.bxor(&1, 0xFF))
tampered = :binary.list_to_bin(tampered)
assert {:error, :decryption_failed} = Vault.decrypt(tampered)
end
end
describe "encrypt!/1 and decrypt!/1" do
test "round-trips successfully" do
plaintext = "test_secret"
ciphertext = Vault.encrypt!(plaintext)
decrypted = Vault.decrypt!(ciphertext)
assert decrypted == plaintext
end
test "encrypt!/1 raises on invalid input type" do
assert_raise FunctionClauseError, fn ->
Vault.encrypt!(123)
end
end
test "decrypt!/1 raises on invalid ciphertext" do
assert_raise RuntimeError, ~r/Decryption failed/, fn ->
Vault.decrypt!("invalid")
end
end
end
end

View File

@@ -0,0 +1,48 @@
defmodule Berrypod.Webhooks.ProductDeleteWorkerTest do
use Berrypod.DataCase
use Oban.Testing, repo: Berrypod.Repo
alias Berrypod.Webhooks.ProductDeleteWorker
alias Berrypod.Products
import Berrypod.ProductsFixtures
describe "perform/1" do
test "deletes product when found" do
conn = provider_connection_fixture(%{provider_type: "printify"})
{:ok, product, :created} =
Products.upsert_product(conn, %{
provider_product_id: "test-product-123",
title: "Test Product",
provider_data: %{}
})
assert Products.get_product(product.id) != nil
assert :ok =
perform_job(ProductDeleteWorker, %{
provider_product_id: "test-product-123"
})
assert Products.get_product(product.id) == nil
end
test "returns ok when product not found" do
_conn = provider_connection_fixture(%{provider_type: "printify"})
assert :ok =
perform_job(ProductDeleteWorker, %{
provider_product_id: "nonexistent-product"
})
end
test "cancels when no provider connection" do
# No connection created
assert {:cancel, :no_connection} =
perform_job(ProductDeleteWorker, %{
provider_product_id: "test-product-123"
})
end
end
end

View File

@@ -0,0 +1,276 @@
defmodule Berrypod.WebhooksTest do
use Berrypod.DataCase, async: false
alias Berrypod.Orders
alias Berrypod.Webhooks
import Berrypod.ProductsFixtures
import Berrypod.OrdersFixtures
setup do
conn = provider_connection_fixture(%{provider_type: "printify"})
{:ok, provider_connection: conn}
end
describe "handle_printify_event/2 — product events" do
test "product:updated triggers sync", %{provider_connection: _conn} do
result =
Webhooks.handle_printify_event(
"product:updated",
%{"id" => "123", "shop_id" => "456"}
)
assert {:ok, %Oban.Job{}} = result
end
test "product:publish:started triggers sync", %{provider_connection: _conn} do
result =
Webhooks.handle_printify_event(
"product:publish:started",
%{"id" => "123"}
)
assert {:ok, %Oban.Job{}} = result
end
test "product:deleted triggers delete", %{provider_connection: _conn} do
result =
Webhooks.handle_printify_event(
"product:deleted",
%{"id" => "123"}
)
assert {:ok, %Oban.Job{}} = result
end
test "shop:disconnected returns ok" do
assert :ok = Webhooks.handle_printify_event("shop:disconnected", %{})
end
test "unknown event returns ok" do
assert :ok = Webhooks.handle_printify_event("unknown:event", %{})
end
test "returns error when no provider connection" do
Berrypod.Repo.delete_all(Berrypod.Products.ProviderConnection)
assert {:error, :no_connection} =
Webhooks.handle_printify_event(
"product:updated",
%{"id" => "123"}
)
end
end
describe "handle_printify_event/2 — order events" do
test "order:sent-to-production updates fulfilment status" do
{order, _v, _p, _c} = submitted_order_fixture()
assert {:ok, updated} =
Webhooks.handle_printify_event("order:sent-to-production", %{
"id" => "printify_abc",
"external_id" => order.order_number
})
assert updated.fulfilment_status == "processing"
assert updated.provider_status == "in-production"
end
test "order:shipment:created sets tracking info and shipped_at" do
{order, _v, _p, _c} = submitted_order_fixture()
assert {:ok, updated} =
Webhooks.handle_printify_event("order:shipment:created", %{
"id" => "printify_abc",
"external_id" => order.order_number,
"shipments" => [
%{
"tracking_number" => "1Z999AA1",
"tracking_url" => "https://ups.com/track/1Z999AA1"
}
]
})
assert updated.fulfilment_status == "shipped"
assert updated.tracking_number == "1Z999AA1"
assert updated.tracking_url == "https://ups.com/track/1Z999AA1"
assert updated.shipped_at != nil
end
test "order:shipment:delivered sets delivered_at" do
{order, _v, _p, _c} = submitted_order_fixture()
# First mark as shipped
{:ok, order} =
Orders.update_fulfilment(order, %{
fulfilment_status: "shipped",
shipped_at: DateTime.utc_now() |> DateTime.truncate(:second)
})
assert {:ok, updated} =
Webhooks.handle_printify_event("order:shipment:delivered", %{
"id" => "printify_abc",
"external_id" => order.order_number
})
assert updated.fulfilment_status == "delivered"
assert updated.delivered_at != nil
end
test "order event with unknown external_id returns error" do
assert {:error, :order_not_found} =
Webhooks.handle_printify_event("order:sent-to-production", %{
"id" => "printify_abc",
"external_id" => "SS-000000-NOPE"
})
end
test "order event with missing external_id returns error" do
assert {:error, :missing_external_id} =
Webhooks.handle_printify_event("order:sent-to-production", %{
"id" => "printify_abc"
})
end
end
# =============================================================================
# Printful events
# =============================================================================
describe "handle_printful_event/2 — product events" do
setup do
conn = provider_connection_fixture(%{provider_type: "printful"})
{:ok, printful_connection: conn}
end
test "product_updated triggers sync", %{printful_connection: _conn} do
assert {:ok, %Oban.Job{}} =
Webhooks.handle_printful_event("product_updated", %{})
end
test "product_synced triggers sync", %{printful_connection: _conn} do
assert {:ok, %Oban.Job{}} =
Webhooks.handle_printful_event("product_synced", %{})
end
test "product_deleted with sync_product id triggers delete" do
assert {:ok, %Oban.Job{}} =
Webhooks.handle_printful_event("product_deleted", %{
"sync_product" => %{"id" => 12345}
})
end
test "product_deleted without id triggers full sync" do
provider_connection_fixture(%{provider_type: "printful"})
assert {:ok, %Oban.Job{}} =
Webhooks.handle_printful_event("product_deleted", %{})
end
test "unknown event returns ok" do
assert :ok = Webhooks.handle_printful_event("stock_updated", %{})
end
test "returns error when no printful connection" do
# Delete the printful connection created in setup
import Ecto.Query
from(pc in Berrypod.Products.ProviderConnection,
where: pc.provider_type == "printful"
)
|> Berrypod.Repo.delete_all()
assert {:error, :no_connection} =
Webhooks.handle_printful_event("product_updated", %{})
end
end
describe "handle_printful_event/2 — order events" do
setup do
provider_connection_fixture(%{provider_type: "printful"})
:ok
end
test "package_shipped sets tracking and shipped_at" do
{order, _v, _p, _c} = submitted_order_fixture()
assert {:ok, updated} =
Webhooks.handle_printful_event("package_shipped", %{
"order" => %{"external_id" => order.order_number},
"shipment" => %{
"tracking_number" => "PF-TRACK-001",
"tracking_url" => "https://tracking.printful.com/PF-TRACK-001"
}
})
assert updated.fulfilment_status == "shipped"
assert updated.tracking_number == "PF-TRACK-001"
assert updated.tracking_url == "https://tracking.printful.com/PF-TRACK-001"
assert updated.shipped_at != nil
end
test "order_failed sets failed status" do
{order, _v, _p, _c} = submitted_order_fixture()
assert {:ok, updated} =
Webhooks.handle_printful_event("order_failed", %{
"order" => %{"external_id" => order.order_number},
"reason" => "Out of stock"
})
assert updated.fulfilment_status == "failed"
assert updated.fulfilment_error == "Out of stock"
end
test "order_failed with no reason uses default message" do
{order, _v, _p, _c} = submitted_order_fixture()
assert {:ok, updated} =
Webhooks.handle_printful_event("order_failed", %{
"order" => %{"external_id" => order.order_number}
})
assert updated.fulfilment_error == "Order failed at Printful"
end
test "order_canceled sets cancelled status" do
{order, _v, _p, _c} = submitted_order_fixture()
assert {:ok, updated} =
Webhooks.handle_printful_event("order_canceled", %{
"order" => %{"external_id" => order.order_number}
})
assert updated.fulfilment_status == "cancelled"
end
test "package_shipped with external_id at top level" do
{order, _v, _p, _c} = submitted_order_fixture()
assert {:ok, updated} =
Webhooks.handle_printful_event("package_shipped", %{
"external_id" => order.order_number,
"shipment" => %{
"tracking_number" => "PF-TRACK-002"
}
})
assert updated.fulfilment_status == "shipped"
assert updated.tracking_number == "PF-TRACK-002"
end
test "order event with unknown external_id returns error" do
assert {:error, :order_not_found} =
Webhooks.handle_printful_event("package_shipped", %{
"order" => %{"external_id" => "SS-000000-NOPE"}
})
end
test "order event with no external_id returns error" do
assert {:error, :missing_external_id} =
Webhooks.handle_printful_event("package_shipped", %{
"order" => %{"id" => 12345}
})
end
end
end