# Phase 1: Always JSON 422 for webhook validation errors

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Webhook `/api/webhook/supplier/*` ВСЕГДА возвращает JSON 422 на ValidationException, никогда не редиректит на `/`. Закрывает ~76 потерянных лидов сутки в логах nginx.

**Architecture:** Один `withExceptions()` render-callback в `bootstrap/app.php`: для запросов матчащих `api/webhook/supplier/*` отдаём `response()->json(['message','errors'], 422)`. Для остальных — `return null` (дефолт). Существующие тесты остаются valid, добавляется один новый тест с `Accept: text/html` (имитация реального поставщика).

**Tech Stack:** Laravel 13 / Pest 4 / PHP 8.3

**Spec:** `docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md` §3 Phase 1
**Ветка:** `feat/supplier-webhook-fixes` (создана)

---

## File Structure

**Создать:**
- `app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php` — единственный новый тест, фиксирующий формат ответа для не-JSON Accept

**Изменить:**
- `app/bootstrap/app.php` — добавить `$exceptions->render(...)` для ValidationException

**Не трогать:**
- `SupplierWebhookController.php` — логика валидации не меняется
- Существующие `SupplierWebhookTest.php` — все `postJson()` тесты продолжают работать

---

## Task 1: Failing test — webhook returns 422 JSON for non-JSON-Accept clients

**Files:**
- Create: `app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php

declare(strict_types=1);

use App\Models\SystemSetting;
use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(DatabaseTransactions::class);

beforeEach(function () {
    SystemSetting::query()
        ->where('key', 'supplier_webhook_secret')
        ->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
    SystemSetting::query()
        ->where('key', 'supplier_ip_allowlist')
        ->update(['value' => '[]']);
});

it('returns 422 JSON when supplier posts invalid payload WITHOUT Accept: application/json header', function () {
    // Воспроизводит реальное поведение crm.bp-gr.ru: POST без Accept-JSON.
    // До фикса (302→422) Laravel редиректил на / с Set-Cookie, поставщик
    // терял тело запроса. После фикса всегда JSON.
    $response = $this->call(
        'POST',
        '/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa',
        [], // params
        [], // cookies
        [], // files
        ['HTTP_CONTENT_TYPE' => 'application/x-www-form-urlencoded'], // server: НЕТ Accept JSON
        http_build_query([
            'vid' => 1,
            'project' => 'invalid_no_b_prefix',
            'phone' => '79991234567',
            'time' => time(),
        ])
    );

    $response->assertStatus(422);
    expect($response->headers->get('Content-Type'))->toContain('application/json');
    $response->assertJsonStructure(['message', 'errors' => ['project']]);
});

it('still works correctly for postJson clients (regression)', function () {
    $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
        'vid' => 1,
        'project' => 'invalid_no_b_prefix',
        'phone' => '79991234567',
        'time' => time(),
    ]);

    $response->assertStatus(422)->assertJsonValidationErrors('project');
});

it('non-webhook routes still use default render (no JSON forced)', function () {
    // Регрессионный тест: дефолтный render остальных routes не сломан
    // (например /login — должен возвращать redirect, а не JSON).
    $response = $this->call(
        'POST',
        '/login',
        ['email' => 'bad', 'password' => ''],
        [], [], [],
    );
    // Любой не-200 кроме 422-JSON допустим — главное чтобы наш fix не перехватил
    expect($response->headers->get('Content-Type'))->not->toContain('application/json');
});
```

- [ ] **Step 2: Run test to verify it fails**

```
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
```

Expected: тест #1 (non-JSON Accept) FAIL с status=302 (или Content-Type=text/html), потому что ValidationException рендерится через redirect.

- [ ] **Step 3: Commit failing test**

```bash
git add app/tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
git commit -m "test(supplier-webhook): assert JSON 422 for non-JSON Accept clients (failing)

Reproduces 302-redirect bug observed on prod 2026-05-25 — when supplier
crm.bp-gr.ru POSTs without Accept: application/json, Laravel renders
ValidationException as redirect to /, losing body. Test calls webhook
without Accept header and asserts JSON 422 response. Will fail until
bootstrap/app.php has render(ValidationException) for api/webhook/supplier/*."
```

