Files
portal/app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php
T

504 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\SupplierSyncLog;
use App\Models\Tenant;
use App\Services\Supplier\Channel\AjaxProjectChannel;
use Carbon\Carbon;
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;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-12 10:00:00', 'Europe/Moscow'));
Cache::store('redis')->put('supplier:session', [
'phpsessid' => 'sess',
'csrf' => 'csrf',
'refreshed_at' => now()->toIso8601String(),
], now()->addHours(6));
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
config(['services.supplier.alert_email' => 'ops@liderra.test']);
});
afterEach(function (): void {
Cache::store('redis')->forget('supplier:session');
Carbon::setTestNow();
});
// ---------------------------------------------------------------------------
// 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();
/** @var Project $project */
$project = Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'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,
'regions' => [],
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['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));
$sps = SupplierProject::on('pgsql_supplier')
->where('unique_key', 'rf-pool.example.com')
->where('signal_type', 'site')
->get();
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);
});
// ---------------------------------------------------------------------------
// 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();
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' => 10,
'delivery_days_mask' => 127,
'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' => '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));
// 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($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();
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'time-budget.example.com',
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [],
]);
Http::fake();
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
Http::assertNothingSent();
});
test('sticky auth error throws and sends critical alert email', function (): void {
Mail::fake();
Bus::fake([RefreshSupplierSessionJob::class]);
$tenant = Tenant::factory()->create();
Project::factory()->create([
'tenant_id' => $tenant->id,
'is_active' => true,
'archived_at' => null,
'signal_type' => 'site',
'signal_identifier' => 'auth-fail.example.com',
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [],
]);
Http::fake([
'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401),
]);
expect(fn () => (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)))
->toThrow(SupplierAuthException::class);
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
return $mail->alertType === 'sticky_auth';
});
});
test('aborts after 50 consecutive transient failures and sends alert', function (): void {
Mail::fake();
$tenant = Tenant::factory()->create();
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' => 'audit-log.example.com',
'daily_limit_target' => 9,
'delivery_days_mask' => 127,
'regions' => [],
]);
Http::fake([
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
['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));
// 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();
});