diff --git a/test/berrypod/reviews/review_request_worker_test.exs b/test/berrypod/reviews/review_request_worker_test.exs new file mode 100644 index 0000000..c4fcea2 --- /dev/null +++ b/test/berrypod/reviews/review_request_worker_test.exs @@ -0,0 +1,149 @@ +defmodule Berrypod.Reviews.ReviewRequestWorkerTest do + use Berrypod.DataCase, async: false + + import Swoosh.TestAssertions + + alias Berrypod.Orders + alias Berrypod.Reviews + alias Berrypod.Reviews.ReviewRequestWorker + + import Berrypod.ProductsFixtures + + defp delivered_order_fixture(attrs \\ %{}) do + conn = provider_connection_fixture(%{provider_type: "printify"}) + product = product_fixture(%{provider_connection: conn}) + variant = product_variant_fixture(%{product: product}) + + # Create order with product_id set on the item so review request can find unreviewable products + items = [ + %{ + variant_id: variant.id, + product_id: product.id, + name: product.title, + variant: variant.title, + price: 1999, + quantity: 1 + } + ] + + order_attrs = + %{ + customer_email: Map.get(attrs, :customer_email, "buyer@example.com"), + currency: "gbp", + items: items + } + + {:ok, order} = Orders.create_order(order_attrs) + + # Mark as paid + {:ok, order} = Orders.mark_paid(order, "pi_test_#{System.unique_integer([:positive])}") + + # Mark as delivered + {:ok, order} = Orders.update_fulfilment(order, %{fulfilment_status: "delivered"}) + + {order, product} + end + + describe "perform/1 — sends review request" do + test "sends review request email for delivered order" do + {order, _product} = delivered_order_fixture() + + assert :ok = + ReviewRequestWorker.new(%{order_id: order.id}) + |> Oban.insert!() + |> then(fn job -> ReviewRequestWorker.perform(job) end) + + # Subject includes shop name so use function to check partial match + assert_email_sent(fn email -> + # to field format varies - just check email address is present + to_string = inspect(email.to) + assert to_string =~ order.customer_email + assert email.subject =~ "How was your order" + end) + end + + test "does not send if customer has already reviewed all products" do + {order, product} = delivered_order_fixture() + + # Create review for the product + {:ok, _review} = + Reviews.create_review(%{ + product_id: product.id, + email: order.customer_email, + author_name: "Buyer", + rating: 5 + }) + + ReviewRequestWorker.perform(%Oban.Job{args: %{"order_id" => order.id}}) + + assert_no_email_sent() + end + + test "does not send if email is suppressed" do + {order, _product} = delivered_order_fixture(%{customer_email: "suppressed@example.com"}) + Orders.add_suppression("suppressed@example.com", "unsubscribed") + + ReviewRequestWorker.perform(%Oban.Job{args: %{"order_id" => order.id}}) + + assert_no_email_sent() + end + + test "cancels if order not found" do + result = + ReviewRequestWorker.perform(%Oban.Job{ + args: %{"order_id" => Ecto.UUID.generate()} + }) + + assert {:cancel, :not_found} = result + assert_no_email_sent() + end + + test "skips if order has no customer email" do + {order, _product} = delivered_order_fixture() + + # Clear email + {:ok, order} = Orders.update_order(order, %{customer_email: nil}) + + result = ReviewRequestWorker.perform(%Oban.Job{args: %{"order_id" => order.id}}) + + assert :ok = result + assert_no_email_sent() + end + + test "skips if order has empty customer email" do + {order, _product} = delivered_order_fixture() + + # Set empty email + {:ok, order} = Orders.update_order(order, %{customer_email: ""}) + + result = ReviewRequestWorker.perform(%Oban.Job{args: %{"order_id" => order.id}}) + + assert :ok = result + assert_no_email_sent() + end + end + + describe "enqueue/1" do + test "creates job with order_id" do + {order, _product} = delivered_order_fixture() + + {:ok, job} = ReviewRequestWorker.enqueue(order.id) + + assert job.args["order_id"] == order.id + end + end + + describe "new/2 with schedule_in" do + test "creates scheduled changeset with delay" do + # Test the changeset creation directly to verify schedule_in is applied + # (Oban inline mode doesn't preserve scheduled_at on insert) + changeset = ReviewRequestWorker.new(%{order_id: "test-id"}, schedule_in: 7 * 24 * 60 * 60) + + assert changeset.changes.args == %{order_id: "test-id"} + assert changeset.changes.scheduled_at != nil + # Should be roughly 7 days in the future + diff = DateTime.diff(changeset.changes.scheduled_at, DateTime.utc_now(), :second) + assert diff > 6 * 24 * 60 * 60 + end + end +end diff --git a/test/berrypod_web/live/admin/reviews_test.exs b/test/berrypod_web/live/admin/reviews_test.exs new file mode 100644 index 0000000..9d93d85 --- /dev/null +++ b/test/berrypod_web/live/admin/reviews_test.exs @@ -0,0 +1,234 @@ +defmodule BerrypodWeb.Admin.ReviewsTest do + use BerrypodWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Berrypod.AccountsFixtures + import Berrypod.ProductsFixtures + + alias Berrypod.Reviews + + setup do + user = user_fixture() + %{user: user} + end + + defp create_review(product, attrs \\ %{}) do + defaults = %{ + product_id: product.id, + email: "reviewer-#{System.unique_integer([:positive])}@example.com", + author_name: "Test Reviewer", + rating: 4 + } + + {:ok, review} = Reviews.create_review(Map.merge(defaults, attrs)) + review + end + + describe "unauthenticated" do + test "redirects to login", %{conn: conn} do + {:error, redirect} = live(conn, ~p"/admin/reviews") + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end + + describe "review list" do + setup %{conn: conn, user: user} do + conn = log_in_user(conn, user) + %{conn: conn} + end + + test "renders empty state when no reviews", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/reviews") + + assert html =~ "No reviews to show" + assert html =~ "Reviews" + end + + test "renders reviews list", %{conn: conn} do + product = product_fixture() + create_review(product, %{author_name: "Jane Doe", rating: 5}) + + {:ok, _view, html} = live(conn, ~p"/admin/reviews") + + assert html =~ product.title + assert html =~ "Jane Doe" + assert html =~ "pending" + end + + test "shows status counts in tabs", %{conn: conn} do + product = product_fixture() + _pending = create_review(product, %{email: "p@test.com"}) + approved = create_review(product, %{email: "a@test.com"}) + Reviews.approve_review(approved) + + {:ok, _view, html} = live(conn, ~p"/admin/reviews") + + # Should show counts - pending has badge + assert html =~ "Pending" + assert html =~ "Approved" + end + + test "filters by pending status", %{conn: conn} do + product = product_fixture() + _pending = create_review(product, %{email: "pending@test.com", author_name: "Pending User"}) + + approved = + create_review(product, %{email: "approved@test.com", author_name: "Approved User"}) + + Reviews.approve_review(approved) + + {:ok, view, _html} = live(conn, ~p"/admin/reviews") + + html = render_click(view, "filter", %{"status" => "pending"}) + + assert html =~ "Pending User" + refute html =~ "Approved User" + end + + test "filters by approved status", %{conn: conn} do + product = product_fixture() + _pending = create_review(product, %{email: "pending@test.com", author_name: "Pending User"}) + + approved = + create_review(product, %{email: "approved@test.com", author_name: "Approved User"}) + + Reviews.approve_review(approved) + + {:ok, view, _html} = live(conn, ~p"/admin/reviews") + + html = render_click(view, "filter", %{"status" => "approved"}) + + assert html =~ "Approved User" + refute html =~ "Pending User" + end + + test "filters by rejected status", %{conn: conn} do + product = product_fixture() + _pending = create_review(product, %{email: "pending@test.com", author_name: "Pending User"}) + + rejected = + create_review(product, %{email: "rejected@test.com", author_name: "Rejected User"}) + + Reviews.reject_review(rejected) + + {:ok, view, _html} = live(conn, ~p"/admin/reviews") + + html = render_click(view, "filter", %{"status" => "rejected"}) + + assert html =~ "Rejected User" + refute html =~ "Pending User" + end + + test "search filters by author name", %{conn: conn} do + product = product_fixture() + create_review(product, %{email: "a@test.com", author_name: "Alice"}) + create_review(product, %{email: "b@test.com", author_name: "Bob"}) + + {:ok, view, _html} = live(conn, ~p"/admin/reviews") + + html = render_submit(view, "search", %{"search" => %{"query" => "Alice"}}) + + assert html =~ "Alice" + refute html =~ "Bob" + end + end + + describe "review moderation" do + setup %{conn: conn, user: user} do + conn = log_in_user(conn, user) + %{conn: conn} + end + + test "expands review to show details", %{conn: conn} do + product = product_fixture() + review = create_review(product, %{title: "Great product!", body: "Would buy again."}) + + {:ok, view, html} = live(conn, ~p"/admin/reviews") + + # Initially collapsed - body not visible + refute html =~ "Would buy again." + + # Expand the review + html = render_click(view, "expand", %{"id" => review.id}) + + assert html =~ "Great product!" + assert html =~ "Would buy again." + end + + test "approves a pending review", %{conn: conn} do + product = product_fixture() + review = create_review(product, %{author_name: "Jane"}) + + {:ok, view, _html} = live(conn, ~p"/admin/reviews") + + # Expand first to see approve button + render_click(view, "expand", %{"id" => review.id}) + html = render_click(view, "approve", %{"id" => review.id}) + + assert html =~ "Review approved" + + # Verify in database + updated = Reviews.get_review(review.id) + assert updated.status == "approved" + end + + test "rejects a pending review", %{conn: conn} do + product = product_fixture() + review = create_review(product, %{author_name: "Jane"}) + + {:ok, view, _html} = live(conn, ~p"/admin/reviews") + + # Expand first to see reject button + render_click(view, "expand", %{"id" => review.id}) + html = render_click(view, "reject", %{"id" => review.id}) + + assert html =~ "Review rejected" + + # Verify in database + updated = Reviews.get_review(review.id) + assert updated.status == "rejected" + end + + test "deletes a review", %{conn: conn} do + product = product_fixture() + review = create_review(product, %{author_name: "Jane"}) + + {:ok, view, _html} = live(conn, ~p"/admin/reviews") + + # Expand first to see delete button + render_click(view, "expand", %{"id" => review.id}) + html = render_click(view, "delete", %{"id" => review.id}) + + assert html =~ "Review deleted" + + # Verify removed from database + assert Reviews.get_review(review.id) == nil + end + + test "shows rating only message when no title/body", %{conn: conn} do + product = product_fixture() + review = create_review(product, %{title: nil, body: nil}) + + {:ok, view, _html} = live(conn, ~p"/admin/reviews") + + html = render_click(view, "expand", %{"id" => review.id}) + + assert html =~ "No written review — rating only" + end + + test "approve button hidden for already approved reviews", %{conn: conn} do + product = product_fixture() + review = create_review(product) + {:ok, review} = Reviews.approve_review(review) + + {:ok, view, _html} = live(conn, ~p"/admin/reviews?status=approved") + + html = render_click(view, "expand", %{"id" => review.id}) + + # Should show reject but not approve + assert html =~ "Reject" + refute html =~ ">Approve" or html =~ "phx-click=\"approve\"" + end + end +end diff --git a/test/berrypod_web/live/shop/review_form_test.exs b/test/berrypod_web/live/shop/review_form_test.exs new file mode 100644 index 0000000..1a598c3 --- /dev/null +++ b/test/berrypod_web/live/shop/review_form_test.exs @@ -0,0 +1,244 @@ +defmodule BerrypodWeb.Shop.ReviewFormTest do + use BerrypodWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Berrypod.AccountsFixtures + import Berrypod.ProductsFixtures + import Berrypod.OrdersFixtures + + alias Berrypod.Reviews + + setup do + user_fixture() + {:ok, _} = Berrypod.Settings.set_site_live(true) + :ok + end + + defp with_email_session(conn, email) do + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session("email_session", email) + end + + describe "review form with valid token" do + setup do + prov_conn = provider_connection_fixture(%{provider_type: "printify"}) + product = product_fixture(%{provider_connection: prov_conn}) + variant = product_variant_fixture(%{product: product}) + + order = + order_fixture(%{ + customer_email: "buyer@example.com", + payment_status: "paid", + variant_id: variant.id + }) + + token = Reviews.generate_review_token("buyer@example.com", product.id) + + %{product: product, order: order, token: token} + end + + test "renders review form", %{conn: conn, product: product, token: token} do + {:ok, _view, html} = live(conn, ~p"/reviews/new?token=#{token}") + + assert html =~ "Write a review" + assert html =~ product.title + assert html =~ "Rating" + assert html =~ "Your name" + assert html =~ "Submit review" + end + + test "pre-fills author name from order shipping address", %{ + conn: conn, + order: order, + token: token + } do + # Update order with shipping address + Berrypod.Orders.update_order(order, %{ + shipping_address: %{"name" => "Jane Doe", "line1" => "123 Test St"} + }) + + {:ok, _view, html} = live(conn, ~p"/reviews/new?token=#{token}") + + assert html =~ "Jane Doe" + end + + test "validates rating is required", %{conn: conn, token: token} do + {:ok, view, _html} = live(conn, ~p"/reviews/new?token=#{token}") + + html = + view + |> form("form", %{"review" => %{"author_name" => "Jane", "rating" => ""}}) + |> render_submit() + + assert html =~ "can't be blank" or html =~ "is invalid" + end + + test "validates author name is required", %{conn: conn, token: token} do + {:ok, view, _html} = live(conn, ~p"/reviews/new?token=#{token}") + + # Set rating via event instead of form data (the hidden field only accepts "") + render_click(view, "set_rating", %{"rating" => "5"}) + + html = + view + |> form("form", %{"review" => %{"author_name" => ""}}) + |> render_submit() + + assert html =~ "can't be blank" + end + + test "sets rating via star buttons", %{conn: conn, token: token} do + {:ok, view, _html} = live(conn, ~p"/reviews/new?token=#{token}") + + # Click on star 4 + render_click(view, "set_rating", %{"rating" => "4"}) + + # The hidden input should have value 4 + html = render(view) + assert html =~ ~r/name="review\[rating\]".*value="4"/s or html =~ "star-filled" + end + + test "submits review successfully", %{conn: conn, product: product, token: token} do + {:ok, view, _html} = live(conn, ~p"/reviews/new?token=#{token}") + + # Set rating first + render_click(view, "set_rating", %{"rating" => "5"}) + + html = + view + |> form("form", %{ + "review" => %{ + "author_name" => "Jane Doe", + "title" => "Great product!", + "body" => "Would recommend to everyone." + } + }) + |> render_submit() + + assert html =~ "Thanks for your review" + assert html =~ "Your review will appear on the product page once it's been approved" + + # Verify review was created + review = Reviews.get_review_by_email_and_product("buyer@example.com", product.id) + assert review + assert review.rating == 5 + assert review.author_name == "Jane Doe" + assert review.title == "Great product!" + assert review.status == "pending" + end + + test "shows error for already reviewed product", %{conn: conn, product: product, token: token} do + # Create existing review first + {:ok, _} = + Reviews.create_review(%{ + product_id: product.id, + email: "buyer@example.com", + author_name: "Jane", + rating: 5 + }) + + {:ok, _view, html} = live(conn, ~p"/reviews/new?token=#{token}") + + assert html =~ "already reviewed" + assert html =~ "Edit your existing review" + end + end + + describe "review form with invalid token" do + test "shows error for invalid token", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/reviews/new?token=invalid_token") + + assert html =~ "expired or is invalid" + assert html =~ "Please request a new one" + end + + test "shows error when no token provided", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/reviews/new") + + assert html =~ "Invalid review link" + end + end + + describe "edit review" do + setup do + prov_conn = provider_connection_fixture(%{provider_type: "printify"}) + product = product_fixture(%{provider_connection: prov_conn}) + variant = product_variant_fixture(%{product: product}) + + _order = + order_fixture(%{ + customer_email: "editor@example.com", + payment_status: "paid", + variant_id: variant.id + }) + + {:ok, review} = + Reviews.create_review(%{ + product_id: product.id, + email: "editor@example.com", + author_name: "Editor", + rating: 4, + title: "Good product", + body: "It works well." + }) + + {:ok, review} = Reviews.approve_review(review) + + %{product: product, review: review} + end + + test "shows edit form with existing values when email session matches", %{ + conn: conn, + product: product, + review: review + } do + conn = with_email_session(conn, "editor@example.com") + + {:ok, _view, html} = live(conn, ~p"/reviews/#{review.id}/edit?product=#{product.slug}") + + assert html =~ "Edit your review" + assert html =~ "Editor" + assert html =~ "Good product" + assert html =~ "It works well" + end + + test "shows error when email session doesn't match", %{conn: conn, review: review} do + conn = with_email_session(conn, "other@example.com") + + {:ok, _view, html} = live(conn, ~p"/reviews/#{review.id}/edit") + + assert html =~ "don't have permission" + end + + test "shows error when no email session", %{conn: conn, review: review} do + {:ok, _view, html} = live(conn, ~p"/reviews/#{review.id}/edit") + + assert html =~ "don't have permission" + end + + test "updates review and resets to pending", %{conn: conn, product: product, review: review} do + conn = with_email_session(conn, "editor@example.com") + + {:ok, view, _html} = live(conn, ~p"/reviews/#{review.id}/edit?product=#{product.slug}") + + html = + view + |> form("form", %{ + "review" => %{ + "title" => "Updated title", + "body" => "Updated body" + } + }) + |> render_submit() + + assert html =~ "Your updated review will appear once approved" + + # Verify review was updated and status reset + updated = Reviews.get_review(review.id) + assert updated.title == "Updated title" + assert updated.body == "Updated body" + assert updated.status == "pending" + end + end +end