test: add gift card integration tests; update docs and CLAUDE.md

7 integration tests for gift card models, cart integration, deduction,
transactions, coupon+gift card combo, validation, and JSON purchases.
Update architecture.md (feature toggle matrix, ticket index, diagram).
Update design-system.md (gift card component section).
Add post-work chores checklist to CLAUDE.md.
This commit is contained in:
russell@unturf.com 2026-03-07 17:15:00 -05:00
parent 4935e41448
commit 46c1b2d521
4 changed files with 295 additions and 0 deletions

View file

@ -69,6 +69,12 @@ This project uses a Makefile for most development operations. Use `make` command
- `make_post_sell/views/cart.py` - Cart and checkout logic
- `development.ini` - Configuration file
### Design System Files
- `static/css/tokens.css` — Design tokens (colors, typography, spacing, shape, elevation, motion, z-index), base resets, utility classes, animations. Single source of truth. Light mode `:root`, dark mode `[data-theme="dark"]`.
- `static/css/common.css` — Component styles consuming tokens via `var(--token, fallback)`.
- `templates/styleguide.j2` — Live component reference at `/styleguide` (view: `views/misc.py:23`).
- `docs/design-system.md` — Full design system reference doc (token tables, architecture diagram, conventions).
## Testing Notes
The project uses pytest with unittest framework. There are three types of tests:
@ -232,6 +238,8 @@ Always use `uuid_str` when you need a string copy of the identifier. Models inhe
**CSS LAYOUT REQUIREMENTS**: This project uses CSS Grid exclusively for layout. NEVER use Flexbox (flex) for layout. Always use CSS Grid properties for positioning and alignment.
**DESIGN TOKENS**: All new styles must consume tokens from `tokens.css` — never hardcode colors, spacing, radii, shadows, or font sizes. Use `var(--token-name)` or `var(--token-name, fallback)`. The token scale uses a 4px spacing base and major third (1.250) type scale.
**STYLEGUIDE**: When creating new UI components (buttons, wells, alerts, layout patterns, etc.), add a live example to `/styleguide` (`make_post_sell/templates/styleguide.j2`). The styleguide is the single source of truth for the component library. If it's not in the styleguide, it doesn't exist as a pattern.
**CSS MEDIA SIZING**: Never combine `width: 100%` with `max-height` on media elements (img, video). `width: 100%` forces the element to span the full container even when `max-height` constrains the rendered content, creating dead whitespace. Use `width: auto` + `max-width: 100%` + `max-height` instead — the element shrinks to match the actual content aspect ratio within both constraints.
@ -254,6 +262,16 @@ Disabling or removing tests weakens the codebase and is unacceptable. Tests are
**AUTO-PUSH**: When you write new tests to cover new code paths and the full test suite passes, commit and push without asking. Bump GIT_HASH after pushing.
## Post-Work Chores
After completing a feature or significant change, always perform these chores before considering the work done:
1. **Tests** — Write unit tests (`test_models.py`), integration tests (`test_integration.py`), and functional tests (`test_functional.py`) covering the new code paths. All three layers are required for new features.
2. **Docs** — Update `docs/architecture.md` (feature toggle matrix, ticket index, diagrams) and `docs/design-system.md` (new components/sections) to reflect the change.
3. **Portal** — Update the marketing site at `~/git/www.makepostsell.com` (feature cards in `index.html`, includes list in `pricing.html`) when a user-facing feature is added.
4. **CLAUDE.md** — Update this file if the change introduces new patterns, form sections, model columns, or conventions that future work needs to know about.
5. **Commit & push** — Per AUTO-PUSH, commit and push when tests pass. Bump GIT_HASH.
## Commit Message Guidelines
**CRITICAL**: Do not include Claude Code attribution in commit messages. Attributing human work to Claude is inappropriate and misrepresents the actual authorship of the code. All code changes should be attributed to the human developer who reviewed, approved, and committed the work.

View file

