content:
  1000→| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |
  1001→| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |
  1002→| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |
  1003→| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |
  1004→
  1005→---
  1006→
  1007→### Unit Tests
  1008→
  1009→---
  1010→
  1011→#### tests/Unit/PricingEngineTest.php
  1012→
  1013→**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.
  1014→
  1015→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1016→|-----------|----------------------|----------------|----------|
  1017→| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |
  1018→| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |
  1019→| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |
  1020→| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |
  1021→| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |
  1022→| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |
  1023→| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |
  1024→| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |
  1025→| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |
  1026→| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |
  1027→| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |
  1028→| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |
  1029→| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |
  1030→| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |
  1031→
  1032→---
  1033→
  1034→#### tests/Unit/DiscountCalculatorTest.php
  1035→
  1036→**Setup:** Instantiate `DiscountService` with mock store and cart.
  1037→
  1038→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1039→|-----------|----------------------|----------------|----------|
  1040→| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |
  1041→| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |
  1042→| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |
  1043→| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |
  1044→| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |
  1045→| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |
  1046→| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |
  1047→| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |
  1048→| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |
  1049→| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |
  1050→| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |
  1051→| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |
  1052→| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |
  1053→
  1054→---
  1055→
  1056→#### tests/Unit/TaxCalculatorTest.php
  1057→
  1058→**Setup:** Instantiate `TaxCalculator` directly.
  1059→
  1060→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1061→|-----------|----------------------|----------------|----------|
  1062→| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |
  1063→| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |
  1064→| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |
  1065→| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |
  1066→| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |
  1067→| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |
  1068→| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |
  1069→
  1070→---
  1071→
  1072→#### tests/Unit/ShippingCalculatorTest.php
  1073→
  1074→**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.
  1075→
  1076→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1077→|-----------|----------------------|----------------|----------|
  1078→| matches a zone by country code | zone countries_json: ["DE","AT","CH"]; address country: "DE" | zone matched | happy path |
  1079→| matches a zone by region code | zone regions_json: ["US-NY","US-CA"]; address: country "US", province_code "US-NY" | zone matched | happy path |
  1080→| returns empty when no zone matches the address | zone countries_json: ["DE"]; address country: "FR" | empty collection returned | edge case |
  1081→| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |
  1082→| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |
  1083→| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |
  1084→| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |
  1085→| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |
  1086→| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |
  1087→
  1088→---
  1089→
  1090→#### tests/Unit/CartVersionTest.php
  1091→
  1092→**Setup:** Instantiate Cart model or CartService.
  1093→
  1094→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1095→|-----------|----------------------|----------------|----------|
  1096→| starts at version 1 | create new cart | cart_version = 1 | happy path |
  1097→| increments version on add line | add a line to cart | cart_version = 2 | happy path |
  1098→| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |
  1099→| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |
  1100→| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |
  1101→
  1102→---
  1103→
  1104→#### tests/Unit/HandleGeneratorTest.php
  1105→
  1106→**Setup:** Instantiate `HandleGenerator` with mock database queries.
  1107→
  1108→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1109→|-----------|----------------------|----------------|----------|
  1110→| generates a slug from title | title: "My Amazing Product" | handle = "my-amazing-product" | happy path |
  1111→| appends suffix on collision | title: "T-Shirt" (handle "t-shirt" already exists) | handle = "t-shirt-1" | edge case |
  1112→| increments suffix on multiple collisions | "t-shirt" and "t-shirt-1" both exist | handle = "t-shirt-2" | edge case |
  1113→| handles special characters | title: "Loewe's Fall/Winter 2026" | handle is a valid slug (e.g., "loewes-fallwinter-2026") | edge case |
  1114→| excludes current record id from collision check | product #5 with handle "t-shirt", updating product #5 | handle = "t-shirt" (no suffix needed) | edge case |
  1115→| scopes uniqueness check to store | store 1 has "t-shirt", generating for store 2 | handle = "t-shirt" (no suffix needed) | edge case |
  1116→
  1117→---
  1118→
  1119→### Feature Tests
  1120→
  1121→All feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.
  1122→
  1123→**Shared helper setup** (in `tests/Pest.php` or trait):
  1124→- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container
  1125→- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session
  1126→- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard
  1127→
  1128→---
  1129→
  1130→#### tests/Feature/Tenancy/TenantResolutionTest.php
  1131→
  1132→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1133→|-----------|----------------------|----------------|----------|
  1134→| resolves store from hostname for storefront requests | store with domain "acme-fashion.test"; GET "/" with Host header "acme-fashion.test" | 200 response, current_store bound in container | happy path |
  1135→| returns 404 for unknown hostname | GET "/" with Host header "nonexistent.test" | 404 response | validation |
  1136→| returns 503 for suspended store on storefront | store with status "suspended"; GET "/" with Host header for suspended store | 503 response | validation |
  1137→| resolves store from session for admin requests | login as admin, set current_store_id in session; GET "/admin" | 200 response, correct store in context | happy path |
  1138→| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET "/admin" | 403 response | authorization |
  1139→| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key "store_domain:{hostname}" exists | happy path |
  1140→
  1141→---
  1142→
  1143→#### tests/Feature/Tenancy/StoreIsolationTest.php
  1144→
  1145→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1146→|-----------|----------------------|----------------|----------|
  1147→| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |
  1148→| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |
  1149→| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |
  1150→| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |
  1151→| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |
  1152→
  1153→---
  1154→
  1155→#### tests/Feature/Auth/AdminAuthTest.php
  1156→
  1157→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1158→|-----------|----------------------|----------------|----------|
  1159→| renders the admin login page | GET /admin/login | 200 response, sees "Login" text | happy path |
  1160→| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |
  1161→| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees "Invalid credentials" | validation |
  1162→| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic "Invalid credentials" error | validation |
  1163→| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |
  1164→| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |
  1165→| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |
  1166→| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |
  1167→| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |
  1168→| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |
  1169→
  1170→---
  1171→
  1172→#### tests/Feature/Auth/CustomerAuthTest.php
  1173→
  1174→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1175→|-----------|----------------------|----------------|----------|
  1176→| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |
  1177→| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |
  1178→| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |
  1179→| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |
  1180→| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |
  1181→| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |
  1182→| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |
  1183→| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |
  1184→| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |
  1185→| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |
  1186→
  1187→---
  1188→
  1189→#### tests/Feature/Auth/SanctumTokenTest.php
  1190→
  1191→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1192→|-----------|----------------------|----------------|----------|
  1193→| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |
  1194→| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |
  1195→| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |
  1196→| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |
  1197→| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |
  1198→
  1199→---
  1200→
  1201→#### tests/Feature/Products/ProductCrudTest.php
  1202→
  1203→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1204→|-----------|----------------------|----------------|----------|
  1205→| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |
  1206→| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |
  1207→| generates a unique handle from the title | create product with title "Summer T-Shirt" | handle = "summer-t-shirt" | happy path |
  1208→| appends suffix when handle collides | create two products with title "T-Shirt" | handles are "t-shirt" and "t-shirt-1" | edge case |
  1209→| updates a product | create product, then update title and description | DB reflects changes | happy path |
  1210→| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |
  1211→| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |
  1212→| transitions product from active to archived | active product; transition to archived | status = archived | happy path |
  1213→| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |
  1214→| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |
  1215→| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |
  1216→| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |
  1217→| searches products by title | product titled "Organic Cotton Hoodie"; search "cotton" | product found in results | happy path |
  1218→
  1219→---
  1220→
  1221→#### tests/Feature/Products/VariantTest.php
  1222→
  1223→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1224→|-----------|----------------------|----------------|----------|
  1225→| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |
  1226→| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |
  1227→| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |
  1228→| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |
  1229→| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |
  1230→| validates SKU uniqueness within store | create variant with SKU "TSH-001" in store A; attempt to create another variant with same SKU in store A | validation error | validation |
  1231→| allows duplicate SKU across different stores | SKU "TSH-001" in store A; create variant with "TSH-001" in store B | success | edge case |
  1232→| allows null SKUs | create two variants with null SKU | both created successfully | edge case |
  1233→
  1234→---
  1235→
  1236→#### tests/Feature/Products/InventoryTest.php
  1237→
  1238→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1239→|-----------|----------------------|----------------|----------|
  1240→| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |
  1241→| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |
  1242→| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |
  1243→| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |
  1244→| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |
  1245→| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |
  1246→| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |
  1247→| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |
  1248→
  1249→---
  1250→
  1251→#### tests/Feature/Products/CollectionTest.php
  1252→
  1253→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1254→|-----------|----------------------|----------------|----------|
  1255→| creates a collection with a unique handle | create collection titled "Summer Sale" | handle = "summer-sale", saved in DB | happy path |
  1256→| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |
  1257→| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |
  1258→| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |
  1259→| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |
  1260→| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |
  1261→| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |
  1262→
  1263→---
  1264→
  1265→#### tests/Feature/Products/MediaUploadTest.php
  1266→
  1267→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1268→|-----------|----------------------|----------------|----------|
  1269→| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |
  1270→| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |
  1271→| rejects non-image file types | attempt to upload a .txt file | validation error | validation |
  1272→| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |
  1273→| reorders media positions | upload 3 images, reorder | position values updated | happy path |
  1274→| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |
  1275→
  1276→---
  1277→
  1278→#### tests/Feature/Cart/CartServiceTest.php
  1279→
  1280→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1281→|-----------|----------------------|----------------|----------|
  1282→| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |
  1283→| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |
  1284→| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |
  1285→| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |
  1286→| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |
  1287→| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |
  1288→| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |
  1289→| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |
  1290→| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |
  1291→| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |
  1292→| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |
  1293→| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |
  1294→
  1295→---
  1296→
  1297→#### tests/Feature/Cart/CartApiTest.php
  1298→
  1299→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1300→|-----------|----------------------|----------------|----------|
  1301→| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |
  1302→| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |
  1303→| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |
  1304→| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |
  1305→| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |
  1306→| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |
  1307→| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |
  1308→| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |
  1309→
  1310→---
  1311→
  1312→#### tests/Feature/Checkout/CheckoutFlowTest.php
  1313→
  1314→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1315→|-----------|----------------------|----------------|----------|
  1316→| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |
  1317→| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |
  1318→| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |
  1319→| expires checkout after timeout | checkout with payment_selected status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |
  1320→| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |
  1321→
  1322→---
  1323→
  1324→#### tests/Feature/Checkout/CheckoutStateTest.php
  1325→
  1326→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1327→|-----------|----------------------|----------------|----------|
  1328→| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |
  1329→| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |
  1330→| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |
  1331→| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |
  1332→| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |
  1333→| transitions from shipping_selected to payment_selected | select payment method | status = payment_selected, expires_at set, inventory reserved | happy path |
  1334→| transitions from payment_selected to completed | simulate successful payment | status = completed, order created | happy path |
  1335→| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |
  1336→| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |
  1337→
  1338→---
  1339→
  1340→#### tests/Feature/Checkout/PricingIntegrationTest.php
  1341→
  1342→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1343→|-----------|----------------------|----------------|----------|
  1344→| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |
  1345→| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |
  1346→| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |
  1347→| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |
  1348→| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |
  1349→
  1350→---
  1351→
  1352→#### tests/Feature/Checkout/DiscountTest.php
  1353→
  1354→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1355→|-----------|----------------------|----------------|----------|
  1356→| applies a valid percent discount code at checkout | discount: 10%, code "SAVE10"; apply to checkout with subtotal 5000 | discount = 500 | happy path |
  1357→| applies a valid fixed discount code at checkout | discount: 500 fixed, code "5OFF" | discount = 500 | happy path |
  1358→| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |
  1359→| rejects expired discount at checkout | discount ended yesterday | error returned | validation |
  1360→| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |
  1361→| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |
  1362→
  1363→---
  1364→
  1365→#### tests/Feature/Checkout/ShippingTest.php
  1366→
  1367→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1368→|-----------|----------------------|----------------|----------|
  1369→| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |
  1370→| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |
  1371→| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |
  1372→| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |
  1373→| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |
  1374→
  1375→---
  1376→
  1377→#### tests/Feature/Checkout/TaxTest.php
  1378→
  1379→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1380→|-----------|----------------------|----------------|----------|
  1381→| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |
  1382→| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |
  1383→| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |
  1384→| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |
  1385→
  1386→---
  1387→
  1388→#### tests/Feature/Orders/OrderCreationTest.php
  1389→
  1390→| Test Name | Setup / Preconditions | Key Assertions | Category |
  1391→|-----------|----------------------|----------------|----------|
  1392→| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |
  1393→| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |
  1394→| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |
  1395→| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |
  1396→| marks cart as converted | create order from checkout | cart status = converted | happy path |
  1397→| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |
  1398→| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |
  1399→| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |

content:
| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |
| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |
| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |
| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |

---

### Unit Tests

---

#### tests/Unit/PricingEngineTest.php

**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |
| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |
| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |
| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |
| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |
| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |
| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |
| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |
| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |
| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |
| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |
| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |
| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |
| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |

---

#### tests/Unit/DiscountCalculatorTest.php

**Setup:** Instantiate `DiscountService` with mock store and cart.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |
| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |
| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |
| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |
| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |
| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |
| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |
| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |
| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |
| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |
| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |
| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |
| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |

---

#### tests/Unit/TaxCalculatorTest.php

**Setup:** Instantiate `TaxCalculator` directly.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |
| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |
| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |
| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |
| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |
| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |
| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |

---

#### tests/Unit/ShippingCalculatorTest.php

**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| matches a zone by country code | zone countries_json: ["DE","AT","CH"]; address country: "DE" | zone matched | happy path |
| matches a zone by region code | zone regions_json: ["US-NY","US-CA"]; address: country "US", province_code "US-NY" | zone matched | happy path |
| returns empty when no zone matches the address | zone countries_json: ["DE"]; address country: "FR" | empty collection returned | edge case |
| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |
| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |
| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |
| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |
| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |
| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |

---

#### tests/Unit/CartVersionTest.php

**Setup:** Instantiate Cart model or CartService.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| starts at version 1 | create new cart | cart_version = 1 | happy path |
| increments version on add line | add a line to cart | cart_version = 2 | happy path |
| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |
| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |
| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |

