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:
413
test/berrypod/accounts_test.exs
Normal file
413
test/berrypod/accounts_test.exs
Normal 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
173
test/berrypod/cart_test.exs
Normal 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
|
||||
48
test/berrypod/clients/printful_test.exs
Normal file
48
test/berrypod/clients/printful_test.exs
Normal 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
|
||||
47
test/berrypod/images/optimize_worker_test.exs
Normal file
47
test/berrypod/images/optimize_worker_test.exs
Normal 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
|
||||
139
test/berrypod/images/optimizer_test.exs
Normal file
139
test/berrypod/images/optimizer_test.exs
Normal 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
|
||||
148
test/berrypod/media/svg_recolorer_test.exs
Normal file
148
test/berrypod/media/svg_recolorer_test.exs
Normal 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
|
||||
113
test/berrypod/media_test.exs
Normal file
113
test/berrypod/media_test.exs
Normal 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
|
||||
84
test/berrypod/orders/fulfilment_status_worker_test.exs
Normal file
84
test/berrypod/orders/fulfilment_status_worker_test.exs
Normal 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
|
||||
161
test/berrypod/orders/order_notifier_test.exs
Normal file
161
test/berrypod/orders/order_notifier_test.exs
Normal 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
|
||||
111
test/berrypod/orders/order_submission_worker_test.exs
Normal file
111
test/berrypod/orders/order_submission_worker_test.exs
Normal 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
|
||||
219
test/berrypod/orders_test.exs
Normal file
219
test/berrypod/orders_test.exs
Normal 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
|
||||
130
test/berrypod/products/product_image_test.exs
Normal file
130
test/berrypod/products/product_image_test.exs
Normal 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
|
||||
323
test/berrypod/products/product_test.exs
Normal file
323
test/berrypod/products/product_test.exs
Normal 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
|
||||
201
test/berrypod/products/product_variant_test.exs
Normal file
201
test/berrypod/products/product_variant_test.exs
Normal 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
|
||||
169
test/berrypod/products/provider_connection_test.exs
Normal file
169
test/berrypod/products/provider_connection_test.exs
Normal 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
|
||||
754
test/berrypod/products_test.exs
Normal file
754
test/berrypod/products_test.exs
Normal 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
|
||||
152
test/berrypod/products_upsert_test.exs
Normal file
152
test/berrypod/products_upsert_test.exs
Normal 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
|
||||
487
test/berrypod/providers/printful_test.exs
Normal file
487
test/berrypod/providers/printful_test.exs
Normal 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
|
||||
151
test/berrypod/providers/printify_test.exs
Normal file
151
test/berrypod/providers/printify_test.exs
Normal 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
|
||||
247
test/berrypod/search_test.exs
Normal file
247
test/berrypod/search_test.exs
Normal 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
|
||||
227
test/berrypod/settings_test.exs
Normal file
227
test/berrypod/settings_test.exs
Normal 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
|
||||
94
test/berrypod/setup_test.exs
Normal file
94
test/berrypod/setup_test.exs
Normal 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
|
||||
310
test/berrypod/shipping_test.exs
Normal file
310
test/berrypod/shipping_test.exs
Normal 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
|
||||
55
test/berrypod/stripe/setup_test.exs
Normal file
55
test/berrypod/stripe/setup_test.exs
Normal 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
|
||||
86
test/berrypod/sync/product_sync_worker_test.exs
Normal file
86
test/berrypod/sync/product_sync_worker_test.exs
Normal 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
|
||||
40
test/berrypod/sync/scheduled_sync_worker_test.exs
Normal file
40
test/berrypod/sync/scheduled_sync_worker_test.exs
Normal 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
|
||||
109
test/berrypod/theme/css_cache_test.exs
Normal file
109
test/berrypod/theme/css_cache_test.exs
Normal 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
|
||||
165
test/berrypod/theme/css_generator_test.exs
Normal file
165
test/berrypod/theme/css_generator_test.exs
Normal 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
|
||||
119
test/berrypod/theme/presets_test.exs
Normal file
119
test/berrypod/theme/presets_test.exs
Normal 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
|
||||
275
test/berrypod/theme/preview_data_test.exs
Normal file
275
test/berrypod/theme/preview_data_test.exs
Normal 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
|
||||
106
test/berrypod/vault_test.exs
Normal file
106
test/berrypod/vault_test.exs
Normal 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
|
||||
48
test/berrypod/webhooks/product_delete_worker_test.exs
Normal file
48
test/berrypod/webhooks/product_delete_worker_test.exs
Normal 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
|
||||
276
test/berrypod/webhooks_test.exs
Normal file
276
test/berrypod/webhooks_test.exs
Normal 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
|
||||
Reference in New Issue
Block a user