feat: add Snipe-IT asset synchronization

This commit is contained in:
2026-08-04 16:15:09 +01:00
parent 604180b735
commit 077015f17a
37 changed files with 1302 additions and 26 deletions

View File

@@ -71,6 +71,9 @@ SNIPE_IT_URL=
SNIPE_IT_READ_TOKEN=
SNIPE_IT_OPERATIONAL_TOKEN=
SNIPE_IT_TIMEOUT=10
SNIPE_IT_PAGE_SIZE=100
SNIPE_IT_RETRIES=3
SNIPE_IT_RETRY_DELAY_MS=250
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=

View File

@@ -14,6 +14,10 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
- Dashboard, catálogo filtrável, formulário de requisição multi-equipamento e histórico pessoal.
- Seeders com perfis, ativos fictícios e contas de demonstração.
- Documentação de arquitetura, requisitos, decisões, operação, estado e handoff.
- Cliente de leitura Snipe-IT com autenticação Bearer, paginação, timeout, retries seletivos, DTOs e mapper defensivo.
- Sincronização horária e manual de equipamentos, job único, lock distribuído, histórico de execuções e desativação segura de ausentes.
- Área administrativa de diagnóstico Snipe-IT com teste de conectividade, métricas do cache e histórico de sincronizações.
- Autorização administrativa através de Gate e navegação condicional por perfil.
### Alterado
@@ -31,7 +35,9 @@ Todas as alterações materiais são registadas neste ficheiro. O formato segue
### Verificado
- Migrations e seeders executados em PostgreSQL 17 real.
- Suite backend: Pint, PHPStan nível 7 e 44 testes Pest (157 asserções).
- Suite backend: Pint, PHPStan nível 7 e 53 testes Pest (201 asserções).
- Frontend: TypeScript, ESLint, Prettier e build Vite de produção.
- Stack Docker operacional e endpoint de login acessível através do Nginx.
- Renderização SSR real do login validada, incluindo o conteúdo “Entrar no Portal de Reservas”.
- API Snipe-IT simulada em testes: paginação, headers, retry, erro em HTTP 200, falha parcial, idempotência e desativação de ausentes.
- Migration `last_seen_sync_run_id` aplicada em PostgreSQL 17.

View File

