generated from westfarn/web_django_template
CI / test (pull_request) Successful in 35s
Shoppers can register, save shipping details, and view order history while cards stay on Stripe. EasyPost tracker updates (including numbers from Pirate Ship) and 1–5 star reviews are limited to buyers. The contact form now only asks for email and a message.
840 lines
33 KiB
Python
840 lines
33 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_checkout_session,
|
|
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", is_staff=True)
|
|
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", is_staff=True)
|
|
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"')
|
|
|
|
|
|
class ShopAccountAndReviewTests(TestCase):
|
|
def setUp(self):
|
|
User = get_user_model()
|
|
self.user = User.objects.create_user(
|
|
username="buyer@example.com",
|
|
email="buyer@example.com",
|
|
password="s3cure-pass-123",
|
|
)
|
|
self.product = _product()
|
|
self.client = Client()
|
|
self.client.login(username="buyer@example.com", password="s3cure-pass-123")
|
|
|
|
def test_checkout_attaches_user_and_uses_stripe_customer(self):
|
|
session = self.client.session
|
|
add_to_cart(session, self.product, 1)
|
|
session.save()
|
|
captured = {}
|
|
|
|
class FakeCustomer:
|
|
id = "cus_abc"
|
|
|
|
class FakeCheckout:
|
|
id = "cs_abc"
|
|
url = "https://stripe.test/pay"
|
|
|
|
class FakeStripe:
|
|
class Customer:
|
|
@staticmethod
|
|
def create(**kwargs):
|
|
captured["customer"] = kwargs
|
|
return FakeCustomer()
|
|
|
|
class checkout:
|
|
class Session:
|
|
@staticmethod
|
|
def create(**kwargs):
|
|
captured["session"] = kwargs
|
|
return FakeCheckout()
|
|
|
|
with patch("shop.services._stripe", return_value=FakeStripe):
|
|
order = create_order_from_cart(
|
|
self.client.session,
|
|
email="buyer@example.com",
|
|
user=self.user,
|
|
)
|
|
url = create_checkout_session(
|
|
order,
|
|
success_url="https://example.test/ok",
|
|
cancel_url="https://example.test/no",
|
|
)
|
|
self.assertEqual(url, "https://stripe.test/pay")
|
|
self.assertEqual(order.user, self.user)
|
|
self.assertEqual(captured["session"]["customer"], "cus_abc")
|
|
self.assertNotIn("customer_email", captured["session"])
|
|
self.assertEqual(
|
|
captured["session"]["payment_intent_data"]["setup_future_usage"],
|
|
"on_session",
|
|
)
|
|
self.user.customer_profile.refresh_from_db()
|
|
self.assertEqual(self.user.customer_profile.stripe_customer_id, "cus_abc")
|
|
|
|
def test_review_requires_purchase(self):
|
|
blocked = self.client.post(
|
|
reverse("shop:review", kwargs={"slug": self.product.slug}),
|
|
{"rating": "5", "title": "Nope", "body": "Did not buy"},
|
|
)
|
|
self.assertEqual(blocked.status_code, 302)
|
|
self.assertEqual(self.product.reviews.count(), 0)
|
|
|
|
order = _sold_order(self.product)
|
|
order.user = self.user
|
|
order.email = self.user.email
|
|
order.save(update_fields=["user", "email"])
|
|
ok = self.client.post(
|
|
reverse("shop:review", kwargs={"slug": self.product.slug}),
|
|
{"rating": "5", "title": "Great", "body": "Loved it"},
|
|
)
|
|
self.assertEqual(ok.status_code, 302)
|
|
review = self.product.reviews.get()
|
|
self.assertEqual(review.rating, 5)
|
|
self.assertEqual(review.user, self.user)
|
|
detail = self.client.get(self.product.get_absolute_url())
|
|
self.assertContains(detail, "Great")
|
|
self.assertContains(detail, "You already reviewed")
|
|
|
|
def test_order_history_hides_other_users(self):
|
|
mine = _sold_order(self.product, number="ORD-MINE")
|
|
mine.user = self.user
|
|
mine.save(update_fields=["user"])
|
|
_sold_order(self.product, number="ORD-THEIRS")
|
|
page = self.client.get(reverse("account:orders"))
|
|
self.assertContains(page, "ORD-MINE")
|
|
self.assertNotContains(page, "ORD-THEIRS")
|
|
|