Files
portal/app/tests/Feature/Admin/SupplierManualQueueTest.php
T
Дмитрий 25088e4a33 feat(supplier): admin endpoints for Tier 3 manual queue
GET /api/admin/supplier-integration/manual-queue — pending список (limit 100).
POST /manual-queue/{id}/resolve — оператор пометил, что вручную создал проект
на портале; reconcile через channel->listProjects() по (platform, signal_type,
unique_key), 409 если не найден.

ОТКЛОНЕНИЕ ОТ plan Step 10.3: план писал portal external_id прямо в
projects.supplier_b*_project_id (FK на local supplier_projects.id) — FK
violation. Resolve делает firstOrCreate local supplier_projects row с
verified external_id, в FK пишет local id.

Routes — в группе saas-admin (web.php, EnsureSaasAdmin стаб). Task 10 of 12.
Tests 4/4 (index pending / exclude resolved / resolve match / resolve 409).

Spec §4.6.

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

131 lines
4.8 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierManualSyncQueue;
use App\Models\SupplierProject;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Supplier\Channel\SupplierProjectChannel;
use App\Services\Supplier\Dto\SupplierProjectDto;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
function authAdmin(): User
{
// EnsureSaasAdmin — стаб (Sprint 3F): в testing пропускает всех без
// проверки роли. actingAs нужен только чтобы $request->user() в
// manualQueueResolve дал id для resolved_by_user_id.
$admin = User::factory()->create();
test()->actingAs($admin);
return $admin;
}
it('GET /api/admin/supplier-integration/manual-queue returns pending rows', function (): void {
authAdmin();
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
SupplierManualSyncQueue::create([
'project_id' => $project->id,
'platform' => 'B1',
'operation' => 'create',
'payload_snapshot' => ['limit' => 10],
'failure_reason' => 'contract_break',
'status' => 'pending',
]);
$r = test()->getJson('/api/admin/supplier-integration/manual-queue');
$r->assertOk()
->assertJsonStructure(['queue' => [['id', 'project_id', 'platform', 'operation', 'payload_snapshot', 'failure_reason', 'created_at']]])
->assertJsonCount(1, 'queue');
});
it('GET excludes resolved rows', function (): void {
authAdmin();
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
SupplierManualSyncQueue::create([
'project_id' => $project->id, 'platform' => 'B1', 'operation' => 'create',
'payload_snapshot' => [], 'failure_reason' => 'contract_break',
'status' => 'resolved', 'resolved_at' => now(),
]);
test()->getJson('/api/admin/supplier-integration/manual-queue')
->assertOk()->assertJsonCount(0, 'queue');
});
it('POST /resolve marks row resolved when listProjects matches', function (): void {
$admin = authAdmin();
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$row = SupplierManualSyncQueue::create([
'project_id' => $project->id, 'platform' => 'B1', 'operation' => 'create',
'payload_snapshot' => ['signal_type' => 'site', 'unique_key' => 'foo.com'],
'failure_reason' => 'contract_break', 'status' => 'pending',
]);
$channelMock = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
return 0;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [['id' => 99999, 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'foo.com']];
}
};
app()->instance(SupplierProjectChannel::class, $channelMock);
test()->postJson("/api/admin/supplier-integration/manual-queue/{$row->id}/resolve")
->assertOk();
expect($row->fresh()->status)->toBe('resolved');
expect($row->fresh()->resolved_by_user_id)->toBe($admin->id);
// FK ведёт на local supplier_projects.id; portal external_id (99999) хранится
// в supplier_external_id созданной строки + в queue-row.external_id.
expect($project->fresh()->supplier_b1_project_id)->not->toBeNull();
expect(SupplierProject::find($project->fresh()->supplier_b1_project_id)->supplier_external_id)->toBe('99999');
expect($row->fresh()->external_id)->toBe('99999');
});
it('POST /resolve returns 409 when listProjects does not match', function (): void {
authAdmin();
$tenant = Tenant::factory()->create();
$project = Project::factory()->for($tenant)->create();
$row = SupplierManualSyncQueue::create([
'project_id' => $project->id, 'platform' => 'B1', 'operation' => 'create',
'payload_snapshot' => ['signal_type' => 'site', 'unique_key' => 'foo.com'],
'failure_reason' => 'contract_break', 'status' => 'pending',
]);
$channelMock = new class implements SupplierProjectChannel
{
public function createProject(SupplierProjectDto $dto): int
{
return 0;
}
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
public function listProjects(): array
{
return [];
}
};
app()->instance(SupplierProjectChannel::class, $channelMock);
test()->postJson("/api/admin/supplier-integration/manual-queue/{$row->id}/resolve")
->assertStatus(409);
expect($row->fresh()->status)->toBe('pending');
});