@@ -4,7 +4,7 @@ Portal interno para consultar, requisitar, aprovar, entregar e devolver equipame
## Estado atual
O projeto concluiu a **Fase 1 — Fundação técnica** e o primeiro fluxo vertical da Fase 3. Estão disponíveis autenticação local, perfis base, catálogo sincronizável, criação de requisição com vários equipamentos, pré-reserva, deteção de conflitos, histórico pessoal e dashboard. A próxima entrega é a integração de leitura e sincronização com o Snipe-IT.
O projeto concluiu a **Fase 1 — Fundação técnica**, a **Fase 2 — Integração de leitura Snipe-IT** e o primeiro fluxo vertical da Fase 3. Estão disponíveis autenticação local, perfis base, catálogo sincronizável, criação de requisição com vários equipamentos, pré-reserva, deteção de conflitos, histórico pessoal, dashboard e diagnóstico administrativo da integração. A próxima entrega completa o motor de reservas com pesquisa temporal, cancelamento e expiração.
Consulte sempre [docs/PROJECT_STATUS.md](docs/PROJECT_STATUS.md) antes de alterar o projeto. Esse documento contém o ponto de situação, riscos e próxima tarefa recomendada.

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use App\Domain\Inventory\Services\AssetSyncService;
use App\Jobs\SyncSnipeAssets;
use Illuminate\Console\Command;
use Throwable;
class SyncSnipeAssetsCommand extends Command
{
protected $signature = 'snipe:sync-assets {--queue : Colocar a sincronização na fila em vez de a executar agora}';
protected $description = 'Sincroniza o cache local de equipamentos com a API do Snipe-IT';
public function handle(AssetSyncService $syncService): int
{
if ($this->option('queue')) {
SyncSnipeAssets::dispatch();
$this->info('Sincronização Snipe-IT colocada na fila.');
return self::SUCCESS;
}
try {
$run = $syncService->sync();
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(
['Processados', 'Criados', 'Atualizados', 'Desativados'],
[[
$run->records_processed,
$run->records_created,
$run->records_updated,
$run->records_disabled,
]],
);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Domain\Inventory\Enums;
enum SyncRunStatus: string
{
case Running = 'running';
case Succeeded = 'succeeded';
case Failed = 'failed';
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Domain\Inventory\Exceptions;
use RuntimeException;
class SyncAlreadyRunningException extends RuntimeException
{
public function __construct()
{
parent::__construct('Já existe uma sincronização de equipamentos em curso.');
}
}

View File

@@ -21,6 +21,7 @@ class SnipeAsset extends Model
'category_id', 'category_name', 'manufacturer', 'status_id', 'status_name',
'location_id', 'location_name', 'requestable', 'assigned_type', 'assigned_id',
'image_url', 'source_updated_at', 'synced_at', 'content_hash', 'active',
'last_seen_sync_run_id',
];
protected function casts(): array

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Domain\Inventory\Models;
use App\Domain\Inventory\Enums\SyncRunStatus;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $type
* @property CarbonImmutable $started_at
* @property CarbonImmutable|null $finished_at
* @property SyncRunStatus $status
* @property int $records_processed
* @property int $records_created
* @property int $records_updated
* @property int $records_disabled
* @property string|null $error_message
*/
class SyncRun extends Model
{
public $timestamps = false;
protected $fillable = [
'type',
'started_at',
'finished_at',
'status',
'records_processed',
'records_created',
'records_updated',
'records_disabled',
'error_message',
];
protected function casts(): array
{
return [
'started_at' => 'datetime',
'finished_at' => 'datetime',
'status' => SyncRunStatus::class,
];
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Domain\Inventory\Services;
use App\Domain\Inventory\Enums\SyncRunStatus;
use App\Domain\Inventory\Exceptions\SyncAlreadyRunningException;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Inventory\Models\SyncRun;
use App\Integrations\SnipeIt\SnipeItAssetMapper;
use App\Integrations\SnipeIt\SnipeItClient;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
final readonly class AssetSyncService
{
public function __construct(
private SnipeItClient $client,
private SnipeItAssetMapper $mapper,
) {}
public function sync(): SyncRun
{
$lock = Cache::lock('snipe-it:asset-sync', 900);
if (! $lock->get()) {
throw new SyncAlreadyRunningException;
}
try {
return $this->runSync();
} finally {
$lock->release();
}
}
private function runSync(): SyncRun
{
$startedAt = now();
$run = SyncRun::query()->create([
'type' => 'assets_full',
'started_at' => $startedAt,
'status' => SyncRunStatus::Running,
]);
$processed = 0;
$created = 0;
$updated = 0;
try {
foreach ($this->client->hardwarePages() as $page) {
DB::transaction(function () use ($page, $run, $startedAt, &$processed, &$created, &$updated): void {
foreach ($page->rows as $row) {
$assetData = $this->mapper->map($row);
$hash = $assetData->contentHash();
$asset = SnipeAsset::query()
->where('snipe_asset_id', $assetData->snipeAssetId)
->first();
$attributes = [
...$assetData->toModelAttributes(),
'synced_at' => $startedAt,
'last_seen_sync_run_id' => $run->id,
'content_hash' => $hash,
'active' => true,
];
if ($asset === null) {
SnipeAsset::query()->create($attributes);
$created++;
} else {
$changed = $asset->content_hash !== $hash || ! $asset->active;
$asset->update($attributes);
if ($changed) {
$updated++;
}
}
$processed++;
}
});
}
$disabled = DB::transaction(fn (): int => SnipeAsset::query()
->where('active', true)
->where(fn ($query) => $query
->whereNull('last_seen_sync_run_id')
->orWhere('last_seen_sync_run_id', '!=', $run->id))
->update(['active' => false]));
$run->update([
'finished_at' => now(),
'status' => SyncRunStatus::Succeeded,
'records_processed' => $processed,
'records_created' => $created,
'records_updated' => $updated,
'records_disabled' => $disabled,
]);
return $run->refresh();
} catch (Throwable $exception) {
$message = preg_replace('/Bearer\s+\S+/i', 'Bearer [redacted]', $exception->getMessage())
?? 'Falha desconhecida durante a sincronização.';
$run->update([
'finished_at' => now(),
'status' => SyncRunStatus::Failed,
'records_processed' => $processed,
'records_created' => $created,
'records_updated' => $updated,
'error_message' => Str::limit($message, 2000),
]);
throw $exception;
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Domain\Inventory\Models\SnipeAsset;
use App\Domain\Inventory\Models\SyncRun;
use App\Http\Controllers\Controller;
use App\Integrations\SnipeIt\SnipeItClient;
use App\Jobs\SyncSnipeAssets;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
use Throwable;
class SnipeItController extends Controller
{
public function index(SnipeItClient $client): Response
{
return Inertia::render('admin/snipe-it', [
'configured' => $client->isConfigured(),
'baseUrl' => $client->configuredUrl(),
'assets' => [
'total' => SnipeAsset::query()->count(),
'active' => SnipeAsset::query()->where('active', true)->count(),
'requestable' => SnipeAsset::query()
->where('active', true)
->where('requestable', true)
->count(),
],
'syncRuns' => SyncRun::query()
->latest('started_at')
->limit(10)
->get()
->map(fn (SyncRun $run): array => [
'id' => $run->id,
'status' => $run->status->value,
'started_at' => $run->started_at->toIso8601String(),
'finished_at' => $run->finished_at?->toIso8601String(),
'records_processed' => $run->records_processed,
'records_created' => $run->records_created,
'records_updated' => $run->records_updated,
'records_disabled' => $run->records_disabled,
'error_message' => $run->error_message,
]),
]);
}
public function testConnection(SnipeItClient $client): RedirectResponse
{
$startedAt = hrtime(true);
try {
$page = $client->listHardwarePage(0, 1);
$duration = (int) round((hrtime(true) - $startedAt) / 1_000_000);
return back()->with('success', "Ligação confirmada em {$duration} ms. O Snipe-IT reporta {$page->total} equipamentos.");
} catch (Throwable $exception) {
report($exception);
return back()->with('error', $exception->getMessage());
}
}
public function sync(): RedirectResponse
{
SyncSnipeAssets::dispatch();
return back()->with('success', 'A sincronização de equipamentos foi colocada na fila.');
}
}

View File

@@ -45,6 +45,9 @@ class HandleInertiaRequests extends Middleware
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
],
'can' => [
'administer' => fn (): bool => $request->user()?->can('administer') ?? false,
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Integrations\SnipeIt\Data;
use Carbon\CarbonImmutable;
final readonly class AssetData
{
public function __construct(
public int $snipeAssetId,
public string $assetTag,
public string $name,
public ?string $serial,
public ?int $modelId,
public ?string $modelName,
public ?int $categoryId,
public ?string $categoryName,
public ?string $manufacturer,
public ?int $statusId,
public ?string $statusName,
public ?int $locationId,
public ?string $locationName,
public bool $requestable,
public ?string $assignedType,
public ?int $assignedId,
public ?string $imageUrl,
public ?CarbonImmutable $sourceUpdatedAt,
) {}
/** @return array<string, mixed> */
public function toModelAttributes(): array
{
return [
'snipe_asset_id' => $this->snipeAssetId,
'asset_tag' => $this->assetTag,
'name' => $this->name,
'serial' => $this->serial,
'model_id' => $this->modelId,
'model_name' => $this->modelName,
'category_id' => $this->categoryId,
'category_name' => $this->categoryName,
'manufacturer' => $this->manufacturer,
'status_id' => $this->statusId,
'status_name' => $this->statusName,
'location_id' => $this->locationId,
'location_name' => $this->locationName,
'requestable' => $this->requestable,
'assigned_type' => $this->assignedType,
'assigned_id' => $this->assignedId,
'image_url' => $this->imageUrl,
'source_updated_at' => $this->sourceUpdatedAt,
];
}
public function contentHash(): string
{
$attributes = $this->toModelAttributes();
$attributes['source_updated_at'] = $this->sourceUpdatedAt?->toIso8601String();
return hash('sha256', json_encode($attributes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE));
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Integrations\SnipeIt\Data;
final readonly class HardwarePage
{
/**
* @param list<array<string, mixed>> $rows
*/
public function __construct(
public int $total,
public int $offset,
public int $limit,
public array $rows,
) {}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Integrations\SnipeIt\Exceptions;
class SnipeItApiException extends SnipeItException
{
public function __construct(string $message, public readonly ?int $statusCode = null)
{
parent::__construct($message);
}
}

View File

@@ -0,0 +1,5 @@
<?php
namespace App\Integrations\SnipeIt\Exceptions;
class SnipeItConfigurationException extends SnipeItException {}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Integrations\SnipeIt\Exceptions;
use RuntimeException;
class SnipeItException extends RuntimeException {}

View File

@@ -0,0 +1,5 @@
<?php
namespace App\Integrations\SnipeIt\Exceptions;
class SnipeItMappingException extends SnipeItException {}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Integrations\SnipeIt;
use App\Integrations\SnipeIt\Data\AssetData;
use App\Integrations\SnipeIt\Exceptions\SnipeItMappingException;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use Throwable;
final class SnipeItAssetMapper
{
/** @param array<string, mixed> $row */
public function map(array $row): AssetData
{
$id = $this->integer($row['id'] ?? null);
$assetTag = $this->text($row['asset_tag'] ?? null);
if ($id === null || $id < 1 || $assetTag === null) {
throw new SnipeItMappingException('A resposta do Snipe-IT contém um ativo sem ID ou etiqueta válida.');
}
$modelName = $this->text(Arr::get($row, 'model.name'));
return new AssetData(
snipeAssetId: $id,
assetTag: $assetTag,
name: $this->text($row['name'] ?? null) ?? $modelName ?? $assetTag,
serial: $this->text($row['serial'] ?? null),
modelId: $this->integer(Arr::get($row, 'model.id')),
modelName: $modelName,
categoryId: $this->integer(Arr::get($row, 'category.id')),
categoryName: $this->text(Arr::get($row, 'category.name')),
manufacturer: $this->text(Arr::get($row, 'manufacturer.name')),
statusId: $this->integer(Arr::get($row, 'status_label.id')),
statusName: $this->text(Arr::get($row, 'status_label.name')),
locationId: $this->integer(Arr::get($row, 'rtd_location.id') ?? Arr::get($row, 'location.id')),
locationName: $this->text(Arr::get($row, 'rtd_location.name') ?? Arr::get($row, 'location.name')),
requestable: $this->boolean($row['requestable'] ?? false),
assignedType: $this->text(Arr::get($row, 'assigned_to.type')),
assignedId: $this->integer(Arr::get($row, 'assigned_to.id')),
imageUrl: $this->text($row['image'] ?? $row['image_url'] ?? null),
sourceUpdatedAt: $this->date(Arr::get($row, 'updated_at.datetime') ?? Arr::get($row, 'updated_at.date') ?? $row['updated_at'] ?? null),
);
}
private function text(mixed $value): ?string
{
if (! is_scalar($value)) {
return null;
}
$decoded = trim(html_entity_decode((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
return $decoded === '' ? null : $decoded;
}
private function integer(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private function boolean(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
return in_array(strtolower((string) $value), ['1', 'true', 'yes', 'sim'], true);
}
private function date(mixed $value): ?CarbonImmutable
{
$text = $this->text($value);
if ($text === null) {
return null;
}
try {
return CarbonImmutable::parse($text);
} catch (Throwable) {
return null;
}
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\Integrations\SnipeIt;
use App\Integrations\SnipeIt\Data\HardwarePage;
use App\Integrations\SnipeIt\Exceptions\SnipeItApiException;
use App\Integrations\SnipeIt\Exceptions\SnipeItConfigurationException;
use Generator;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;
final class SnipeItClient
{
public function isConfigured(): bool
{
return filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token'));
}
public function configuredUrl(): ?string
{
$url = trim((string) config('services.snipe_it.url'));
return $url === '' ? null : rtrim($url, '/');
}
public function listHardwarePage(int $offset = 0, ?int $limit = null): HardwarePage
{
$pageSize = $limit ?? (int) config('services.snipe_it.page_size', 100);
$pageSize = max(1, min(500, $pageSize));
$response = $this->request()->get('/hardware', [
'limit' => $pageSize,
'offset' => max(0, $offset),
'sort' => 'id',
'order' => 'asc',
]);
if (! $response->successful()) {
throw new SnipeItApiException(
"O Snipe-IT respondeu com HTTP {$response->status()} ao consultar equipamentos.",
$response->status(),
);
}
$payload = $response->json();
if (! is_array($payload)) {
throw new SnipeItApiException('O Snipe-IT devolveu uma resposta que não é JSON válido.');
}
if (($payload['status'] ?? null) === 'error') {
throw new SnipeItApiException('O Snipe-IT recusou a consulta de equipamentos apesar de responder com HTTP 200.');
}
$total = $payload['total'] ?? null;
$rows = $payload['rows'] ?? null;
if (! is_numeric($total) || ! is_array($rows)) {
throw new SnipeItApiException('A resposta do Snipe-IT não contém os campos de paginação esperados.');
}
/** @var list<array<string, mixed>> $normalizedRows */
$normalizedRows = [];
foreach (array_values($rows) as $row) {
if (! is_array($row)) {
throw new SnipeItApiException('A resposta do Snipe-IT contém um equipamento num formato inválido.');
}
/** @var array<string, mixed> $row */
$normalizedRows[] = $row;
}
return new HardwarePage((int) $total, max(0, $offset), $pageSize, $normalizedRows);
}
/** @return Generator<int, HardwarePage> */
public function hardwarePages(): Generator
{
$offset = 0;
do {
$page = $this->listHardwarePage($offset);
yield $page;
$received = count($page->rows);
$offset += $received;
} while ($received > 0 && $offset < $page->total);
}
private function request(): PendingRequest
{
if (! $this->isConfigured()) {
throw new SnipeItConfigurationException('A URL e o token de leitura do Snipe-IT ainda não estão configurados.');
}
$retries = max(1, (int) config('services.snipe_it.retries', 3));
$retryDelay = max(0, (int) config('services.snipe_it.retry_delay_ms', 250));
return Http::baseUrl($this->apiBaseUrl())
->acceptJson()
->asJson()
->withToken((string) config('services.snipe_it.read_token'))
->withUserAgent(Str::slug((string) config('app.name')).'/inventory-sync')
->connectTimeout(min(5, max(1, (int) config('services.snipe_it.timeout', 10))))
->timeout(max(1, (int) config('services.snipe_it.timeout', 10)))
->retry(
$retries,
$retryDelay,
fn (Throwable $exception): bool => $this->shouldRetry($exception),
throw: false,
);
}
private function apiBaseUrl(): string
{
$baseUrl = $this->configuredUrl();
if ($baseUrl === null || ! filter_var($baseUrl, FILTER_VALIDATE_URL)) {
throw new SnipeItConfigurationException('A URL configurada para o Snipe-IT não é válida.');
}
return str_ends_with($baseUrl, '/api/v1') ? $baseUrl : $baseUrl.'/api/v1';
}
private function shouldRetry(Throwable $exception): bool
{
if ($exception instanceof ConnectionException) {
return true;
}
if (! $exception instanceof RequestException || $exception->response === null) {
return false;
}
return $exception->response->status() === 429 || $exception->response->serverError();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Jobs;
use App\Domain\Inventory\Services\AssetSyncService;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class SyncSnipeAssets implements ShouldBeUnique, ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 900;
public int $uniqueFor = 900;
/** @var list<int> */
public array $backoff = [30, 120, 300];
public function uniqueId(): string
{
return 'snipe-it-assets';
}
public function handle(AssetSyncService $syncService): void
{
$syncService->sync();
}
}

View File

@@ -2,9 +2,12 @@
namespace App\Providers;
use App\Domain\Identity\Enums\RoleName;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
@@ -24,6 +27,11 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void
{
$this->configureDefaults();
Gate::define(
'administer',
fn (User $user): bool => $user->active && $user->hasRole(RoleName::Administrator),
);
}
/**

View File

@@ -14,6 +14,7 @@ return Application::configure(basePath: dirname(__DIR__))
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withCommands()
->withMiddleware(function (Middleware $middleware): void {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);

View File

@@ -40,6 +40,9 @@ return [
'read_token' => env('SNIPE_IT_READ_TOKEN'),
'operational_token' => env('SNIPE_IT_OPERATIONAL_TOKEN'),
'timeout' => (int) env('SNIPE_IT_TIMEOUT', 10),
'page_size' => (int) env('SNIPE_IT_PAGE_SIZE', 100),
'retries' => (int) env('SNIPE_IT_RETRIES', 3),
'retry_delay_ms' => (int) env('SNIPE_IT_RETRY_DELAY_MS', 250),
],
'microsoft' => [

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('snipe_assets', function (Blueprint $table) {
$table->foreignId('last_seen_sync_run_id')
->nullable()
->after('synced_at')
->constrained('sync_runs')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('snipe_assets', function (Blueprint $table) {
$table->dropConstrainedForeignId('last_seen_sync_run_id');
});
}
};

View File

@@ -35,7 +35,7 @@ Nginx ──► Laravel + Inertia/React
- `app/Domain/Approvals`: decisões e concorrência de aprovação (a criar).
- `app/Domain/Handovers`: entrega, devolução e incidentes (a criar).
- `app/Domain/Audit`: logs imutáveis pela interface.
- `app/Integrations/SnipeIt`: único ponto de comunicação com Snipe-IT (a criar).
- `app/Integrations/SnipeIt`: cliente HTTP, DTOs, mapper e exceções da API Snipe-IT.
## Consistência temporal
@@ -52,11 +52,15 @@ Uma criação ou aprovação deve bloquear as linhas dos ativos, voltar a valida
1. Um job consulta a API paginada com token de leitura.
2. Um mapper reduz a resposta aos campos documentados.
3. `AssetSyncService` faz upsert por `snipe_asset_id` e calcula `content_hash`.
4. Ativos ausentes numa sincronização completa ficam `active = false`; não são apagados para preservar histórico.
3. `AssetSyncService` faz upsert por `snipe_asset_id`, calcula `content_hash` e marca cada ativo com `last_seen_sync_run_id`.
4. Apenas após todas as páginas serem recebidas com sucesso, ativos cujo marcador não corresponde à execução atual ficam `active = false`; não são apagados para preservar histórico.
5. Antes de aprovação ou entrega, o ativo é validado novamente.
6. Checkout/check-in usam um token operacional separado e guardam apenas a resposta necessária para auditoria.
Uma falha intermédia pode deixar upserts válidos das páginas anteriores, mas nunca executa a desativação global. A sincronização seguinte converge o cache. Um lock distribuído e um job único impedem duas sincronizações simultâneas.
Referências oficiais usadas para o contrato: [autenticação da API](https://snipe-it.readme.io/reference/authenticating-with-the-api), [listagem de hardware](https://snipe-it.readme.io/reference/hardware-list) e [limites/paginação](https://snipe-it.readme.io/reference/api-throttling).
## Segurança
- Sem registo público.

View File

@@ -41,3 +41,10 @@ Desenvolvimento e primeira instalação utilizam serviços separados para Nginx,
**Estado:** aceite
Textos funcionais são escritos em português europeu. Nomes de código, eventos e tabelas permanecem em inglês para consistência técnica.
## ADR-007 — Sincronização Snipe-IT convergente por execução
**Data:** 2026-08-04
**Estado:** aceite
Consumir `/api/v1/hardware` por `offset`/`limit`, com páginas configuráveis até ao máximo oficial de 500 registos. Cada ativo visto recebe o ID da execução em `last_seen_sync_run_id`; a desativação de ausentes só ocorre depois de todas as páginas terminarem com sucesso. Upserts de páginas já válidas podem permanecer após uma falha, permitindo convergência na execução seguinte sem manter uma transação aberta durante chamadas HTTP. Repetir apenas falhas de ligação, HTTP 429 e HTTP 5xx. Usar lock distribuído, job único e token exclusivo de leitura.

View File

@@ -1,7 +1,7 @@
# Estado do projeto e handoff
**Atualizado em:** 4 de agosto de 2026
**Fase:** Fase 1 concluída; primeiro fluxo vertical da Fase 3 concluído
**Fase:** Fases 1 e 2 concluídas; primeiro fluxo vertical da Fase 3 concluído
**Último agente:** Codex
## Resultado disponível
@@ -18,10 +18,13 @@
- Dashboard pessoal, catálogo com pesquisa/filtros, formulário de nova requisição e histórico.
- Docker Compose com Nginx, PHP-FPM, PostgreSQL, Redis, Node/Vite, filas, scheduler e Mailpit.
- Dados fictícios e duas contas locais para desenvolvimento.
- Cliente Snipe-IT autenticado com paginação, timeout, retry seletivo e validação de respostas.
- Sincronização convergente de ativos com histórico, hash de conteúdo, lock, job único e desativação apenas após execução completa.
- Comando `snipe:sync-assets`, execução horária e painel de diagnóstico exclusivo para administradores.
## Limites conhecidos
- A API real do Snipe-IT ainda não está implementada. O catálogo utiliza dados fictícios do seeder.
- A integração está implementada e testada contra respostas simuladas; falta configurar e validar URL, versão e token da instância institucional. Até lá, o catálogo utiliza dados fictícios do seeder.
- Microsoft Entra ID ainda não está implementado; as variáveis de ambiente já estão definidas.
- Aprovação, rejeição, entrega e devolução têm tabelas preparadas, mas ainda não possuem ações ou interface.
- As notificações e jobs de expiração ainda não foram implementados.
@@ -30,14 +33,14 @@
## Próxima tarefa recomendada
Implementar a **Fase 2 — cliente e sincronização Snipe-IT**:
Completar a **Fase 3 — motor de reservas**:
1. abrir `docs/ARCHITECTURE.md` e `config/services.php`;
2. criar `app/Integrations/SnipeIt/SnipeItClient.php`, DTOs, mapper e exceções;
3. implementar paginação, timeout, retry limitado e token de leitura;
4. criar `AssetSyncService`, job e comando `snipe:sync-assets`;
5. testar com `Http::fake()` paginação, falhas e desativação de ativos ausentes;
6. criar página administrativa de diagnóstico apenas para administradores;
1. abrir `app/Domain/Reservations/Services/AvailabilityService.php` e `ReservationService.php`;
2. acrescentar pesquisa do catálogo por período e apresentar disponibilidade real;
3. implementar cancelamento pelo requisitante com transição explícita e auditoria;
4. criar expiração automática de pré-reservas pendentes e libertação dos itens;
5. adicionar uma vista de calendário pessoal;
6. testar limites temporais, cancelamento, expiração e autorização;
7. atualizar este documento e o changelog.
## Testes da última sessão
@@ -45,18 +48,19 @@ Implementar a **Fase 2 — cliente e sincronização Snipe-IT**:
Validação concluída em 4 de agosto de 2026:
- `docker compose exec -T app composer test`: passou;
- Laravel Pint: 78 ficheiros sem desvios;
- PHPStan/Larastan nível 7: 59 ficheiros, zero erros;
- Pest: 44 testes passaram, 157 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
- Laravel Pint: 97 ficheiros sem desvios;
- PHPStan/Larastan nível 7: 75 ficheiros, zero erros;
- Pest: 53 testes passaram, 201 asserções e 2 testes de registo ignorados por a funcionalidade estar deliberadamente desativada;
- TypeScript (`tsc --noEmit`): passou;
- ESLint: passou;
- Prettier: passou;
- Vite: build de produção concluído, 2304 módulos transformados;
- Vite: builds cliente e SSR concluídos, 2305 módulos cliente transformados;
- PostgreSQL 17: `migrate:fresh --seed` concluído, incluindo extensão `btree_gist` e restrição de exclusão temporal;
- PostgreSQL 17: migration incremental do marcador `last_seen_sync_run_id` aplicada com sucesso;
- smoke test HTTP: `/login` respondeu com estado 200 através do Nginx;
- Vite em Docker: URL pública, HMR e CORS verificados em `localhost:5173`; corrigida a página em branco causada pela publicação anterior de `0.0.0.0:5173`;
- Inertia SSR: renderização do login validada através do serviço `node`; corrigido o componente de mensagens flash que usava `usePage()` fora do contexto Inertia;
- verificação visual no browser integrado: não executada devido a uma política administrativa temporariamente indisponível no browser desta sessão; não foi detetado erro da aplicação.
- verificação visual no browser integrado: a inspeção controlada continua impedida por uma política administrativa temporariamente indisponível; o painel administrativo foi validado por teste Inertia, TypeScript e builds cliente/SSR.
## Decisões pendentes do contexto institucional

View File

@@ -50,6 +50,36 @@ docker compose exec app php artisan optimize:clear
docker compose restart queue scheduler
```
## Integração Snipe-IT
Definir no ambiente, sem colocar tokens no repositório:
```dotenv
SNIPE_IT_URL=https://inventario.exemplo.pt
SNIPE_IT_READ_TOKEN=
SNIPE_IT_TIMEOUT=10
SNIPE_IT_PAGE_SIZE=100
SNIPE_IT_RETRIES=3
SNIPE_IT_RETRY_DELAY_MS=250
```
O token deve pertencer a uma conta técnica com acesso apenas de leitura aos ativos. A aplicação acrescenta `/api/v1` automaticamente quando a URL não o contém.
Operações:
```bash
# Sincronização imediata e resultado no terminal
docker compose exec app php artisan snipe:sync-assets
# Colocar na fila
docker compose exec app php artisan snipe:sync-assets --queue
# Confirmar o agendamento horário
docker compose exec app php artisan schedule:list
```
Administradores podem testar a ligação, iniciar uma execução e consultar as últimas dez sincronizações em `http://localhost:8000/administracao/snipe-it`. Uma execução falhada mantém o último catálogo válido e não desativa registos ausentes. Consultar `sync_runs`, os logs do worker e o painel antes de repetir.
## Produção
Antes de disponibilizar:

View File

@@ -18,17 +18,17 @@
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_ENV" value="testing" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="CACHE_STORE" value="array" force="true"/>
<env name="DB_CONNECTION" value="sqlite" force="true"/>
<env name="DB_DATABASE" value=":memory:" force="true"/>
<env name="DB_URL" value="" force="true"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="MAIL_MAILER" value="array" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>

View File

@@ -1,9 +1,10 @@
import { Link } from '@inertiajs/react';
import { Link, usePage } from '@inertiajs/react';
import {
CalendarDays,
LayoutGrid,
PackageSearch,
PlusCircle,
ServerCog,
ShieldCheck,
} from 'lucide-react';
import AppLogo from '@/components/app-logo';
@@ -45,6 +46,18 @@ const footerNavItems: NavItem[] = [
];
export function AppSidebar() {
const { can } = usePage().props;
const visibleNavItems = can.administer
? [
...mainNavItems,
{
title: 'Integração Snipe-IT',
href: '/administracao/snipe-it',
icon: ServerCog,
},
]
: mainNavItems;
return (
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
@@ -60,7 +73,7 @@ export function AppSidebar() {
</SidebarHeader>
<SidebarContent>
<NavMain items={mainNavItems} />
<NavMain items={visibleNavItems} />
</SidebarContent>
<SidebarFooter>

View File

@@ -0,0 +1,246 @@
import { Head, router } from '@inertiajs/react';
import { Database, PlugZap, RefreshCw, ServerCog } from 'lucide-react';
import { useState } from 'react';
import { PageHeading } from '@/components/page-heading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import AppLayout from '@/layouts/app-layout';
import type { BreadcrumbItem } from '@/types';
type SyncRun = {
id: number;
status: 'running' | 'succeeded' | 'failed';
started_at: string | null;
finished_at: string | null;
records_processed: number;
records_created: number;
records_updated: number;
records_disabled: number;
error_message: string | null;
};
type Props = {
configured: boolean;
baseUrl: string | null;
assets: {
total: number;
active: number;
requestable: number;
};
syncRuns: SyncRun[];
};
const breadcrumbs: BreadcrumbItem[] = [
{ title: 'Administração', href: '/administracao/snipe-it' },
{ title: 'Snipe-IT', href: '/administracao/snipe-it' },
];
const statusLabel: Record<SyncRun['status'], string> = {
running: 'Em curso',
succeeded: 'Concluída',
failed: 'Falhou',
};
function formatDate(value: string | null) {
if (!value) {
return '—';
}
return new Intl.DateTimeFormat('pt-PT', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(value));
}
export default function SnipeItAdmin({
configured,
baseUrl,
assets,
syncRuns,
}: Props) {
const [action, setAction] = useState<'test' | 'sync' | null>(null);
const postAction = (url: string, currentAction: 'test' | 'sync') => {
router.post(
url,
{},
{
preserveScroll: true,
onStart: () => setAction(currentAction),
onFinish: () => setAction(null),
},
);
};
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Integração Snipe-IT" />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
<PageHeading
eyebrow="Administração"
title="Integração Snipe-IT"
description="Estado do cache local e operações seguras de leitura da fonte oficial de inventário."
action={
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
disabled={!configured || action !== null}
onClick={() =>
postAction(
'/administracao/snipe-it/testar',
'test',
)
}
>
<PlugZap />
Testar ligação
</Button>
<Button
disabled={!configured || action !== null}
onClick={() =>
postAction(
'/administracao/snipe-it/sincronizar',
'sync',
)
}
>
<RefreshCw
className={
action === 'sync' ? 'animate-spin' : ''
}
/>
Sincronizar agora
</Button>
</div>
}
/>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<ServerCog className="size-4 text-primary" />
Configuração
</CardTitle>
</CardHeader>
<CardContent>
<Badge
variant={configured ? 'default' : 'destructive'}
>
{configured ? 'Configurada' : 'Por configurar'}
</Badge>
<p className="mt-3 truncate text-xs text-muted-foreground">
{baseUrl ?? 'Defina URL e token no ambiente.'}
</p>
</CardContent>
</Card>
{[
['Registos locais', assets.total],
['Ativos', assets.active],
['Requisitáveis', assets.requestable],
].map(([label, value]) => (
<Card key={label}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Database className="size-4 text-primary" />
{label}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-semibold">
{value}
</p>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>Últimas sincronizações</CardTitle>
</CardHeader>
<CardContent>
{syncRuns.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
Ainda não existem sincronizações registadas.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="border-b text-xs tracking-wide text-muted-foreground uppercase">
<tr>
<th className="px-3 py-3">
Início
</th>
<th className="px-3 py-3">
Estado
</th>
<th className="px-3 py-3">
Processados
</th>
<th className="px-3 py-3">
Criados
</th>
<th className="px-3 py-3">
Atualizados
</th>
<th className="px-3 py-3">
Desativados
</th>
</tr>
</thead>
<tbody className="divide-y">
{syncRuns.map((run) => (
<tr key={run.id}>
<td className="px-3 py-3">
{formatDate(run.started_at)}
</td>
<td className="px-3 py-3">
<Badge
variant={
run.status ===
'failed'
? 'destructive'
: run.status ===
'running'
? 'secondary'
: 'default'
}
title={
run.error_message ??
undefined
}
>
{
statusLabel[
run.status
]
}
</Badge>
</td>
<td className="px-3 py-3">
{run.records_processed}
</td>
<td className="px-3 py-3">
{run.records_created}
</td>
<td className="px-3 py-3">
{run.records_updated}
</td>
<td className="px-3 py-3">
{run.records_disabled}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</AppLayout>
);
}

View File

@@ -16,6 +16,9 @@ declare module '@inertiajs/core' {
success?: string | null;
error?: string | null;
};
can: {
administer: boolean;
};
sidebarOpen: boolean;
[key: string]: unknown;
};

View File

@@ -1,8 +1,15 @@
<?php
use App\Jobs\SyncSnipeAssets;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
Schedule::job(new SyncSnipeAssets)
->hourly()
->when(fn (): bool => filled(config('services.snipe_it.url')) && filled(config('services.snipe_it.read_token')))
->withoutOverlapping();

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Admin\SnipeItController;
use App\Http\Controllers\AssetController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\ReservationController;
@@ -13,6 +14,12 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('requisicoes', [ReservationController::class, 'index'])->name('reservations.index');
Route::get('requisicoes/nova', [ReservationController::class, 'create'])->name('reservations.create');
Route::post('requisicoes', [ReservationController::class, 'store'])->name('reservations.store');
Route::middleware('can:administer')->prefix('administracao')->name('admin.')->group(function () {
Route::get('snipe-it', [SnipeItController::class, 'index'])->name('snipe-it.index');
Route::post('snipe-it/testar', [SnipeItController::class, 'testConnection'])->name('snipe-it.test');
Route::post('snipe-it/sincronizar', [SnipeItController::class, 'sync'])->name('snipe-it.sync');
});
});
require __DIR__.'/settings.php';

View 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 &amp; 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'],
];
}

View 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);
});

View 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}",
];
}