Skip to content

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.

  1. 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 horizon

    Expected: horizon RUNNING.

  2. Meilisearch is reachable. From the API box:

    bash
    curl -sS -H "Authorization: Bearer $MEILISEARCH_KEY" https://meili-stage.easyotc.com/health

    Expected: {"status":"available"}.

  3. S3 is reachable. Upload a small probe and read it back:

    bash
    php artisan tinker --execute="Storage::disk('s3')->put('healthcheck.txt', now()); echo Storage::disk('s3')->get('healthcheck.txt');"

    Expected: a timestamp prints, no exception.

  4. Stage DB has seed data. Quick sanity counts:

    sql
    SELECT
      (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 (see docs/test-accounts.md).

  5. 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:

    sql
    SELECT 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

  1. Login to /admin as superadmin@easyotc.com (role OTC_ONE_ADMIN).
  2. Categories tab → confirm the target category (e.g., "Pain Relief") exists. If not, click New Category, fill in name, upload an image, save.
  3. Sub-Categories tab → confirm the target sub-category exists under that category. If not, New Sub-Category → pick parent category → save.
  4. ProductsNew 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)
  5. 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...).
  6. 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

sql
-- 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.webp
  • images/products/abc-123_md.webp
  • images/products/abc-123_lg.webp

Verify:

bash
# Replace with the actual URL from product_images.image_url
curl -I https://otc-stage.s3.amazonaws.com/images/products/abc-123_md.webp

Expected: HTTP/1.1 200 OK. In Horizon (/horizon/completed) you should see GenerateImageVariantsJob with status completed.

Step 7 follow-up — The invisibility gotcha

  1. Switch to the Categories tab on the Edit page. Add the tag "Purses" (or any tag — see purses table). 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 with manufacturer_id = NULL will not be searchable).

  2. Click Save. The product is only shouldBeSearchable() === true when it has at least one tag AND at least one category. Without both, it will not appear in Meilisearch or on the storefront, regardless of is_available.

Verification — Meilisearch

bash
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

  1. Visit /products?categories=<slug-of-the-category> on the storefront.
  2. Expected: the new product card renders. The thumbnail loads from the _sm WebP 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

  1. From healthinsurance.com (or the SSO test endpoint for stage):
    GET https://stage.easyotc.com/api/sso/test-callback?agent_email=agent@easyotc.com&member_code=<MEMBER_CODE>
    This issues an SSO token and redirects to https://stage.easyotc.com/auth/callback?token=....
  2. The storefront callback page exchanges the token via POST /api/sso/exchange, stores the auth cookie, and lands you on /.
  3. Verify auth: top-right of the storefront shows the member's name. The header cart icon shows 0 items.
  4. Browse /products. Click any in-stock product (inventory_count > 0, is_available = true).
  5. Click Add to Cart (quantity 1).
  6. Click the cart icon → View Cart (/cart). Confirm the line item, unit price, and subtotal.
  7. Click Checkout (/checkout). Fill or confirm shipping address and billing address.
  8. Click Continue to Payment (/checkout/payment). The page shows order totals and the amountResponsible after purse deductions.
  9. Click Submit Order. The Nuxt page calls ordersStore.createOrder(...)POST /api/member/ordersStoreOrderActionOrderService::createOrderFromCart().
  10. 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/success shows the order number.
  • /account/orders lists the new order at the top.
  • Clicking the order shows the line items, addresses, totals.

Verification — DB

sql
-- 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 orders row, payment_status = 'pending' (Stripe not live), total_amount matches what you saw on /checkout/payment.
  • One order_items row per cart line, total_price = unit_price * quantity.
  • The active cart row has 0 items (cleared by clearCart()).

Scenario 3: ShipHero inbound sync

Goal: pull inventory + names from ShipHero and confirm products and product_packaging_details reflect the upstream data.

