# ADMIN BULK UPDATE — TESTS.md

## Unit Tests

### Price Handlers

```
BULK-UT-001: INCREASE_PERCENT 5% on 350 cents → proposed = 368 cents
BULK-UT-002: INCREASE_PERCENT 100% on 200 cents → proposed = 400 cents
BULK-UT-003: DECREASE_PERCENT 5% on 350 cents → proposed = 333 cents
BULK-UT-004: DECREASE_PERCENT 50% on 200 cents → proposed = 100 cents
BULK-UT-005: DECREASE_FIXED 50 on 350 cents → proposed = 300 cents
BULK-UT-006: INCREASE_FIXED 50 on 350 cents → proposed = 400 cents
BULK-UT-007: DECREASE_FIXED 500 on 350 cents → proposed = 0 (floor at 0)
BULK-UT-008: SET_PRICE 500 → proposed = 500
BULK-UT-009: INCREASE_PERCENT with percent=0 → ValidationResult.valid=false
BULK-UT-010: INCREASE_PERCENT with percent=-5 → ValidationResult.valid=false
BULK-UT-011: INCREASE_PERCENT with percent=1001 → ValidationResult.valid=false
BULK-UT-012: INCREASE_PERCENT without vatRate → ValidationResult.valid=false
BULK-UT-013: DECREASE_PERCENT with vatRate=general → validation passes
BULK-UT-014: preview() for product without price → status=failed, error=PRICE_NOT_FOUND
BULK-UT-015: preview() for product with price → status=valid, proposedPriceCents calculated
BULK-UT-016: buildPatch() returns correct { pricing: { netUnitAmountCents, vatRate } }
```

### Category Handlers

```
BULK-UT-020: ADD_CATEGORY to product with [A, B] → proposedCategoryIds = [A, B, C] (C added)
BULK-UT-021: ADD_CATEGORY where C already present → no duplicate (deduplicated)
BULK-UT-022: REMOVE_CATEGORY from product with [A, B, C] → [A, B]
BULK-UT-023: REMOVE_CATEGORY where not present → no change
BULK-UT-024: REPLACE_CATEGORIES with [X, Y] → proposedCategoryIds = [X, Y]
BULK-UT-025: REPLACE_CATEGORIES with empty array → proposedCategoryIds = []
BULK-UT-026: ADD_CATEGORY without categoryId → ValidationResult.valid=false
BULK-UT-027: REMOVE_CATEGORY without categoryId → ValidationResult.valid=false
BULK-UT-028: buildPatch() returns correct { categoryIds: [...] }
```

### BulkService

```
BULK-UT-030: preview() returns items for all provided productIds
BULK-UT-031: preview() returns summary with correct valid/failed counts
BULK-UT-032: preview() throws AppError(400) for unknown operation type
BULK-UT-033: preview() throws AppError(422) for invalid parameters
BULK-UT-034: execute() returns operationId
BULK-UT-035: execute() returns COMPLETED when all succeed
BULK-UT-036: execute() returns COMPLETED_WITH_ERRORS when some fail
BULK-UT-037: execute() calls audit.log() with correct metadata
BULK-UT-038: execute() rollback on error → no product modified (verify by checking DB)
```

## Integration Tests

### Database

```
BULK-IT-001: catalog_products.updated_at updated after bulk price change
BULK-IT-002: pricing_variants.net_unit_amount_cents updated after SET_PRICE
BULK-IT-003: catalog_product_categories updated correctly for ADD_CATEGORY
BULK-IT-004: catalog_product_categories updated correctly for REMOVE_CATEGORY
BULK-IT-005: catalog_product_categories updated correctly for REPLACE_CATEGORIES
BULK-IT-006: Concurrent bulk operations on overlapping product sets → no race condition
BULK-IT-007: Price changed by bulk update → subsequent checkout uses new price
```

### API Routes