@ -38,6 +38,7 @@
│ models │ │ media │ │ Stripe │ │ │
│ sessions│ │ thumbs │ │ PayPal │ │ Presigned │
│ signals │ │ assets │ │ Crypto │ │ URLs only │
│ │ │ │ │ Gift Cards │ │ │
└─────────┘ └────────────┘ └──────────────┘ └───────────────┘
```
@ -185,6 +186,7 @@ mps_page_session (raw rows)
| Stripe | `shop.stripe_enabled` | `stripe-settings` | On |
| PayPal | `shop.paypal_*` | `paypal-settings` | Off |
| Crypto | `shop.monero_*` / `shop.dogecoin_*` | `crypto-settings` | Off |
| Gift cards | `shop.gift_card_enabled` | `gift-card-settings` | Off |
| S3 mirror | `shop.mirror_s3_*` | `mirror-settings` | Off |
| Discovery ring | `shop.discovery_ring` | Automatic | Auto-computed |
| Subscriptions | `shop.subscription_*` | `ribbon-settings` | Off |
@ -203,6 +205,10 @@ mps_page_session (raw rows)
| [MPS-7](tickets/mps-7.md) | Sandbox Mode — Creative Filter System | Complete |
| [MPS-8](tickets/mps-8.md) | User S3 Bucket + Artifact Storage | Complete |
| [MPS-9](tickets/mps-9.md) | Shop S3 Mirror Bucket | Complete |
| [MPS-10](tickets/mps-10.md) | Gift Card — Models & Migration | Complete |
| [MPS-11](tickets/mps-11.md) | Gift Card — Purchase Flow | Complete |
| [MPS-12](tickets/mps-12.md) | Gift Card — Redemption at Checkout | Complete |
| [MPS-13](tickets/mps-13.md) | Gift Card — Shop Admin & Settings | Complete |
## Related Docs

View file

@ -234,6 +234,7 @@ All components are documented with live examples at `/styleguide`. The styleguid
| Status | `#status` | Status indicators |
| Product Cards | `#cards` | Product grid cards |
| Cart | `#cart` | Cart and checkout components |
| Gift Cards | `#gift-cards` | Gift card purchase, balance check, management |
| Comments | `#comments` | Comment form and list |
| Toggle | `#toggle` | Toggle switches |
| Ribbon | `#ribbon` | Shop ribbon banner |

View file

