diff --git a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php index 7c53cfae..4b6353f3 100644 --- a/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php +++ b/app/app/Jobs/Supplier/SyncSupplierProjectsJob.php @@ -14,47 +14,46 @@ use App\Models\SupplierProject; use App\Models\SupplierSyncLog; use App\Services\Supplier\Channel\Exceptions\TierEscalatedException; use App\Services\Supplier\Channel\Exceptions\WindowDeferredException; -use App\Services\Supplier\Channel\FailoverProjectChannel; use App\Services\Supplier\Channel\SupplierProjectChannel; use App\Services\Supplier\Dto\SupplierProjectDto; +use App\Services\Supplier\SupplierPortalClient; use App\Services\Supplier\SupplierQuotaAllocator; +use App\Support\RussianRegions; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; -use stdClass; use Throwable; /** * Daily 20:30 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru. * - * Алгоритм (per spec §4.3): - * 1. Итерация по всем активным (inactive_since IS NULL) supplier_projects. - * 2. Для каждого: - * a. Подтянуть активные Лидерра-projects через FK supplier_b{1,2,3}_project_id. - * b. Адаптировать в plain stdClass с полями daily_limit/workdays/regions. - * c. Вызвать SupplierQuotaAllocator::allocate() — pure distribution. - * d. Сравнить с current state через SupplierProjectDto::equals(); skip if no diff. - * e. saveProject() при supplier_external_id=null, иначе updateProject(). - * f. Записать audit row в supplier_sync_log. - * 3. Failure-handling: - * - SupplierAuthException → SupplierCriticalAlertMail('sticky_auth') + Sentry + throw. - * - SupplierTransientException → log + continue. После 50 подряд → mass_transient alert + break. - * - SupplierClientException → log + continue. - * 4. Time budget cutoff: после 20:55 МСК прервать loop (буфер 5 мин до 21:00). + * Алгоритм (Plan 3 Task 5 — per-subject grouping): + * 1. Загрузить активные Лидерра-projects (is_active=true, archived_at IS NULL). + * 2. Развернуть каждый в группы (signal_type, identifier, subject_code): + * - subjects = project.regions (1..89); пусто → одна группа subject_code=null («Вся РФ»). + * - identifier = buildUniqueKey() (site/call → signal_identifier; sms B2 → sender+keyword; B3 → sender). + * - platforms = resolvePlatforms() (site/call → B1+B2+B3; sms+keyword → B2+B3; sms → B3). + * 3. Для каждой группы: + * - eligible-today проекты группы (workday-маска на завтра). + * - order = computeOrder($eligibleLimits); workdays = union; tag / regions из subject. + * - Найти существующие supplier_projects (unique_key, subject_code): + * - Нет → saveProjectMultiFlag → 3 id → upsert supplier_projects. + * - Есть → updateProject каждого (R6: один лимит). + * - Pivot: для каждого Лидерра-проекта × каждого supplier_project → INSERT ... ON CONFLICT DO NOTHING. + * 4. Failure-handling (Auth/Transient/Client/Window/TierEscalated), time-budget cutoff — сохранены. * - * NOTE про connection: Job's $connection — это queue connection, не DB. Используем - * Eloquent::on('pgsql_supplier') для cross-tenant видимости (Plan 3 Task 3 learning). + * NOTE про connection: Eloquent::on('pgsql_supplier') для cross-tenant видимости. * * Spec: - * - docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3-§4.4 - * - docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §4 + * - docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.3 + * - docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md Task 5 */ class SyncSupplierProjectsJob implements ShouldQueue { @@ -68,33 +67,71 @@ class SyncSupplierProjectsJob implements ShouldQueue private SupplierProjectChannel $channel; + private SupplierPortalClient $client; + public function handle(?SupplierProjectChannel $channel = null): void { $this->channel = $channel ?? app(SupplierProjectChannel::class); + $this->client = app(SupplierPortalClient::class); $consecutiveTransient = 0; - $projects = SupplierProject::on(self::DB_CONNECTION) - ->whereNull('inactive_since') + // 1. Load active Лидерра-projects via pgsql_supplier + /** @var Collection $projects */ + $projects = Project::on(self::DB_CONNECTION) + ->where('is_active', true) + ->whereNull('archived_at') ->orderBy('id') ->get(); - foreach ($projects as $sp) { + // 2. Expand into groups (signal_type, identifier, subject_code) + // group key => [ 'signal_type', 'identifier', 'subject_code', 'platforms', 'projects' => [...] ] + /** @var array, projects: list}> $groups */ + $groups = []; + + foreach ($projects as $project) { + $platforms = $this->resolvePlatforms($project); + if ($platforms === []) { + continue; + } + // For sms, identifier depends on whether B2 is in platforms (keyword-aware) + // We use the B2 key as identifier when B2 is present (sms+keyword), else B3 key (sender only) + $identifier = $this->buildUniqueKey($project); + + $subjects = $this->subjectsOf($project); + + foreach ($subjects as $subjectCode) { + $key = $project->signal_type.'|'.$identifier.'|'.($subjectCode ?? 'null'); + if (! isset($groups[$key])) { + $groups[$key] = [ + 'signal_type' => (string) $project->signal_type, + 'identifier' => $identifier, + 'subject_code' => $subjectCode, + 'platforms' => $platforms, + 'projects' => [], + ]; + } + $groups[$key]['projects'][] = $project; + } + } + + // 3. Sync each group + foreach ($groups as $group) { if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) { Log::warning('supplier.sync.time_budget_reached', [ - 'processed_until' => $sp->id, + 'group' => $group['identifier'], ]); break; } try { - $this->syncOne($sp); + $this->syncGroup($group); $consecutiveTransient = 0; } catch (TierEscalatedException $e) { - Log::info("SyncSupplierProjectsJob: sp #{$sp->id} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}"); + Log::info("SyncSupplierProjectsJob: group {$group['identifier']} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}"); continue; } catch (WindowDeferredException) { - Log::info("SyncSupplierProjectsJob: sp #{$sp->id} deferred by portal window"); + Log::info("SyncSupplierProjectsJob: group {$group['identifier']} deferred by portal window"); continue; } catch (SupplierAuthException $e) { @@ -107,7 +144,7 @@ class SyncSupplierProjectsJob implements ShouldQueue throw $e; } catch (SupplierTransientException $e) { $consecutiveTransient++; - $this->logSyncFailure($sp, $e); + $this->logGroupFailure($group, $e); if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) { Mail::to((string) config('services.supplier.alert_email')) ->queue(new SupplierCriticalAlertMail( @@ -120,7 +157,7 @@ class SyncSupplierProjectsJob implements ShouldQueue continue; } catch (SupplierClientException $e) { - $this->logSyncFailure($sp, $e); + $this->logGroupFailure($group, $e); report($e); continue; @@ -128,131 +165,254 @@ class SyncSupplierProjectsJob implements ShouldQueue } } - private function syncOne(SupplierProject $sp): void + /** + * @param array{signal_type: string, identifier: string, subject_code: int|null, platforms: list, projects: list} $group + */ + private function syncGroup(array $group): void { - $fkColumn = $this->fkColumnForPlatform($sp->platform); + $signalType = $group['signal_type']; + $identifier = $group['identifier']; + $subjectCode = $group['subject_code']; + $platforms = $group['platforms']; - /** @var EloquentCollection $liderraProjects */ - $liderraProjects = Project::on(self::DB_CONNECTION) - ->where($fkColumn, $sp->id) - ->where('is_active', true) + /** @var list $groupProjects */ + $groupProjects = $group['projects']; + + // Eligible-today: workday-mask for tomorrow + $targetDate = Carbon::tomorrow('Europe/Moscow'); + $targetWeekday = $targetDate->isoWeekday(); + + /** @var list $eligible */ + $eligible = array_values(array_filter( + $groupProjects, + fn (Project $p) => ($p->delivery_days_mask & (1 << ($targetWeekday - 1))) !== 0 + )); + + if ($eligible === []) { + return; + } + + // Compute order and union workdays + $eligibleLimits = array_map(fn (Project $p) => (int) $p->daily_limit_target, $eligible); + $order = SupplierQuotaAllocator::computeOrder($eligibleLimits); + + $workdaysUnion = []; + foreach ($eligible as $p) { + foreach ($this->bitmaskToList((int) $p->delivery_days_mask, 7) as $d) { + $workdaysUnion[$d] = $d; + } + } + sort($workdaysUnion); + $workdays = $workdaysUnion; + + // Tag and regions from subject + $tag = $subjectCode !== null ? (RussianRegions::CODE_TO_NAME[$subjectCode] ?? (string) $subjectCode) : 'РФ'; + $regions = $subjectCode !== null ? [$subjectCode] : []; + + // Find existing supplier_projects for this group + $existingSps = SupplierProject::on(self::DB_CONNECTION) + ->where('unique_key', $identifier) + ->where('signal_type', $signalType) + ->when( + $subjectCode !== null, + fn ($q) => $q->where('subject_code', $subjectCode), + fn ($q) => $q->whereNull('subject_code'), + ) + ->whereIn('platform', $platforms) ->get(); - if ($liderraProjects->isEmpty()) { - return; - } + if ($existingSps->isEmpty()) { + // Create path: saveProjectMultiFlag → [platform => external_id] + $dto = new SupplierProjectDto( + platform: $platforms[0], + signalType: $signalType, + uniqueKey: $identifier, + limit: $order, + workdays: $workdays, + regions: $regions, + regionsReverse: false, + status: 'active', + tag: $tag, + platforms: $platforms, + ); - $adapted = $this->adaptProjectsForAllocator($liderraProjects); + $idMap = $this->client->saveProjectMultiFlag($dto); - $allocation = SupplierQuotaAllocator::allocate( - platform: $sp->platform, - signalType: $sp->signal_type, - uniqueKey: $sp->unique_key, - activeLiderraProjects: $adapted, - targetDate: Carbon::tomorrow('Europe/Moscow'), - ); + // Upsert supplier_projects rows (one per platform) + foreach ($platforms as $platform) { + $externalId = $idMap[$platform] ?? null; + if ($externalId === null) { + continue; + } - if ($allocation === null) { - return; - } + $sp = SupplierProject::on(self::DB_CONNECTION)->forceCreate([ + 'platform' => $platform, + 'signal_type' => $signalType, + 'unique_key' => $identifier, + 'subject_code' => $subjectCode, + 'supplier_external_id' => (string) $externalId, + 'current_limit' => $order, + 'current_workdays' => $workdays, + 'current_regions' => $regions, + 'sync_status' => 'ok', + 'last_synced_at' => now(), + ]); - $current = SupplierProjectDto::fromModel($sp); - if ($allocation->equals($current)) { - return; - } + SupplierSyncLog::on(self::DB_CONNECTION)->create([ + 'supplier_project_id' => $sp->id, + 'action' => 'create', + 'http_status' => 200, + 'created_at' => now(), + ]); - $isCreate = $sp->supplier_external_id === null; - - // NOTE: НЕ оборачиваем в DB::transaction() — HTTP-call к supplier выходит за - // границы транзакционного контекста, атомарности всё равно нет. Два DB-write - // (supplier_project update + supplier_sync_log insert) на одной connection - // выполняются последовательно; ошибка между ними — recoverable through retry - // на следующем cron-tick'е (supplier_external_id уже записан, скип через equals()). - // Context-project для project_id в очереди яруса 3 при эскалации. - $contextProject = $liderraProjects->first(); - - if ($isCreate) { - $externalId = $this->channel instanceof FailoverProjectChannel - ? $this->channel->createProjectForLiderra($contextProject, $allocation) - : $this->channel->createProject($allocation); - $sp->forceFill([ - 'supplier_external_id' => (string) $externalId, - 'current_limit' => $allocation->limit, - 'current_workdays' => $allocation->workdays, - 'current_regions' => $allocation->regions, - 'sync_status' => 'ok', - 'last_synced_at' => now(), - ])->save(); - } else { - if ($this->channel instanceof FailoverProjectChannel) { - $this->channel->updateProjectForLiderra($contextProject, (int) $sp->supplier_external_id, $allocation); - } else { - $this->channel->updateProject((int) $sp->supplier_external_id, $allocation); + $existingSps->push($sp); + } + } else { + // Update path: updateProject each external_id (R6: one shared limit) + $dto = new SupplierProjectDto( + platform: $existingSps->first()->platform, + signalType: $signalType, + uniqueKey: $identifier, + limit: $order, + workdays: $workdays, + regions: $regions, + regionsReverse: false, + status: 'active', + tag: $tag, + ); + + foreach ($existingSps as $sp) { + if ($sp->supplier_external_id === null) { + continue; + } + $this->channel->updateProject((int) $sp->supplier_external_id, $dto); + $sp->forceFill([ + 'current_limit' => $order, + 'current_workdays' => $workdays, + 'current_regions' => $regions, + 'sync_status' => 'ok', + 'last_synced_at' => now(), + ])->save(); + + SupplierSyncLog::on(self::DB_CONNECTION)->create([ + 'supplier_project_id' => $sp->id, + 'action' => 'update', + 'http_status' => 200, + 'created_at' => now(), + ]); } - $sp->forceFill([ - 'current_limit' => $allocation->limit, - 'current_workdays' => $allocation->workdays, - 'current_regions' => $allocation->regions, - 'sync_status' => 'ok', - 'last_synced_at' => now(), - ])->save(); } - SupplierSyncLog::on(self::DB_CONNECTION)->create([ - 'supplier_project_id' => $sp->id, - 'action' => $isCreate ? 'create' : 'update', - 'http_status' => 200, - 'created_at' => now(), - ]); + // Pivot: for each contributing Лидерра-project × each supplier_project → ON CONFLICT DO NOTHING + foreach ($groupProjects as $lp) { + foreach ($existingSps as $sp) { + DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([ + 'project_id' => $lp->id, + 'supplier_project_id' => $sp->id, + 'platform' => $sp->platform, + // @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag + 'subject_code' => $sp->subject_code, + ]); + } + } } - private function logSyncFailure(SupplierProject $sp, Throwable $e): void + /** + * Log failure for a group (before any supplier_project is created/updated we don't have sp id, + * so we look up existing or skip — best-effort audit). + * + * @param array{signal_type: string, identifier: string, subject_code: int|null, platforms: list, projects: list} $group + */ + private function logGroupFailure(array $group, Throwable $e): void { $httpStatus = null; if ($e instanceof SupplierException) { $httpStatus = $e->httpStatus; } - SupplierSyncLog::on(self::DB_CONNECTION)->create([ - 'supplier_project_id' => $sp->id, - 'action' => $sp->supplier_external_id === null ? 'create' : 'update', - 'http_status' => $httpStatus, - 'error_message' => substr($e->getMessage(), 0, 1000), - 'created_at' => now(), - ]); + // Find any existing sp row for the group to link log entry + $sp = SupplierProject::on(self::DB_CONNECTION) + ->where('unique_key', $group['identifier']) + ->where('signal_type', $group['signal_type']) + ->when( + $group['subject_code'] !== null, + fn ($q) => $q->where('subject_code', $group['subject_code']), + fn ($q) => $q->whereNull('subject_code'), + ) + ->first(); + + if ($sp !== null) { + SupplierSyncLog::on(self::DB_CONNECTION)->create([ + 'supplier_project_id' => $sp->id, + 'action' => $sp->supplier_external_id === null ? 'create' : 'update', + 'http_status' => $httpStatus, + 'error_message' => substr($e->getMessage(), 0, 1000), + 'created_at' => now(), + ]); + } } /** - * Адаптер Eloquent Project → stdClass с полями daily_limit/workdays/regions, - * которые ожидает SupplierQuotaAllocator (pure function, не вяжется к Eloquent). + * Returns subjects (region codes 1..89) for a project. + * Empty regions → [null] (one group, "Вся РФ" pool). * - * Маппинг: - * daily_limit ← daily_limit_target - * workdays ← биты delivery_days_mask (bit 0=Пн, …, bit 6=Вс) → ISO 1..7 - * regions ← projects.regions INT[] (subject codes 1..89) direct copy - * - * @param EloquentCollection $projects - * @return Collection + * @return list */ - private function adaptProjectsForAllocator(EloquentCollection $projects): Collection + private function subjectsOf(Project $project): array { - return $projects->map(function (Project $p): stdClass { - $obj = new stdClass; - $obj->daily_limit = (int) $p->daily_limit_target; - $obj->workdays = $this->bitmaskToList((int) $p->delivery_days_mask, 7); + $regions = array_values((array) $project->regions); + // @phpstan-ignore-next-line identical.alwaysFalse — PostgresIntArray PHPDoc non-empty, runtime can be empty + if (count($regions) === 0) { + return [null]; + } - // Plan 6: projects.regions[] напрямую копируется в supplier_projects.current_regions. - // Empty array = "вся РФ" (паритет с supplier API semantics). - // Legacy region_mask/region_mode игнорируются — они dual-write для PhonePrefixService, - // outbound к supplier использует только regions[]. Cleanup в Plan 6.5. - $obj->regions = array_values((array) $p->regions); - - return $obj; - })->values(); + return array_map(fn ($r) => (int) $r, $regions); } /** - * Bitmask → ordered list 1..maxBits для bits, выставленных в 1. + * Unique identifier key for this project (platform-agnostic for site/call; + * uses B2 key when sms+keyword present, else B3 key). + * + * Task 6 will extract this to SupplierProjectGrouping — for now inline. + */ + private function buildUniqueKey(Project $project): string + { + if (in_array($project->signal_type, ['site', 'call'], true)) { + return (string) $project->signal_identifier; + } + + // sms: use B2 key (sender+keyword) when keyword present, else B3 key (sender) + $sender = (string) ($project->sms_senders[0] ?? ''); + if ($project->sms_keyword !== null && $project->sms_keyword !== '') { + return $sender.'+'.$project->sms_keyword; + } + + return $sender; + } + + /** + * Platforms for this project's signal_type. + * Task 6 will extract to SupplierProjectGrouping. + * + * @return list + */ + private function resolvePlatforms(Project $project): array + { + if (in_array($project->signal_type, ['site', 'call'], true)) { + return ['B1', 'B2', 'B3']; + } + + if ($project->signal_type === 'sms') { + return ($project->sms_keyword !== null && $project->sms_keyword !== '') + ? ['B2', 'B3'] + : ['B3']; + } + + return []; + } + + /** + * Bitmask → ordered list 1..maxBits. * * @return array */ @@ -267,14 +427,4 @@ class SyncSupplierProjectsJob implements ShouldQueue return $out; } - - private function fkColumnForPlatform(string $platform): string - { - return match ($platform) { - 'B1' => 'supplier_b1_project_id', - 'B2' => 'supplier_b2_project_id', - 'B3' => 'supplier_b3_project_id', - default => throw new \InvalidArgumentException("Unknown supplier platform: {$platform}"), - }; - } } diff --git a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php index 7c84655e..55b56908 100644 --- a/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php +++ b/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php @@ -12,10 +12,10 @@ use App\Models\SupplierSyncLog; use App\Models\Tenant; use App\Services\Supplier\Channel\AjaxProjectChannel; use Carbon\Carbon; -use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; use Tests\Concerns\SharesSupplierPdo; @@ -41,279 +41,356 @@ afterEach(function (): void { Carbon::setTestNow(); }); -test('creates supplier_project at supplier when supplier_external_id is null', function (): void { +// --------------------------------------------------------------------------- +// Per-subject grouping +// --------------------------------------------------------------------------- + +/** + * Project regions=[82,83] site → 2 groups (Москва, СПб) → + * 2 multi-flag saves → 6 supplier_projects (2 subjects × 3 platforms B1/B2/B3) + * with correct subject_code/tag; pivot — 6 links for the project. + */ +test('per-subject: regions=[82,83] site → 6 supplier_projects + 6 pivot links', function (): void { $tenant = Tenant::factory()->create(); - $sp = SupplierProject::factory()->create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'create-flow.example.com', - 'supplier_external_id' => null, - 'current_limit' => 0, - 'current_workdays' => [], - 'current_regions' => [], - ]); - Project::factory()->create([ + + /** @var Project $project */ + $project = Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, + 'archived_at' => null, 'signal_type' => 'site', - 'signal_identifier' => 'create-flow.example.com', - 'supplier_b1_project_id' => $sp->id, + 'signal_identifier' => 'persubject.example.com', 'daily_limit_target' => 9, + 'delivery_days_mask' => 127, // all days + 'regions' => [82, 83], + ]); + + // saveProjectMultiFlag calls rt-project-save once per subject, then listProjects to get ids + Http::fake([ + // first save (subject 82 = Москва) + 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::sequence() + ->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '1001'], 200) + // second save (subject 83 = Санкт-Петербург) + ->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '2001'], 200), + // listProjects called after each save — return 3 rows per group + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::sequence() + // After first save (Москва tag) + ->push(['projects' => [ + ['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'], + ['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'], + ['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'], + ]], 200) + // After second save (СПб tag) + ->push(['projects' => [ + ['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'], + ['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'], + ['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'], + ['id' => '2001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'], + ['id' => '2002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'], + ['id' => '2003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'], + ]], 200), + ]); + + (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); + + // 6 supplier_projects created: 2 subjects × 3 platforms + $sps = SupplierProject::on('pgsql_supplier') + ->where('unique_key', 'persubject.example.com') + ->where('signal_type', 'site') + ->get(); + + expect($sps)->toHaveCount(6); + + // subject_code 82 → 3 rows (B1/B2/B3) + $m = $sps->where('subject_code', 82); + expect($m)->toHaveCount(3); + expect($m->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']); + + // subject_code 83 → 3 rows + $spb = $sps->where('subject_code', 83); + expect($spb)->toHaveCount(3); + + // pivot: 6 links for this project + $pivotCount = DB::table('project_supplier_links') + ->where('project_id', $project->id) + ->count(); + expect($pivotCount)->toBe(6); +}); + +// --------------------------------------------------------------------------- +// All-RF pool +// --------------------------------------------------------------------------- + +test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 supplier_projects', function (): void { + $tenant = Tenant::factory()->create(); + + /** @var Project $project */ + $project = Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'archived_at' => null, + 'signal_type' => 'site', + 'signal_identifier' => 'rf-pool.example.com', + 'daily_limit_target' => 6, 'delivery_days_mask' => 127, - 'region_mask' => 255, - 'region_mode' => 'include', + 'regions' => [], ]); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( - ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'], + ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '500'], + 200, + ), + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( + ['projects' => [ + ['id' => '500', 'src' => 'rt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'], + ['id' => '501', 'src' => 'bl', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'], + ['id' => '502', 'src' => 'mt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'], + ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); - $sp->refresh(); - expect($sp->supplier_external_id)->toBe('555') - ->and($sp->sync_status)->toBe('ok') - ->and($sp->current_limit)->toBe(3); + $sps = SupplierProject::on('pgsql_supplier') + ->where('unique_key', 'rf-pool.example.com') + ->where('signal_type', 'site') + ->get(); - Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save')); + expect($sps)->toHaveCount(3); + expect($sps->pluck('subject_code')->unique()->all())->toContain(null); + expect($sps->pluck('current_regions')->first())->toBe([]); + + // pivot + $pivotCount = DB::table('project_supplier_links') + ->where('project_id', $project->id) + ->count(); + expect($pivotCount)->toBe(3); }); -test('updates when diff detected', function (): void { +// --------------------------------------------------------------------------- +// Order: 2 projects on one (source × subject) → computeOrder +// --------------------------------------------------------------------------- + +test('order: 2 projects same source×subject → computeOrder(limits=[10,20]) → limit=20', function (): void { $tenant = Tenant::factory()->create(); - $sp = SupplierProject::factory()->create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'update-flow.example.com', - 'supplier_external_id' => '12345', - 'current_limit' => 1, - 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], - 'current_regions' => [], - ]); + Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, + 'archived_at' => null, 'signal_type' => 'site', - 'signal_identifier' => 'update-flow.example.com', - 'supplier_b1_project_id' => $sp->id, - 'daily_limit_target' => 30, + 'signal_identifier' => 'order-test.example.com', + 'daily_limit_target' => 10, 'delivery_days_mask' => 127, - 'region_mask' => 255, - 'region_mode' => 'include', + 'regions' => [], ]); + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'archived_at' => null, + 'signal_type' => 'site', + 'signal_identifier' => 'order-test.example.com', + 'daily_limit_target' => 20, + 'delivery_days_mask' => 127, + 'regions' => [], + ]); + + // saveProjectMultiFlag called once (both projects share same group) Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( - ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12345'], + ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '600'], + 200, + ), + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( + ['projects' => [ + ['id' => '600', 'src' => 'rt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'], + ['id' => '601', 'src' => 'bl', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'], + ['id' => '602', 'src' => 'mt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'], + ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); - $sp->refresh(); - expect($sp->current_limit)->toBe(10) - ->and($sp->sync_status)->toBe('ok'); - - // Update теперь идёт на тот же endpoint что и save (verified 2026-05-19 — Task 1 recon), - // с id:N в body вместо id:0. - Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save') && $r['id'] === 12345); -}); - -test('skips when no diff between current and computed allocation', function (): void { - $tenant = Tenant::factory()->create(); - $sp = SupplierProject::factory()->create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'no-diff.example.com', - 'supplier_external_id' => '999', - 'current_limit' => 9, - 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], - 'current_regions' => [], - 'sync_status' => 'ok', - ]); - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => 'no-diff.example.com', - 'supplier_b1_project_id' => $sp->id, - 'daily_limit_target' => 27, - 'delivery_days_mask' => 127, - 'region_mask' => 255, - 'region_mode' => 'include', - ]); - - Http::fake(); - (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); - - Http::assertNothingSent(); -}); - -test('isolates failure: one bad supplier_project does not stop others', function (): void { - $tenant = Tenant::factory()->create(); - - $bad = SupplierProject::factory()->create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'bad.example.com', - 'supplier_external_id' => null, - 'current_limit' => 0, - 'current_workdays' => [], - 'current_regions' => [], - ]); - $good = SupplierProject::factory()->create([ - 'platform' => 'B2', - 'signal_type' => 'site', - 'unique_key' => 'good.example.com', - 'supplier_external_id' => null, - 'current_limit' => 0, - 'current_workdays' => [], - 'current_regions' => [], - ]); - - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => 'bad.example.com', - 'supplier_b1_project_id' => $bad->id, - 'daily_limit_target' => 9, - 'delivery_days_mask' => 127, - 'region_mask' => 255, - 'region_mode' => 'include', - ]); - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => 'good.example.com', - 'supplier_b2_project_id' => $good->id, - 'daily_limit_target' => 9, - 'delivery_days_mask' => 127, - 'region_mask' => 255, - 'region_mode' => 'include', - ]); - - Http::fakeSequence('crm.bp-gr.ru/admin/visit/rt-project-save') - ->push('bad request', 422) - ->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '777'], 200); - - (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); - - expect( - SupplierSyncLog::on('pgsql_supplier') - ->where('supplier_project_id', $bad->id) - ->whereNotNull('error_message') - ->exists() - )->toBeTrue(); - - expect($good->fresh()->supplier_external_id)->toBe('777'); -}); - -test('aborts after 50 consecutive transient failures and sends alert', function (): void { - Mail::fake(); - $tenant = Tenant::factory()->create(); - - for ($i = 1; $i <= 60; $i++) { - $sp = SupplierProject::factory()->create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => "host{$i}.example.com", - 'supplier_external_id' => null, - 'current_limit' => 0, - 'current_workdays' => [], - 'current_regions' => [], - ]); - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => "host{$i}.example.com", - 'supplier_b1_project_id' => $sp->id, - 'daily_limit_target' => 9, - 'delivery_days_mask' => 127, - 'region_mask' => 255, - 'region_mode' => 'include', - ]); - } - - Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]); - - (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); - - Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool { - return $mail->alertType === 'mass_transient'; - }); -}); - -test('writes supplier_sync_log row for each successful action', function (): void { - $tenant = Tenant::factory()->create(); - $sp = SupplierProject::factory()->create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'audit-log.example.com', - 'supplier_external_id' => null, - 'current_limit' => 0, - 'current_workdays' => [], - 'current_regions' => [], - ]); - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'is_active' => true, - 'signal_type' => 'site', - 'signal_identifier' => 'audit-log.example.com', - 'supplier_b1_project_id' => $sp->id, - 'daily_limit_target' => 9, - 'delivery_days_mask' => 127, - 'region_mask' => 255, - 'region_mode' => 'include', - ]); - - Http::fake([ - 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( - ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'], - 200, - ), - ]); - - (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); - - $log = SupplierSyncLog::on('pgsql_supplier') - ->where('supplier_project_id', $sp->id) + // computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20 + $sp = SupplierProject::on('pgsql_supplier') + ->where('unique_key', 'order-test.example.com') + ->where('platform', 'B1') ->first(); - expect($log)->not->toBeNull() - ->and($log->action)->toBe('create') - ->and($log->http_status)->toBe(200) - ->and($log->error_message)->toBeNull(); + expect($sp)->not->toBeNull(); + expect($sp->current_limit)->toBe(20); + + // Only one save call (single group) — not 2 + Http::assertSentCount(2); // 1 save + 1 listProjects }); +// --------------------------------------------------------------------------- +// SMS platforms +// --------------------------------------------------------------------------- + +test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', function (): void { + $tenant = Tenant::factory()->create(); + + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'archived_at' => null, + 'signal_type' => 'sms', + 'signal_identifier' => null, + 'sms_senders' => ['79001234567'], + 'sms_keyword' => 'KVARTIRA', + 'daily_limit_target' => 5, + 'delivery_days_mask' => 127, + 'regions' => [], + ]); + + Http::fake([ + 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( + ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700'], + 200, + ), + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( + ['projects' => [ + ['id' => '700', 'src' => 'bl', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'], + ['id' => '701', 'src' => 'mt', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'], + ]], + 200, + ), + ]); + + (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); + + $sps = SupplierProject::on('pgsql_supplier') + ->where('signal_type', 'sms') + ->get(); + + // sms+keyword → B2+B3 only + expect($sps)->toHaveCount(2); + expect($sps->pluck('platform')->sort()->values()->all())->toBe(['B2', 'B3']); + expect($sps->where('platform', 'B1')->count())->toBe(0); +}); + +test('sms without keyword → platform B3 only (1 supplier_project)', function (): void { + $tenant = Tenant::factory()->create(); + + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'archived_at' => null, + 'signal_type' => 'sms', + 'signal_identifier' => null, + 'sms_senders' => ['79009876543'], + 'sms_keyword' => null, + 'daily_limit_target' => 5, + 'delivery_days_mask' => 127, + 'regions' => [], + ]); + + Http::fake([ + 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( + ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '800'], + 200, + ), + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( + ['projects' => [ + ['id' => '800', 'src' => 'mt', 'name' => '79009876543', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79009876543'], + ]], + 200, + ), + ]); + + (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); + + $sps = SupplierProject::on('pgsql_supplier') + ->where('signal_type', 'sms') + ->get(); + + expect($sps)->toHaveCount(1); + expect($sps->first()->platform)->toBe('B3'); +}); + +// --------------------------------------------------------------------------- +// Idempotent: repeat run → updateProject (no duplicate supplier_projects/pivot) +// --------------------------------------------------------------------------- + +test('idempotent: repeat run with no changes → updateProject not duplicate', function (): void { + $tenant = Tenant::factory()->create(); + + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'archived_at' => null, + 'signal_type' => 'site', + 'signal_identifier' => 'idempotent.example.com', + 'daily_limit_target' => 9, + 'delivery_days_mask' => 127, + 'regions' => [], + ]); + + // First run: create + Http::fake([ + 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( + ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'], + 200, + ), + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( + ['projects' => [ + ['id' => '900', 'src' => 'rt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'], + ['id' => '901', 'src' => 'bl', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'], + ['id' => '902', 'src' => 'mt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'], + ]], + 200, + ), + ]); + + (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); + + expect(SupplierProject::on('pgsql_supplier') + ->where('unique_key', 'idempotent.example.com') + ->count())->toBe(3); + + // Second run: no changes → updateProject calls (rt-project-save with id != 0) + Http::fake([ + 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( + ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'], + 200, + ), + ]); + + (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); + + // Still 3 (no duplicates) + expect(SupplierProject::on('pgsql_supplier') + ->where('unique_key', 'idempotent.example.com') + ->count())->toBe(3); + + // updateProject sends id != 0 + Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save') + && (int) ($r['id'] ?? 0) !== 0); +}); + +// --------------------------------------------------------------------------- +// Orthogonal: time budget, auth, abort-50, sync_log +// --------------------------------------------------------------------------- + test('respects time budget by stopping at 20:55 МСК', function (): void { Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow')); $tenant = Tenant::factory()->create(); - $sp = SupplierProject::factory()->create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'time-budget.example.com', - 'supplier_external_id' => null, - 'current_limit' => 0, - 'current_workdays' => [], - 'current_regions' => [], - ]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, + 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'time-budget.example.com', - 'supplier_b1_project_id' => $sp->id, 'daily_limit_target' => 9, 'delivery_days_mask' => 127, - 'region_mask' => 255, - 'region_mode' => 'include', + 'regions' => [], ]); Http::fake(); @@ -322,60 +399,20 @@ test('respects time budget by stopping at 20:55 МСК', function (): void { Http::assertNothingSent(); }); -test('passes regions directly to allocator without bitmask conversion', function (): void { - $tenant = Tenant::factory()->create(); - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'regions' => [82, 83], - 'region_mask' => 255, - ]); - - $job = new SyncSupplierProjectsJob; - $projects = Project::where('tenant_id', $tenant->id)->get(); - $adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects); - - expect($adapted->first()->regions)->toBe([82, 83]); -}); - -test('passes empty array to allocator when project has regions=[]', function (): void { - $tenant = Tenant::factory()->create(); - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'regions' => [], - 'region_mask' => 255, - ]); - - $job = new SyncSupplierProjectsJob; - $projects = Project::where('tenant_id', $tenant->id)->get(); - $adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects); - - expect($adapted->first()->regions)->toBe([]); -}); - test('sticky auth error throws and sends critical alert email', function (): void { Mail::fake(); Bus::fake([RefreshSupplierSessionJob::class]); $tenant = Tenant::factory()->create(); - $sp = SupplierProject::factory()->create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'auth-fail.example.com', - 'supplier_external_id' => null, - 'current_limit' => 0, - 'current_workdays' => [], - 'current_regions' => [], - ]); Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, + 'archived_at' => null, 'signal_type' => 'site', 'signal_identifier' => 'auth-fail.example.com', - 'supplier_b1_project_id' => $sp->id, 'daily_limit_target' => 9, 'delivery_days_mask' => 127, - 'region_mask' => 255, - 'region_mode' => 'include', + 'regions' => [], ]); Http::fake([ @@ -390,40 +427,77 @@ test('sticky auth error throws and sends critical alert email', function (): voi }); }); -test('outbound: copies project regions[] into supplier_project current_regions via full handle()', function (): void { +test('aborts after 50 consecutive transient failures and sends alert', function (): void { + Mail::fake(); $tenant = Tenant::factory()->create(); - $sp = SupplierProject::factory()->create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'regions-flow.example.com', - 'supplier_external_id' => null, - 'current_limit' => 0, - 'current_workdays' => [], - 'current_regions' => [], - ]); + + for ($i = 1; $i <= 60; $i++) { + Project::factory()->create([ + 'tenant_id' => $tenant->id, + 'is_active' => true, + 'archived_at' => null, + 'signal_type' => 'site', + 'signal_identifier' => "host{$i}.abort.com", + 'daily_limit_target' => 9, + 'delivery_days_mask' => 127, + 'regions' => [], + ]); + } + + Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]); + + (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); + + Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool { + return $mail->alertType === 'mass_transient'; + }); +}); + +test('writes supplier_sync_log row for each successful action', function (): void { + $tenant = Tenant::factory()->create(); + Project::factory()->create([ 'tenant_id' => $tenant->id, 'is_active' => true, + 'archived_at' => null, 'signal_type' => 'site', - 'signal_identifier' => 'regions-flow.example.com', - 'supplier_b1_project_id' => $sp->id, + 'signal_identifier' => 'audit-log.example.com', 'daily_limit_target' => 9, 'delivery_days_mask' => 127, - 'regions' => [82, 83], - 'region_mask' => 255, - 'region_mode' => 'include', + 'regions' => [], ]); Http::fake([ 'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response( - ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '556'], + ['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'], + 200, + ), + 'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response( + ['projects' => [ + ['id' => '555', 'src' => 'rt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'], + ['id' => '556', 'src' => 'bl', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'], + ['id' => '557', 'src' => 'mt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'], + ]], 200, ), ]); (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)); - $sp->refresh(); - expect($sp->current_regions)->toBe([82, 83]) - ->and($sp->supplier_external_id)->toBe('556'); + // 3 supplier_projects created → 3 log rows (one per platform) + $sp = SupplierProject::on('pgsql_supplier') + ->where('unique_key', 'audit-log.example.com') + ->where('platform', 'B1') + ->first(); + + expect($sp)->not->toBeNull(); + + $log = SupplierSyncLog::on('pgsql_supplier') + ->where('supplier_project_id', $sp->id) + ->first(); + + expect($log)->not->toBeNull() + ->and($log->action)->toBe('create') + ->and($log->http_status)->toBe(200) + ->and($log->error_message)->toBeNull(); });