Steps

  1. SSH into the stage API box.
  2. Run a dry run first:
    bash
    php artisan shiphero:sync-products --dry-run --limit=10
    Expected output (truncated):
    Starting 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     |
    +----------+-------+
  3. If dry-run output looks right, run for real (no --dry-run):
    bash
    php artisan shiphero:sync-products
  4. 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

sql
-- 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_count matches the Inventory value the command printed.
  • products.is_available = true if inventory > 0, false otherwise.
  • products.updated_at is within the last minute.
  • product_packaging_details.quantity reflects the ShipHero packaging (the CSV-loaded quantity is 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

  1. Drop the CSV into the API box at storage/csv/easy_otc_products_1.csv (or use the file already in the repo root).
  2. Always dry-run first. Update only sub_category:
    bash
    php artisan products:update-from-csv easy_otc_products_1.csv --columns=sub_category --dry-run
    Expected output (truncated):
    Parsing 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).
  3. Review the diff. If correct, apply (drop --dry-run):
    bash
    php artisan products:update-from-csv easy_otc_products_1.csv --columns=sub_category
  4. 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

sql
-- 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

  1. Backfill product image variants — process only rows missing at least one variant on S3:

    bash
    php artisan products:generate-image-variants --missing-only

    Expected: a progress bar over the matching rows. Each one dispatches a GenerateImageVariantsJob to the default queue.

  2. Backfill category image variants:

    bash
    php artisan categories:generate-image-variants --missing-only
  3. Watch progress in /horizon. The queue should drain within a few minutes for a typical batch.

  4. If you want to regenerate everything (e.g., changed sizes), use --force:

    bash
    php artisan products:generate-image-variants --force
  5. Sync instead of queue (useful for debugging a single failure):

    bash
    php 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:

bash
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.webp

Verification — UI

  • Storefront /products cards load the _sm variant (check DevTools → Network).
  • Category pages render category hero images without broken placeholders.

Verification — Horizon

  • /horizon/completed → recent GenerateImageVariantsJob entries with status completed.
  • /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

  1. Confirm the new CDN host serves the same objects:

    bash
    curl -I https://cdn-stage.easyotc.com/images/products/some-known-file.jpg

    Expected: 200 OK.

  2. Dry-run the rewrite:

    bash
    php artisan images:rewrite-url-domain \
      --from=https://otc-stage.s3.amazonaws.com \
      --to=https://cdn-stage.easyotc.com \
      --dry-run

    Expected 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.
  3. Apply for real:

    bash
    php artisan images:rewrite-url-domain \
      --from=https://otc-stage.s3.amazonaws.com \
      --to=https://cdn-stage.easyotc.com

Verification — DB

sql
-- 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 /products on the storefront. Open DevTools → Network. All image requests go to cdn-stage.easyotc.com, none to otc-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

  1. Login to /admin as superadmin@easyotc.com.
  2. Navigate to Promotions (URL: /admin/promotions).
  3. Click New Promotion. Fill:
    • name: e.g. "Test 10% Off"
    • code: e.g. TEST10 (the code customers enter)
    • discount_type: percentage
    • discount_value: 10
    • starts_at: now
    • ends_at: now + 7 days
    • is_active: true
    • Attach at least one product via the relation manager (or leave site-wide if applicable)
  4. Click Create.
  5. 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.
  6. 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/promotions list shows the new row with is_active = true.
  • Storefront product card shows the sale price and % off badge.
  • Cart shows the discount line: Discount: -$X.XX.

Verification — DB

sql
-- 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

bash
# 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 -5

Key channels logged here: ShipHero sync errors, image-variant job failures, Stripe exceptions, generic 500s.

Horizon failed jobs

  • /horizon/failed lists 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), or intervention/image couldn't decode the file.
    • SyncManufacturersToShipHeroCommand jobs: ShipHero token expired (run the command again to refresh).

Find an indexed product in Meilisearch

bash
# 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:

bash
php artisan scout:import "App\Models\Product"

Verify CORS via curl

bash
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.com
  • Access-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).