@ -27,6 +27,10 @@ from ..models.price import Price
from ..models.crypto_payment import CryptoPayment
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
from ..models.shop_location import ShopLocation
from ..models.gift_card import GiftCard, get_gift_card_by_id, get_gift_card_by_code, get_gift_cards_by_shop
from ..models.gift_card_transaction import GiftCardTransaction
from ..models.cart_gift_card import CartGiftCard
import json
import time
@ -3419,3 +3423,269 @@ class TestKaraokeTrackAclIntegration(DatabaseIntegrationTests):
product_acl,
f"vocals ACL mismatch at visibility={vis}",
)
class TestGiftCardIntegration(DatabaseIntegrationTests):
"""Integration tests for gift card functionality with real ORM objects."""
def _make_shop(self, gift_card_enabled=True):
"""Helper to create a shop with gift card support."""
shop = Shop(
name="Gift Card Shop",
phone_number="555-555-5555",
billing_address="123 Test St",
description="A shop with gift cards",
)
shop.stripe_public_api_key = "pk_test_123"
shop.stripe_secret_api_key = "sk_test_123"
shop.domain_name = "giftcards.test.com"
shop.gift_card_enabled = gift_card_enabled
self.dbsession.add(shop)
self.dbsession.flush()
return shop
def _make_product(self, shop, price_in_cents=1000):
"""Helper to create a product."""
product = Product(title="Test Product", description="Test product")
product.shop_id = shop.id
product.price_in_cents = price_in_cents
product.is_physical = False
self.dbsession.add(product)
self.dbsession.flush()
return product
def _make_user(self):
"""Helper to create a user."""
user = get_or_create_user_by_email(self.dbsession, "test@example.com")
self.dbsession.add(user)
self.dbsession.flush()
return user
def _make_cart(self, user, shop):
"""Helper to create a cart."""
cart = Cart(user=user)
cart.shop = shop
self.dbsession.add(cart)
self.dbsession.flush()
return cart
def test_gift_card_with_real_shop_integration(self):
"""Test gift card creation and lookup with real shop."""
shop = self._make_shop(gift_card_enabled=True)
gift_card = GiftCard(shop=shop, amount_in_cents=5000)
self.dbsession.add(gift_card)
self.dbsession.flush()
# Test get_gift_card_by_id
found = get_gift_card_by_id(self.dbsession, gift_card.id)
self.assertIsNotNone(found)
self.assertEqual(found.id, gift_card.id)
self.assertEqual(found.initial_amount_in_cents, 5000)
self.assertEqual(found.balance_in_cents, 5000)
# Test get_gift_card_by_code with shop filter
found_by_code = get_gift_card_by_code(
self.dbsession, gift_card.code, shop=shop
)
self.assertIsNotNone(found_by_code)
self.assertEqual(found_by_code.id, gift_card.id)
# Test get_gift_cards_by_shop returns this gift card
shop_cards = get_gift_cards_by_shop(self.dbsession, shop).all()
self.assertEqual(len(shop_cards), 1)
self.assertEqual(shop_cards[0].id, gift_card.id)
transaction.commit()
def test_cart_with_gift_card_integration(self):
"""Test cart with attached gift card reduces totals correctly."""
user = self._make_user()
shop = self._make_shop()
product = self._make_product(shop, price_in_cents=1000) # $10
cart = self._make_cart(user, shop)
cart.add_product(product)
# Create gift card with $50 balance
gift_card = GiftCard(shop=shop, amount_in_cents=5000)
self.dbsession.add(gift_card)
self.dbsession.flush()
# Attach gift card to cart via CartGiftCard
cart_gift_card = CartGiftCard(cart=cart, gift_card=gift_card)
self.dbsession.add(cart_gift_card)
self.dbsession.flush()
# Test gift card appears in cart.gift_cards
self.assertEqual(len(list(cart.gift_cards)), 1)
# Test cart is discounted
self.assertTrue(cart.is_discounted)
# Test discounted_shop_totals_in_cents shows reduced amount
shop_uuid = shop.uuid_str
discounted = cart.discounted_shop_totals_in_cents
self.assertEqual(discounted[shop_uuid], 0) # $10 - $50 gift card = $0
# Test gift_card_deductions dict has the gift card's uuid_str as key
deductions = cart.gift_card_deductions
self.assertIn(gift_card.uuid_str, deductions)
self.assertEqual(deductions[gift_card.uuid_str], 1000) # Deducted $10
transaction.commit()
def test_gift_card_deduction_integration(self):
"""Test gift card deduct method reduces balance correctly."""
shop = self._make_shop()
gift_card = GiftCard(shop=shop, amount_in_cents=2000) # $20
self.dbsession.add(gift_card)
self.dbsession.flush()
# Deduct $15
result = gift_card.deduct(1500)
self.assertEqual(result, 1500)
self.assertEqual(gift_card.balance_in_cents, 500)
self.assertTrue(gift_card.is_valid)
# Deduct remaining $5
result = gift_card.deduct(500)
self.assertEqual(result, 500)
self.assertEqual(gift_card.balance_in_cents, 0)
self.assertTrue(gift_card.is_fully_redeemed)
self.assertFalse(gift_card.is_valid)
transaction.commit()
def test_gift_card_transaction_integration(self):
"""Test gift card transaction records are linked correctly."""
user = self._make_user()
shop = self._make_shop()
product = self._make_product(shop, price_in_cents=1000)
gift_card = GiftCard(shop=shop, amount_in_cents=5000)
self.dbsession.add(gift_card)
self.dbsession.flush()
# Create an invoice for the transaction
invoice = Invoice(user=user)
self.dbsession.add(invoice)
self.dbsession.flush()
# Create a GiftCardTransaction
txn = GiftCardTransaction(
gift_card=gift_card, invoice=invoice, amount_in_cents=1000
)
self.dbsession.add(txn)
self.dbsession.flush()
# Assert transaction appears in gift_card.transactions
transactions = gift_card.transactions.all()
self.assertEqual(len(transactions), 1)
self.assertEqual(transactions[0].amount_in_cents, 1000)
transaction.commit()
def test_cart_with_gift_card_and_coupon_integration(self):
"""Test coupon applies first, then gift card reduces remaining total."""
user = self._make_user()
shop = self._make_shop()
product = self._make_product(shop, price_in_cents=2000) # $20
cart = self._make_cart(user, shop)
cart.add_product(product)
# Create 50% off coupon ($10 off via dollar-off for predictability)
coupon = Coupon(
shop=shop,
code="HALF",
description="$10 off",
action_type="dollar-off",
action_value=10, # $10.00 off
max_redemptions=100,
max_redemptions_per_user=1,
cart_qualifier=0,
)
self.dbsession.add(coupon)
self.dbsession.flush()
# Attach coupon to cart
cart_coupon = CartCoupon(cart=cart, coupon=coupon)
self.dbsession.add(cart_coupon)
self.dbsession.flush()
# Create gift card with $5 balance
gift_card = GiftCard(shop=shop, amount_in_cents=500)
self.dbsession.add(gift_card)
self.dbsession.flush()
# Attach gift card to cart
cart_gift_card = CartGiftCard(cart=cart, gift_card=gift_card)
self.dbsession.add(cart_gift_card)
self.dbsession.flush()
# Coupon reduces $20 to $10, gift card reduces $10 to $5
shop_uuid = shop.uuid_str
discounted = cart.discounted_shop_totals_in_cents
self.assertEqual(discounted[shop_uuid], 500) # $5.00
transaction.commit()
def test_gift_card_validate_attached_integration(self):
"""Test validation catches disabled gift cards attached to cart."""
user = self._make_user()
shop = self._make_shop()
product = self._make_product(shop, price_in_cents=1000)
cart = self._make_cart(user, shop)
cart.add_product(product)
# Create a disabled gift card
gift_card = GiftCard(shop=shop, amount_in_cents=5000)
gift_card.disabled = True
self.dbsession.add(gift_card)
self.dbsession.flush()
# Attach disabled gift card to cart
cart_gift_card = CartGiftCard(cart=cart, gift_card=gift_card)
self.dbsession.add(cart_gift_card)
self.dbsession.flush()
# Validate should return errors about disabled card
errors = cart.validate_attached_gift_cards()
self.assertTrue(len(errors) > 0)
self.assertTrue(
any("disabled" in err.lower() for err in errors),
f"Expected 'disabled' in error messages, got: {errors}",
)
transaction.commit()
def test_gift_card_purchases_in_cart_integration(self):
"""Test cart.json_gift_cards parses gift card purchase entries."""
user = self._make_user()
shop = self._make_shop()
cart = self._make_cart(user, shop)
# Set json_gift_cards with one purchase entry
cart.json_gift_cards = json.dumps([
{
"shop_id": shop.uuid_str,
"amount_in_cents": 2500,
"gift_email": "friend@test.com",
"gift_message": "Enjoy!",
}
])
self.dbsession.flush()
# Test gift_card_purchases returns parsed list
purchases = cart.gift_card_purchases
self.assertEqual(len(purchases), 1)
self.assertEqual(purchases[0]["amount_in_cents"], 2500)
self.assertEqual(purchases[0]["gift_email"], "friend@test.com")
# Test total
self.assertEqual(cart.gift_card_purchases_total_in_cents, 2500)
transaction.commit()