add persistent email session for order lookup and reviews
All checks were successful
deploy / deploy (push) Successful in 1m13s

Replaces the short-lived (1 hour) session-based order lookup with a
persistent cookie-based email session lasting 30 days. This foundation
enables customers to leave reviews and view orders without re-verifying
their email each time.

- Add EmailSession module for signed cookie management
- Add EmailSession plug to load verified email into session
- Set email session on order lookup verification
- Set email session on checkout completion (via /checkout/complete)
- Update orders and order detail pages to use email session
- Add reviews system plan document

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-04-01 09:44:53 +01:00
parent 3b23a413ed
commit 34822254e3
13 changed files with 811 additions and 5 deletions

View File

@ -0,0 +1,540 @@
# Product reviews system
Status: In Progress (Phase 1 complete)
## Overview
A review system for verified purchasers without requiring customer accounts. Uses email verification with a persistent browser session token (similar to "remember me") so customers can leave reviews, view orders, and edit reviews without re-verifying each time.
Customers can upload photos with their reviews (1-3 images). Photos display inline as thumbnails and open in the existing lightbox component.
## Design principles
1. **No customer accounts** — email verification only, stored as a browser session
2. **One review per email+product** — prevents spam, simplifies UX
3. **Verified purchases only** — must have a paid order containing the product
4. **Edit forever** — via new magic link if session expired, resets to pending for re-moderation
5. **Moderation by default** — reviews start as pending, admin approves
6. **Photos optional** — 1-3 images per review, no video (yet)
---
## Email session token
Replaces the old short-lived session-based order lookup with a persistent cookie.
### Flow (30 days, multi-purpose)
- User enters email on `/contact` → system sends verification link with 1-hour token
- On verify, sets a signed cookie: `email_session` containing email
- Cookie lasts 30 days, httponly, secure, same-site lax
- Also set on checkout completion (via `/checkout/complete` controller)
- Cookie checked by: order lookup, order detail, review submission, review editing
### Implementation
**New module:** `lib/berrypod/email_session.ex`
```elixir
defmodule Berrypod.EmailSession do
@cookie_name "email_session"
@max_age 30 * 24 * 60 * 60 # 30 days
@salt "email_session_v1"
def put_session(conn, email) do
token = Phoenix.Token.sign(BerrypodWeb.Endpoint, @salt, email)
Plug.Conn.put_resp_cookie(conn, @cookie_name, token,
max_age: @max_age,
http_only: true,
secure: Mix.env() == :prod,
same_site: "Lax"
)
end
def get_email(conn) do
with token when is_binary(token) <- conn.cookies[@cookie_name],
{:ok, email} <- Phoenix.Token.verify(BerrypodWeb.Endpoint, @salt, token, max_age: @max_age) do
{:ok, email}
else
_ -> :error
end
end
def clear_session(conn) do
Plug.Conn.delete_resp_cookie(conn, @cookie_name)
end
end
```
**Plug for LiveView access:** `lib/berrypod_web/plugs/email_session.ex`
Adds `email_session` to conn assigns so LiveViews can access via `@email_session`.
**Update existing flows:**
- `OrderLookupController.verify/2` — call `EmailSession.put_session/2`
- `Shop.Pages.Orders` — check `EmailSession` cookie instead of session
- Checkout success — call `EmailSession.put_session/2`
---
## Reviews schema
**Migration:** `priv/repo/migrations/xxx_create_reviews.exs`
```elixir
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, default: "pending" # pending, approved, rejected
add :image_ids, {:array, :binary_id}, default: [] # references to images table
timestamps()
end
create unique_index(:reviews, [:email, :product_id])
create index(:reviews, [:product_id, :status])
create index(:reviews, [:status])
```
**Schema:** `lib/berrypod/reviews/review.ex`
Fields:
- `id` — binary_id
- `product_id` — required, references products
- `order_id` — optional, for "verified purchase" badge (first matching order)
- `email` — required, normalised lowercase
- `author_name` — required, display name
- `rating` — required, 1-5
- `title` — optional
- `body` — optional
- `status` — pending/approved/rejected
- `image_ids` — array of binary_ids referencing images table (max 3)
**Context:** `lib/berrypod/reviews.ex`
Functions:
- `get_review(id)`
- `get_review_by_email_and_product(email, product_id)` — for edit check
- `list_reviews_for_product(product_id, status: :approved)` — public display
- `list_pending_reviews()` — admin queue
- `create_review(attrs)` — validates purchase, uniqueness
- `update_review(review, attrs)` — resets status to pending
- `approve_review(review)`
- `reject_review(review)`
- `average_rating_for_product(product_id)` — returns {avg, count}
- `can_review?(email, product_id)` — checks if email has purchased this product
- `get_review_images(review)` — preloads image records for a review
---
## Review photos
### Storage approach
Review photos stored in existing `images` table, referenced by `image_ids` array in the review. This reuses the existing media/image infrastructure:
- Same upload pipeline
- Same optimisation (Oban jobs)
- Same serving (blob storage)
Not added to the main media library (no `media_items` record) — just raw images for reviews.
### Upload flow
1. User selects photos in review form (standard file input, accepts jpg/png/webp/heic)
2. Photos uploaded via LiveView `allow_upload` with 3 file limit
3. On submit, photos processed through existing image pipeline
4. Image IDs stored in review's `image_ids` array
5. EXIF data stripped for privacy
### Display
Extract a generic `image_lightbox` component from the existing `product_lightbox`, then create a simpler `review_photos` component for inline thumbnails.
**New component:** `review_photos` in `shop_components/content.ex`
```elixir
attr :images, :list, required: true # list of image URLs
attr :review_id, :string, required: true
def review_photos(assigns) do
~H"""
<div :if={@images != []} class="review-photos">
<button
:for={{url, idx} <- Enum.with_index(@images)}
type="button"
class="review-photo-thumb"
phx-click={
JS.exec("data-show", to: "#review-#{@review_id}-lightbox")
|> JS.set_attribute({"data-current-index", to_string(idx)},
to: "#review-#{@review_id}-lightbox")
}
>
<img src={thumb_url(url)} alt="Customer photo" loading="lazy" />
</button>
<.image_lightbox
id={"review-#{@review_id}-lightbox"}
images={@images}
alt="Customer photo"
/>
</div>
"""
end
```
The existing `Lightbox` JS hook works unchanged — it's already generic.
### Moderation
Photos shown to admin alongside review text during moderation. Admin approves/rejects the whole review (no separate photo moderation).
---
## Review submission flows
### 1. From product page (email verification)
**UI on product page:**
```
+-------------------------------------+
| Write a review |
| |
| [email@example.com ] [Continue] |
| |
| We'll send a link to verify your |
| purchase and let you leave a review |
+-------------------------------------+
```
**Flow:**
1. User enters email
2. System checks: has this email purchased this product?
- No -> "We couldn't find a matching order for this product"
- Yes -> send verification email with review link
3. Email contains link: `/reviews/new?token=xxx`
4. Token encodes: `{email, product_id}`
5. On click -> set email session cookie -> show review form
**If they already have a valid email session:**
- Skip email entry, show review form directly
- Or show "Leave a review as email@example.com" with form
**If they've already reviewed this product:**
- Show their existing review with "Edit" option
### 2. From order lookup page
On `/orders`, for each delivered order line item:
```
+-------------------------------------+
| Mountain Sunrise Canvas |
| Delivered 15 Jan 2024 |
| |
| [Write a review] or [Edit review] |
+-------------------------------------+
```
Since they're already verified (have email session), clicking goes straight to review form.
### 3. Via email request (post-delivery)
**Oban job:** `lib/berrypod/reviews/review_request_job.ex`
Scheduled X days after order marked delivered:
- Check if customer has already reviewed all products in order
- Send email with links to review each unreviewed product
- Each link is a signed token for that email+product
**Email template:**
```
Subject: How was your order from {shop_name}?
Hi {name},
Your order #{ref} was delivered {days} days ago. We'd love to hear what you think!
{for each product}
[{product_title}] - [Leave a review]
{end}
Thanks for shopping with us!
```
**Admin setting:** Days after delivery to send request (default: 7)
---
## Review form
**Route:** `GET /reviews/new?token=xxx` or `GET /products/:slug/review`
**LiveView:** `lib/berrypod_web/live/shop/review_form.ex`
**Form fields:**
- Rating (required) — 5 star selector
- Title (optional) — text input, max 100 chars
- Body (optional) — textarea, max 2000 chars
- Name (required) — pre-filled from order if available, max 50 chars
- Photos (optional) — file upload, max 3 images
**Photo upload UI:**
- "Add photos (optional)" button with camera icon
- Shows thumbnail previews after selection
- Easy remove button on each thumbnail
- Accepts jpg, png, webp, heic
**Validation:**
- Rating 1-5
- Title max 100 chars
- Body max 2000 chars
- Name max 50 chars
- Max 3 photos, max 10MB each
**On submit:**
- Upload and process photos via existing image pipeline
- Create review with status: pending
- Show confirmation: "Thanks! Your review will appear once approved."
- Redirect to product page
**Edit mode:**
- Same form, pre-filled with existing review
- Existing photos shown with remove option
- Submit updates review, resets status to pending
- Show: "Your updated review will appear once approved."
---
## Reviews display
### Product page
**Update `reviews_section` component** to accept real data:
```elixir
# In product page init
reviews = Reviews.list_reviews_for_product(product.id, status: :approved)
{avg, count} = Reviews.average_rating_for_product(product.id)
assign(socket,
reviews: reviews,
average_rating: avg,
review_count: count
)
```
**Component changes:**
- Show "Verified purchase" badge if `review.order_id` present
- Show review date as relative time
- Show review photos as clickable thumbnails (using `review_photos` component)
- Pagination if > 10 reviews
### Product cards (optional enhancement)
Show average rating + count on collection/home pages:
```
****- (24)
```
Requires preloading aggregate data — consider caching in products table:
- `rating_avg` decimal
- `rating_count` integer
- Updated via Oban job or on review approval
---
## Admin moderation
**Route:** `/admin/reviews`
**LiveView:** `lib/berrypod_web/live/admin/reviews.ex`
**Features:**
- Tabs: Pending | Approved | Rejected
- List with: product, author, rating, excerpt, photos (thumbnails), date
- Click to expand full review with full-size photos
- Approve / Reject buttons
- Bulk actions: approve selected, reject selected
**Counts in nav:**
- Show pending count badge on Reviews nav item
**Optional:** Email notification to admin on new review
---
## Schema markup
**Update product JSON-LD** in `lib/berrypod_web/components/seo_components.ex`:
```json
{
"@type": "Product",
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.5",
"reviewCount": "24",
"bestRating": "5",
"worstRating": "1"
},
"review": [
{
"@type": "Review",
"author": {"@type": "Person", "name": "Jane D."},
"datePublished": "2024-01-15",
"reviewRating": {
"@type": "Rating",
"ratingValue": "5"
},
"reviewBody": "..."
}
]
}
```
Only include if product has approved reviews.
---
## Task breakdown
### Phase 1: Email session foundation (~2h) ✓
1. **Email session module** (1h) ✓
- Create `Berrypod.EmailSession` module
- Create plug to load into assigns
- Add to endpoint/router
2. **Update existing flows** (1h) ✓
- Order lookup -> set email session on verify
- Orders page -> read from email session
- Checkout success -> set email session (via /checkout/complete controller)
### Phase 2: Reviews schema and context (~2.5h)
3. **Reviews migration and schema** (1h)
- Create migration with image_ids array
- Create `Review` schema with changeset
4. **Reviews context** (1.5h)
- CRUD functions
- Query helpers (by product, by email, pending)
- Purchase verification
- Image preloading helpers
### Phase 3: Review submission (~4h)
5. **Product page review section** (1.5h)
- Email entry form (if no session)
- Review form (if session + can review)
- Edit existing review
- Verification email sending
6. **Review form LiveView** (2h)
- Token verification
- Form with star rating
- Photo upload with LiveView uploads (max 3)
- Create/update handling
- Photo processing via existing pipeline
7. **Orders page integration** (0.5h)
- "Write a review" / "Edit review" buttons
- Link to review form
### Phase 4: Display and components (~2.5h)
8. **Extract image_lightbox component** (0.5h)
- Generic lightbox from product_lightbox
- Same JS hook, just cleaner component interface
9. **Review photos component** (0.5h)
- Inline thumbnails
- Opens image_lightbox on click
10. **Product page display** (1.5h)
- Update reviews_section to use real data
- Verified purchase badge
- Review photos display
- Pagination
### Phase 5: Admin moderation (~2h)
11. **Admin reviews list** (1.5h)
- Reviews list with tabs (pending/approved/rejected)
- Photo thumbnails in list
- Expand to see full review + photos
- Approve/reject actions
12. **Nav and notifications** (0.5h)
- Pending count badge in admin nav
- Optional: email to admin on new review
### Phase 6: Automation and SEO (~2h)
13. **Review request emails** (1h)
- Oban job for post-delivery requests
- Email template
- Admin setting for delay
14. **Schema markup** (0.5h)
- AggregateRating on product pages
- Individual Review markup
15. **Rating cache on products** (0.5h)
- Add rating_avg, rating_count to products
- Update on review approval
- Display on product cards
---
## Files to create
- `lib/berrypod/email_session.ex` — email session token handling
- `lib/berrypod_web/plugs/email_session.ex` — plug to load session into assigns
- `priv/repo/migrations/xxx_create_reviews.exs` — reviews table
- `lib/berrypod/reviews/review.ex` — Review schema
- `lib/berrypod/reviews.ex` — Reviews context
- `lib/berrypod/reviews/review_notifier.ex` — review verification emails
- `lib/berrypod/reviews/review_request_job.ex` — Oban job for post-delivery emails
- `lib/berrypod_web/live/shop/review_form.ex` — review submission form
- `lib/berrypod_web/live/admin/reviews.ex` — admin moderation
## Files to modify
- `lib/berrypod_web/router.ex` — new routes
- `lib/berrypod_web/controllers/order_lookup_controller.ex` — set email session
- `lib/berrypod_web/live/shop/pages/orders.ex` — review buttons
- `lib/berrypod_web/live/shop/pages/product.ex` — review section with real data
- `lib/berrypod_web/components/shop_components/product.ex` — extract image_lightbox
- `lib/berrypod_web/components/shop_components/content.ex` — reviews_section updates, review_photos component
- `lib/berrypod_web/components/seo_components.ex` — review schema markup
- `lib/berrypod/pages/block_types.ex` — real data loader for reviews
- `lib/berrypod_web/components/admin_components.ex` — nav badge for pending reviews
- `lib/berrypod/products/product.ex` — optional rating_avg, rating_count fields
---
## Open questions
1. **Delay for review request email** — 7 days after delivery? Make configurable in admin settings?
2. **Review replies** — can shop owner reply to reviews publicly? (later feature)
3. **Import reviews** — any need to import from other platforms like Etsy?
4. **Incentives** — discount code for leaving a review? (later feature)
5. **Photo size limit** — 10MB per photo reasonable? Or smaller?
---
## Total estimate
~15 hours across 6 phases. Can be broken into sessions:
- Session 1: Phases 1-2 (email session + schema) ~4.5h
- Session 2: Phase 3 (submission flows) ~4h
- Session 3: Phases 4-5 (display + admin) ~4.5h
- Session 4: Phase 6 (automation + SEO) ~2h

