Files
print_forge/site/shop/tests.py
T
westfarnandCursor 10263e08ff
CI / test (pull_request) Successful in 38s
Align the public site with the PRINTFORGE banner for client demos.
Replace orange with cyan/lime/navy, swap the splash for a print animation, make smart-crop optional, and add a prod-safe seed_demo command. Closes #5.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 06:05:29 -05:00

744 lines
29 KiB
Python

import struct
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from pathlib import Path
from unittest.mock import patch
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core import mail
from django.core.files.storage import default_storage
from django.core.files.storage.memory import InMemoryStorage
from django.core.files.uploadedfile import SimpleUploadedFile, TemporaryUploadedFile
from django.db import models
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from PIL import Image
from core.models import StoredFile
from shop.models import Order, OrderItem, Product, ProductColor, ProductImage
from shop.services import (
ShopError,
add_to_cart,
adjust_stock,
available_qty,
create_order_from_cart,
looks_like_stl,
mark_paid,
next_order_number,
store_product_image,
store_product_stl,
)
from shop.stats import sales_dashboard
def _tiny_png() -> bytes:
image = Image.new("RGBA", (8, 8), (200, 40, 40, 255))
buf = BytesIO()
image.save(buf, format="PNG")
return buf.getvalue()
def _tiny_stl() -> bytes:
header = b"tiny" + b"\x00" * 76
count = struct.pack("<I", 1)
triangle = struct.pack(
"<12fH",
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0,
)
return header + count + triangle
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")
def test_detail_and_list_show_product_image_and_colors(self):
stored = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename="dragon.png",
content_type="image/png",
size=len(_tiny_png()),
data=_tiny_png(),
)
product = _product(image=stored)
ProductColor.objects.create(
product=product, name="Crimson", hex="#c41e3a", sort_order=0
)
ProductColor.objects.create(
product=product, name="Navy", hex="#1e3a8a", sort_order=1
)
client = Client()
listing = client.get(reverse("shop:list"))
self.assertContains(listing, product.image_url)
self.assertContains(listing, 'title="Crimson"')
detail = client.get(product.get_absolute_url())
self.assertEqual(detail.status_code, 200)
self.assertContains(detail, product.image_url)
self.assertContains(detail, "Crimson")
self.assertContains(detail, 'name="color"')
html = detail.content.decode()
self.assertIn('data-kind="photo"', html)
def test_detail_shows_photo_before_3d_and_multiple_photos(self):
photo_a = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename="a.png",
content_type="image/png",
size=len(_tiny_png()),
data=_tiny_png(),
)
photo_b = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename="b.png",
content_type="image/png",
size=len(_tiny_png()),
data=_tiny_png(),
)
stl = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_STL,
filename="dragon.stl",
content_type="model/stl",
size=len(_tiny_stl()),
data=_tiny_stl(),
)
product = _product(image=photo_a, stl=stl)
ProductImage.objects.create(product=product, file=photo_a, sort_order=0)
ProductImage.objects.create(product=product, file=photo_b, sort_order=1)
detail = Client().get(product.get_absolute_url())
html = detail.content.decode()
self.assertLess(html.index('data-kind="photo"'), html.index('data-kind="model"'))
self.assertIn("data-gallery-model", html)
self.assertContains(detail, reverse("core:stored_file", kwargs={"pk": photo_a.pk}))
self.assertContains(detail, reverse("core:stored_file", kwargs={"pk": photo_b.pk}))
self.assertRegex(html, r"data-gallery-model\s+hidden")
def test_detail_color_swaps_photos_and_keeps_shared_stl(self):
red_file = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename="red.png",
content_type="image/png",
size=len(_tiny_png()),
data=_tiny_png(),
)
blue_file = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_IMAGE,
filename="blue.png",
content_type="image/png",
size=len(_tiny_png()),
data=_tiny_png(),
)
stl = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_STL,
filename="toy.stl",
content_type="model/stl",
size=len(_tiny_stl()),
data=_tiny_stl(),
)
product = _product(image=red_file, stl=stl)
red = ProductColor.objects.create(
product=product, name="Red", hex="#ff0000", stock_qty=2, sort_order=0
)
blue = ProductColor.objects.create(
product=product, name="Blue", hex="#0000ff", stock_qty=9, sort_order=1
)
ProductImage.objects.create(
product=product, color=red, file=red_file, sort_order=0
)
ProductImage.objects.create(
product=product, color=blue, file=blue_file, sort_order=0
)
detail = Client().get(product.get_absolute_url())
self.assertContains(detail, reverse("core:stored_file", kwargs={"pk": red_file.pk}))
self.assertContains(detail, reverse("core:stored_file", kwargs={"pk": blue_file.pk}))
self.assertContains(detail, product.stl_url)
self.assertContains(detail, "2 in stock")
payload = detail.context["gallery_data"]
self.assertEqual(payload["stl"], product.stl_url)
self.assertEqual(
payload["colors"][str(red.pk)]["images"][0],
reverse("core:stored_file", kwargs={"pk": red_file.pk}),
)
self.assertEqual(payload["colors"][str(blue.pk)]["available"], 9)
def test_detail_embeds_stl_viewer(self):
stored = StoredFile.objects.create(
kind=StoredFile.Kind.PRODUCT_STL,
filename="dragon.stl",
content_type="model/stl",
size=len(_tiny_stl()),
data=_tiny_stl(),
)
product = _product(stl=stored)
detail = Client().get(product.get_absolute_url())
self.assertEqual(detail.status_code, 200)
self.assertContains(detail, 'data-stl-viewer')
self.assertContains(detail, product.stl_url)
self.assertContains(detail, "stl-viewer.js")
self.assertContains(detail, "Drag to spin")
def test_add_to_cart_requires_color_when_product_has_colors(self):
product = _product()
color = ProductColor.objects.create(
product=product, name="Gold", hex="#d4af37"
)
client = Client()
missing = client.post(
reverse("shop:cart_add", kwargs={"slug": product.slug}),
{"quantity": "1"},
)
self.assertEqual(missing.status_code, 302)
self.assertEqual(missing["Location"], product.get_absolute_url())
added = client.post(
reverse("shop:cart_add", kwargs={"slug": product.slug}),
{"quantity": "1", "color": str(color.pk)},
)
self.assertEqual(added.status_code, 302)
cart = client.get(reverse("shop:cart"))
self.assertContains(cart, "Color: Gold")
checkout = client.get(reverse("shop:checkout"))
self.assertContains(checkout, "Gold")
def test_order_snapshots_selected_color(self):
product = _product()
color = ProductColor.objects.create(
product=product, name="Forest", hex="#228b22", stock_qty=2
)
session = self.client.session
add_to_cart(session, product, 1, color=color)
session.save()
order = create_order_from_cart(
self.client.session, email="buyer@example.com"
)
item = order.items.get()
self.assertEqual(item.color, color)
self.assertEqual(item.color_name, "Forest")
self.assertIn("Forest", item.name)
def test_paid_order_decrements_color_stock(self):
product = _product(stock_qty=10)
red = ProductColor.objects.create(
product=product, name="Red", hex="#ff0000", stock_qty=4
)
blue = ProductColor.objects.create(
product=product, name="Blue", hex="#0000ff", stock_qty=6
)
session = self.client.session
add_to_cart(session, product, 3, color=red)
session.save()
order = create_order_from_cart(
self.client.session, email="buyer@example.com"
)
mark_paid(order)
red.refresh_from_db()
blue.refresh_from_db()
product.refresh_from_db()
self.assertEqual(red.stock_qty, 1)
self.assertEqual(blue.stock_qty, 6)
self.assertEqual(product.stock_qty, 10)
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")
def test_insufficient_color_stock_blocks_order(self):
product = _product(stock_qty=10)
color = ProductColor.objects.create(
product=product, name="Red", hex="#ff0000", stock_qty=1
)
session = self.client.session
add_to_cart(session, product, 3, color=color)
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)
def test_looks_like_stl(self):
self.assertTrue(looks_like_stl(_tiny_stl(), "toy.stl"))
self.assertFalse(looks_like_stl(_tiny_stl(), "toy.bin"))
self.assertFalse(looks_like_stl(b"not an stl file at all" + b"x" * 80, "toy.stl"))
ascii_stl = (
b"solid test\n"
b" facet normal 0 0 1\n"
b" outer loop\n"
b" vertex 0 0 0\n"
b" vertex 1 0 0\n"
b" vertex 0 1 0\n"
b" endloop\n"
b" endfacet\n"
b"endsolid test\n"
)
self.assertTrue(looks_like_stl(ascii_stl, "ascii.stl"))
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 test_portal_uses_public_favicon(self):
response = self.client.get(reverse("shop_portal:product_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "brand/favicon.ico")
self.assertContains(response, "apple-touch-icon")
def test_new_product_form_has_card_preview(self):
response = self.client.get(reverse("shop_portal:product_new"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Shop card preview")
self.assertContains(response, 'name="images"')
self.assertContains(response, 'name="smart_crop"')
self.assertContains(response, "Smart crop and background filter")
self.assertContains(response, 'name="stl"')
self.assertContains(response, "Add color")
def test_create_product_with_stl(self):
upload = SimpleUploadedFile(
"dragon.stl", _tiny_stl(), content_type="application/octet-stream"
)
response = self.client.post(
reverse("shop_portal:product_new"),
{
"name": "Spinny toy",
"sku": "STL-1",
"price": "12.00",
"stock_qty": "2",
"fulfillment": "stocked",
"is_published": "on",
"track_inventory": "on",
"stl": upload,
},
)
self.assertEqual(response.status_code, 302)
product = Product.objects.get(sku="STL-1")
self.assertTrue(product.stl_id)
self.assertEqual(product.stl.kind, StoredFile.Kind.PRODUCT_STL)
self.assertEqual(product.stl.content_type, "model/stl")
detail = Client().get(product.get_absolute_url())
self.assertContains(detail, product.stl_url)
def test_rejects_invalid_stl(self):
upload = SimpleUploadedFile(
"dragon.stl", b"nope" + b"\x00" * 100, content_type="application/octet-stream"
)
response = self.client.post(
reverse("shop_portal:product_new"),
{
"name": "Bad mesh",
"sku": "STL-BAD",
"price": "12.00",
"stock_qty": "1",
"fulfillment": "stocked",
"stl": upload,
},
)
self.assertEqual(response.status_code, 200)
self.assertFalse(Product.objects.filter(sku="STL-BAD").exists())
def test_create_product_with_image_and_colors(self):
upload = SimpleUploadedFile("dot.png", _tiny_png(), content_type="image/png")
with patch(
"shop.imaging.cutout_subject",
side_effect=lambda image: image.convert("RGBA"),
):
response = self.client.post(
reverse("shop_portal:product_new"),
{
"name": "Mech dragon",
"sku": "MECH-1",
"price": "24.00",
"stock_qty": "3",
"fulfillment": "made_to_order",
"print_minutes": "45",
"filament_grams": "80",
"description": "Articulated toy.",
"is_published": "on",
"track_inventory": "on",
"images": upload,
"color_id": ["", ""],
"color_key": ["new-1", "new-2"],
"color_name": ["Red", "Blue"],
"color_hex": ["#ff0000", "#0000ff"],
"color_stock": ["4", "7"],
},
)
self.assertEqual(response.status_code, 302)
product = Product.objects.get(sku="MECH-1")
self.assertTrue(product.image_id)
self.assertEqual(product.image.kind, StoredFile.Kind.PRODUCT_IMAGE)
self.assertEqual(product.image.content_type, "image/png")
self.assertEqual(product.image.filename, "dot.png")
framed = Image.open(BytesIO(bytes(product.image.data)))
self.assertEqual(framed.size, (1200, 1200))
colors = list(product.colors.all())
self.assertEqual([c.name for c in colors], ["Red", "Blue"])
self.assertEqual(colors[0].hex, "#ff0000")
self.assertEqual([c.stock_qty for c in colors], [4, 7])
self.assertEqual(product.images.count(), 1)
self.assertIsNone(product.images.get().color_id)
listing = Client().get(reverse("shop:list"))
self.assertContains(listing, product.image_url)
self.assertContains(listing, 'title="Red"')
self.assertTrue(product.image_url.startswith("/files/"))
self.assertNotIn("/media/", product.image_url)
fetch = Client().get(product.image_url)
self.assertEqual(fetch.status_code, 200)
self.assertEqual(
b"".join(fetch.streaming_content), bytes(product.image.data)
)
def test_create_product_skips_smart_crop_when_unchecked(self):
raw = _tiny_png()
upload = SimpleUploadedFile("keep.png", raw, content_type="image/png")
with patch("shop.imaging.cutout_subject") as cut:
response = self.client.post(
reverse("shop_portal:product_new"),
{
"name": "Raw photo toy",
"sku": "RAW-1",
"price": "10.00",
"stock_qty": "1",
"fulfillment": "stocked",
"track_inventory": "on",
"smart_crop": "off",
"images": upload,
},
)
self.assertEqual(response.status_code, 302)
cut.assert_not_called()
product = Product.objects.get(sku="RAW-1")
stored = product.images.get().file
self.assertEqual(bytes(stored.data), raw)
self.assertEqual(stored.filename, "keep.png")
framed = Image.open(BytesIO(bytes(stored.data)))
self.assertEqual(framed.size, (8, 8))
def test_create_product_with_color_photos_and_shared_stl(self):
catalog = SimpleUploadedFile("card.png", _tiny_png(), content_type="image/png")
red_photo = SimpleUploadedFile("red.png", _tiny_png(), content_type="image/png")
extra = SimpleUploadedFile("red-2.png", _tiny_png(), content_type="image/png")
stl = SimpleUploadedFile(
"toy.stl", _tiny_stl(), content_type="application/octet-stream"
)
with patch(
"shop.imaging.cutout_subject",
side_effect=lambda image: image.convert("RGBA"),
):
response = self.client.post(
reverse("shop_portal:product_new"),
{
"name": "Color dragon",
"sku": "COLOR-1",
"price": "30.00",
"stock_qty": "0",
"fulfillment": "stocked",
"is_published": "on",
"track_inventory": "on",
"images": catalog,
"stl": stl,
"color_id": ["", ""],
"color_key": ["new-1", "new-2"],
"color_name": ["Crimson", "Navy"],
"color_hex": ["#c41e3a", "#1e3a8a"],
"color_stock": ["3", "5"],
"color_images_new-1": [red_photo, extra],
},
)
self.assertEqual(response.status_code, 302)
product = Product.objects.get(sku="COLOR-1")
crimson = product.colors.get(name="Crimson")
navy = product.colors.get(name="Navy")
self.assertTrue(product.stl_id)
self.assertEqual(product.images.filter(color=None).count(), 1)
self.assertEqual(product.images.filter(color=crimson).count(), 2)
self.assertEqual(product.images.filter(color=navy).count(), 0)
self.assertEqual(crimson.stock_qty, 3)
detail = Client().get(product.get_absolute_url())
html = detail.content.decode()
self.assertLess(html.index('data-kind="photo"'), html.index('data-kind="model"'))
self.assertContains(detail, product.stl_url)
self.assertEqual(detail.context["available"], 3)
def test_product_image_is_database_blob_not_file_field(self):
image_field = Product._meta.get_field("image")
self.assertIsInstance(image_field, models.ForeignKey)
self.assertFalse(isinstance(image_field, models.FileField))
self.assertEqual(image_field.related_model, StoredFile)
self.assertIsInstance(StoredFile._meta.get_field("data"), models.BinaryField)
self.assertIsInstance(default_storage, InMemoryStorage)
self.assertGreaterEqual(settings.FILE_UPLOAD_MAX_MEMORY_SIZE, 15 * 1024 * 1024)
def test_temporary_image_upload_copied_into_database_then_unlinked(self):
with TemporaryUploadedFile("dot.png", "image/png", 0, "utf-8") as tmp:
tmp.write(_tiny_png())
tmp.seek(0)
tmp_path = Path(tmp.temporary_file_path())
self.assertTrue(tmp_path.exists())
with patch(
"shop.imaging.cutout_subject",
side_effect=lambda image: image.convert("RGBA"),
):
stored = store_product_image(upload=tmp, user=self.user)
self.assertFalse(tmp_path.exists())
self.assertTrue(bytes(stored.data))
self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_IMAGE)
def test_store_product_image_can_skip_smart_crop(self):
raw = _tiny_png()
with TemporaryUploadedFile("dot.png", "image/png", 0, "utf-8") as tmp:
tmp.write(raw)
tmp.seek(0)
with patch("shop.imaging.cutout_subject") as cut:
stored = store_product_image(
upload=tmp, user=self.user, smart_crop=False
)
cut.assert_not_called()
self.assertEqual(bytes(stored.data), raw)
self.assertEqual(stored.content_type, "image/png")
self.assertEqual(stored.filename, "dot.png")
def test_temporary_stl_upload_copied_into_database_then_unlinked(self):
with TemporaryUploadedFile(
"toy.stl", "application/octet-stream", 0, "utf-8"
) as tmp:
tmp.write(_tiny_stl())
tmp.seek(0)
tmp_path = Path(tmp.temporary_file_path())
self.assertTrue(tmp_path.exists())
stored = store_product_stl(upload=tmp, user=self.user)
self.assertFalse(tmp_path.exists())
self.assertEqual(bytes(stored.data), _tiny_stl())
self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_STL)
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"')