```
BULK-IT-010: POST /admin/bulk/preview with valid INCREASE_PERCENT → 200 + PreviewResult
BULK-IT-011: POST /admin/bulk/preview with DECREASE_PERCENT → 200 + PreviewResult
BULK-IT-012: POST /admin/bulk/preview with ADD_CATEGORY → 200 + PreviewResult
BULK-IT-013: POST /admin/bulk/preview with REMOVE_CATEGORY → 200 + PreviewResult
BULK-IT-014: POST /admin/bulk/preview with REPLACE_CATEGORIES → 200 + PreviewResult
BULK-IT-015: POST /admin/bulk/preview with invalid percent → 422 + error message
BULK-IT-016: POST /admin/bulk/preview without session cookie → 401
BULK-IT-017: POST /admin/bulk/preview with customer role → 403

BULK-IT-020: POST /admin/bulk/execute with valid SET_PRICE → 200 + ExecuteResult + DB updated
BULK-IT-021: POST /admin/bulk/execute with valid INCREASE_PERCENT → 200 + DB updated
BULK-IT-022: POST /admin/bulk/execute with valid ADD_CATEGORY → 200 + DB updated
BULK-IT-023: POST /admin/bulk/execute → security_audit_log row created
BULK-IT-024: POST /admin/bulk/execute without auth → 401
BULK-IT-025: POST /admin/bulk/execute with customer role → 403
BULK-IT-026: POST /admin/bulk/execute with partial failures → 200 + COMPLETED_WITH_ERRORS
```

## Admin UI Tests

```
BULK-ADM-UT-001: Page loads with product table and checkboxes
BULK-ADM-UT-002: Selecting 3 products updates "3 products selected" counter
BULK-ADM-UT-003: Deselecting all shows "0 products selected"
BULK-ADM-UT-004: Search filter narrows product list
BULK-ADM-UT-005: "Continue" button disabled when 0 products selected

BULK-ADM-UT-010: Clicking Price card shows SET, +%, -%, +FIXED, -FIXED options
BULK-ADM-UT-011: Clicking Categories card shows ADD, REMOVE, REPLACE options
BULK-ADM-UT-012: INCREASE_PERCENT selected → percentage input appears
BULK-ADM-UT-013: INCREASE_PERCENT with invalid input → error state on field
BULK-ADM-UT-014: ADD_CATEGORY selected → category picker appears

BULK-ADM-UT-020: Preview shows product rows with current → proposed values
BULK-ADM-UT-021: Failed rows highlighted in red with error reason
BULK-ADM-UT-022: Summary bar shows "X valid, Y failed"
BULK-ADM-UT-023: "Back to Configure" navigates back
BULK-ADM-UT-024: Preview does NOT show [Apply Changes] button

BULK-ADM-UT-030: Confirmation dialog shows warning text with correct count
BULK-ADM-UT-031: "Cancel" returns to preview without calling execute
BULK-ADM-UT-032: "Apply Changes" calls execute API once
BULK-ADM-UT-033: Result shows success count and failure count
BULK-ADM-UT-034: "New Bulk Update" button resets wizard to step 1
```

## Given/When/Then Acceptance Criteria

```
GIVEN 20 selected products with valid prices
WHEN operator previews a 5% price increase
THEN no product is modified
AND all 20 proposed prices are calculated by the backend
AND the preview shows current and proposed values

GIVEN a product without a price variant
WHEN operator previews any price operation on that product
THEN the preview item has status=failed and error=PRICE_NOT_FOUND
AND the summary shows failed=1

GIVEN 20 selected products
WHEN operator clicks "Apply Changes" on the confirmation step
THEN the backend applies the changes in a single transaction
AND the security_audit_log receives one entry for the bulk operation
AND each affected product's updated_at is updated
AND the result shows successful=20, failed=0

GIVEN a bulk execute with 2 products invalid and 18 valid
WHEN the operation runs
THEN the 18 valid products are updated
AND the 2 invalid products are not modified
AND the result shows COMPLETED_WITH_ERRORS with failure details
AND the audit log entry captures the partial failure

GIVEN a non-admin user
WHEN they call POST /admin/bulk/execute
THEN the backend returns 403 Forbidden
AND no audit entry is created
AND no product is modified

GIVEN a preview shows 18 valid changes
WHEN a second operator applies a price change to one of those products before execute
THEN the execute still applies the original preview's proposed price
AND no optimistic concurrency check is silently bypassed (document this behavior)
```

---

## Preview Staleness Policy

Preview data may be stale when execute runs (another admin changed a price). Current design: execute uses the same calculation on current DB state, which is correct behavior for bulk updates (we want to apply the percentage to whatever the current price is, not the stale preview price).

If stricter concurrency control is needed (e.g., "apply only if price hasn't changed since preview"), this requires:
- `expected_version` or `updated_at` in preview response
- Compare at execute time
- This is a future enhancement, not MVP scope.
