Template
Add shop, POS sync, event ticketing, and shipping catalog apps (#6)
## Summary - Closes #5 - Optional `shop`, `pos_sync`, `events`, and `shipping` apps gated by `FEATURE_*` flags - Deps match the catalog: shop/events need email + Stripe; POS/shipping need shop - Portal inventory, POS webhooks, capacity tickets, EasyPost/Pirate Ship shipping ## Test plan - [ ] `manage.py test` (152 passed locally) - [ ] Shop cart + paid order decrements stocked inventory - [ ] POS inbound webhook decrements SKU; paid order queues outbound reserve - [ ] Event capacity blocks overbook; paid order emails ticket codes - [ ] Shipping stub label + Pirate Ship CSV of unshipped paid orders - [ ] `validate-env.sh` rejects shop without email/payments, POS/shipping without shop Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from shop.models import Order, OrderItem, Product
|
||||
from shop.services import (
|
||||
ShopError,
|
||||
add_to_cart,
|
||||
adjust_stock,
|
||||
available_qty,
|
||||
create_order_from_cart,
|
||||
mark_paid,
|
||||
next_order_number,
|
||||
)
|
||||
from shop.stats import sales_dashboard
|
||||
|
||||
|
||||
def _product(**kwargs):
|
||||
defaults = dict(
|
||||
name="Dragon figurine",
|
||||
sku="TOY-001",
|
||||
price=Decimal("18.00"),
|
||||
stock_qty=5,
|
||||
is_published=True,
|
||||
fulfillment=Product.Fulfillment.STOCKED,
|
||||
)
|
||||
defaults.update(kwargs)
|
||||
return Product.objects.create(**defaults)
|
||||
|
||||
|
||||
class ShopPublicTests(TestCase):
|
||||
def test_list_hides_unpublished(self):
|
||||
_product(name="Live", sku="LIVE-1")
|
||||
_product(name="Draft", sku="DRAFT-1", is_published=False)
|
||||
response = Client().get(reverse("shop:list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Live")
|
||||
self.assertNotContains(response, "Draft")
|
||||
|
||||
def test_add_to_cart_then_checkout_form(self):
|
||||
product = _product()
|
||||
client = Client()
|
||||
response = client.post(
|
||||
reverse("shop:cart_add", kwargs={"slug": product.slug}),
|
||||
{"quantity": "2"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
cart = client.get(reverse("shop:cart"))
|
||||
self.assertContains(cart, "Dragon figurine")
|
||||
self.assertContains(cart, "36.00")
|
||||
checkout = client.get(reverse("shop:checkout"))
|
||||
self.assertEqual(checkout.status_code, 200)
|
||||
self.assertContains(checkout, "Pay with Stripe")
|
||||
|
||||
|
||||
class ShopInventoryTests(TestCase):
|
||||
def test_stocked_availability_and_adjust(self):
|
||||
product = _product(stock_qty=4)
|
||||
self.assertEqual(available_qty(product), 4)
|
||||
adjust_stock(product, -2)
|
||||
product.refresh_from_db()
|
||||
self.assertEqual(product.stock_qty, 2)
|
||||
with self.assertRaises(ShopError):
|
||||
adjust_stock(product, -5)
|
||||
|
||||
def test_mark_paid_decrements_stock_and_emails(self):
|
||||
product = _product(stock_qty=3)
|
||||
session = self.client.session
|
||||
add_to_cart(session, product, 2)
|
||||
session.save()
|
||||
order = create_order_from_cart(
|
||||
self.client.session, email="buyer@example.com", customer_name="Pat"
|
||||
)
|
||||
mark_paid(order)
|
||||
product.refresh_from_db()
|
||||
self.assertEqual(product.stock_qty, 1)
|
||||
order.refresh_from_db()
|
||||
self.assertEqual(order.status, Order.Status.PAID)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertIn(order.number, mail.outbox[0].subject)
|
||||
|
||||
def test_insufficient_stock_blocks_order(self):
|
||||
product = _product(stock_qty=1)
|
||||
session = self.client.session
|
||||
add_to_cart(session, product, 3)
|
||||
session.save()
|
||||
with self.assertRaises(ShopError):
|
||||
create_order_from_cart(self.client.session, email="buyer@example.com")
|
||||
|
||||
@override_settings(SHOP_PRINT_QUEUE_LIMIT_MINUTES=60)
|
||||
def test_made_to_order_queue_limit(self):
|
||||
product = _product(
|
||||
sku="MTO-1",
|
||||
fulfillment=Product.Fulfillment.MADE_TO_ORDER,
|
||||
print_minutes=30,
|
||||
stock_qty=0,
|
||||
)
|
||||
self.assertEqual(available_qty(product), 2)
|
||||
|
||||
def test_next_number_increments(self):
|
||||
n1 = next_order_number()
|
||||
Order.objects.create(
|
||||
number=n1,
|
||||
email="a@example.com",
|
||||
amount=Decimal("1.00"),
|
||||
)
|
||||
n2 = next_order_number()
|
||||
self.assertNotEqual(n1, n2)
|
||||
|
||||
|
||||
class ShopPortalTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user("merchant", password="test-pass-123")
|
||||
self.client = Client()
|
||||
self.client.login(username="merchant", password="test-pass-123")
|
||||
|
||||
def test_list_requires_login(self):
|
||||
anon = Client()
|
||||
self.assertEqual(anon.get(reverse("shop_portal:product_list")).status_code, 302)
|
||||
|
||||
def test_create_and_adjust_stock(self):
|
||||
response = self.client.post(
|
||||
reverse("shop_portal:product_new"),
|
||||
{
|
||||
"name": "Booster box",
|
||||
"sku": "TCG-BOX",
|
||||
"price": "89.99",
|
||||
"stock_qty": "10",
|
||||
"fulfillment": "stocked",
|
||||
"is_published": "on",
|
||||
"track_inventory": "on",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
product = Product.objects.get(sku="TCG-BOX")
|
||||
self.assertTrue(product.is_published)
|
||||
self.assertEqual(product.slug, "booster-box")
|
||||
adjust = self.client.post(
|
||||
reverse("shop_portal:product_stock", kwargs={"pk": product.pk}),
|
||||
{"delta": "-3"},
|
||||
)
|
||||
self.assertEqual(adjust.status_code, 302)
|
||||
product.refresh_from_db()
|
||||
self.assertEqual(product.stock_qty, 7)
|
||||
|
||||
|
||||
def _sold_order(product, *, qty=1, paid_at=None, status=None, number=None):
|
||||
n = Order.objects.count() + 1
|
||||
order = Order.objects.create(
|
||||
number=number or f"ORD-TEST-{n:04d}",
|
||||
email=f"buyer{n}@example.com",
|
||||
customer_name="Pat",
|
||||
status=status or Order.Status.PAID,
|
||||
amount=product.price * qty,
|
||||
currency="usd",
|
||||
paid_at=paid_at if paid_at is not None else timezone.now(),
|
||||
)
|
||||
OrderItem.objects.create(
|
||||
order=order,
|
||||
product=product,
|
||||
name=product.name,
|
||||
sku=product.sku,
|
||||
quantity=qty,
|
||||
unit_price=product.price,
|
||||
)
|
||||
return order
|
||||
|
||||
|
||||
class ShopSalesDashboardTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user("merchant", password="test-pass-123")
|
||||
self.client = Client()
|
||||
self.client.login(username="merchant", password="test-pass-123")
|
||||
self.dragon = _product(name="Dragon", sku="DRAGON", price=Decimal("18.00"))
|
||||
self.fox = _product(name="Fox", sku="FOX", price=Decimal("12.00"))
|
||||
|
||||
def test_sales_requires_login(self):
|
||||
anon = Client()
|
||||
self.assertEqual(anon.get(reverse("shop_portal:sales")).status_code, 302)
|
||||
|
||||
def test_empty_dashboard(self):
|
||||
data = sales_dashboard()
|
||||
self.assertEqual(data["order_count"], 0)
|
||||
self.assertEqual(data["revenue"], Decimal("0.00"))
|
||||
self.assertEqual(len(data["daily_sales"]), 30)
|
||||
self.assertFalse(data["has_sales"])
|
||||
response = self.client.get(reverse("shop_portal:sales"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "No paid orders in this window")
|
||||
self.assertContains(response, "Sales")
|
||||
self.assertContains(response, "Orders per day")
|
||||
self.assertContains(response, "Top products")
|
||||
|
||||
def test_counts_paid_and_fulfilled_in_window(self):
|
||||
now = timezone.now()
|
||||
_sold_order(self.dragon, qty=2, paid_at=now)
|
||||
_sold_order(self.fox, qty=5, paid_at=now - timedelta(days=2))
|
||||
_sold_order(
|
||||
self.dragon,
|
||||
qty=1,
|
||||
paid_at=now - timedelta(days=1),
|
||||
status=Order.Status.FULFILLED,
|
||||
)
|
||||
_sold_order(
|
||||
self.fox,
|
||||
qty=9,
|
||||
paid_at=now - timedelta(days=40),
|
||||
)
|
||||
open_order = _sold_order(self.dragon, qty=3, paid_at=None, status=Order.Status.OPEN)
|
||||
open_order.paid_at = None
|
||||
open_order.save(update_fields=["paid_at"])
|
||||
cancelled = _sold_order(
|
||||
self.fox, qty=4, paid_at=now, status=Order.Status.CANCELLED
|
||||
)
|
||||
self.assertEqual(cancelled.status, Order.Status.CANCELLED)
|
||||
|
||||
data = sales_dashboard()
|
||||
self.assertEqual(data["order_count"], 3)
|
||||
self.assertEqual(data["units_sold"], 8)
|
||||
self.assertEqual(data["revenue"], Decimal("114.00"))
|
||||
self.assertEqual(data["aov"], Decimal("38.00"))
|
||||
self.assertEqual([row["sku"] for row in data["top_products"]], ["FOX", "DRAGON"])
|
||||
self.assertEqual(data["top_products"][0]["units"], 5)
|
||||
self.assertEqual(data["top_products"][1]["units"], 3)
|
||||
today_bar = data["daily_sales"][-1]
|
||||
self.assertEqual(today_bar["count"], 1)
|
||||
|
||||
response = self.client.get(reverse("shop_portal:sales"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Dragon")
|
||||
self.assertContains(response, "Fox")
|
||||
self.assertContains(response, "114.00")
|
||||
self.assertContains(response, "Orders per day")
|
||||
self.assertContains(response, "Revenue per day")
|
||||
|
||||
def test_portal_nav_and_home_stats(self):
|
||||
_sold_order(self.dragon, qty=1)
|
||||
home = self.client.get(reverse("dashboard:home"))
|
||||
self.assertEqual(home.status_code, 200)
|
||||
self.assertEqual(home.context["shop_sales_30d"], 1)
|
||||
self.assertEqual(home.context["shop_revenue_30d"], Decimal("18.00"))
|
||||
self.assertContains(home, reverse("shop_portal:sales"))
|
||||
self.assertContains(home, "Sales (30 days)")
|
||||
products = self.client.get(reverse("shop_portal:product_list"))
|
||||
self.assertContains(products, "Sales")
|
||||
sales = self.client.get(reverse("shop_portal:sales"))
|
||||
self.assertContains(sales, 'class="active"')
|
||||
Reference in New Issue
Block a user