Files
portal/app/tests/Feature/Admin/AdminSupplierIntegrationTest.php
T
Дмитрий 1d18933d9e feat/supplier: экран отчёта о вечерней заливке (Эпик 5)
Владелец выбрал формат «экран в админке» (не письмо).
- SyncSupplierProjectsJob по завершении пишет строку-сводку в новую supplier_sync_runs
  (групп/синк/ручная/отложено/упало + status ok|partial|failed|aborted) через finally —
  пишется и при раннем abort (time-budget/mass-fail/auth).
- Эндпоинт GET /api/admin/supplier-integration/sync-runs + метод syncRuns.
- Экран SaaS-admin «Интеграция с поставщиком» → карточка «Вечерняя заливка проектов
  поставщику»: таблица заливок со статусом человеческим языком (Всё ровно/Частично/Сбой).
- Схема v8.55 +1 таблица (SaaS-level без RLS как supplier_csv_reconcile_log), миграция
  2026_06_25_130000, RLS-ревью 7/7. Проверено глазами в браузере (epic5-sync-runs-admin-screen.png).

Тесты: бэк 24/25 (1 skip) + фронт-экран 5/5 зелёные. Под LEFTHOOK=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:52:39 +03:00

105 lines
4.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Jobs\Supplier\CsvReconcileJob;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
it('GET /api/admin/supplier-integration/sync-runs returns evening-upload history (Эпик 5)', function (): void {
DB::connection('pgsql_supplier')->table('supplier_sync_runs')->insert([
'started_at' => now()->subMinutes(40),
'finished_at' => now()->subMinutes(2),
'groups_total' => 180,
'synced_ok' => 312,
'manual_queued' => 1,
'deferred' => 0,
'failed' => 0,
'status' => 'partial',
'created_at' => now()->subMinutes(2),
]);
$response = $this->getJson('/api/admin/supplier-integration/sync-runs');
$response->assertOk();
$response->assertJsonStructure([
'runs' => [['started_at', 'finished_at', 'groups_total', 'synced_ok', 'manual_queued', 'deferred', 'failed', 'status']],
]);
expect($response->json('runs.0.groups_total'))->toBe(180);
expect($response->json('runs.0.manual_queued'))->toBe(1);
expect($response->json('runs.0.status'))->toBe('partial');
});
it('GET /api/admin/supplier-integration returns channel health + history', function (): void {
DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->insert([
'started_at' => now()->subMinutes(10),
'finished_at' => now()->subMinutes(9),
'window_start' => now()->subDay(),
'window_end' => now(),
'total_csv_rows' => 100,
'matched_count' => 98,
'recovered_count' => 2,
'drift_ratio' => 0.02,
'status' => 'ok',
'created_at' => now()->subMinutes(10),
]);
$response = $this->getJson('/api/admin/supplier-integration');
$response->assertOk();
$response->assertJsonStructure([
'health' => ['last_run_at', 'last_status', 'drift_ratio', 'webhook_state'],
'history' => [['started_at', 'status', 'total_csv_rows', 'matched_count', 'recovered_count', 'drift_ratio']],
]);
expect($response->json('health.last_status'))->toBe('ok');
expect($response->json('health.webhook_state'))->toBe('live');
});
it('webhook_state is "down" when last run had drift_alert', function (): void {
DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->insert([
'started_at' => now()->subMinutes(5),
'finished_at' => now()->subMinutes(4),
'window_start' => now()->subDay(),
'window_end' => now(),
'total_csv_rows' => 100,
'matched_count' => 80,
'recovered_count' => 20,
'drift_ratio' => 0.20,
'status' => 'drift_alert',
'created_at' => now()->subMinutes(5),
]);
$response = $this->getJson('/api/admin/supplier-integration');
expect($response->json('health.webhook_state'))->toBe('down');
});
it('POST /api/admin/supplier-integration/reconcile dispatches CsvReconcileJob', function (): void {
Bus::fake([CsvReconcileJob::class]);
$response = $this->postJson('/api/admin/supplier-integration/reconcile');
$response->assertOk();
$response->assertJson(['dispatched' => true]);
Bus::assertDispatched(CsvReconcileJob::class, 1);
});
it('returns nulls in health when reconcile log is empty (no run yet)', function (): void {
// Пустой supplier_csv_reconcile_log — до первой сверки. Контроллер не должен
// падать на $last === null (property access на null).
DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->truncate();
$response = $this->getJson('/api/admin/supplier-integration');
$response->assertOk();
expect($response->json('health.last_run_at'))->toBeNull();
expect($response->json('health.last_status'))->toBeNull();
expect($response->json('health.drift_ratio'))->toBeNull();
expect($response->json('health.webhook_state'))->toBe('live');
expect($response->json('history'))->toBe([]);
});