d760036972
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>
306 lines
9.5 KiB
PHP
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();
|
|
});
|