View File

@ -0,0 +1,67 @@
defmodule Berrypod.EmailSession do
@moduledoc """
Manages persistent email sessions for verified customers.
Used for order lookup, review submission, and review editing without
requiring re-verification each time. The session is stored as a signed
cookie that lasts 30 days.
Unlike the short-lived order lookup tokens (1 hour), this provides a
"remember me" style experience for returning customers.
"""
import Plug.Conn
@cookie_name "email_session"
@max_age 30 * 24 * 60 * 60
@salt "email_session_v1"
@doc """
Sets the email session cookie for a verified email address.
Call this after successful email verification (order lookup, review
submission, checkout completion) to enable the customer to access
their orders and reviews without re-verifying.
"""
def put_session(conn, email) when is_binary(email) do
email = String.downcase(String.trim(email))
token = Phoenix.Token.sign(BerrypodWeb.Endpoint, @salt, email)
put_resp_cookie(conn, @cookie_name, token,
max_age: @max_age,
http_only: true,
secure: Application.get_env(:berrypod, :env) == :prod,
same_site: "Lax"
)
end
@doc """
Retrieves the verified email address from the session cookie.
Returns `{:ok, email}` if the cookie is valid and not expired,
or `:error` otherwise.
"""
def get_email(conn) do
with token when is_binary(token) <- conn.cookies[@cookie_name],
{:ok, email} <-
Phoenix.Token.verify(BerrypodWeb.Endpoint, @salt, token, max_age: @max_age) do
{:ok, email}
else
_ -> :error
end
end
@doc """
Clears the email session cookie.
Use when the customer explicitly logs out or requests to be forgotten.
"""
def clear_session(conn) do
delete_resp_cookie(conn, @cookie_name)
end
@doc """
Returns the cookie name for testing purposes.
"""
def cookie_name, do: @cookie_name
end