---

#### tests/Unit/HandleGeneratorTest.php

**Setup:** Instantiate `HandleGenerator` with mock database queries.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| generates a slug from title | title: "My Amazing Product" | handle = "my-amazing-product" | happy path |
| appends suffix on collision | title: "T-Shirt" (handle "t-shirt" already exists) | handle = "t-shirt-1" | edge case |
| increments suffix on multiple collisions | "t-shirt" and "t-shirt-1" both exist | handle = "t-shirt-2" | edge case |
| handles special characters | title: "Loewe's Fall/Winter 2026" | handle is a valid slug (e.g., "loewes-fallwinter-2026") | edge case |
| excludes current record id from collision check | product #5 with handle "t-shirt", updating product #5 | handle = "t-shirt" (no suffix needed) | edge case |
| scopes uniqueness check to store | store 1 has "t-shirt", generating for store 2 | handle = "t-shirt" (no suffix needed) | edge case |

---

### Feature Tests

All feature tests use the `RefreshDatabase` trait. Factory setup creates the necessary Store, User, and domain context.

**Shared helper setup** (in `tests/Pest.php` or trait):
- `createStoreContext()`: creates Organization, Store, StoreDomain, User with Owner role, binds `current_store` in container
- `actingAsAdmin(User $user)`: authenticates as admin and sets store in session
- `actingAsCustomer(Customer $customer)`: authenticates as customer with customer guard

