add reviews schema and context (phase 2)
All checks were successful
deploy / deploy (push) Successful in 1m4s
All checks were successful
deploy / deploy (push) Successful in 1m4s
- Create reviews table with product/order associations - Add Review schema with create/update/moderation changesets - Add Reviews context with CRUD, purchase verification, aggregates - Add get_images/1 to Media, get_variant/1 to Products - 23 tests covering all context functions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
34822254e3
commit
8dc17a6f4d
@ -1,6 +1,6 @@
|
||||
# Product reviews system
|
||||
|
||||
Status: In Progress (Phase 1 complete)
|
||||
Status: In Progress (Phase 1-2 complete)
|
||||
|
||||
## Overview
|
||||
|
||||
@ -416,13 +416,13 @@ Only include if product has approved reviews.
|
||||
- Orders page -> read from email session
|
||||
- Checkout success -> set email session (via /checkout/complete controller)
|
||||
|
||||
### Phase 2: Reviews schema and context (~2.5h)
|
||||
### Phase 2: Reviews schema and context (~2.5h) ✓
|
||||
|
||||
3. **Reviews migration and schema** (1h)
|
||||
3. **Reviews migration and schema** (1h) ✓
|
||||
- Create migration with image_ids array
|
||||
- Create `Review` schema with changeset
|
||||
|
||||
4. **Reviews context** (1.5h)
|
||||
4. **Reviews context** (1.5h) ✓
|
||||
- CRUD functions
|
||||
- Query helpers (by product, by email, pending)
|
||||
- Purchase verification
|
||||
|
||||
@ -134,6 +134,25 @@ defmodule Berrypod.Media do
|
||||
Repo.get(ImageSchema, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets multiple images by their IDs.
|
||||
Returns images in the order they appear in the IDs list.
|
||||
"""
|
||||
def get_images(ids) when is_list(ids) and ids != [] do
|
||||
images =
|
||||
Repo.all(
|
||||
from i in ImageSchema,
|
||||
where: i.id in ^ids
|
||||
)
|
||||
|
||||
# Return in the order of the input IDs
|
||||
Map.new(images, &{&1.id, &1})
|
||||
|> then(fn map -> Enum.map(ids, &Map.get(map, &1)) end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
def get_images(_), do: []
|
||||
|
||||
@doc """
|
||||
Gets the current logo image.
|
||||
|
||||
|
||||
@ -1004,6 +1004,13 @@ defmodule Berrypod.Products do
|
||||
|> Map.new(&{&1.id, &1})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a variant by ID.
|
||||
"""
|
||||
def get_variant(id) do
|
||||
Repo.get(ProductVariant, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a product variant.
|
||||
"""
|
||||
|
||||
233
lib/berrypod/reviews.ex
Normal file
233
lib/berrypod/reviews.ex
Normal file
@ -0,0 +1,233 @@
|
||||
defmodule Berrypod.Reviews do
|
||||
@moduledoc """
|
||||
Product review management.
|
||||
|
||||
Reviews require verified purchase (via email session) and are moderated
|
||||
before public display. Each email can only review a product once.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Berrypod.Media
|
||||
alias Berrypod.Orders
|
||||
alias Berrypod.Repo
|
||||
alias Berrypod.Reviews.Review
|
||||
|
||||
# ── Queries ────────────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Gets a review by ID.
|
||||
"""
|
||||
def get_review(id) do
|
||||
Repo.get(Review, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a review by ID, raising if not found.
|
||||
"""
|
||||
def get_review!(id) do
|
||||
Repo.get!(Review, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the review for a specific email and product combination.
|
||||
Returns nil if no review exists.
|
||||
"""
|
||||
def get_review_by_email_and_product(email, product_id) do
|
||||
email = normalise_email(email)
|
||||
|
||||
Repo.one(
|
||||
from r in Review,
|
||||
where: r.email == ^email and r.product_id == ^product_id
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists approved reviews for a product, newest first.
|
||||
"""
|
||||
def list_reviews_for_product(product_id, opts \\ []) do
|
||||
status = Keyword.get(opts, :status, "approved")
|
||||
limit = Keyword.get(opts, :limit)
|
||||
|
||||
query =
|
||||
from r in Review,
|
||||
where: r.product_id == ^product_id and r.status == ^status,
|
||||
order_by: [desc: r.inserted_at]
|
||||
|
||||
query = if limit, do: limit(query, ^limit), else: query
|
||||
|
||||
Repo.all(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists pending reviews for admin moderation queue.
|
||||
"""
|
||||
def list_pending_reviews do
|
||||
Repo.all(
|
||||
from r in Review,
|
||||
where: r.status == "pending",
|
||||
order_by: [asc: r.inserted_at],
|
||||
preload: [:product]
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all reviews with optional status filter, for admin.
|
||||
"""
|
||||
def list_reviews(opts \\ []) do
|
||||
status = Keyword.get(opts, :status)
|
||||
|
||||
query =
|
||||
from r in Review,
|
||||
order_by: [desc: r.inserted_at],
|
||||
preload: [:product]
|
||||
|
||||
query = if status, do: where(query, [r], r.status == ^status), else: query
|
||||
|
||||
Repo.all(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Counts pending reviews for admin nav badge.
|
||||
"""
|
||||
def count_pending_reviews do
|
||||
Repo.one(from r in Review, where: r.status == "pending", select: count(r.id))
|
||||
end
|
||||
|
||||
# ── Aggregates ─────────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Returns the average rating and count for a product's approved reviews.
|
||||
Returns `{nil, 0}` if no approved reviews exist.
|
||||
"""
|
||||
def average_rating_for_product(product_id) do
|
||||
result =
|
||||
Repo.one(
|
||||
from r in Review,
|
||||
where: r.product_id == ^product_id and r.status == "approved",
|
||||
select: {avg(r.rating), count(r.id)}
|
||||
)
|
||||
|
||||
case result do
|
||||
{nil, 0} -> {nil, 0}
|
||||
{avg, count} when is_float(avg) -> {Decimal.from_float(avg) |> Decimal.round(1), count}
|
||||
{avg, count} -> {Decimal.round(avg, 1), count}
|
||||
end
|
||||
end
|
||||
|
||||
# ── Purchase verification ──────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Checks if an email has purchased a specific product (via any paid order).
|
||||
Returns true if they have a paid order containing the product.
|
||||
"""
|
||||
def can_review?(email, product_id) do
|
||||
email = normalise_email(email)
|
||||
orders = Orders.list_orders_by_email(email)
|
||||
|
||||
Enum.any?(orders, fn order ->
|
||||
Enum.any?(order.items, fn item ->
|
||||
item_matches_product?(item, product_id)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Finds the first order where this email purchased this product.
|
||||
Used to link the review to an order for "verified purchase" badge.
|
||||
"""
|
||||
def find_matching_order(email, product_id) do
|
||||
email = normalise_email(email)
|
||||
orders = Orders.list_orders_by_email(email)
|
||||
|
||||
Enum.find(orders, fn order ->
|
||||
Enum.any?(order.items, fn item ->
|
||||
item_matches_product?(item, product_id)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
# Check if an order item matches a product by looking up the variant
|
||||
defp item_matches_product?(item, product_id) do
|
||||
case Berrypod.Products.get_variant(item.variant_id) do
|
||||
%{product_id: ^product_id} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
# ── CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Creates a new review.
|
||||
|
||||
The review starts in "pending" status. If the email has a matching order
|
||||
for this product, the order_id is automatically linked.
|
||||
"""
|
||||
def create_review(attrs) do
|
||||
email = attrs[:email] || attrs["email"]
|
||||
product_id = attrs[:product_id] || attrs["product_id"]
|
||||
|
||||
# Auto-link to matching order for "verified purchase" badge
|
||||
order = if email && product_id, do: find_matching_order(email, product_id), else: nil
|
||||
attrs = if order, do: Map.put(attrs, :order_id, order.id), else: attrs
|
||||
|
||||
%Review{}
|
||||
|> Review.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an existing review.
|
||||
Resets status to pending for re-moderation.
|
||||
"""
|
||||
def update_review(%Review{} = review, attrs) do
|
||||
review
|
||||
|> Review.update_changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Approves a review for public display.
|
||||
"""
|
||||
def approve_review(%Review{} = review) do
|
||||
review
|
||||
|> Review.moderation_changeset("approved")
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Rejects a review (won't be displayed).
|
||||
"""
|
||||
def reject_review(%Review{} = review) do
|
||||
review
|
||||
|> Review.moderation_changeset("rejected")
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a review.
|
||||
"""
|
||||
def delete_review(%Review{} = review) do
|
||||
Repo.delete(review)
|
||||
end
|
||||
|
||||
# ── Images ─────────────────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Gets the image records for a review's image_ids.
|
||||
Returns a list of Image structs.
|
||||
"""
|
||||
def get_review_images(%Review{image_ids: ids}) when is_list(ids) and ids != [] do
|
||||
Media.get_images(ids)
|
||||
end
|
||||
|
||||
def get_review_images(_), do: []
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
defp normalise_email(email) when is_binary(email) do
|
||||
email |> String.trim() |> String.downcase()
|
||||
end
|
||||
|
||||
defp normalise_email(_), do: ""
|
||||
end
|
||||
91
lib/berrypod/reviews/review.ex
Normal file
91
lib/berrypod/reviews/review.ex
Normal file
@ -0,0 +1,91 @@
|
||||
defmodule Berrypod.Reviews.Review do
|
||||
@moduledoc """
|
||||
Schema for product reviews.
|
||||
|
||||
Reviews are linked to a product and optionally to an order (for "verified
|
||||
purchase" badge). Each email can only review a product once. Reviews start
|
||||
in pending status and must be approved by an admin before displaying.
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Berrypod.Orders.Order
|
||||
alias Berrypod.Products.Product
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "reviews" do
|
||||
field :email, :string
|
||||
field :author_name, :string
|
||||
field :rating, :integer
|
||||
field :title, :string
|
||||
field :body, :string
|
||||
field :status, :string, default: "pending"
|
||||
field :image_ids, {:array, :binary_id}, default: []
|
||||
|
||||
belongs_to :product, Product
|
||||
belongs_to :order, Order
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for creating a new review.
|
||||
"""
|
||||
def changeset(review, attrs) do
|
||||
review
|
||||
|> cast(attrs, [
|
||||
:product_id,
|
||||
:order_id,
|
||||
:email,
|
||||
:author_name,
|
||||
:rating,
|
||||
:title,
|
||||
:body,
|
||||
:image_ids
|
||||
])
|
||||
|> validate_required([:product_id, :email, :author_name, :rating])
|
||||
|> validate_inclusion(:rating, 1..5)
|
||||
|> validate_length(:title, max: 100)
|
||||
|> validate_length(:body, max: 2000)
|
||||
|> validate_length(:author_name, max: 50)
|
||||
|> validate_length(:image_ids, max: 3)
|
||||
|> foreign_key_constraint(:product_id)
|
||||
|> foreign_key_constraint(:order_id)
|
||||
|> unique_constraint([:email, :product_id], message: "you have already reviewed this product")
|
||||
|> normalise_email()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for updating an existing review.
|
||||
Resets status to pending for re-moderation.
|
||||
"""
|
||||
def update_changeset(review, attrs) do
|
||||
review
|
||||
|> cast(attrs, [:author_name, :rating, :title, :body, :image_ids])
|
||||
|> validate_required([:author_name, :rating])
|
||||
|> validate_inclusion(:rating, 1..5)
|
||||
|> validate_length(:title, max: 100)
|
||||
|> validate_length(:body, max: 2000)
|
||||
|> validate_length(:author_name, max: 50)
|
||||
|> validate_length(:image_ids, max: 3)
|
||||
|> put_change(:status, "pending")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for admin moderation (approve/reject).
|
||||
"""
|
||||
def moderation_changeset(review, status) when status in ["approved", "rejected"] do
|
||||
review
|
||||
|> change(status: status)
|
||||
end
|
||||
|
||||
defp normalise_email(changeset) do
|
||||
case get_change(changeset, :email) do
|
||||
nil -> changeset
|
||||
email -> put_change(changeset, :email, String.downcase(String.trim(email)))
|
||||
end
|
||||
end
|
||||
end
|
||||
27
priv/repo/migrations/20260401095806_create_reviews.exs
Normal file
27
priv/repo/migrations/20260401095806_create_reviews.exs
Normal file
@ -0,0 +1,27 @@
|
||||
defmodule Berrypod.Repo.Migrations.CreateReviews do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:reviews, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :product_id, references(:products, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :order_id, references(:orders, type: :binary_id, on_delete: :nilify_all)
|
||||
add :email, :string, null: false
|
||||
add :author_name, :string, null: false
|
||||
add :rating, :integer, null: false
|
||||
add :title, :string
|
||||
add :body, :text
|
||||
add :status, :string, null: false, default: "pending"
|
||||
add :image_ids, {:array, :binary_id}, default: []
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:reviews, [:email, :product_id])
|
||||
create index(:reviews, [:product_id, :status])
|
||||
create index(:reviews, [:status])
|
||||
end
|
||||
end
|
||||
385
test/berrypod/reviews_test.exs
Normal file
385
test/berrypod/reviews_test.exs
Normal file
@ -0,0 +1,385 @@
|
||||
defmodule Berrypod.ReviewsTest do
|
||||
use Berrypod.DataCase, async: true
|
||||
|
||||
alias Berrypod.Reviews
|
||||
alias Berrypod.Reviews.Review
|
||||
|
||||
import Berrypod.ProductsFixtures
|
||||
import Berrypod.OrdersFixtures
|
||||
|
||||
# ── Create ─────────────────────────────────────────────────────────
|
||||
|
||||
describe "create_review/1" do
|
||||
test "creates a review with valid attrs" do
|
||||
product = product_fixture()
|
||||
|
||||
attrs = %{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 5
|
||||
}
|
||||
|
||||
assert {:ok, %Review{} = review} = Reviews.create_review(attrs)
|
||||
assert review.product_id == product.id
|
||||
assert review.email == "buyer@example.com"
|
||||
assert review.author_name == "Jane"
|
||||
assert review.rating == 5
|
||||
assert review.status == "pending"
|
||||
end
|
||||
|
||||
test "normalises email to lowercase" do
|
||||
product = product_fixture()
|
||||
|
||||
attrs = %{
|
||||
product_id: product.id,
|
||||
email: "BUYER@EXAMPLE.COM",
|
||||
author_name: "Jane",
|
||||
rating: 5
|
||||
}
|
||||
|
||||
{:ok, review} = Reviews.create_review(attrs)
|
||||
assert review.email == "buyer@example.com"
|
||||
end
|
||||
|
||||
test "auto-links to matching order for verified purchase badge" do
|
||||
conn = provider_connection_fixture(%{provider_type: "printify"})
|
||||
product = product_fixture(%{provider_connection: conn})
|
||||
variant = product_variant_fixture(%{product: product})
|
||||
|
||||
order =
|
||||
order_fixture(%{
|
||||
customer_email: "buyer@example.com",
|
||||
payment_status: "paid",
|
||||
variant_id: variant.id
|
||||
})
|
||||
|
||||
attrs = %{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 5
|
||||
}
|
||||
|
||||
{:ok, review} = Reviews.create_review(attrs)
|
||||
assert review.order_id == order.id
|
||||
end
|
||||
|
||||
test "allows optional title and body" do
|
||||
product = product_fixture()
|
||||
|
||||
attrs = %{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 4,
|
||||
title: "Great product!",
|
||||
body: "Would buy again."
|
||||
}
|
||||
|
||||
{:ok, review} = Reviews.create_review(attrs)
|
||||
assert review.title == "Great product!"
|
||||
assert review.body == "Would buy again."
|
||||
end
|
||||
|
||||
test "validates rating is 1-5" do
|
||||
product = product_fixture()
|
||||
|
||||
attrs = %{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 6
|
||||
}
|
||||
|
||||
assert {:error, changeset} = Reviews.create_review(attrs)
|
||||
assert "is invalid" in errors_on(changeset).rating
|
||||
end
|
||||
|
||||
test "enforces one review per email+product" do
|
||||
product = product_fixture()
|
||||
|
||||
attrs = %{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 5
|
||||
}
|
||||
|
||||
{:ok, _} = Reviews.create_review(attrs)
|
||||
{:error, changeset} = Reviews.create_review(attrs)
|
||||
assert "you have already reviewed this product" in errors_on(changeset).email
|
||||
end
|
||||
end
|
||||
|
||||
# ── Read ───────────────────────────────────────────────────────────
|
||||
|
||||
describe "get_review/1" do
|
||||
test "returns the review" do
|
||||
product = product_fixture()
|
||||
|
||||
{:ok, review} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 5
|
||||
})
|
||||
|
||||
assert Reviews.get_review(review.id) == review
|
||||
end
|
||||
|
||||
test "returns nil for unknown id" do
|
||||
assert Reviews.get_review(Ecto.UUID.generate()) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_review_by_email_and_product/2" do
|
||||
test "returns the review for the email+product" do
|
||||
product = product_fixture()
|
||||
|
||||
{:ok, review} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 5
|
||||
})
|
||||
|
||||
assert Reviews.get_review_by_email_and_product("buyer@example.com", product.id) == review
|
||||
end
|
||||
|
||||
test "returns nil when no review exists" do
|
||||
product = product_fixture()
|
||||
assert Reviews.get_review_by_email_and_product("nobody@example.com", product.id) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_reviews_for_product/2" do
|
||||
test "lists only approved reviews by default" do
|
||||
product = product_fixture()
|
||||
|
||||
{:ok, pending} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "pending@example.com",
|
||||
author_name: "Pending",
|
||||
rating: 3
|
||||
})
|
||||
|
||||
{:ok, approved} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "approved@example.com",
|
||||
author_name: "Approved",
|
||||
rating: 5
|
||||
})
|
||||
|
||||
Reviews.approve_review(approved)
|
||||
|
||||
reviews = Reviews.list_reviews_for_product(product.id)
|
||||
assert length(reviews) == 1
|
||||
assert hd(reviews).email == "approved@example.com"
|
||||
refute Enum.any?(reviews, &(&1.id == pending.id))
|
||||
end
|
||||
|
||||
test "can filter by status" do
|
||||
product = product_fixture()
|
||||
|
||||
{:ok, _} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "pending@example.com",
|
||||
author_name: "Pending",
|
||||
rating: 3
|
||||
})
|
||||
|
||||
reviews = Reviews.list_reviews_for_product(product.id, status: "pending")
|
||||
assert length(reviews) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_pending_reviews/0" do
|
||||
test "lists pending reviews with preloaded product" do
|
||||
product = product_fixture()
|
||||
|
||||
{:ok, _} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 5
|
||||
})
|
||||
|
||||
reviews = Reviews.list_pending_reviews()
|
||||
assert length(reviews) == 1
|
||||
assert hd(reviews).product.id == product.id
|
||||
end
|
||||
end
|
||||
|
||||
# ── Update ─────────────────────────────────────────────────────────
|
||||
|
||||
describe "update_review/2" do
|
||||
test "updates the review and resets status to pending" do
|
||||
product = product_fixture()
|
||||
|
||||
{:ok, review} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 5
|
||||
})
|
||||
|
||||
Reviews.approve_review(review)
|
||||
|
||||
{:ok, updated} = Reviews.update_review(review, %{rating: 4, body: "Updated!"})
|
||||
assert updated.rating == 4
|
||||
assert updated.body == "Updated!"
|
||||
assert updated.status == "pending"
|
||||
end
|
||||
end
|
||||
|
||||
# ── Moderation ─────────────────────────────────────────────────────
|
||||
|
||||
describe "approve_review/1" do
|
||||
test "sets status to approved" do
|
||||
product = product_fixture()
|
||||
|
||||
{:ok, review} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 5
|
||||
})
|
||||
|
||||
{:ok, approved} = Reviews.approve_review(review)
|
||||
assert approved.status == "approved"
|
||||
end
|
||||
end
|
||||
|
||||
describe "reject_review/1" do
|
||||
test "sets status to rejected" do
|
||||
product = product_fixture()
|
||||
|
||||
{:ok, review} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 5
|
||||
})
|
||||
|
||||
{:ok, rejected} = Reviews.reject_review(review)
|
||||
assert rejected.status == "rejected"
|
||||
end
|
||||
end
|
||||
|
||||
# ── Aggregates ─────────────────────────────────────────────────────
|
||||
|
||||
describe "average_rating_for_product/1" do
|
||||
test "returns average and count for approved reviews" do
|
||||
product = product_fixture()
|
||||
|
||||
{:ok, r1} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "a@example.com",
|
||||
author_name: "A",
|
||||
rating: 5
|
||||
})
|
||||
|
||||
{:ok, r2} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "b@example.com",
|
||||
author_name: "B",
|
||||
rating: 4
|
||||
})
|
||||
|
||||
Reviews.approve_review(r1)
|
||||
Reviews.approve_review(r2)
|
||||
|
||||
{avg, count} = Reviews.average_rating_for_product(product.id)
|
||||
assert Decimal.equal?(avg, Decimal.new("4.5"))
|
||||
assert count == 2
|
||||
end
|
||||
|
||||
test "returns {nil, 0} when no approved reviews" do
|
||||
product = product_fixture()
|
||||
assert {nil, 0} = Reviews.average_rating_for_product(product.id)
|
||||
end
|
||||
|
||||
test "excludes pending and rejected reviews" do
|
||||
product = product_fixture()
|
||||
|
||||
{:ok, _} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "pending@example.com",
|
||||
author_name: "Pending",
|
||||
rating: 1
|
||||
})
|
||||
|
||||
assert {nil, 0} = Reviews.average_rating_for_product(product.id)
|
||||
end
|
||||
end
|
||||
|
||||
# ── Purchase verification ──────────────────────────────────────────
|
||||
|
||||
describe "can_review?/2" do
|
||||
test "returns true when email has purchased the product" do
|
||||
conn = provider_connection_fixture(%{provider_type: "printify"})
|
||||
product = product_fixture(%{provider_connection: conn})
|
||||
variant = product_variant_fixture(%{product: product})
|
||||
|
||||
order_fixture(%{
|
||||
customer_email: "buyer@example.com",
|
||||
payment_status: "paid",
|
||||
variant_id: variant.id
|
||||
})
|
||||
|
||||
assert Reviews.can_review?("buyer@example.com", product.id)
|
||||
end
|
||||
|
||||
test "returns false when email has not purchased the product" do
|
||||
product = product_fixture()
|
||||
refute Reviews.can_review?("nobody@example.com", product.id)
|
||||
end
|
||||
|
||||
test "returns false when order exists but for different product" do
|
||||
conn = provider_connection_fixture(%{provider_type: "printify"})
|
||||
product1 = product_fixture(%{provider_connection: conn})
|
||||
product2 = product_fixture(%{provider_connection: conn})
|
||||
variant1 = product_variant_fixture(%{product: product1})
|
||||
|
||||
order_fixture(%{
|
||||
customer_email: "buyer@example.com",
|
||||
payment_status: "paid",
|
||||
variant_id: variant1.id
|
||||
})
|
||||
|
||||
refute Reviews.can_review?("buyer@example.com", product2.id)
|
||||
end
|
||||
end
|
||||
|
||||
# ── Delete ─────────────────────────────────────────────────────────
|
||||
|
||||
describe "delete_review/1" do
|
||||
test "deletes the review" do
|
||||
product = product_fixture()
|
||||
|
||||
{:ok, review} =
|
||||
Reviews.create_review(%{
|
||||
product_id: product.id,
|
||||
email: "buyer@example.com",
|
||||
author_name: "Jane",
|
||||
rating: 5
|
||||
})
|
||||
|
||||
{:ok, _} = Reviews.delete_review(review)
|
||||
assert Reviews.get_review(review.id) == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user