View File

@ -71,7 +71,7 @@ defmodule BerrypodWeb.CheckoutController do
%{ %{
mode: "payment", mode: "payment",
line_items: line_items, line_items: line_items,
success_url: R.url(R.checkout_success()) <> "?session_id={CHECKOUT_SESSION_ID}", success_url: R.url("/checkout/complete") <> "?session_id={CHECKOUT_SESSION_ID}",
cancel_url: R.url(R.cart()), cancel_url: R.url(R.cart()),
metadata: %{"order_id" => order.id}, metadata: %{"order_id" => order.id},
shipping_address_collection: %{ shipping_address_collection: %{

View File

@ -0,0 +1,32 @@
defmodule BerrypodWeb.CheckoutSuccessController do
@moduledoc """
Handles the redirect back from Stripe checkout.
This controller intercepts the Stripe redirect to set the email session
cookie before forwarding to the checkout success LiveView. This allows
customers to later view their orders and leave reviews without needing
to re-verify their email.
"""
use BerrypodWeb, :controller
alias Berrypod.{EmailSession, Orders}
def show(conn, %{"session_id" => session_id}) do
# Look up the order to get the customer email
order = Orders.get_order_by_stripe_session(session_id)
conn =
if order && order.customer_email do
EmailSession.put_session(conn, order.customer_email)
else
conn
end
redirect(conn, to: R.checkout_success() <> "?session_id=#{session_id}")
end
def show(conn, _params) do
redirect(conn, to: R.home())
end
end

View File

@ -1,6 +1,7 @@
defmodule BerrypodWeb.OrderLookupController do defmodule BerrypodWeb.OrderLookupController do
use BerrypodWeb, :controller use BerrypodWeb, :controller
alias Berrypod.EmailSession
alias Berrypod.Orders alias Berrypod.Orders
alias Berrypod.Orders.OrderNotifier alias Berrypod.Orders.OrderNotifier
@ -44,7 +45,7 @@ defmodule BerrypodWeb.OrderLookupController do
case Phoenix.Token.verify(BerrypodWeb.Endpoint, @salt, token, max_age: @max_age) do case Phoenix.Token.verify(BerrypodWeb.Endpoint, @salt, token, max_age: @max_age) do
{:ok, email} -> {:ok, email} ->
conn conn
|> put_session(:order_lookup_email, email) |> EmailSession.put_session(email)
|> redirect(to: R.orders()) |> redirect(to: R.orders())
{:error, :expired} -> {:error, :expired} ->

View File

@ -16,7 +16,7 @@ defmodule BerrypodWeb.Shop.Pages.OrderDetail do
socket = socket =
socket socket
|> assign(:lookup_email, session["order_lookup_email"]) |> assign(:lookup_email, session["email_session"])
|> assign(:page, page) |> assign(:page, page)
{:noreply, socket} {:noreply, socket}

View File

@ -1,6 +1,9 @@
defmodule BerrypodWeb.Shop.Pages.Orders do defmodule BerrypodWeb.Shop.Pages.Orders do
@moduledoc """ @moduledoc """
Orders list page handler for the unified Shop.Page LiveView. Orders list page handler for the unified Shop.Page LiveView.
Uses the email session cookie (30 days) set during order lookup
verification or checkout completion.
""" """
import Phoenix.Component, only: [assign: 3] import Phoenix.Component, only: [assign: 3]
@ -8,7 +11,7 @@ defmodule BerrypodWeb.Shop.Pages.Orders do
alias Berrypod.{Orders, Pages} alias Berrypod.{Orders, Pages}
def init(socket, _params, _uri, session) do def init(socket, _params, _uri, session) do
email = session["order_lookup_email"] email = session["email_session"]
page = Pages.get_page("orders") page = Pages.get_page("orders")
socket = socket =

View File

@ -0,0 +1,26 @@
defmodule BerrypodWeb.Plugs.EmailSession do
@moduledoc """
Plug that loads the verified email from the email session cookie into assigns.
This makes `@email_session` available in controllers and LiveViews,
containing the verified email address if the customer has one.
"""
import Plug.Conn
alias Berrypod.EmailSession
def init(opts), do: opts
def call(conn, _opts) do
case EmailSession.get_email(conn) do
{:ok, email} ->
conn
|> assign(:email_session, email)
|> put_session("email_session", email)
:error ->
assign(conn, :email_session, nil)
end
end
end

View File

@ -14,6 +14,7 @@ defmodule BerrypodWeb.Router do
plug :protect_from_forgery plug :protect_from_forgery
plug :put_secure_browser_headers plug :put_secure_browser_headers
plug :fetch_current_scope_for_user plug :fetch_current_scope_for_user
plug BerrypodWeb.Plugs.EmailSession
plug BerrypodWeb.Plugs.CountryDetect plug BerrypodWeb.Plugs.CountryDetect
plug BerrypodWeb.Plugs.LoadTheme plug BerrypodWeb.Plugs.LoadTheme
end end
@ -219,10 +220,12 @@ defmodule BerrypodWeb.Router do
end end
# Order lookup verification — sets session email then redirects to /orders # Order lookup verification — sets session email then redirects to /orders
# Checkout complete — sets email session cookie then redirects to success page
scope "/", BerrypodWeb do scope "/", BerrypodWeb do
pipe_through [:browser] pipe_through [:browser]
get "/orders/verify/:token", OrderLookupController, :verify get "/orders/verify/:token", OrderLookupController, :verify
get "/checkout/complete", CheckoutSuccessController, :show
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
get "/newsletter/confirm/:token", NewsletterController, :confirm get "/newsletter/confirm/:token", NewsletterController, :confirm
end end

View File

@ -0,0 +1,64 @@
defmodule Berrypod.EmailSessionTest do
use BerrypodWeb.ConnCase, async: true
alias Berrypod.EmailSession
describe "put_session/2" do
test "sets a signed cookie with the email", %{conn: conn} do
conn = EmailSession.put_session(conn, "test@example.com")
assert cookie = conn.resp_cookies[EmailSession.cookie_name()]
assert cookie.max_age == 30 * 24 * 60 * 60
assert cookie.http_only == true
assert cookie.same_site == "Lax"
end
test "normalises email to lowercase", %{conn: conn} do
conn = EmailSession.put_session(conn, "TEST@EXAMPLE.COM")
cookie = conn.resp_cookies[EmailSession.cookie_name()]
# The cookie value is a signed token, so we verify by getting it back
conn = %{conn | cookies: %{EmailSession.cookie_name() => cookie.value}}
assert {:ok, "test@example.com"} = EmailSession.get_email(conn)
end
test "trims whitespace from email", %{conn: conn} do
conn = EmailSession.put_session(conn, " test@example.com ")
cookie = conn.resp_cookies[EmailSession.cookie_name()]
conn = %{conn | cookies: %{EmailSession.cookie_name() => cookie.value}}
assert {:ok, "test@example.com"} = EmailSession.get_email(conn)
end
end
describe "get_email/1" do
test "returns the email from a valid cookie", %{conn: conn} do
conn = EmailSession.put_session(conn, "buyer@shop.com")
cookie = conn.resp_cookies[EmailSession.cookie_name()]
conn = %{conn | cookies: %{EmailSession.cookie_name() => cookie.value}}
assert {:ok, "buyer@shop.com"} = EmailSession.get_email(conn)
end
test "returns :error when no cookie present", %{conn: conn} do
conn = %{conn | cookies: %{}}
assert :error = EmailSession.get_email(conn)
end
test "returns :error for invalid token", %{conn: conn} do
conn = %{conn | cookies: %{EmailSession.cookie_name() => "invalid-token"}}
assert :error = EmailSession.get_email(conn)
end
end
describe "clear_session/1" do
test "deletes the cookie", %{conn: conn} do
conn =
conn
|> EmailSession.put_session("test@example.com")
|> EmailSession.clear_session()
assert conn.resp_cookies[EmailSession.cookie_name()].max_age == 0
end
end
end

View File

@ -0,0 +1,45 @@
defmodule BerrypodWeb.CheckoutSuccessControllerTest do
use BerrypodWeb.ConnCase, async: false
import Berrypod.AccountsFixtures
import Berrypod.OrdersFixtures
alias Berrypod.{EmailSession, Orders}
setup do
user_fixture()
{:ok, _} = Berrypod.Settings.set_site_live(true)
:ok
end
describe "GET /checkout/complete" do
test "sets email session cookie and redirects to success page when order found", %{conn: conn} do
order = order_fixture(%{customer_email: "buyer@test.com"})
{:ok, order} = Orders.set_stripe_session(order, "cs_test_123")
conn = get(conn, ~p"/checkout/complete", %{"session_id" => order.stripe_session_id})
assert redirected_to(conn) == "/checkout/success?session_id=cs_test_123"
# Verify the email session cookie was set
cookie = conn.resp_cookies[EmailSession.cookie_name()]
assert cookie != nil
assert cookie.max_age == 30 * 24 * 60 * 60
end
test "redirects without setting cookie when order not found", %{conn: conn} do
conn = get(conn, ~p"/checkout/complete", %{"session_id" => "nonexistent"})
assert redirected_to(conn) == "/checkout/success?session_id=nonexistent"
# No cookie should be set
assert conn.resp_cookies[EmailSession.cookie_name()] == nil
end
test "redirects to home when no session_id provided", %{conn: conn} do
conn = get(conn, ~p"/checkout/complete")
assert redirected_to(conn) == "/"
end
end
end

View File

@ -4,6 +4,8 @@ defmodule BerrypodWeb.OrderLookupControllerTest do
import Berrypod.AccountsFixtures import Berrypod.AccountsFixtures
import Berrypod.OrdersFixtures import Berrypod.OrdersFixtures
alias Berrypod.EmailSession
setup do setup do
user_fixture() user_fixture()
{:ok, _} = Berrypod.Settings.set_site_live(true) {:ok, _} = Berrypod.Settings.set_site_live(true)
@ -34,4 +36,27 @@ defmodule BerrypodWeb.OrderLookupControllerTest do
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "enter your email" assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "enter your email"
end end
end end
describe "GET /orders/verify/:token" do
test "sets email session cookie and redirects to orders page", %{conn: conn} do
order_fixture(%{customer_email: "buyer@test.com", payment_status: "paid"})
token = BerrypodWeb.OrderLookupController.generate_token("buyer@test.com")
conn = get(conn, ~p"/orders/verify/#{token}")
assert redirected_to(conn) == "/orders"
# Verify the email session cookie was set
cookie = conn.resp_cookies[EmailSession.cookie_name()]
assert cookie != nil
assert cookie.max_age == 30 * 24 * 60 * 60
end
test "returns error for invalid token", %{conn: conn} do
conn = get(conn, ~p"/orders/verify/invalid-token")
assert redirected_to(conn) == "/contact"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "invalid"
end
end
end end

View File

@ -14,7 +14,7 @@ defmodule BerrypodWeb.Shop.OrdersTest do
defp with_lookup_email(conn, email) do defp with_lookup_email(conn, email) do
conn conn
|> Phoenix.ConnTest.init_test_session(%{}) |> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session("order_lookup_email", email) |> Plug.Conn.put_session("email_session", email)
end end
describe "orders list — no session email" do describe "orders list — no session email" do