feat: add Snipe-IT asset synchronization
This commit is contained in:
106
tests/Feature/AssetSyncServiceTest.php
Normal file
106
tests/Feature/AssetSyncServiceTest.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
use App\Domain\Inventory\Enums\SyncRunStatus;
|
||||
use App\Domain\Inventory\Models\SnipeAsset;
|
||||
use App\Domain\Inventory\Models\SyncRun;
|
||||
use App\Domain\Inventory\Services\AssetSyncService;
|
||||
use App\Integrations\SnipeIt\Exceptions\SnipeItApiException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('services.snipe_it', [
|
||||
'url' => 'https://inventory.example.test',
|
||||
'read_token' => 'read-token-for-tests',
|
||||
'timeout' => 2,
|
||||
'page_size' => 1,
|
||||
'retries' => 1,
|
||||
'retry_delay_ms' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
it('sincroniza por páginas e desativa apenas ativos ausentes', function () {
|
||||
$stale = SnipeAsset::factory()->create([
|
||||
'snipe_asset_id' => 99,
|
||||
'asset_tag' => 'ANTIGO-99',
|
||||
'active' => true,
|
||||
'synced_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Http::fakeSequence()
|
||||
->push(['total' => 2, 'rows' => [snipeSyncRow(1, 'EQ-001')]])
|
||||
->push(['total' => 2, 'rows' => [snipeSyncRow(2, 'EQ-002')]]);
|
||||
|
||||
$run = app(AssetSyncService::class)->sync();
|
||||
|
||||
expect($run->status)->toBe(SyncRunStatus::Succeeded)
|
||||
->and($run->records_processed)->toBe(2)
|
||||
->and($run->records_created)->toBe(2)
|
||||
->and($run->records_disabled)->toBe(1)
|
||||
->and($stale->refresh()->active)->toBeFalse();
|
||||
|
||||
$this->assertDatabaseHas('snipe_assets', [
|
||||
'snipe_asset_id' => 1,
|
||||
'asset_tag' => 'EQ-001',
|
||||
'name' => 'Câmara & tripé',
|
||||
'category_name' => 'Audiovisual',
|
||||
'requestable' => true,
|
||||
'active' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('não desativa ativos quando uma página remota falha', function () {
|
||||
$existing = SnipeAsset::factory()->create([
|
||||
'snipe_asset_id' => 99,
|
||||
'asset_tag' => 'PRESERVAR-99',
|
||||
'active' => true,
|
||||
'synced_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Http::fakeSequence()
|
||||
->push(['total' => 2, 'rows' => [snipeSyncRow(1, 'EQ-001')]])
|
||||
->push(['message' => 'failure'], 500);
|
||||
|
||||
expect(fn () => app(AssetSyncService::class)->sync())
|
||||
->toThrow(SnipeItApiException::class, 'HTTP 500');
|
||||
|
||||
expect($existing->refresh()->active)->toBeTrue();
|
||||
|
||||
$run = SyncRun::query()->latest('id')->firstOrFail();
|
||||
expect($run->status)->toBe(SyncRunStatus::Failed)
|
||||
->and($run->records_processed)->toBe(1)
|
||||
->and($run->records_disabled)->toBe(0);
|
||||
});
|
||||
|
||||
it('não conta como atualização um ativo cujo conteúdo não mudou', function () {
|
||||
Http::fake([
|
||||
'*' => Http::response(['total' => 1, 'rows' => [snipeSyncRow(1, 'EQ-001')]]),
|
||||
]);
|
||||
|
||||
app(AssetSyncService::class)->sync();
|
||||
$secondRun = app(AssetSyncService::class)->sync();
|
||||
|
||||
expect($secondRun->records_processed)->toBe(1)
|
||||
->and($secondRun->records_created)->toBe(0)
|
||||
->and($secondRun->records_updated)->toBe(0)
|
||||
->and($secondRun->records_disabled)->toBe(0);
|
||||
});
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
function snipeSyncRow(int $id, string $tag): array
|
||||
{
|
||||
return [
|
||||
'id' => $id,
|
||||
'asset_tag' => $tag,
|
||||
'name' => 'Câmara & tripé',
|
||||
'serial' => "SERIAL-{$id}",
|
||||
'model' => ['id' => 20, 'name' => 'Lumix'],
|
||||
'category' => ['id' => 30, 'name' => 'Audiovisual'],
|
||||
'manufacturer' => ['id' => 40, 'name' => 'Panasonic'],
|
||||
'status_label' => ['id' => 2, 'name' => 'Pronto a utilizar'],
|
||||
'rtd_location' => ['id' => 5, 'name' => 'Armazém central'],
|
||||
'requestable' => true,
|
||||
'assigned_to' => null,
|
||||
'image' => 'https://inventory.example.test/uploads/asset.png',
|
||||
'updated_at' => ['datetime' => '2026-08-04T12:00:00+00:00'],
|
||||
];
|
||||
}
|
||||
57
tests/Feature/SnipeItAdminTest.php
Normal file
57
tests/Feature/SnipeItAdminTest.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use App\Domain\Identity\Enums\RoleName;
|
||||
use App\Domain\Identity\Models\Role;
|
||||
use App\Jobs\SyncSnipeAssets;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
it('permite ao administrador consultar o diagnóstico Snipe-IT', function () {
|
||||
$administrator = User::factory()->create();
|
||||
$administrator->roles()->attach(Role::query()->create([
|
||||
'name' => RoleName::Administrator->value,
|
||||
'description' => 'Administração do portal.',
|
||||
]));
|
||||
|
||||
config()->set('services.snipe_it.url', 'https://inventory.example.test');
|
||||
config()->set('services.snipe_it.read_token', 'configured-token');
|
||||
|
||||
$this->actingAs($administrator)
|
||||
->get(route('admin.snipe-it.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('admin/snipe-it')
|
||||
->where('configured', true)
|
||||
->where('assets.total', 0)
|
||||
->has('syncRuns', 0));
|
||||
});
|
||||
|
||||
it('impede um requisitante de consultar o diagnóstico', function () {
|
||||
$requester = User::factory()->create();
|
||||
$requester->roles()->attach(Role::query()->create([
|
||||
'name' => RoleName::Requester->value,
|
||||
'description' => 'Requisitante.',
|
||||
]));
|
||||
|
||||
$this->actingAs($requester)
|
||||
->get(route('admin.snipe-it.index'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('coloca a sincronização manual na fila', function () {
|
||||
Queue::fake();
|
||||
|
||||
$administrator = User::factory()->create();
|
||||
$administrator->roles()->attach(Role::query()->create([
|
||||
'name' => RoleName::Administrator->value,
|
||||
'description' => 'Administração do portal.',
|
||||
]));
|
||||
|
||||
$this->actingAs($administrator)
|
||||
->post(route('admin.snipe-it.sync'))
|
||||
->assertRedirect()
|
||||
->assertSessionHas('success');
|
||||
|
||||
Queue::assertPushed(SyncSnipeAssets::class);
|
||||
});
|
||||
72
tests/Feature/SnipeItClientTest.php
Normal file
72
tests/Feature/SnipeItClientTest.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Integrations\SnipeIt\Exceptions\SnipeItApiException;
|
||||
use App\Integrations\SnipeIt\SnipeItClient;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('services.snipe_it', [
|
||||
'url' => 'https://inventory.example.test',
|
||||
'read_token' => 'read-token-for-tests',
|
||||
'timeout' => 2,
|
||||
'page_size' => 2,
|
||||
'retries' => 1,
|
||||
'retry_delay_ms' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
it('percorre todas as páginas de equipamentos com os cabeçalhos obrigatórios', function () {
|
||||
Http::fakeSequence()
|
||||
->push(['total' => 3, 'rows' => [snipeClientRow(1), snipeClientRow(2)]])
|
||||
->push(['total' => 3, 'rows' => [snipeClientRow(3)]]);
|
||||
|
||||
$pages = iterator_to_array(app(SnipeItClient::class)->hardwarePages());
|
||||
|
||||
expect($pages)->toHaveCount(2)
|
||||
->and($pages[0]->offset)->toBe(0)
|
||||
->and($pages[1]->offset)->toBe(2)
|
||||
->and($pages[1]->rows[0]['id'])->toBe(3);
|
||||
|
||||
Http::assertSentCount(2);
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);
|
||||
|
||||
return $request->hasHeader('Authorization', 'Bearer read-token-for-tests')
|
||||
&& $request->hasHeader('Accept', 'application/json')
|
||||
&& ($query['limit'] ?? null) === '2'
|
||||
&& in_array($query['offset'] ?? null, ['0', '2'], true);
|
||||
});
|
||||
});
|
||||
|
||||
it('repete falhas transitórias e recupera', function () {
|
||||
config()->set('services.snipe_it.retries', 2);
|
||||
|
||||
Http::fakeSequence()
|
||||
->push(['message' => 'temporarily unavailable'], 503)
|
||||
->push(['total' => 1, 'rows' => [snipeClientRow(1)]]);
|
||||
|
||||
$page = app(SnipeItClient::class)->listHardwarePage();
|
||||
|
||||
expect($page->total)->toBe(1);
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
|
||||
it('recusa erros transportados num HTTP 200', function () {
|
||||
Http::fake([
|
||||
'*' => Http::response(['status' => 'error', 'messages' => 'Unauthorized'], 200),
|
||||
]);
|
||||
|
||||
expect(fn () => app(SnipeItClient::class)->listHardwarePage())
|
||||
->toThrow(SnipeItApiException::class, 'recusou a consulta');
|
||||
});
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
function snipeClientRow(int $id): array
|
||||
{
|
||||
return [
|
||||
'id' => $id,
|
||||
'asset_tag' => "EQ-{$id}",
|
||||
'name' => "Equipamento {$id}",
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user