---

## Task 2: Implement bootstrap render — force JSON 422 for webhook routes

**Files:**
- Modify: `app/bootstrap/app.php` (lines 35-48 — withExceptions block)

- [ ] **Step 1: Add ValidationException render in bootstrap/app.php**

В `withExceptions` callback (после существующего `QueryException` render) добавить новый render для `ValidationException`:

```php
->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (QueryException $e, Request $request) {
        // ... existing code, не менять ...
    });

    // Supplier webhook always returns JSON, even when client omits Accept header.
    // Without this render, Laravel's default ValidationException handler returns
    // 302 redirect to /, which strips POST body — losing supplier leads.
    // Confirmed 2026-05-25: 76 of 234 webhook hits today got 302 instead of 422.
    $exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) {
        if ($request->is('api/webhook/supplier/*')) {
            return response()->json([
                'message' => 'Validation failed',
                'errors' => $e->errors(),
            ], 422);
        }
        return null; // default render for other routes
    });
});
```

NB: `use Illuminate\Validation\ValidationException;` — не нужен, используем FQN inline чтобы не трогать existing imports section.

- [ ] **Step 2: Run new test to verify it passes**

```
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
```

Expected: все 3 теста PASS.

- [ ] **Step 3: Run full webhook test suite (regression)**

```
cd app && ./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
```

Expected: все тесты (≥14 в обоих файлах) PASS. Особенно проверить что `'rejects invalid project format (no B[123]_ prefix) with 422'` (line 95 в SupplierWebhookTest.php) продолжает PASS — он использует `postJson()`, поэтому новый render для него не сработает (default handler уже даёт 422 для JSON Accept), но мы не должны его сломать.

- [ ] **Step 4: Commit implementation**

```bash
git add app/bootstrap/app.php
git commit -m "fix(supplier-webhook): always return JSON 422 on ValidationException

Adds withExceptions render callback for ValidationException that forces
JSON 422 response when request matches api/webhook/supplier/* — regardless
of Accept header. Default Laravel behavior is 302 redirect for non-JSON
clients, which strips POST body.

Observed on prod 2026-05-25: 76 of 234 supplier webhook hits got 302 (Location: /),
mostly for non-B-prefix projects (client.carmoney.ru, cabinet.caranga.ru,
cashmotor.ru). Supplier doesn't follow 302 redirects on POST, so the
lead body is lost. This fix ensures supplier always sees a meaningful
422 with errors[] instead of a redirect.

Other routes unaffected (render returns null for non-webhook URLs)."
```

---

## Task 3: Reproduce on staging-clone or local — manual smoke

**Files:**
- Test: manual curl (no file)

- [ ] **Step 1: Run dev server locally (if available) or skip to Task 4**

Если на машине поднят `php artisan serve --port=8000`:
```bash
cd app && php artisan serve --port=8000 &
sleep 2
```

- [ ] **Step 2: POST without Accept header — assert 422 JSON**

```bash
curl -sk -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d 'vid=1&project=invalid_no_b_prefix&phone=79991234567&time='$(date +%s) \
  http://localhost:8000/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa \
  -w "\nSTATUS: %{http_code}\nCT: %{content_type}\n"
```

Expected: `STATUS: 422`, `CT: application/json`, тело содержит `"errors":{"project":...}`.

- [ ] **Step 3: POST with Accept: application/json — same result (regression)**

```bash
curl -sk -X POST \
  -H "Accept: application/json" -H "Content-Type: application/json" \
  -d '{"vid":1,"project":"invalid_no_b_prefix","phone":"79991234567","time":'$(date +%s)'}' \
  http://localhost:8000/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa \
  -w "\nSTATUS: %{http_code}\n"
```

Expected: `STATUS: 422`, JSON body.

- [ ] **Step 4: Stop server (если запускал)**

```bash
pkill -f 'artisan serve' || true
```

Если dev-сервер не поднимается на этой машине — пропустить Task 3, прод-smoke в Task 5 покроет.

---

## Task 4: Regression — quick mode

**Files:**
- None

- [ ] **Step 1: Run /regression quick**

```
/regression quick
```

