e87b1385cf
Live discovery через Playwright MCP (Task 1):
- создан LIDPOTOK_TEST_DELETE_ME (B1+B2+B3) → 3 rt-проекта на портале;
- записаны сетевые запросы /admin/visit/rt-*;
- все три проекта удалены вручную, портал чист.
Endpoints (verified):
- POST /admin/visit/rt-project-save (create id:0, update id:N — same URL)
- POST /admin/visit/rt-project-delete (id строкой)
- GET /admin/visit/rt-projects-load?src=none
Все три — application/json. Конверт ответа:
- success: HTTP 200 + {status:OK, message, result, id?:string}
- error: HTTP 200 + {status:Error, message, result:null}
ID — строка (12721245), приводится к int (fits в int64).
Один save с B1+B2+B3 включёнными создаёт 3 rt-проекта — toPayload()
шлёт ровно один платформенный флаг (srcrt|srcbl|srcmt).
SupplierPortalClient:
- docblock переписан под verified контракт
- listProjects: путь /admin/visit/rt-projects-load + ?src=none query
- saveProject: путь /admin/visit/rt-project-save, asJson, парсинг id
- updateProject: тот же endpoint что save, id:N в body
- deleteProject: путь /admin/visit/rt-project-delete, asJson, id строкой
- new assertStatusOk() — HTTP 200 + status:Error → SupplierClientException
- toPayload(): полный Vuex-payload с маппингом DTO → portal:
- platform B1/B2/B3 → srcrt/srcbl/srcmt (single-true)
- signalType site/call/sms → type:hosts/calls/sms
- workdays int[] → string[]
- status active/paused → bool
- + tag:_lidpotok, name/content из uniqueKey, defaults для show/depth/etc
Tests:
- new: tests/Feature/Supplier/SupplierPortalClientRtProjectTest.php (7 tests,
contract: save+update+delete+list + 2 status:Error error-paths + B2/calls
mapping)
- Sync/Cleanup/Unit тесты обновлены под новый URL + envelope shape.
Закрывает spec §1 honest-caveat «placeholder, не верифицирован»
и журнал решений запись 9. Регрессия: Pest 944/941/0 failed / 3 skipped
/ 2768 assertions / 59.2s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
5.4 KiB
PHP
164 lines
5.4 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
|
||
use App\Models\Project;
|
||
use App\Models\SupplierProject;
|
||
use App\Models\SupplierSyncLog;
|
||
use App\Models\Tenant;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
uses(DatabaseTransactions::class);
|
||
uses(SharesSupplierPdo::class);
|
||
|
||
beforeEach(function (): void {
|
||
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']);
|
||
});
|
||
|
||
afterEach(function (): void {
|
||
Cache::store('redis')->forget('supplier:session');
|
||
Carbon::setTestNow();
|
||
});
|
||
|
||
test('phase A re-activates supplier_project when active liderra project still references it', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'reactivate.example.com',
|
||
'inactive_since' => now()->subDays(30),
|
||
]);
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'reactivate.example.com',
|
||
'supplier_b1_project_id' => $sp->id,
|
||
]);
|
||
|
||
(new CleanupInactiveSupplierProjectsJob)->handle();
|
||
|
||
expect($sp->fresh()->inactive_since)->toBeNull();
|
||
});
|
||
|
||
test('phase B marks inactive_since=NOW for newly orphaned supplier_project', function (): void {
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'orphan.example.com',
|
||
'inactive_since' => null,
|
||
]);
|
||
|
||
Carbon::setTestNow(Carbon::parse('2026-05-12 02:00:00'));
|
||
(new CleanupInactiveSupplierProjectsJob)->handle();
|
||
|
||
expect($sp->fresh()->inactive_since)->not->toBeNull();
|
||
});
|
||
|
||
test('phase C deletes supplier_project after 180 days inactive and writes audit row', function (): void {
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'old.example.com',
|
||
'supplier_external_id' => '999',
|
||
'inactive_since' => now()->subDays(181),
|
||
]);
|
||
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
|
||
['status' => 'OK', 'message' => '', 'result' => null],
|
||
200,
|
||
),
|
||
]);
|
||
|
||
(new CleanupInactiveSupplierProjectsJob)->handle();
|
||
|
||
expect(SupplierProject::on('pgsql_supplier')->find($sp->id))->toBeNull();
|
||
// FK ON DELETE SET NULL зануляет supplier_project_id у лога после delete'а
|
||
// supplier_project — трассировка через request_payload (JSONB snapshot).
|
||
expect(
|
||
SupplierSyncLog::on('pgsql_supplier')
|
||
->where('action', 'delete')
|
||
->where('http_status', 200)
|
||
->whereRaw("request_payload->>'supplier_project_id' = ?", [(string) $sp->id])
|
||
->exists()
|
||
)->toBeTrue();
|
||
});
|
||
|
||
test('phase A runs before phase C (safety ordering): returned-active is reactivated, not deleted', function (): void {
|
||
$tenant = Tenant::factory()->create();
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'edge.example.com',
|
||
'supplier_external_id' => '888',
|
||
'inactive_since' => now()->subDays(185),
|
||
]);
|
||
Project::factory()->create([
|
||
'tenant_id' => $tenant->id,
|
||
'is_active' => true,
|
||
'signal_type' => 'site',
|
||
'signal_identifier' => 'edge.example.com',
|
||
'supplier_b1_project_id' => $sp->id,
|
||
]);
|
||
|
||
Http::fake();
|
||
(new CleanupInactiveSupplierProjectsJob)->handle();
|
||
|
||
expect($sp->fresh()?->inactive_since)->toBeNull();
|
||
expect(SupplierProject::on('pgsql_supplier')->find($sp->id))->not->toBeNull();
|
||
Http::assertNothingSent();
|
||
});
|
||
|
||
test('handles 404 from supplier as already-deleted: local delete + audit row with 404 status', function (): void {
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'ghost.example.com',
|
||
'supplier_external_id' => '777',
|
||
'inactive_since' => now()->subDays(181),
|
||
]);
|
||
|
||
Http::fake([
|
||
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response('not found', 404),
|
||
]);
|
||
|
||
(new CleanupInactiveSupplierProjectsJob)->handle();
|
||
|
||
expect(SupplierProject::on('pgsql_supplier')->find($sp->id))->toBeNull();
|
||
expect(
|
||
SupplierSyncLog::on('pgsql_supplier')
|
||
->where('action', 'delete')
|
||
->where('http_status', 404)
|
||
->whereRaw("request_payload->>'supplier_project_id' = ?", [(string) $sp->id])
|
||
->exists()
|
||
)->toBeTrue();
|
||
});
|
||
|
||
test('does not delete supplier_project marked inactive less than 180 days ago', function (): void {
|
||
$sp = SupplierProject::factory()->create([
|
||
'platform' => 'B1',
|
||
'signal_type' => 'site',
|
||
'unique_key' => 'recent.example.com',
|
||
'supplier_external_id' => '555',
|
||
'inactive_since' => now()->subDays(100),
|
||
]);
|
||
|
||
Http::fake();
|
||
(new CleanupInactiveSupplierProjectsJob)->handle();
|
||
|
||
expect(SupplierProject::on('pgsql_supplier')->find($sp->id))->not->toBeNull();
|
||
Http::assertNothingSent();
|
||
});
|