Add shopper accounts, reviews, tracking, and seed_demo (#10)

## Summary
- Closes #9. Port shop-gated buyer accounts (register/login/profile, password reset, order history), purchase-gated reviews, and Stripe customer ids. Cards stay on Stripe.
- Slim public contact form to email + interest + message. Shipping tracking status, scan events, paste-in numbers, and EasyPost webhook when FEATURE_SHIPPING is on.
- Add template-neutral `seed_demo` (generic SKUs, demo shoppers, guest orders, tracking stubs). Prod refused. `--reset` deletes tagged demo rows after invoices.

## Test plan
- [ ] `cd site && uv run python manage.py test` (173 tests passed locally)
- [ ] Shop on: `/account/register`, login, profile, password reset, claim guest orders by email
- [ ] Shop off: `/account/` URLs absent; Sign in / Account links hidden
- [ ] Checkout as logged-in buyer stores `Order.user` and Stripe customer id; product review only after paid purchase
- [ ] Shipping on: portal paste-in tracking + EasyPost webhook updates status; shipping off: orders still work without tracking UI
- [ ] `seed_demo` refused in prod; `--reset` rebuilds tagged catalog/shoppers without deleting staff invoices

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
westfarn committed 2026-09-21 13:29:36 -07:00
1 parent 9cdce7a897
commit f3e34f5099
66 files changed
+3051 -285

No files matched your search

+111
View File
@@ -1,4 +1,6 @@
import os
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django.test import Client, TestCase, override_settings
@@ -30,6 +32,11 @@ class PublicSmokeTests(TestCase):
client = Client()
self.assertEqual(client.get(reverse("public:about")).status_code, 200)
self.assertEqual(client.get(reverse("public:contact")).status_code, 200)
contact = client.get(reverse("public:contact"))
self.assertContains(contact, "contact-email")
self.assertNotContains(contact, "contact-first-name")
self.assertNotContains(contact, "contact-phone")
self.assertNotContains(contact, "contact-address-line1")
class DispatchDueTests(TestCase):
@@ -37,3 +44,107 @@ class DispatchDueTests(TestCase):
out = StringIO()
call_command("dispatch_due", stdout=out)
self.assertEqual(out.getvalue(), "")
class SeedDemoTests(TestCase):
def test_refuses_prod(self):
from django.core.management.base import CommandError
with patch.dict(os.environ, {"DJANGO_ENV": "prod"}):
with self.assertRaises(CommandError) as ctx:
call_command("seed_demo", stdout=StringIO())
self.assertIn("prod", str(ctx.exception).lower())
def test_seeds_shop_and_portal_rows(self):
out = StringIO()
call_command("seed_demo", stdout=out)
self.assertIn("Demo seed ready", out.getvalue())
from contacts.models import Contact
from leads.models import Lead
from shop.models import Order, Product
self.assertTrue(Product.objects.filter(sku="DEMO-NOTEBOOK").exists())
self.assertTrue(Product.objects.filter(sku="DEMO-PROTO", is_published=False).exists())
self.assertGreaterEqual(Contact.objects.filter(email__startswith="demo+").count(), 8)
self.assertTrue(Lead.objects.filter(message__startswith="[demo-seed]").exists())
self.assertTrue(Order.objects.filter(number="DEMO-ORD-001", status="paid").exists())
listing = Client().get(reverse("shop:list"))
self.assertContains(listing, "Notebook")
from django.contrib.auth import get_user_model
from shop.models import ProductReview
from shipping.models import Shipment
User = get_user_model()
jordan = User.objects.get(username="demo+jordan@example.com")
self.assertFalse(jordan.is_staff)
self.assertTrue(jordan.check_password("Demo-buyer-pass-123"))
self.assertEqual(
Order.objects.get(number="DEMO-ORD-001").user_id, jordan.pk
)
self.assertIsNone(Order.objects.get(number="DEMO-ORD-004").user_id)
self.assertTrue(
ProductReview.objects.filter(
user=jordan, product__sku="DEMO-NOTEBOOK", rating=5
).exists()
)
delivered = Shipment.objects.get(order__number="DEMO-ORD-002")
self.assertEqual(delivered.tracking_status, "delivered")
self.assertGreaterEqual(len(delivered.tracking_events), 2)
transit = Shipment.objects.get(order__number="DEMO-ORD-001")
self.assertEqual(transit.tracking_status, "in_transit")
client = Client()
self.assertTrue(
client.login(
username="demo+jordan@example.com", password="Demo-buyer-pass-123"
)
)
history = client.get(reverse("account:orders"))
self.assertContains(history, "DEMO-ORD-001")
self.assertNotContains(history, "DEMO-ORD-004")
detail = client.get(reverse("shop:detail", kwargs={"slug": "demo-notebook"}))
self.assertContains(detail, "Solid notebook")
def test_second_run_is_idempotent(self):
call_command("seed_demo", stdout=StringIO())
from shop.models import Product
count = Product.objects.filter(sku__startswith="DEMO-").count()
call_command("seed_demo", stdout=StringIO())
self.assertEqual(Product.objects.filter(sku__startswith="DEMO-").count(), count)
def test_reset_rebuilds_tagged_rows(self):
call_command("seed_demo", stdout=StringIO())
from shop.models import Product
Product.objects.filter(sku="DEMO-NOTEBOOK").delete()
call_command("seed_demo", reset=True, stdout=StringIO())
self.assertTrue(Product.objects.filter(sku="DEMO-NOTEBOOK").exists())
from django.contrib.auth import get_user_model
self.assertTrue(
get_user_model().objects.filter(username="demo+jordan@example.com").exists()
)
@patch.dict(os.environ, {"DJANGO_ENV": "beta"})
def test_allows_beta(self):
call_command("seed_demo", stdout=StringIO())
from shop.models import Product
self.assertTrue(Product.objects.filter(sku__startswith="DEMO-").exists())
def test_succeeds_when_shop_not_installed(self):
from django.apps import apps
from contacts.models import Contact
real = apps.is_installed
def fake(name):
if name in {"shop", "shipping"}:
return False
return real(name)
with patch("django.apps.apps.is_installed", side_effect=fake):
call_command("seed_demo", stdout=StringIO())
self.assertTrue(Contact.objects.filter(email__startswith="demo+").exists())