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
|
# Product reviews system
|
||||||
|
|
||||||
Status: In Progress (Phase 1 complete)
|
Status: In Progress (Phase 1-2 complete)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@ -416,13 +416,13 @@ Only include if product has approved reviews.
|
|||||||
- Orders page -> read from email session
|
- Orders page -> read from email session
|
||||||
- Checkout success -> set email session (via /checkout/complete controller)
|
- 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 migration with image_ids array
|
||||||
- Create `Review` schema with changeset
|
- Create `Review` schema with changeset
|
||||||
|
|
||||||
4. **Reviews context** (1.5h)
|
4. **Reviews context** (1.5h) ✓
|
||||||
- CRUD functions
|
- CRUD functions
|
||||||
- Query helpers (by product, by email, pending)
|
- Query helpers (by product, by email, pending)
|
||||||
- Purchase verification
|
- Purchase verification
|
||||||
|
|||||||
@ -134,6 +134,25 @@ defmodule Berrypod.Media do
|
|||||||
Repo.get(ImageSchema, id)
|
Repo.get(ImageSchema, id)
|
||||||
end
|
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 """
|
@doc """
|
||||||
Gets the current logo image.
|
Gets the current logo image.
|
||||||
|
|
||||||
|
|||||||
@ -1004,6 +1004,13 @@ defmodule Berrypod.Products do
|
|||||||
|> Map.new(&{&1.id, &1})
|
|> Map.new(&{&1.id, &1})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets a variant by ID.
|
||||||
|
"""
|
||||||
|
def get_variant(id) do
|
||||||
|
Repo.get(ProductVariant, id)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Creates a product variant.
|
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