Files
portal/app/tests/Feature/Supplier/CleanupInactiveSupplierProjectsJobTest.php
T
Дмитрий c6859859a3 feat(supplier): Plan 3 Task 7 — CleanupInactiveSupplierProjectsJob (Phase A→B→C)
Daily 02:00 МСК cron, 3 фазы со строгим порядком:
- Phase A: re-activate supplier_projects где появился active liderra
  (СНАЧАЛА — safety: Phase C не удалит недавно вернувшихся)
- Phase B: mark inactive_since=NOW() для newly orphaned
- Phase C: для inactive_since < NOW() - 180d → rt-project-delete + local delete
  + 404 от поставщика → trust 'already deleted' + локальный delete

180-day TTL paritet со spec §3.3. Audit в supplier_sync_log на каждый delete.

SupplierProject не имеет SoftDeletes → используется hard delete; audit-trail
durability через JSONB request_payload snapshot (FK ON DELETE SET NULL зануляет
supplier_project_id, но строка лога остаётся).

+6 тестов (Phase A reactivation / Phase B mark / Phase C delete + audit /
critical ordering safety / 404 trust / < 180d boundary). 19/19 Feature/Supplier PASS.
2026-05-11 06:46:13 +03:00

161 lines
5.3 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\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/rt-project-delete' => Http::response('', 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/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();
});