---

#### tests/Feature/Tenancy/TenantResolutionTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| resolves store from hostname for storefront requests | store with domain "acme-fashion.test"; GET "/" with Host header "acme-fashion.test" | 200 response, current_store bound in container | happy path |
| returns 404 for unknown hostname | GET "/" with Host header "nonexistent.test" | 404 response | validation |
| returns 503 for suspended store on storefront | store with status "suspended"; GET "/" with Host header for suspended store | 503 response | validation |
| resolves store from session for admin requests | login as admin, set current_store_id in session; GET "/admin" | 200 response, correct store in context | happy path |
| denies admin access when user has no store_users record | login as admin, session points to store user is not associated with; GET "/admin" | 403 response | authorization |
| caches hostname lookup | first request resolves from DB; second request resolves from cache (no additional query) | cache key "store_domain:{hostname}" exists | happy path |

---

#### tests/Feature/Tenancy/StoreIsolationTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| scopes product queries to the current store | store A has 3 products, store B has 5 products; set current_store to store A | Product::all() count = 3 | happy path |
| scopes order queries to the current store | store A has 2 orders, store B has 7 orders; set current_store to store A | Order::count() = 2 | happy path |
| automatically sets store_id on model creation | set current_store to store A; create a product without explicitly setting store_id | product->store_id = store A's id | happy path |
| prevents accessing another stores records via direct ID | store A product with id 1; set current_store to store B | Product::find(1) returns null | authorization |
| allows cross-store access when global scope is removed | remove StoreScope; query all products | count includes all stores | edge case |

