Testing Playbook
Scripted end-to-end QA flows for stage. Each scenario lists steps, expected outcome, and how to verify in both the UI and the database.
Default base URLs (stage):
- Admin (Filament):
https://stage.easyotc.com/admin - Storefront (Nuxt):
https://stage.easyotc.com - Horizon:
https://stage.easyotc.com/horizon - Meilisearch dashboard:
https://meili-stage.easyotc.com(use the master key)
If your stage uses different hostnames, substitute throughout.
Pre-test checklist
Verify these five things before opening a scenario. If any one fails, stop and ping engineering — almost every "weird" bug in this app comes from one of these being wrong.
Queue worker is running. Open
/horizon. The status pill (top-right) should read Active. Pending jobs should be draining, not piling up.bash# On the server sudo supervisorctl status | grep horizonExpected:
horizon RUNNING.Meilisearch is reachable. From the API box:
bashcurl -sS -H "Authorization: Bearer $MEILISEARCH_KEY" https://meili-stage.easyotc.com/healthExpected:
{"status":"available"}.S3 is reachable. Upload a small probe and read it back:
bashphp artisan tinker --execute="Storage::disk('s3')->put('healthcheck.txt', now()); echo Storage::disk('s3')->get('healthcheck.txt');"Expected: a timestamp prints, no exception.
Stage DB has seed data. Quick sanity counts:
sqlSELECT (SELECT COUNT(*) FROM products) AS products, (SELECT COUNT(*) FROM categories) AS categories, (SELECT COUNT(*) FROM members) AS members;Expected: products > 100, categories > 10, members > 5. If 0, run
php artisan app:setup(seedocs/test-accounts.md).You're logged in as the expected role. Top-right of Filament shows your user. For most scenarios you need
superadmin@easyotc.com(OTC_ONE_ADMIN). To confirm in DB:sqlSELECT u.email, mhr.name AS role FROM users u JOIN model_has_roles mhr_link ON mhr_link.model_id = u.id JOIN roles mhr ON mhr.id = mhr_link.role_id WHERE u.email = 'superadmin@easyotc.com';Expected: one row,
role = OTC_ONE_ADMIN.
Scenario 1: Add a new product end-to-end
Goal: prove a brand-new product flows from admin form → DB → S3 image variants → Meilisearch → storefront card.
Steps
- Login to
/adminassuperadmin@easyotc.com(roleOTC_ONE_ADMIN). - Categories tab → confirm the target category (e.g., "Pain Relief") exists. If not, click New Category, fill in
name, upload an image, save. - Sub-Categories tab → confirm the target sub-category exists under that category. If not, New Sub-Category → pick parent category → save.
- Products → New Product. The form has 7 tabs; fill the 13 required fields (marked with
*in the UI):- Overview: Product Name, SKU, Description
- Pricing: Price (cents) — remember 1000 = $10.00
- Packaging: Quantity, Quantity Label (e.g.
tablets), Inner Pack, Case Pack, Cases / Pallet - Identifiers: UPC
- Regulatory: Country of Origin
- Categories: at least one Category (required for storefront visibility — see step 8)
- (and the Manufacturer association set on the product — see step 8)
- Upload 2–3 images on the Overview tab. Drag to reorder; the first image becomes primary. Each gets a sort order matching its position (0, 1, 2...).
- Click Create. Expected: redirect to the Edit page, success toast ("Created" or similar), no red error toast.
Verification — UI
- The Edit page header shows the product name and SKU.
- The Images section shows the thumbnails you uploaded.
- Visit
/admin/products— the new row appears at the top of the list.
Verification — DB
-- Replace 'YOUR-SKU' with the SKU you used
SELECT id, name, sku, slug, manufacturer_id, is_available
FROM products WHERE sku = 'YOUR-SKU';
-- Image rows (one per upload)
SELECT id, image_url, sort_order, is_primary
FROM product_images WHERE product_id = <id from above>
ORDER BY sort_order;
-- Price + packaging exist
SELECT * FROM product_prices WHERE product_id = <id>;
SELECT * FROM product_packaging_details WHERE product_id = <id>;Expected: one products row, one product_images row per uploaded file, one product_prices row, one product_packaging_details row.
Verification — Image variants (S3)
GenerateImageVariantsJob is dispatched on save. Within ~10 seconds, three WebP variants should exist on S3 per uploaded image. If the original was images/products/abc-123.jpg, expect:
images/products/abc-123_sm.webpimages/products/abc-123_md.webpimages/products/abc-123_lg.webp
Verify:
# Replace with the actual URL from product_images.image_url
curl -I https://otc-stage.s3.amazonaws.com/images/products/abc-123_md.webpExpected: HTTP/1.1 200 OK. In Horizon (/horizon/completed) you should see GenerateImageVariantsJob with status completed.
Step 7 follow-up — The invisibility gotcha
Switch to the Categories tab on the Edit page. Add the tag "Purses" (or any tag — see
pursestable). Confirm at least one Category is selected. Open the Overview tab again and confirm a Manufacturer is set (if the field isn't on your form's Overview tab, it can be set via DB or via the Manufacturers resource — products withmanufacturer_id = NULLwill not be searchable).Click Save. The product is only
shouldBeSearchable() === truewhen it has at least one tag AND at least one category. Without both, it will not appear in Meilisearch or on the storefront, regardless ofis_available.
Verification — Meilisearch
curl -sS -H "Authorization: Bearer $MEILISEARCH_KEY" \
"https://meili-stage.easyotc.com/indexes/products/search" \
-H 'Content-Type: application/json' \
-d '{"q":"YOUR-SKU"}'Expected: hits array contains the product with matching id and sku.
Verification — Storefront
- Visit
/products?categories=<slug-of-the-category>on the storefront. - Expected: the new product card renders. The thumbnail loads from the
_smWebP variant. Click through to/products/<slug>and confirm the description, price, and all images render.
If the card image is broken or shows a placeholder, the variants didn't generate — check /horizon/failed for GenerateImageVariantsJob failures.
Scenario 2: Member places an order (SSO path)
Goal: walk a member through the healthinsurance.com → storefront → cart → checkout flow. Stripe is not live yet — the flow stops at order creation; payment is currently bypassed.
Steps
- From healthinsurance.com (or the SSO test endpoint for stage):This issues an SSO token and redirects to
GET https://stage.easyotc.com/api/sso/test-callback?agent_email=agent@easyotc.com&member_code=<MEMBER_CODE>https://stage.easyotc.com/auth/callback?token=.... - The storefront callback page exchanges the token via
POST /api/sso/exchange, stores the auth cookie, and lands you on/. - Verify auth: top-right of the storefront shows the member's name. The header cart icon shows
0items. - Browse
/products. Click any in-stock product (inventory_count > 0,is_available = true). - Click Add to Cart (quantity 1).
- Click the cart icon → View Cart (
/cart). Confirm the line item, unit price, and subtotal. - Click Checkout (
/checkout). Fill or confirm shipping address and billing address. - Click Continue to Payment (
/checkout/payment). The page shows order totals and theamountResponsibleafter purse deductions. - Click Submit Order. The Nuxt page calls
ordersStore.createOrder(...)→POST /api/member/orders→StoreOrderAction→OrderService::createOrderFromCart(). - Expected: redirect to
/checkout/success?order=ORD<date><random>. Cart is cleared.
Where the flow stops (Stripe not live)
StoreOrderAction creates the orders row and order_items rows, decrements purse balance via transactions, and clears the cart. It does not call Stripe to capture a card payment — that integration is wired (PaymentIntent model, Stripe\StripeClient singleton in AppServiceProvider), but no charge is dispatched in the current code path. Treat the order as "placed, awaiting payment".
Verification — UI
/checkout/successshows the order number./account/orderslists the new order at the top.- Clicking the order shows the line items, addresses, totals.
Verification — DB
-- The new order
SELECT id, order_number, member_id, status, payment_status,
subtotal, discount_amount, total_amount, payment_method
FROM orders ORDER BY id DESC LIMIT 1;
-- Items
SELECT product_id, quantity, unit_price, total_price
FROM order_items WHERE order_id = <id>;
-- Cart is now empty for that member
SELECT c.id, COUNT(ci.id) AS items
FROM carts c LEFT JOIN cart_items ci ON ci.cart_id = c.id
WHERE c.member_id = <member_id> AND c.status = 'active'
GROUP BY c.id;
-- Purse transactions (if purse balance was used)
SELECT * FROM transactions WHERE order_id = <id>;Expected:
- One
ordersrow,payment_status = 'pending'(Stripe not live),total_amountmatches what you saw on/checkout/payment. - One
order_itemsrow per cart line,total_price = unit_price * quantity. - The active cart row has
0items (cleared byclearCart()).
Scenario 3: ShipHero inbound sync
Goal: pull inventory + names from ShipHero and confirm products and product_packaging_details reflect the upstream data.
Steps
- SSH into the stage API box.
- Run a dry run first:bashExpected output (truncated):
php artisan shiphero:sync-products --dry-run --limit=10Starting ShipHero product sync... Authenticating with ShipHero... Authentication successful! Token expires in: 7.0 days DRY RUN MODE - No changes will be made to the database Fetching page 1 (limit: 10)... Processing 10 products... [DRY RUN] SKU: 81020107 Name: Acetaminophen 500mg 100ct Inventory: 142 ... Sync completed! +----------+-------+ | Metric | Count | +----------+-------+ | Created | 10 | | Updated | 0 | | Failed | 0 | +----------+-------+ - If dry-run output looks right, run for real (no
--dry-run):bashphp artisan shiphero:sync-products - Optional — sync one specific SKU only:bash
php artisan shiphero:sync-products --sku=81020107
Important behaviour: the command only updates products that already exist (matched by sku). It will not create new products from ShipHero — those have to be in our DB first (typically loaded via CSV — see Scenario 4). Missing SKUs are counted as Failed.
Verification — DB
-- Pick a SKU you saw in the sync output
SELECT p.sku, p.name, p.inventory_count, p.is_available, p.updated_at
FROM products p WHERE p.sku = '81020107';
-- Packaging dimensions should be populated/refreshed
SELECT quantity, quantity_label, height, width, length, case_weight
FROM product_packaging_details
WHERE product_id = (SELECT id FROM products WHERE sku = '81020107');Expected:
products.inventory_countmatches theInventoryvalue the command printed.products.is_available = trueif inventory > 0,falseotherwise.products.updated_atis within the last minute.product_packaging_details.quantityreflects the ShipHero packaging (the CSV-loadedquantityis preserved if ShipHero has none).
Verification — UI
/admin/products→ search by SKU → open the record. Inventory Count on the Availability tab matches. Dimensions on the Packaging tab match.
Scenario 4: CSV bulk import
Goal: update existing products from a vendor CSV, targeting only the columns you specify.
Steps
- Drop the CSV into the API box at
storage/csv/easy_otc_products_1.csv(or use the file already in the repo root). - Always dry-run first. Update only
sub_category:bashExpected output (truncated):php artisan products:update-from-csv easy_otc_products_1.csv --columns=sub_category --dry-runParsing CSV: easy_otc_products_1.csv Targeted columns: sub_category DRY RUN — no DB changes will be written. SKU 81020107 — would update sub_category: "Pain Relief" → "Headache & Migraine" SKU 81020112 — no change SKU 81020201 — would update sub_category: NULL → "Allergy" ... Summary: 47 would update, 153 unchanged, 2 skipped (SKU not in DB). - Review the diff. If correct, apply (drop
--dry-run):bashphp artisan products:update-from-csv easy_otc_products_1.csv --columns=sub_category - Multiple columns at once:bash
php artisan products:update-from-csv easy_otc_products_1.csv --columns=price,unit_price,case_price
Supported --columns keys (from the command's HEADER_MAP): sku, name, description, price, unit_price, case_price, upc, ndc_10, ndc_11, nbe_compare_to, category, sub_category, quantity, active_ingredient, country_of_origin, is_available, inventory_count, is_subscribe_save_eligible.
Verification — DB
-- Pick a SKU that the dry-run said "would update"
SELECT p.sku, sc.name AS sub_category
FROM products p
JOIN sub_categorizables scz ON scz.subcategorizable_id = p.id
AND scz.subcategorizable_type = 'App\\Models\\Product'
JOIN sub_categories sc ON sc.id = scz.sub_category_id
WHERE p.sku = '81020107';Expected: the sub-category matches the new value from the CSV.
Verification — UI
/admin/products→ open the SKU → Categories tab → Sub-Categories shows the new value.
Scenario 5: Image variant backfill
Goal: regenerate missing _sm / _md / _lg WebP variants for product images and category images. Useful after a bulk import or after a CDN change.
Steps
Backfill product image variants — process only rows missing at least one variant on S3:
bashphp artisan products:generate-image-variants --missing-onlyExpected: a progress bar over the matching rows. Each one dispatches a
GenerateImageVariantsJobto thedefaultqueue.Backfill category image variants:
bashphp artisan categories:generate-image-variants --missing-onlyWatch progress in
/horizon. The queue should drain within a few minutes for a typical batch.If you want to regenerate everything (e.g., changed sizes), use
--force:bashphp artisan products:generate-image-variants --forceSync instead of queue (useful for debugging a single failure):
bashphp artisan products:generate-image-variants --id=42 --sync
Verification — S3
Pick any row from product_images. If image_url is https://otc-stage.s3.amazonaws.com/images/products/foo.jpg, then these three URLs should respond 200 OK:
curl -I https://otc-stage.s3.amazonaws.com/images/products/foo_sm.webp
curl -I https://otc-stage.s3.amazonaws.com/images/products/foo_md.webp
curl -I https://otc-stage.s3.amazonaws.com/images/products/foo_lg.webpVerification — UI
- Storefront
/productscards load the_smvariant (check DevTools → Network). - Category pages render category hero images without broken placeholders.
Verification — Horizon
/horizon/completed→ recentGenerateImageVariantsJobentries with statuscompleted./horizon/failed→ should be empty. If not, click into a failure for the exception trace.
Scenario 6: Domain rewrite (S3 → CDN)
Goal: after pointing AWS_URL to a new CDN host, rewrite all stored image URLs in the DB so existing rows use the new host.
Steps
Confirm the new CDN host serves the same objects:
bashcurl -I https://cdn-stage.easyotc.com/images/products/some-known-file.jpgExpected:
200 OK.Dry-run the rewrite:
bashphp artisan images:rewrite-url-domain \ --from=https://otc-stage.s3.amazonaws.com \ --to=https://cdn-stage.easyotc.com \ --dry-runExpected output:
Rewrite: https://otc-stage.s3.amazonaws.com → https://cdn-stage.easyotc.com (DRY RUN) categories.image_url: would update 12 rows product_images.image_url: would update 487 rows sub_categories.image_url: would update 8 rows Total: 507 rows would be updated.Apply for real:
bashphp artisan images:rewrite-url-domain \ --from=https://otc-stage.s3.amazonaws.com \ --to=https://cdn-stage.easyotc.com
Verification — DB
-- Should return 0
SELECT COUNT(*) FROM product_images
WHERE image_url LIKE 'https://otc-stage.s3.amazonaws.com/%';
SELECT COUNT(*) FROM categories
WHERE image_url LIKE 'https://otc-stage.s3.amazonaws.com/%';
SELECT COUNT(*) FROM sub_categories
WHERE image_url LIKE 'https://otc-stage.s3.amazonaws.com/%';
-- Spot-check a few rows now use the CDN host
SELECT image_url FROM product_images LIMIT 5;Expected: all three counts return 0. Spot-checked URLs start with https://cdn-stage.easyotc.com/.
Verification — UI
- Hard-refresh
/productson the storefront. Open DevTools → Network. All image requests go tocdn-stage.easyotc.com, none tootc-stage.s3.amazonaws.com. /admin/products→ open a product → images preview still renders.
Scenario 7: Promotion / discount
Goal: create a promotion that applies to a product and confirm the discounted price reflects on the storefront.
Steps
- Login to
/adminassuperadmin@easyotc.com. - Navigate to Promotions (URL:
/admin/promotions). - Click New Promotion. Fill:
name: e.g. "Test 10% Off"code: e.g.TEST10(the code customers enter)discount_type:percentagediscount_value:10starts_at: nowends_at: now + 7 daysis_active: true- Attach at least one product via the relation manager (or leave site-wide if applicable)
- Click Create.
- On the storefront, browse to the product the promotion targets. The product card and PDP should display the discounted price with the original price struck through.
- Add to cart →
/cart. Apply the promo code if it's a code-redeemed promotion (not auto-applied). Subtotal should drop by 10%.
Verification — UI
/admin/promotionslist shows the new row withis_active = true.- Storefront product card shows the sale price and
% offbadge. - Cart shows the discount line:
Discount: -$X.XX.
Verification — DB
-- The promotion row
SELECT id, name, code, discount_type, discount_value, is_active, starts_at, ends_at
FROM promotions ORDER BY id DESC LIMIT 1;
-- Product → promotion pivot (if applicable)
SELECT * FROM product_discounts WHERE product_id = <product_id>;
-- After adding to cart with the code applied
SELECT id, promotion_id, discount_amount, status
FROM carts WHERE member_id = <member_id> AND status = 'active';Expected: carts.promotion_id is set to the promo's id, carts.discount_amount equals 10% of the cart subtotal (in cents).
Common debugging tips
Where to look in laravel.log
# Live tail
tail -f /var/www/easy-otc-api/storage/logs/laravel.log
# Last error
grep -i "production.ERROR" /var/www/easy-otc-api/storage/logs/laravel.log | tail -5Key channels logged here: ShipHero sync errors, image-variant job failures, Stripe exceptions, generic 500s.
Horizon failed jobs
/horizon/failedlists every failed queue job with the exception class, message, and full trace.- Click a row → Retry to re-dispatch.
- Bulk retry from the CLI:bash
php artisan horizon:retry all - Common failures:
GenerateImageVariantsJob: the source object isn't on S3 (URL points to a host we can't read), orintervention/imagecouldn't decode the file.SyncManufacturersToShipHeroCommandjobs: ShipHero token expired (run the command again to refresh).
Find an indexed product in Meilisearch
# Search by name
curl -sS -H "Authorization: Bearer $MEILISEARCH_KEY" \
"https://meili-stage.easyotc.com/indexes/products/search" \
-H 'Content-Type: application/json' \
-d '{"q":"ibuprofen","limit":3}'
# Fetch one document by primary key (the product's id)
curl -sS -H "Authorization: Bearer $MEILISEARCH_KEY" \
"https://meili-stage.easyotc.com/indexes/products/documents/42"
# List the indexes that exist
curl -sS -H "Authorization: Bearer $MEILISEARCH_KEY" \
"https://meili-stage.easyotc.com/indexes"If a product is in the DB but not in Meilisearch, the most likely cause is shouldBeSearchable() returning false: it needs at least one tag and at least one category. Force a reindex:
php artisan scout:import "App\Models\Product"Verify CORS via curl
curl -i -X OPTIONS https://stage.easyotc.com/api/cart \
-H "Origin: https://stage.easyotc.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,authorization"Expected response headers include:
Access-Control-Allow-Origin: https://stage.easyotc.comAccess-Control-Allow-Methods: ...POST...Access-Control-Allow-Credentials: true
If the Access-Control-Allow-Origin is missing, check config/cors.php allowed_origins.
How to file a bug
When you hit something broken, file an issue with this template. The clearer this is, the faster engineering can fix it.
Title: <short, specific — e.g. "Cart subtotal shows $0 after applying TEST10 promo">
Steps to reproduce:
1. Login as <role> (<email>) at <URL>
2. ...
3. ...
Expected:
<what you thought would happen>
Actual:
<what actually happened — include exact error text if shown>
URL: <full URL where the bug surfaced>
Time: <YYYY-MM-DD HH:MM TZ> (so engineering can grep logs)
Role: <OTC_ONE_ADMIN | CARRIER_ADMIN | AGENT | MEMBER>
Browser / device: <Chrome 131 on macOS, iPhone 15 Safari, etc.>
Screenshots / video: <attach>
Console errors (DevTools): <paste any red errors>
Network: <if relevant — failing request URL, status, response body>For storefront bugs, also include the order number or product SKU if applicable. For admin bugs, include the resource and record id (visible in the URL: /admin/products/123/edit).