Files
portal/app/tests/Feature/Supplier/Channel/FailoverProjectChannelTest.php
T
Дмитрий d760036972 feat(supplier): FailoverProjectChannel portal-side dedup before create
listProjects() матч по (platform, signal_type, unique_key) до create.
Защита от дубля при полу-успехе яруса 1 (create прошёл на портале, но
локальная запись не сохранилась → следующий запуск дублировал бы).
listProjects-сбой проглатывается — ярус-эскалация всё равно покроет.

Spec §4.4 шаг 2, §7. Task 5 of 12. Тесты 7/7 (19 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:55:07 +03:00

306 lines
9.5 KiB
PHP

<?php
declare(strict_types=1);
use App\Exceptions\Supplier\SupplierAuthException;
use App\Exceptions\Supplier\SupplierClientException;
use App\Exceptions\Supplier\SupplierTransientException;
use App\Mail\SupplierCriticalAlertMail;
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\Tenant;
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 Illuminate\Contracts\Mail\Mailer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
uses(DatabaseTransactions::class);
function makeDto(): SupplierProjectDto
{
return new SupplierProjectDto(
platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
limit: 10, workdays: [1, 2], regions: [], regionsReverse: false, status: 'active',
);
}
function makeFailover(SupplierProjectChannel $tier1, ?SupplierProjectChannel $tier2 = null): FailoverProjectChannel
{
return new FailoverProjectChannel(
$tier1,
$tier2 ?? new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new RuntimeException('tier2 not configured');
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
},
app(Mailer::class),
);
}
beforeEach(function (): void {
Mail::fake();
});
it('createProject — Tier 1 success: returns id, no queue, no alert', function (): void {
$tier1 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
return 700123;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$id = makeFailover($tier1)->createProject(makeDto());
expect($id)->toBe(700123);
expect(SupplierManualSyncQueue::count())->toBe(0);
Mail::assertNothingQueued();
});
it('createProject — Tier 1 transient-exhausted: skips Tier 2, jumps to Tier 3 with portal_unreachable', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create(['signal_type' => 'site', 'signal_identifier' => 'foo.com']);
$tier1 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new SupplierTransientException('5xx exhausted', httpStatus: 503);
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$tier2Called = false;
$tier2 = new class($tier2Called) implements SupplierProjectChannel
{
public function __construct(public bool &$called) {}
public function createProject(SupplierProjectDto $dto): int
{
$this->called = true;
return 0;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void
{
$this->called = true;
}
public function listProjects(): array
{
$this->called = true;
return [];
}
};
expect(fn () => makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto()))
->toThrow(TierEscalatedException::class);
expect($tier2Called)->toBeFalse();
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('failure_reason', 'portal_unreachable')->count())->toBe(1);
Mail::assertQueued(SupplierCriticalAlertMail::class);
});
it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$tier1 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new SupplierClientException('4xx contract break', httpStatus: 400);
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$tier2 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
return 800001;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$id = makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto());
expect($id)->toBe(800001);
expect(SupplierManualSyncQueue::count())->toBe(0);
Mail::assertQueued(SupplierCriticalAlertMail::class); // failover_to_form alert
});
it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_required alert', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$tier1 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new SupplierClientException('4xx', httpStatus: 400);
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$tier2 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new RuntimeException('form_selector_break');
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
expect(fn () => makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto()))
->toThrow(TierEscalatedException::class);
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('status', 'pending')->count())->toBe(1);
Mail::assertQueued(SupplierCriticalAlertMail::class);
});
it('createProject — Tier 1 auth-exc → Tier 2 success', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$tier1 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new SupplierAuthException('sticky 401', httpStatus: 401);
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$tier2 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
return 900042;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
$id = makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto());
expect($id)->toBe(900042);
});
it('createProject — WindowDeferred: no queue, no escalation, op rescheduled (re-throws WindowDeferred)', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$tier1 = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
throw new WindowDeferredException('portal returned 22:00-00:00 window-block');
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
expect(fn () => makeFailover($tier1)->createProjectForLiderra($project, makeDto()))
->toThrow(WindowDeferredException::class);
expect(SupplierManualSyncQueue::count())->toBe(0);
Mail::assertNothingQueued();
});
it('createProject — portal already has project (listProjects match): adopts external_id, skips create', function (): void {
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$tier1CreateCalled = false;
$tier1 = new class($tier1CreateCalled) implements SupplierProjectChannel
{
public function __construct(public bool &$createCalled) {}
public function createProject(SupplierProjectDto $dto): int
{
$this->createCalled = true;
return 0;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [
['id' => 555555, 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'foo.com'],
];
}
};
$id = makeFailover($tier1)->createProjectForLiderra($project, makeDto());
expect($id)->toBe(555555);
expect($tier1CreateCalled)->toBeFalse();
});