---

#### tests/Feature/Auth/AdminAuthTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| renders the admin login page | GET /admin/login | 200 response, sees "Login" text | happy path |
| authenticates an admin user with valid credentials | POST /admin/login with valid email/password | redirected to /admin, session has auth | happy path |
| rejects invalid credentials | POST /admin/login with wrong password | redirected back, session has errors, sees "Invalid credentials" | validation |
| does not reveal whether email or password is incorrect | POST /admin/login with non-existent email | same generic "Invalid credentials" error | validation |
| rate limits login attempts | make 6 POST requests with wrong credentials within 1 minute | 6th request returns 429 Too Many Requests | validation |
| regenerates session on successful login | record session ID before login; login successfully | session ID changed | happy path |
| logs out and invalidates session | login, then POST /admin/logout | redirected to /admin/login, session invalidated, no longer authenticated | happy path |
| redirects unauthenticated users to login | GET /admin without auth | redirected to /admin/login | authorization |
| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |
| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |

---

#### tests/Feature/Auth/CustomerAuthTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |
| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |
| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |
| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |
| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |
| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |
| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |
| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |
| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |
| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |

---

#### tests/Feature/Auth/SanctumTokenTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |
| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |
| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |
| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |
| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |

---

#### tests/Feature/Products/ProductCrudTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |
| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |
| generates a unique handle from the title | create product with title "Summer T-Shirt" | handle = "summer-t-shirt" | happy path |
| appends suffix when handle collides | create two products with title "T-Shirt" | handles are "t-shirt" and "t-shirt-1" | edge case |
| updates a product | create product, then update title and description | DB reflects changes | happy path |
| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |
| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |
| transitions product from active to archived | active product; transition to archived | status = archived | happy path |
| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |
| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |
| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |
| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |
| searches products by title | product titled "Organic Cotton Hoodie"; search "cotton" | product found in results | happy path |

---

#### tests/Feature/Products/VariantTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |
| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |
| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |
| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |
| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |
| validates SKU uniqueness within store | create variant with SKU "TSH-001" in store A; attempt to create another variant with same SKU in store A | validation error | validation |
| allows duplicate SKU across different stores | SKU "TSH-001" in store A; create variant with "TSH-001" in store B | success | edge case |
| allows null SKUs | create two variants with null SKU | both created successfully | edge case |

---

#### tests/Feature/Products/InventoryTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |
| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |
| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |
| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |
| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |
| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |
| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |
| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |

---

#### tests/Feature/Products/CollectionTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates a collection with a unique handle | create collection titled "Summer Sale" | handle = "summer-sale", saved in DB | happy path |
| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |
| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |
| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |
| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |
| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |
| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |

---

#### tests/Feature/Products/MediaUploadTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |
| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |
| rejects non-image file types | attempt to upload a .txt file | validation error | validation |
| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |
| reorders media positions | upload 3 images, reorder | position values updated | happy path |
| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |

---

#### tests/Feature/Cart/CartServiceTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |
| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |
| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |
| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |
| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |
| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |
| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |
| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |
| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |
| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |
| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |
| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |

---

#### tests/Feature/Cart/CartApiTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |
| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |
| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |
| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |
| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |
| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |
| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |
| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |

---

#### tests/Feature/Checkout/CheckoutFlowTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |
| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |
| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |
| expires checkout after timeout | checkout with payment_selected status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |
| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |

---

#### tests/Feature/Checkout/CheckoutStateTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |
| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |
| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |
| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |
| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |
| transitions from shipping_selected to payment_selected | select payment method | status = payment_selected, expires_at set, inventory reserved | happy path |
| transitions from payment_selected to completed | simulate successful payment | status = completed, order created | happy path |
| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |
| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |

---

#### tests/Feature/Checkout/PricingIntegrationTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |
| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |
| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |
| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |
| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |

---

#### tests/Feature/Checkout/DiscountTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| applies a valid percent discount code at checkout | discount: 10%, code "SAVE10"; apply to checkout with subtotal 5000 | discount = 500 | happy path |
| applies a valid fixed discount code at checkout | discount: 500 fixed, code "5OFF" | discount = 500 | happy path |
| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |
| rejects expired discount at checkout | discount ended yesterday | error returned | validation |
| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |
| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |

---

#### tests/Feature/Checkout/ShippingTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |
| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |
| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |
| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |
| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |

---

#### tests/Feature/Checkout/TaxTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |
| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |
| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |
| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |

---

#### tests/Feature/Orders/OrderCreationTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |
| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |
| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |
| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |
| marks cart as converted | create order from checkout | cart status = converted | happy path |
| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |
| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |
| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |


teamName: wild-sleeping-shell

userType: external

slug: iterative-seeking-pine

type: message

role: assistant

type: text

text: Now let me read the database schema for Phase 4 tables and the business logic spec.

service_tier: standard

type: assistant


Task

description: Read Phase 4 DB schema

prompt:
Read the database schema spec at /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md and extract ONLY the table definitions for the following tables that belong to Phase 4 (Cart, Checkout, Discounts, Shipping, Taxes):
- carts
- cart_lines
- checkouts
- shipping_zones
- shipping_rates
- tax_settings
- discounts

Return the complete table definitions including columns, types, constraints, indexes, and any notes.


type: progress

type: user

role: user

text:
Read the database schema spec at /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md and extract ONLY the table definitions for the following tables that belong to Phase 4 (Cart, Checkout, Discounts, Shipping, Taxes):
- carts
- cart_lines
- checkouts
- shipping_zones
- shipping_rates
- tax_settings
- discounts

Return the complete table definitions including columns, types, constraints, indexes, and any notes.

prompt:
Read the database schema spec at /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md and extract ONLY the table definitions for the following tables that belong to Phase 4 (Cart, Checkout, Discounts, Shipping, Taxes):
- carts
- cart_lines
- checkouts
- shipping_zones
- shipping_rates
- tax_settings
- discounts

Return the complete table definitions including columns, types, constraints, indexes, and any notes.


prompt:
Read the database schema spec at /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md and extract ONLY the table definitions for the following tables that belong to Phase 4 (Cart, Checkout, Discounts, Shipping, Taxes):
- carts
- cart_lines
- checkouts
- shipping_zones
- shipping_rates
- tax_settings
- discounts

Return the complete table definitions including columns, types, constraints, indexes, and any notes.


description: Read Phase 5 DB schema

prompt:
Read the database schema spec at /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md and extract ONLY the table definitions for the following tables that belong to Phase 5 (Payments, Orders, Fulfillment):
- customers
- customer_addresses
- orders
- order_lines
- payments
- refunds
- fulfillments
- fulfillment_lines

Return the complete table definitions including columns, types, constraints, indexes, and any notes.


text:
Read the database schema spec at /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md and extract ONLY the table definitions for the following tables that belong to Phase 5 (Payments, Orders, Fulfillment):
- customers
- customer_addresses
- orders
- order_lines
- payments
- refunds
- fulfillments
- fulfillment_lines

Return the complete table definitions including columns, types, constraints, indexes, and any notes.

prompt:
Read the database schema spec at /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md and extract ONLY the table definitions for the following tables that belong to Phase 5 (Payments, Orders, Fulfillment):
- customers
- customer_addresses
- orders
- order_lines
- payments
- refunds
- fulfillments
- fulfillment_lines

Return the complete table definitions including columns, types, constraints, indexes, and any notes.