504 lines
20 KiB
PHP
504 lines
20 KiB
PHP
<?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();
|
||
});
|