Expected: GREEN — lint, format, type-check ОК. Если pre-commit hook падает (memory `feedback_environment.md` #111 — gitleaks висит на heavy diff), использовать `LEFTHOOK=0` при коммите.

- [ ] **Step 2: If quick GREEN, proceed to /regression full**

```
/regression full
```

Expected: Pest 742+ pass / 0 fail, Vitest 736+ pass, Vite build OK, lychee 0 broken, gitleaks 0. Допустимы pre-existing skipped.

Если найдены регрессии — НЕ переходить к деплою. Зафиксировать в отдельном fixup-commit либо вернуться к Task 2.

---

## Task 5: Deploy to liderra.ru (prod)

**Files:**
- None — деплой через ssh + redeploy.sh

- [ ] **Step 1: Pre-deploy validation via prod-deploy-validator agent**

Через Task tool:
```
subagent_type: prod-deploy-validator
prompt: проверь готовность боевого liderra.ru к выкату ветки feat/supplier-webhook-fixes на коммит после Phase 1 (bootstrap/app.php изменён). Что меняется: webhook /api/webhook/supplier/* теперь всегда отвечает JSON 422 на validation errors. Миграций БД нет. Очередь queue:restart нужен? проверь 8 pre-flight.
```

Expected: вердикт GO. Если NO-GO — устранить причину (квирки 104-108) и повторить.

- [ ] **Step 2: Merge feature branch fixup to main**

После одобрения Phase 1 changes:
```bash
cd "c:/моя/проекты/портал crm/Документация"
git checkout main
git merge --ff-only feat/supplier-webhook-fixes
git push origin main
```

NB: ОДНОВРЕМЕННО другие phases ещё не закоммичены, поэтому FF-merge содержит только Phase 1.

- [ ] **Step 3: Run redeploy.sh on prod**

```bash
ssh liderra "cd /var/www/liderra/app && sudo -u www-data ./redeploy.sh 2>&1 | tail -50"
```

Expected: успешный pull + composer install + `optimize:clear` + `optimize` + queue:restart. Errors → revert (git revert + redeploy).

- [ ] **Step 4: Prod smoke — webhook returns 422 not 302**

```bash
ssh liderra 'curl -sk -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "vid=1&project=invalid&phone=79991234567&time="$(date +%s) \
  https://liderra.ru/api/webhook/supplier/8c1c07ddb0768763661b357198e0625832f74ad0915d91b1 \
  -w "\nSTATUS: %{http_code}\nCT: %{content_type}\n"'
```

Expected: `STATUS: 422`, `CT: application/json`. **Если 302 — деплой не применился, откатывать.**

- [ ] **Step 5: Wait 30 min, check nginx access.log**

```bash
ssh liderra "sudo grep '/api/webhook/supplier' /var/log/nginx/access.log | tail -50 | awk '{print \$9}' | sort | uniq -c"
```

Expected: только 202, 422, 429, 404. **0 × 302, 0 × 301** для запросов на webhook URL.

- [ ] **Step 6: Update ПИЛОТ.md + memory**

Через прямой Edit, отметка «Phase 1 deployed 25.05.2026 HH:MM МСК, webhook always JSON». Memory update — `project_billing_v2.md` или новый `project_supplier_webhook_fixes.md`.

```bash
# Update ПИЛОТ.md as needed manually
git add ПИЛОТ.md
git commit -m "docs(пилот): Phase 1 supplier webhook JSON-422 deployed"
git push origin main
```

---

## Done criteria для Phase 1

- [ ] Все тесты в `SupplierWebhookTest.php` + `SupplierWebhookValidationFormatTest.php` PASS
- [ ] /regression full GREEN
- [ ] Прод-smoke: curl без Accept → 422 JSON
- [ ] За 30 мин после деплоя в nginx access.log — 0 × 302 на webhook URL
- [ ] Phase 2 plan starts only after Phase 1 deployed AND observed clean for ≥30 min

---

## Откат (если что-то пошло не так)

```bash
ssh liderra "cd /var/www/liderra/app && git revert --no-edit HEAD && sudo -u www-data ./redeploy.sh 2>&1 | tail -20"
```

Изменение касается только обработки исключений — откат без миграций, мгновенный.
