feat/042-inventory-dependencies-graph (#49)
Ordering + limit-only Test für created_at DESC in DependencyExtractionFeatureTest.php UI Test für masked Identifier (ID: 123456…) + Guest-Access blocked in InventoryItemDependenciesTest.php Quickstart ergänzt um manuellen <2s Check in quickstart.md pr-gate Checkbox-Format normalisiert (kein leading space) in pr-gate.md Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #49
This commit is contained in:
parent
cf5b0027e3
commit
361e301f67
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -6,6 +6,8 @@ ## Active Technologies
|
||||
- PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations)
|
||||
- PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations)
|
||||
- PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp)
|
||||
- PHP 8.4.x + Laravel 12, Filament v4, Livewire v3 (feat/042-inventory-dependencies-graph)
|
||||
- PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -25,10 +27,10 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- feat/042-inventory-dependencies-graph: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
|
||||
- feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
|
||||
- feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
|
||||
|
||||
- feat/005-bulk-operations: Added PHP 8.4.15
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\DependencyQueryService;
|
||||
use App\Support\Enums\RelationshipType;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
@ -70,6 +72,36 @@ public static function infolist(Schema $schema): Schema
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Dependencies')
|
||||
->schema([
|
||||
ViewEntry::make('dependencies')
|
||||
->label('')
|
||||
->view('filament.components.dependency-edges')
|
||||
->state(function (InventoryItem $record) {
|
||||
$direction = request()->query('direction', 'all');
|
||||
$relationshipType = request()->query('relationship_type', 'all');
|
||||
$relationshipType = is_string($relationshipType) ? $relationshipType : 'all';
|
||||
|
||||
$relationshipType = $relationshipType === 'all'
|
||||
? null
|
||||
: RelationshipType::tryFrom($relationshipType)?->value;
|
||||
|
||||
$service = app(DependencyQueryService::class);
|
||||
|
||||
$edges = collect();
|
||||
if ($direction === 'inbound' || $direction === 'all') {
|
||||
$edges = $edges->merge($service->getInboundEdges($record, $relationshipType));
|
||||
}
|
||||
if ($direction === 'outbound' || $direction === 'all') {
|
||||
$edges = $edges->merge($service->getOutboundEdges($record, $relationshipType));
|
||||
}
|
||||
|
||||
return $edges->take(100); // both directions combined
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Metadata (Safe Subset)')
|
||||
->schema([
|
||||
ViewEntry::make('meta_jsonb')
|
||||
|
||||
22
app/Models/InventoryLink.php
Normal file
22
app/Models/InventoryLink.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class InventoryLink extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'inventory_links';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
}
|
||||
147
app/Services/Inventory/DependencyExtractionService.php
Normal file
147
app/Services/Inventory/DependencyExtractionService.php
Normal file
@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Inventory;
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventoryLink;
|
||||
use App\Support\Enums\RelationshipType;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DependencyExtractionService
|
||||
{
|
||||
/**
|
||||
* Extracts dependencies for a given inventory item using the raw policy payload if available.
|
||||
* Idempotent via unique key on inventory_links. Enforces a max of 50 outbound edges per item.
|
||||
*
|
||||
* @param array<string, mixed> $policyData
|
||||
*/
|
||||
public function extractForPolicyData(InventoryItem $item, array $policyData): array
|
||||
{
|
||||
$warnings = [];
|
||||
$edges = collect();
|
||||
|
||||
$edges = $edges->merge($this->extractAssignedTo($item, $policyData, $warnings));
|
||||
$edges = $edges->merge($this->extractScopedBy($item, $policyData));
|
||||
|
||||
// Enforce max 50 outbound edges by priority: assigned_to > scoped_by > others
|
||||
$priorities = [
|
||||
RelationshipType::AssignedTo->value => 1,
|
||||
RelationshipType::ScopedBy->value => 2,
|
||||
RelationshipType::Targets->value => 3,
|
||||
RelationshipType::DependsOn->value => 4,
|
||||
];
|
||||
|
||||
/** @var Collection<int, array{tenant_id:int,source_type:string,source_id:string,target_type:string,target_id:?string,relationship_type:string,metadata:array}> $sorted */
|
||||
$sorted = $edges->sortBy(fn ($e) => $priorities[$e['relationship_type']] ?? 99)->values();
|
||||
|
||||
$limited = $sorted->take(50);
|
||||
|
||||
$now = now();
|
||||
$payload = $limited->map(function (array $e) use ($now) {
|
||||
$metadata = $e['metadata'] ?? null;
|
||||
if (is_array($metadata)) {
|
||||
// Ensure portability across SQLite/Postgres when using upsert via query builder
|
||||
$e['metadata'] = json_encode($metadata);
|
||||
}
|
||||
|
||||
return array_merge($e, [
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
})->all();
|
||||
|
||||
if (! empty($payload)) {
|
||||
InventoryLink::query()->upsert(
|
||||
$payload,
|
||||
['tenant_id', 'source_type', 'source_id', 'target_type', 'target_id', 'relationship_type'],
|
||||
['metadata', 'updated_at']
|
||||
);
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $policyData
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function extractAssignedTo(InventoryItem $item, array $policyData, array &$warnings): Collection
|
||||
{
|
||||
$assignments = Arr::get($policyData, 'assignments');
|
||||
if (! is_array($assignments)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$edges = [];
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
if (! is_array($assignment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Known shapes: ['target' => ['groupId' => '...']] or direct ['groupId' => '...']
|
||||
$groupId = Arr::get($assignment, 'target.groupId') ?? Arr::get($assignment, 'groupId');
|
||||
if (is_string($groupId) && $groupId !== '') {
|
||||
$edges[] = [
|
||||
'tenant_id' => (int) $item->tenant_id,
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => (string) $item->external_id,
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => $groupId,
|
||||
'relationship_type' => RelationshipType::AssignedTo->value,
|
||||
'metadata' => [
|
||||
'last_known_name' => null,
|
||||
'foundation_type' => 'aad_group',
|
||||
],
|
||||
];
|
||||
} else {
|
||||
$warning = [
|
||||
'type' => 'unsupported_reference',
|
||||
'policy_id' => (string) ($policyData['id'] ?? $item->external_id),
|
||||
'raw_ref' => $assignment,
|
||||
'reason' => 'unsupported_assignment_target_shape',
|
||||
];
|
||||
|
||||
$warnings[] = $warning;
|
||||
Log::info('Unsupported reference shape encountered', $warning);
|
||||
}
|
||||
}
|
||||
|
||||
return collect($edges);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $policyData
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function extractScopedBy(InventoryItem $item, array $policyData): Collection
|
||||
{
|
||||
$scopeTags = Arr::get($policyData, 'roleScopeTagIds');
|
||||
if (! is_array($scopeTags)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$edges = [];
|
||||
|
||||
foreach ($scopeTags as $tagId) {
|
||||
if (is_string($tagId) && $tagId !== '') {
|
||||
$edges[] = [
|
||||
'tenant_id' => (int) $item->tenant_id,
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => (string) $item->external_id,
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => $tagId,
|
||||
'relationship_type' => RelationshipType::ScopedBy->value,
|
||||
'metadata' => [
|
||||
'last_known_name' => null,
|
||||
'foundation_type' => 'scope_tag',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return collect($edges);
|
||||
}
|
||||
}
|
||||
40
app/Services/Inventory/DependencyQueryService.php
Normal file
40
app/Services/Inventory/DependencyQueryService.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Inventory;
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventoryLink;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
|
||||
class DependencyQueryService
|
||||
{
|
||||
public function getOutboundEdges(InventoryItem $item, ?string $relationshipType = null, int $limit = 50): EloquentCollection
|
||||
{
|
||||
$query = InventoryLink::query()
|
||||
->where('tenant_id', $item->tenant_id)
|
||||
->where('source_type', 'inventory_item')
|
||||
->where('source_id', $item->external_id)
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($relationshipType !== null) {
|
||||
$query->where('relationship_type', $relationshipType);
|
||||
}
|
||||
|
||||
return $query->limit($limit)->get();
|
||||
}
|
||||
|
||||
public function getInboundEdges(InventoryItem $item, ?string $relationshipType = null, int $limit = 50): EloquentCollection
|
||||
{
|
||||
$query = InventoryLink::query()
|
||||
->where('tenant_id', $item->tenant_id)
|
||||
->where('target_type', 'inventory_item')
|
||||
->where('target_id', $item->external_id)
|
||||
->orderByDesc('created_at');
|
||||
|
||||
if ($relationshipType !== null) {
|
||||
$query->where('relationship_type', $relationshipType);
|
||||
}
|
||||
|
||||
return $query->limit($limit)->get();
|
||||
}
|
||||
}
|
||||
@ -239,6 +239,7 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
||||
$errors = 0;
|
||||
$errorCodes = [];
|
||||
$hadErrors = false;
|
||||
$warnings = [];
|
||||
|
||||
try {
|
||||
$typesConfig = $this->supportedTypeConfigByType();
|
||||
@ -307,7 +308,7 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
||||
'warnings' => [],
|
||||
]);
|
||||
|
||||
InventoryItem::query()->updateOrCreate(
|
||||
$item = InventoryItem::query()->updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'policy_type' => $policyType,
|
||||
@ -324,6 +325,16 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
||||
);
|
||||
|
||||
$upserted++;
|
||||
|
||||
// Extract dependencies if requested in selection
|
||||
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true);
|
||||
if ($includeDeps) {
|
||||
$warnings = array_merge(
|
||||
$warnings,
|
||||
app(\App\Services\Inventory\DependencyExtractionService::class)
|
||||
->extractForPolicyData($item, $policyData)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$onPolicyTypeProcessed && $onPolicyTypeProcessed($policyType, true, null);
|
||||
@ -335,7 +346,9 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
||||
'status' => $status,
|
||||
'had_errors' => $hadErrors,
|
||||
'error_codes' => array_values(array_unique($errorCodes)),
|
||||
'error_context' => null,
|
||||
'error_context' => [
|
||||
'warnings' => array_values($warnings),
|
||||
],
|
||||
'items_observed_count' => $observed,
|
||||
'items_upserted_count' => $upserted,
|
||||
'errors_count' => $errors,
|
||||
@ -344,11 +357,14 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
||||
|
||||
return $run->refresh();
|
||||
} catch (Throwable $throwable) {
|
||||
$errorContext = $this->safeErrorContext($throwable);
|
||||
$errorContext['warnings'] = array_values($warnings);
|
||||
|
||||
$run->update([
|
||||
'status' => InventorySyncRun::STATUS_FAILED,
|
||||
'had_errors' => true,
|
||||
'error_codes' => ['unexpected_exception'],
|
||||
'error_context' => $this->safeErrorContext($throwable),
|
||||
'error_context' => $errorContext,
|
||||
'items_observed_count' => $observed,
|
||||
'items_upserted_count' => $upserted,
|
||||
'errors_count' => $errors + 1,
|
||||
|
||||
35
app/Support/Enums/RelationshipType.php
Normal file
35
app/Support/Enums/RelationshipType.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Enums;
|
||||
|
||||
enum RelationshipType: string
|
||||
{
|
||||
case AssignedTo = 'assigned_to';
|
||||
case ScopedBy = 'scoped_by';
|
||||
case Targets = 'targets';
|
||||
case DependsOn = 'depends_on';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::AssignedTo => 'Assigned to',
|
||||
self::ScopedBy => 'Scoped by',
|
||||
self::Targets => 'Targets',
|
||||
self::DependsOn => 'Depends on',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
foreach (self::cases() as $case) {
|
||||
$options[$case->value] = $case->label();
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
30
database/factories/InventoryLinkFactory.php
Normal file
30
database/factories/InventoryLinkFactory.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\InventoryLink;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<InventoryLink>
|
||||
*/
|
||||
class InventoryLinkFactory extends Factory
|
||||
{
|
||||
protected $model = InventoryLink::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $this->faker->uuid(),
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => $this->faker->uuid(),
|
||||
'relationship_type' => 'assigned_to',
|
||||
'metadata' => [
|
||||
'last_known_name' => $this->faker->words(3, true),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
<?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::create('inventory_links', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('source_type');
|
||||
$table->uuid('source_id');
|
||||
$table->string('target_type');
|
||||
$table->uuid('target_id')->nullable();
|
||||
$table->string('relationship_type');
|
||||
$table->jsonb('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique([
|
||||
'tenant_id',
|
||||
'source_type',
|
||||
'source_id',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'relationship_type',
|
||||
], 'inventory_links_unique');
|
||||
|
||||
$table->index(['tenant_id', 'source_type', 'source_id']);
|
||||
$table->index(['tenant_id', 'target_type', 'target_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('inventory_links');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,66 @@
|
||||
@php /** @var callable $getState */ @endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<form method="GET" class="flex items-center gap-2">
|
||||
<label for="direction" class="text-sm text-gray-600">Direction</label>
|
||||
<select id="direction" name="direction" class="fi-input fi-select">
|
||||
<option value="all" {{ request('direction', 'all') === 'all' ? 'selected' : '' }}>All</option>
|
||||
<option value="inbound" {{ request('direction') === 'inbound' ? 'selected' : '' }}>Inbound</option>
|
||||
<option value="outbound" {{ request('direction') === 'outbound' ? 'selected' : '' }}>Outbound</option>
|
||||
</select>
|
||||
|
||||
<label for="relationship_type" class="text-sm text-gray-600">Relationship</label>
|
||||
<select id="relationship_type" name="relationship_type" class="fi-input fi-select">
|
||||
<option value="all" {{ request('relationship_type', 'all') === 'all' ? 'selected' : '' }}>All</option>
|
||||
@foreach (\App\Support\Enums\RelationshipType::options() as $value => $label)
|
||||
<option value="{{ $value }}" {{ request('relationship_type') === $value ? 'selected' : '' }}>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<button type="submit" class="fi-btn">Apply</button>
|
||||
</form>
|
||||
|
||||
@php
|
||||
$raw = $getState();
|
||||
$edges = $raw instanceof \Illuminate\Support\Collection ? $raw : collect($raw);
|
||||
@endphp
|
||||
|
||||
@if ($edges->isEmpty())
|
||||
<div class="text-sm text-gray-500">No dependencies found</div>
|
||||
@else
|
||||
<div class="divide-y">
|
||||
@foreach ($edges->groupBy('relationship_type') as $type => $group)
|
||||
<div class="py-2">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-600 mb-2">{{ str_replace('_', ' ', $type) }}</div>
|
||||
<ul class="space-y-1">
|
||||
@foreach ($group as $edge)
|
||||
@php
|
||||
$isMissing = ($edge['target_type'] ?? null) === 'missing';
|
||||
$name = $edge['metadata']['last_known_name'] ?? null;
|
||||
$targetId = $edge['target_id'] ?? null;
|
||||
$display = $name ?: ($targetId ? ("ID: ".substr($targetId,0,6)."…") : 'Unknown');
|
||||
|
||||
$missingTitle = 'Missing target';
|
||||
if (is_string($name) && $name !== '') {
|
||||
$missingTitle .= ". Last known: {$name}";
|
||||
}
|
||||
$rawRef = $edge['metadata']['raw_ref'] ?? null;
|
||||
if ($rawRef !== null) {
|
||||
$encodedRef = json_encode($rawRef);
|
||||
if (is_string($encodedRef) && $encodedRef !== '') {
|
||||
$missingTitle .= '. Ref: '.\Illuminate\Support\Str::limit($encodedRef, 200);
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<li class="flex items-center gap-2 text-sm">
|
||||
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $display }}</span>
|
||||
@if ($isMissing)
|
||||
<span class="fi-badge fi-badge-danger" title="{{ $missingTitle }}">Missing</span>
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,78 @@
|
||||
# Dependencies Checklist: Inventory Dependencies Graph
|
||||
|
||||
**Purpose**: Validate that Spec 042’s dependency-graph requirements are complete, unambiguous, and testable ("unit tests for English").
|
||||
**Created**: 2026-01-07
|
||||
**Feature**: `specs/042-inventory-dependencies-graph/spec.md`
|
||||
|
||||
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] CHK001 Are relationship types fully enumerated with definitions and at least one example per type? [Completeness, Spec §FR1]
|
||||
- [x] CHK002 Are the minimum required fields for a dependency edge explicitly specified (e.g., source, target, type, directionality, timestamps, provenance)? [Gap, Spec §FR2]
|
||||
- [x] CHK003 Are the categories of “other objects” (non-inventory foundations) explicitly enumerated and bounded (what is in-scope vs out-of-scope)? [Completeness, Spec §FR2]
|
||||
- [x] CHK004 Are the identifiers/keys used to reference inventory items and foundational objects clearly specified (stable IDs vs display names)? [Gap, Plan §Dependencies]
|
||||
- [x] CHK005 Are inbound and outbound edge queries both explicitly required for all supported relationship types (or are exceptions called out)? [Completeness, Spec §FR3]
|
||||
- [x] CHK006 Are “missing prerequisites” criteria specified (what counts as missing, and how missing is detected)? [Completeness, Spec §Scenario 2, FR4]
|
||||
- [x] CHK007 Are access control requirements defined beyond “access-controlled” (roles/permissions, tenant admin vs read-only, etc.)? [Gap, Spec §FR5]
|
||||
|
||||
## Requirement Clarity
|
||||
|
||||
- [x] CHK008 Is the meaning of “inbound” vs “outbound” relationships defined unambiguously (especially for asymmetric relations like “assigned to”)? [Clarity, Spec §Scenario 1, FR3]
|
||||
- [x] CHK009 Are relationship labels (“uses”, “assigned to”, “scoped by”) defined as a taxonomy with consistent naming, directionality, and semantics? [Clarity, Spec §Scenario 1, FR1]
|
||||
- [x] CHK010 Is “blast radius” translated into concrete, observable dependency-graph concepts (e.g., outbound edges only, both directions, depth)? [Ambiguity, Spec §Purpose]
|
||||
- [x] CHK011 Is “prerequisite” defined precisely (e.g., hard prerequisite vs informational dependency; required vs optional)? [Ambiguity, Spec §Purpose, Scenario 2]
|
||||
- [x] CHK012 Is the filter behavior for relationship types specified (single vs multi-select, default selection, empty selection meaning)? [Gap, Spec §Scenario 3]
|
||||
- [x] CHK013 Are “safe warning” requirements specified with a format/fields and where they surface (sync logs, UI banner, audit log)? [Clarity, Spec §NFR2]
|
||||
|
||||
## Requirement Consistency
|
||||
|
||||
- [x] CHK014 Do “missing prerequisites” requirements align with “no separate deleted state in core inventory” without introducing contradictory states or terminology? [Consistency, Spec §FR4]
|
||||
- [x] CHK015 Are the relationship examples in scenarios consistent with the relationship taxonomy required by FR1 (no scenario-only types)? [Consistency, Spec §Scenario 1, FR1]
|
||||
- [x] CHK016 Do idempotency requirements (NFR1) align with determinism requirements (SC2) without ambiguity about ordering, deduplication, or normalization? [Consistency, Spec §NFR1, SC2]
|
||||
- [x] CHK017 Are tenant-scoping requirements consistent across storage, querying, and UI exposure (no implied cross-tenant joins)? [Consistency, Spec §FR5, Out of Scope]
|
||||
|
||||
## Acceptance Criteria Quality
|
||||
|
||||
- [x] CHK018 Is SC1 (“under 2 minutes”) made measurable with a defined starting point, scope (single item, depth), and success signal (what the admin must be able to conclude)? [Measurability, Spec §SC1]
|
||||
- [x] CHK019 Is SC2 (“deterministic output”) made measurable by defining what equivalence means (edge set equality, stable IDs, normalized relationship types)? [Measurability, Spec §SC2]
|
||||
- [x] CHK020 Are acceptance criteria mapped to each Functional Requirement (FR1–FR5) so each requirement has an objective pass/fail definition? [Gap, Spec §Functional Requirements]
|
||||
|
||||
## Scenario Coverage
|
||||
|
||||
- [x] CHK021 Do scenarios cover both inbound and outbound viewing requirements explicitly (or is one direction implicitly assumed)? [Coverage, Spec §Scenario 1, FR3]
|
||||
- [x] CHK022 Are scenarios defined for “no dependencies” (zero edges) and how that is communicated to the user? [Gap, Spec §User Scenarios & Testing]
|
||||
- [x] CHK023 Are scenarios defined for “only missing prerequisites” (all targets missing) and how that impacts filtering or display? [Gap, Spec §Scenario 2]
|
||||
- [x] CHK024 Are scenarios defined for mixed object types (inventory item → foundation object, foundation → inventory item) if both are supported? [Gap, Spec §FR2]
|
||||
|
||||
## Edge Case Coverage
|
||||
|
||||
- [x] CHK025 Are requirements specified for unknown/unsupported references beyond “record a safe warning” (e.g., whether an edge is skipped, recorded as unknown node, or preserved as raw ref)? [Coverage, Spec §NFR2]
|
||||
- [x] CHK026 Are requirements defined for duplicate references within a single item (e.g., same target referenced multiple times) and expected edge deduplication rules? [Gap, Spec §NFR1, SC2]
|
||||
- [x] CHK027 Are cyclic dependencies explicitly addressed (allow, detect, show, and/or bound traversal depth)? [Gap, Spec §Purpose]
|
||||
- [x] CHK028 Are requirements defined for very large graphs (pagination, depth limits, maximum edges returned) given the stated “edge explosion” risk? [Gap, Plan §Risks]
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- [x] CHK029 Does NFR1 define idempotency scope and mechanism expectations (e.g., uniqueness keys, replace-all vs upsert, run-scoped vs global)? [Clarity, Spec §NFR1]
|
||||
- [x] CHK030 Does NFR2 define what constitutes “must not fail an inventory sync run” (soft-fail boundary, error severity classes, retries)? [Clarity, Spec §NFR2]
|
||||
- [x] CHK031 Are performance requirements (latency, memory, query limits) specified for dependency extraction and for dependency viewing queries? [Gap, Plan §Risks]
|
||||
- [x] CHK032 Are security/privacy requirements specified for what dependency data may expose (e.g., names/IDs of foundation objects) and who can see it? [Gap, Spec §FR5]
|
||||
|
||||
## Dependencies & Assumptions
|
||||
|
||||
- [x] CHK033 Are dependencies on Spec 040 identifiers and Spec 041 UI explicitly stated as hard requirements vs optional integration points? [Clarity, Plan §Dependencies]
|
||||
- [x] CHK034 Are assumptions documented about which Intune object types contain references and the reference shapes expected (“heterogeneous reference shapes” risk)? [Assumption, Plan §Risks]
|
||||
- [x] CHK035 Are assumptions documented about data freshness (when edges are extracted relative to inventory sync, and how stale edges are handled)? [Gap, Spec §NFR1]
|
||||
|
||||
## Ambiguities & Conflicts
|
||||
|
||||
- [x] CHK036 Is “foundation object not present in inventory” terminology consistent with “not requiring a deleted state” (missing vs absent vs excluded)? [Ambiguity, Spec §FR4]
|
||||
- [x] CHK037 Is it explicitly defined whether cross-item dependencies are limited to within a policy type or across all inventory types? [Gap, Spec §Purpose, FR2]
|
||||
- [x] CHK038 Is it clear whether relationship filtering applies only to edge types, or also to node/object types (inventory vs foundations)? [Gap, Spec §Scenario 3]
|
||||
|
||||
## Notes
|
||||
|
||||
- Check items off as completed: `[x]`
|
||||
- Add findings inline under the relevant checklist item
|
||||
- Each `/speckit.checklist` run creates a new checklist file
|
||||
76
specs/042-inventory-dependencies-graph/checklists/pr-gate.md
Normal file
76
specs/042-inventory-dependencies-graph/checklists/pr-gate.md
Normal file
@ -0,0 +1,76 @@
|
||||
# PR Gate Checklist: Inventory Dependencies Graph
|
||||
|
||||
**Purpose**: PR-Review-Checklist zur Bewertung der Anforderungsqualität für Spec 042 (50% Daten/Pipeline, 50% Darstellung). Fokus: messbar, deterministisch reviewbar, scope-stabil durch „Enumerate now“.
|
||||
**Created**: 2026-01-07
|
||||
**Feature**: `specs/042-inventory-dependencies-graph/spec.md`
|
||||
|
||||
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] CHK001 Ist die Relationship-Taxonomie vollständig enumeriert (Set an Typen) und pro Typ mit Definition + Richtung + Beispiel beschrieben? [Completeness, Spec §FR1]
|
||||
- [x] CHK002 Sind alle in-scope „foundational Intune objects“ explizit aufgelistet (Enumerate now) inkl. klarer Exclusions, um Scope zu binden? [Gap, Spec §FR2, Plan §Risks]
|
||||
- [x] CHK003 Sind Mindestfelder einer Dependency-Edge als Requirement beschrieben (source, target, relationship_type, directionality, provenance/derived_from, timestamps/created_at, optional metadata)? [Gap, Spec §FR2]
|
||||
- [x] CHK004 Ist klar spezifiziert, wie „inventory item“ eindeutig referenziert wird (stable identifier), und wie foundation objects referenziert werden (ID/uri/type tuple)? [Gap, Plan §Dependencies]
|
||||
- [x] CHK005 Sind inbound UND outbound Abfragen als explizite Requirements beschrieben (inkl. erwarteter Sortierung/Limitierung oder bewusst „unspecified“)? [Completeness, Spec §FR3]
|
||||
- [x] CHK006 Sind Missing-Prerequisites als eigene Requirement-Klasse beschrieben (Erkennung, Darstellung, und welche Daten minimal gezeigt werden dürfen)? [Completeness, Spec §FR4, Scenario 2]
|
||||
- [x] CHK007 Ist Relationship-Type-Filtering vollständig spezifiziert (Scope, Default, Mehrfachauswahl ja/nein, Verhalten bei „none selected“)? [Completeness, Spec §Scenario 3]
|
||||
- [x] CHK008 Sind Tenant-Scoping und Access Control Requirements konkretisiert (welche Rollen/Capabilities; Read vs View Details; ggf. audit expectations)? [Gap, Spec §FR5]
|
||||
|
||||
## Requirement Clarity
|
||||
|
||||
- [x] CHK009 Ist „inbound“ vs „outbound“ formal definiert pro Relationship-Type (nicht nur im Textbeispiel), um Interpretationsspielraum zu vermeiden? [Clarity, Spec §Scenario 1, FR3]
|
||||
- [x] CHK010 Sind Relationship-Namen und Semantik konsistent (z.B. „assigned to“ ist eindeutig Richtung A→B) und nicht synonym/überlappend? [Clarity, Spec §FR1]
|
||||
- [x] CHK011 Ist „blast radius“ in messbare Graph-Konzepte übersetzt (z.B. „outbound edges bis Tiefe N“, „both directions“, oder explizit „only direct neighbors“)? [Ambiguity, Spec §Purpose]
|
||||
- [x] CHK012 Ist „prerequisite“ eindeutig definiert (hard vs informational, required vs optional) und ist diese Definition in Missing-Prerequisites konsistent wiederverwendet? [Ambiguity, Spec §Purpose, Scenario 2]
|
||||
- [x] CHK013 Ist „safe warning“ (NFR2) klar operationalisiert: Inhalt/Felder, Severity, Persistenz, und wo es sichtbar wird (Run-Log vs UI vs Audit)? [Clarity, Spec §NFR2]
|
||||
|
||||
## Requirement Consistency
|
||||
|
||||
- [x] CHK014 Sind Scenario-Beispiele („uses“, „assigned to“, „scoped by“) vollständig Teil der FR1-Taxonomie (keine scenario-only Typen)? [Consistency, Spec §Scenario 1, FR1]
|
||||
- [x] CHK015 Ist FR4 („missing prerequisites“) konsistent mit „ohne deleted state in core inventory“ beschrieben (kein implizites soft-delete/archived eingeführt)? [Consistency, Spec §FR4]
|
||||
- [x] CHK016 Sind NFR1 (idempotent) und SC2 (deterministic output) konfliktfrei und eindeutig, was Gleichheit bedeutet (Edge-Set, Normalisierung, Sortierung)? [Consistency, Spec §NFR1, SC2]
|
||||
- [x] CHK017 Ist der Tenant-Scope konsistent in Storage, Query und UI (keine impliziten cross-tenant Graphs; Out-of-scope ist explizit)? [Consistency, Spec §FR5, Out of Scope]
|
||||
|
||||
## Acceptance Criteria Quality
|
||||
|
||||
- [x] CHK018 Ist SC1 („unter 2 Minuten“) so definiert, dass Reviewer objektiv prüfen können, was „determine prerequisites and blast radius“ konkret bedeutet (Tiefe, Umfang, Informationsumfang)? [Measurability, Spec §SC1]
|
||||
- [x] CHK019 Ist SC2 so messbar formuliert, dass deterministische Output-Gleichheit ohne Interpretationsspielraum prüfbar ist (z.B. canonical ordering + uniqueness rules)? [Measurability, Spec §SC2]
|
||||
- [x] CHK020 Gibt es eine klare Traceability zwischen FR1–FR5 und Success Criteria (jedes FR hat mindestens ein objektives Akzeptanzkriterium oder ist bewusst als „non-testable“ markiert)? [Gap, Spec §Functional Requirements, Success Criteria]
|
||||
|
||||
## Scenario Coverage
|
||||
|
||||
- [x] CHK021 Deckt die Spec explizit den Zero-State ab („no edges“ / „no deps“), inkl. erwarteter UI-Messaging-Requirement? [Gap, Spec §User Scenarios & Testing]
|
||||
- [x] CHK022 Deckt die Spec explizit Mixed-Targets ab (Inventory→Foundation, Inventory→Inventory) und ob Foundation→Inventory als inbound dargestellt werden soll? [Gap, Spec §FR2, FR3]
|
||||
- [x] CHK023 Gibt es definierte Requirements für „only missing prerequisites“ (alle Targets missing) und wie Filter/Display damit umgehen? [Gap, Spec §Scenario 2]
|
||||
|
||||
## Edge Case Coverage
|
||||
|
||||
- [x] CHK024 Sind Unknown/Unsupported References (NFR2) vollständig als Requirements abgedeckt: ob Edge erzeugt wird, ob Node „unknown“ erlaubt ist, ob raw reference gespeichert wird? [Coverage, Spec §NFR2]
|
||||
- [x] CHK025 Sind Duplicate References innerhalb eines Items geregelt (Dedup-Key, Merge Rules), um NFR1/SC2 deterministisch einzuhalten? [Gap, Spec §NFR1, SC2]
|
||||
- [x] CHK026 Sind zyklische Dependencies als Requirement adressiert (Erkennung/Handling/Traversal-Limits), damit „blast radius“ nicht unendlich wird? [Gap, Spec §Purpose, Plan §Risks]
|
||||
- [x] CHK027 Sind Grenzen für Edge-Explosion als Requirements spezifiziert (Limits, pagination, depth caps, server-side constraints), nicht nur als Risiko erwähnt? [Gap, Plan §Risks]
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- [x] CHK028 Ist Idempotenz (NFR1) präzisiert: Scope (per-run vs global), Unique Keys, Upsert vs Replace-All, und ob Deletions/Orphan-Edges geregelt sind? [Clarity, Spec §NFR1]
|
||||
- [x] CHK029 Ist „must not fail an inventory sync run“ präzisiert: welche Fehler sind soft-fail, welche sind hard-fail, und wie wird das für Reviewer nachvollziehbar? [Clarity, Spec §NFR2]
|
||||
- [x] CHK030 Sind Performance-/Skalierungsanforderungen spezifiziert (UI Query Latency, max edges returned, extraction time budget) statt nur „Risiko“? [Gap, Plan §Risks]
|
||||
- [x] CHK031 Sind Security/Privacy-Anforderungen spezifiziert, welche foundation-object Daten sichtbar sein dürfen (IDs vs Names) und ob das tenant- & permission-scoped ist? [Gap, Spec §FR5]
|
||||
|
||||
## Dependencies & Assumptions
|
||||
|
||||
- [x] CHK032 Sind Abhängigkeiten zu Spec 040 (stable identifiers) und Spec 041 (UI navigation/detail pages) als Requirements eindeutig dokumentiert (hard requirement vs optional)? [Clarity, Plan §Dependencies]
|
||||
- [x] CHK033 Sind Annahmen über heterogene Reference-Shapes explizit dokumentiert und ist klar, wie neue Shapes in Scope aufgenommen werden (Change control / taxonomy update)? [Assumption, Plan §Risks]
|
||||
- [x] CHK034 Ist explizit dokumentiert, wann Edges extrahiert werden (im sync run vs post-processing), und wie Staleness/Refresh geregelt ist? [Gap, Spec §NFR1]
|
||||
|
||||
## Ambiguities & Conflicts
|
||||
|
||||
- [x] CHK035 Sind Begriffe „missing“, „not present“, „excluded“, „out-of-scope“ sauber definiert und konsistent verwendet (kein Vermischen von Datenzustand und Scope)? [Ambiguity, Spec §FR4, Out of Scope]
|
||||
- [x] CHK036 Ist klar, ob Filtering nur Relationship-Types betrifft oder auch Node-Types (Inventory vs Foundation) und ob beides kombinierbar sein soll? [Gap, Spec §Scenario 3]
|
||||
|
||||
## Notes
|
||||
|
||||
- Check items off as completed: `[x]`
|
||||
- Findings als kurze Stichpunkte direkt unter dem jeweiligen Item ergänzen
|
||||
- Jede `/speckit.checklist` Ausführung erzeugt eine neue Datei (kein Overwrite)
|
||||
@ -0,0 +1,37 @@
|
||||
# Requirements Checklist — Inventory Dependencies Graph (042)
|
||||
|
||||
## Scope
|
||||
|
||||
- [x] This checklist applies only to Spec 042 (Inventory Dependencies Graph).
|
||||
- [x] MVP scope: show **direct** inbound/outbound edges only (no depth>1 traversal / transitive blast radius).
|
||||
|
||||
## Constitution Gates
|
||||
|
||||
- [x] Inventory-first: edges derived from last-observed inventory data (no snapshot/backup side effects)
|
||||
- [x] Read/write separation: no Intune write paths introduced
|
||||
- [x] Single contract path to Graph: Graph access only via GraphClientInterface + contracts (if used)
|
||||
- [x] Tenant isolation: all reads/writes tenant-scoped
|
||||
- [x] Automation is idempotent & observable: unique key + upsert + run records + stable error codes
|
||||
- [x] Data minimization & safe logging: no secrets/tokens; avoid storing raw payloads outside allowed fields
|
||||
- [x] No new tables for warnings; warnings persist on InventorySyncRun.error_context.warnings[]
|
||||
|
||||
## Functional Requirements Coverage
|
||||
|
||||
- [x] FR-001 Relationship taxonomy exists and is testable (labels, directionality, descriptions)
|
||||
- [x] FR-002 Dependency edges stored in `inventory_links` with unique key (idempotent upsert)
|
||||
- [x] FR-003 Inbound/outbound query services tenant-scoped, limited (MVP: limit-only unless pagination is explicitly implemented)
|
||||
- [x] FR-004 Missing prerequisites represented as `target_type='missing'` with safe metadata + UI badge/tooltip
|
||||
- [x] FR-005 Relationship-type filtering available in UI (single-select, default “All”)
|
||||
|
||||
## Non-Functional Requirements Coverage
|
||||
|
||||
- [x] NFR-001 Idempotency: re-running extraction does not create duplicates; updates metadata deterministically
|
||||
- [x] NFR-002 Unknown reference shapes handled gracefully: warning recorded in run metadata; does not fail sync; no edge created for unsupported types
|
||||
|
||||
## Tests (Pest)
|
||||
|
||||
- [x] Extraction determinism + unique key (re-run equality)
|
||||
- [x] Missing edges show “Missing” badge and safe tooltip
|
||||
- [x] 50-edge limit enforced and truncation behavior is observable (if specified)
|
||||
- [x] Tenant isolation for queries and UI
|
||||
- [x] UI smoke: relationship-type filter limits visible edges
|
||||
@ -0,0 +1,5 @@
|
||||
# Contracts — Inventory Dependencies Graph (042)
|
||||
|
||||
This feature does not introduce a new public HTTP API in MVP (Filament page is server-rendered).
|
||||
|
||||
The contracts in this folder describe the internal data shape passed from query/services to the UI rendering layer.
|
||||
@ -0,0 +1,33 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "tenantpilot://contracts/042/dependency-edge.schema.json",
|
||||
"title": "DependencyEdge",
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"required": [
|
||||
"tenant_id",
|
||||
"source_type",
|
||||
"source_id",
|
||||
"target_type",
|
||||
"relationship_type"
|
||||
],
|
||||
"properties": {
|
||||
"tenant_id": { "type": "integer" },
|
||||
"source_type": { "type": "string", "enum": ["inventory_item", "foundation_object"] },
|
||||
"source_id": { "type": "string" },
|
||||
"target_type": { "type": "string", "enum": ["inventory_item", "foundation_object", "missing"] },
|
||||
"target_id": { "type": ["string", "null"] },
|
||||
"relationship_type": { "type": "string", "enum": ["assigned_to", "scoped_by", "targets", "depends_on"] },
|
||||
"metadata": {
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"last_known_name": { "type": ["string", "null"] },
|
||||
"raw_ref": {},
|
||||
"foundation_type": { "type": "string", "enum": ["aad_group", "scope_tag", "device_category"] }
|
||||
}
|
||||
},
|
||||
"created_at": { "type": ["string", "null"], "description": "ISO-8601 timestamp" },
|
||||
"updated_at": { "type": ["string", "null"], "description": "ISO-8601 timestamp" }
|
||||
}
|
||||
}
|
||||
72
specs/042-inventory-dependencies-graph/data-model.md
Normal file
72
specs/042-inventory-dependencies-graph/data-model.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Data Model — Inventory Dependencies Graph (042)
|
||||
|
||||
## Entities
|
||||
|
||||
### InventoryItem
|
||||
Existing entity (Spec 040).
|
||||
|
||||
Key fields used by this feature:
|
||||
- `tenant_id` (FK)
|
||||
- `external_id` (string; stable identifier used as edge endpoint)
|
||||
- `policy_type` (string)
|
||||
- `display_name` (nullable string)
|
||||
- `meta_jsonb` (array/jsonb; safe subset)
|
||||
|
||||
### InventorySyncRun
|
||||
Existing entity used for observability of sync operations.
|
||||
|
||||
Key fields used by this feature:
|
||||
- `tenant_id`
|
||||
- `selection_hash`
|
||||
- `selection_payload` (array)
|
||||
- `status` (running/success/partial/failed/skipped)
|
||||
- `had_errors` (bool)
|
||||
- `error_codes` (array)
|
||||
- `error_context` (array)
|
||||
|
||||
For MVP warnings persistence:
|
||||
- `error_context.warnings[]` (array of warning objects)
|
||||
- Warning object shape (stable): `{type: 'unsupported_reference', policy_id, raw_ref, reason}`
|
||||
|
||||
### InventoryLink
|
||||
Dependency edge storage.
|
||||
|
||||
Fields:
|
||||
- `tenant_id`
|
||||
- `source_type` (string; MVP uses `inventory_item`)
|
||||
- `source_id` (string; stores `InventoryItem.external_id`)
|
||||
- `target_type` (string; `inventory_item` | `foundation_object` | `missing`)
|
||||
- `target_id` (nullable string; null when missing)
|
||||
- `relationship_type` (string; values from RelationshipType enum)
|
||||
- `metadata` (jsonb)
|
||||
- timestamps
|
||||
|
||||
Unique key (idempotency):
|
||||
- `(tenant_id, source_type, source_id, target_type, target_id, relationship_type)`
|
||||
|
||||
#### InventoryLink.metadata
|
||||
Common keys:
|
||||
- `last_known_name` (nullable string)
|
||||
- `raw_ref` (mixed/array; only when safe)
|
||||
|
||||
Required when `target_type='foundation_object'`:
|
||||
- `foundation_type` (string enum-like): `aad_group` | `scope_tag` | `device_category`
|
||||
|
||||
## Enums
|
||||
|
||||
### RelationshipType
|
||||
- `assigned_to`
|
||||
- `scoped_by`
|
||||
- `targets`
|
||||
- `depends_on`
|
||||
|
||||
## Relationships
|
||||
|
||||
- InventoryItem (source) has many outbound InventoryLinks via `source_id` + `tenant_id`.
|
||||
- InventoryItem (target) has many inbound InventoryLinks via `target_id` + `tenant_id` where `target_type='inventory_item'`.
|
||||
|
||||
## Constraints / Limits
|
||||
|
||||
- Query: limit-only, ordered by `created_at DESC`.
|
||||
- UI: max 50 per direction (<=100 combined).
|
||||
- Extraction: max 50 outbound edges per item; unknown shapes are warning-only.
|
||||
@ -1,24 +1,100 @@
|
||||
# Implementation Plan: Inventory Dependencies Graph
|
||||
# Implementation Plan: Inventory Dependencies Graph (042)
|
||||
|
||||
**Date**: 2026-01-07
|
||||
**Spec**: `specs/042-inventory-dependencies-graph/spec.md`
|
||||
**Branch**: `feat/042-inventory-dependencies-graph` | **Date**: 2026-01-10 | **Spec**: specs/042-inventory-dependencies-graph/spec.md
|
||||
|
||||
## Summary
|
||||
|
||||
Add dependency edge model, extraction logic, and UI views to explain relationships between inventory items and prerequisite/foundation objects.
|
||||
Provide a read-only dependency view for an Inventory Item (direct inbound/outbound edges) with filters for direction and relationship type. Dependencies are derived from inventory sync payloads and stored idempotently in `inventory_links`.
|
||||
|
||||
## Dependencies
|
||||
## MVP Constraints (Explicit)
|
||||
|
||||
- Inventory items and stable identifiers (Spec 040)
|
||||
- Inventory UI detail pages (Spec 041) or equivalent navigation
|
||||
- Direct neighbors only (no depth > 1 traversal / transitive blast radius).
|
||||
- Limit-only queries (no pagination/cursors).
|
||||
- UI shows <= 50 edges per direction (<= 100 total when showing both directions).
|
||||
- Unknown/unsupported reference shapes are warning-only (no edge created).
|
||||
- Warnings persist on `InventorySyncRun.error_context.warnings[]`.
|
||||
- No new tables for warnings.
|
||||
|
||||
## Deliverables
|
||||
## Technical Context
|
||||
|
||||
- Relationship taxonomy
|
||||
- Persisted dependency edges
|
||||
- Query and rendering in UI
|
||||
**Language/Version**: PHP 8.4.x
|
||||
**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3
|
||||
**Storage**: PostgreSQL (JSONB)
|
||||
**Testing**: Pest v4
|
||||
**Target Platform**: Web (Filament admin)
|
||||
**Project Type**: Laravel monolith
|
||||
**Performance Goals**: dependency section renders in <2s with indexed + limited queries
|
||||
**Constraints**: tenant scoped only; no extra Graph lookups for enrichment
|
||||
**Scale/Scope**: edge rendering and extraction are hard-capped
|
||||
|
||||
## Risks
|
||||
## Constitution Check
|
||||
|
||||
- Heterogeneous reference shapes across policy types
|
||||
- Edge explosion for large tenants
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: edges reflect last observed sync payloads; no backups/snapshots.
|
||||
- Read/write separation: UI is read-only; no Intune write paths.
|
||||
- Single contract path to Graph: no new Graph calls for this feature.
|
||||
- Tenant isolation: all edges stored/queried with `tenant_id`.
|
||||
- Automation: idempotent via unique key + upsert; observable via run record; warnings persisted.
|
||||
- Data minimization: only metadata stored; no secrets/tokens.
|
||||
|
||||
Gate status: PASS.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/042-inventory-dependencies-graph/
|
||||
├── plan.md
|
||||
├── spec.md
|
||||
├── tasks.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ ├── README.md
|
||||
│ └── dependency-edge.schema.json
|
||||
└── checklists/
|
||||
├── pr-gate.md
|
||||
└── requirements.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/Filament/Resources/InventoryItemResource.php
|
||||
app/Models/InventoryItem.php
|
||||
app/Models/InventoryLink.php
|
||||
app/Models/InventorySyncRun.php
|
||||
app/Services/Inventory/DependencyExtractionService.php
|
||||
app/Services/Inventory/DependencyQueryService.php
|
||||
app/Support/Enums/RelationshipType.php
|
||||
resources/views/filament/components/dependency-edges.blade.php
|
||||
tests/Feature/InventoryItemDependenciesTest.php
|
||||
tests/Feature/DependencyExtractionFeatureTest.php
|
||||
tests/Unit/DependencyExtractionServiceTest.php
|
||||
```
|
||||
|
||||
## Phase 0: Research (Output: research.md)
|
||||
|
||||
Document decisions + rationale + alternatives for MVP clarifications (limit-only, 50 per direction, warnings-on-run-record, warning-only unknown shapes, required foundation_type metadata, relationship-type filter).
|
||||
|
||||
## Phase 1: Design (Outputs: data-model.md, contracts/*, quickstart.md)
|
||||
|
||||
- Data model: entities and fields, including `inventory_links` unique key and metadata shapes.
|
||||
- Contracts: JSON schema describing the dependency edge data passed to the UI.
|
||||
- Quickstart: how to view dependencies and run targeted tests.
|
||||
|
||||
## Phase 2: Implementation Plan (MVP)
|
||||
|
||||
1. UI filters: direction + relationship-type via querystring.
|
||||
2. Query: use DB filtering via `DependencyQueryService` optional `relationship_type`.
|
||||
3. Extraction: align unknown/unsupported shapes to warning-only and persist warnings on run record.
|
||||
4. Tests: add/adjust unit/feature/UI smoke tests for relationship filtering and warning-only behavior.
|
||||
5. Quality gates: Pint + targeted Pest tests.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
None for MVP (no constitution violations).
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
|
||||
34
specs/042-inventory-dependencies-graph/quickstart.md
Normal file
34
specs/042-inventory-dependencies-graph/quickstart.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Quickstart — Inventory Dependencies Graph (042)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Run the app via Sail.
|
||||
- Ensure you have at least one tenant and inventory items.
|
||||
|
||||
## Viewing Dependencies
|
||||
|
||||
1. Navigate to **Inventory** → select an Inventory Item.
|
||||
2. In the **Dependencies** section use the querystring-backed filters:
|
||||
|
||||
- `direction`: `all` (default) | `inbound` | `outbound`
|
||||
- `relationship_type`: `all` (default) | `assigned_to` | `scoped_by` | `targets` | `depends_on`
|
||||
|
||||
Example URLs:
|
||||
- `...?direction=outbound&relationship_type=scoped_by`
|
||||
|
||||
## Running the Targeted Tests
|
||||
|
||||
- UI smoke tests:
|
||||
- `./vendor/bin/sail artisan test tests/Feature/InventoryItemDependenciesTest.php`
|
||||
|
||||
## MVP Notes
|
||||
|
||||
- Limit-only, no pagination.
|
||||
- Shows <=50 edges per direction (<=100 total when showing both directions).
|
||||
- Unknown/unsupported reference shapes are warning-only and should be visible via `InventorySyncRun.error_context.warnings[]`.
|
||||
|
||||
## Manual Performance Check (<2s)
|
||||
|
||||
1. Open an Inventory Item with ~50 inbound and/or ~50 outbound edges.
|
||||
2. Use browser devtools Network tab to confirm the page request completes quickly.
|
||||
3. Toggle `direction` and `relationship_type` filters and confirm responses remain fast.
|
||||
47
specs/042-inventory-dependencies-graph/research.md
Normal file
47
specs/042-inventory-dependencies-graph/research.md
Normal file
@ -0,0 +1,47 @@
|
||||
# Research — Inventory Dependencies Graph (042)
|
||||
|
||||
This document resolves all implementation clarifications for the MVP and records the key decisions with rationale and alternatives.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1) Pagination vs limit-only
|
||||
- Decision: **Limit-only** (no pagination/cursors in MVP).
|
||||
- Rationale: Pagination introduces cursor semantics, UI states, sorting guarantees, and additional tests; MVP goal is fast/stable/testable.
|
||||
- Alternatives considered:
|
||||
- Add pagination now: rejected due to complexity and low MVP value.
|
||||
|
||||
### 2) Edge limits in UI
|
||||
- Decision: **50 per direction** (inbound and outbound), so up to **100 total** when showing both directions.
|
||||
- Rationale: Keeps each query bounded and predictable; matches existing UI composition (combine inbound + outbound).
|
||||
- Alternatives considered:
|
||||
- 50 total across both directions: rejected because it makes results direction-dependent and less intuitive.
|
||||
|
||||
### 3) Relationship-type filter (UI)
|
||||
- Decision: Add **single-select Relationship filter** with default **All**; persists in querystring.
|
||||
- Rationale: Small UX improvement with high usefulness; minimal risk.
|
||||
- Alternatives considered:
|
||||
- No relationship filter: rejected (spec requires it; improves scanability).
|
||||
|
||||
### 4) Unknown/unsupported reference shapes
|
||||
- Decision: **Warning-only** (no edge created).
|
||||
- Rationale: Creating “missing” edges for unknown shapes is misleading; it inflates perceived missing prerequisites and reduces trust.
|
||||
- Alternatives considered:
|
||||
- Create missing edge: rejected as potentially inaccurate.
|
||||
|
||||
### 5) Where warnings are stored
|
||||
- Decision: Persist warnings on the **sync run record** at `InventorySyncRun.error_context.warnings[]`.
|
||||
- Rationale: Auditable, debuggable, no new schema, consistent with “observable automation”.
|
||||
- Alternatives considered:
|
||||
- Per-item warnings in `InventoryItem.meta_jsonb`: rejected (pollutes inventory, harder to reason about run-level issues).
|
||||
- New warnings table: rejected (migrations/models/retention/cleanup burden).
|
||||
|
||||
### 6) Foundation object typing
|
||||
- Decision: For `target_type='foundation_object'`, always store `metadata.foundation_type`.
|
||||
- Rationale: Deterministic UI labeling/resolution; avoids inference.
|
||||
- Alternatives considered:
|
||||
- Infer foundation type from relationship type: rejected (brittle).
|
||||
|
||||
## Notes / Implementation Implications
|
||||
|
||||
- If the current code path creates missing edges for unknown assignment shapes, it must be adjusted to **warning-only** to match spec.
|
||||
- Warning payload shape should be stable: `{type: 'unsupported_reference', policy_id, raw_ref, reason}`.
|
||||
@ -8,6 +8,24 @@ ## Purpose
|
||||
|
||||
Represent and surface dependency relationships between inventory items and foundational Intune objects so admins can understand blast radius and prerequisites.
|
||||
|
||||
MVP shows direct inbound/outbound edges only; depth > 1 traversal is out of scope for this iteration.
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-01-10
|
||||
|
||||
- Q: Should FR3 be paginated or limit-only for MVP? → A: Limit-only (no pagination).
|
||||
- Q: Where should unknown/unsupported reference warnings be persisted? → A: On the inventory sync run record (e.g., `InventorySyncRun.error_context.warnings[]`).
|
||||
- Q: For unknown assignment target shapes, should we create a missing edge or warning-only? → A: Warning-only (no edge created).
|
||||
- Q: Should `foundation_object` edges always store `metadata.foundation_type`? → A: Yes (required).
|
||||
- Q: Should the UI show 50 edges total or 50 per direction? → A: 50 per direction (up to 100 total when showing both directions).
|
||||
|
||||
**Definitions**:
|
||||
- **Blast radius**: All resources directly affected by a change to a given item (outbound edges only; no transitive traversal in MVP).
|
||||
- **Prerequisite**: A hard dependency required for an item to function; missing prerequisites are explicitly surfaced.
|
||||
- **Inbound edge**: A relationship pointing TO this item (e.g., "Policy A is assigned to Group X" → Group X has inbound edge from Policy A).
|
||||
- **Outbound edge**: A relationship pointing FROM this item (e.g., "Policy A is scoped by ScopeTag Y" → Policy A has outbound edge to ScopeTag Y).
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### Scenario 1: View dependencies for an item
|
||||
@ -18,30 +36,132 @@ ### Scenario 1: View dependencies for an item
|
||||
### Scenario 2: Identify missing prerequisites
|
||||
- Given an item references a prerequisite object not present in inventory
|
||||
- When the user views dependencies
|
||||
- Then missing prerequisites are clearly indicated
|
||||
- Then missing prerequisites are clearly indicated (red badge, "Missing" label, tooltip with last-known displayName if available)
|
||||
|
||||
### Scenario 3: Filter dependencies by relationship type
|
||||
### Scenario 3: Zero dependencies
|
||||
- Given an item has no inbound or outbound edges
|
||||
- When the user opens dependencies view
|
||||
- Then a "No dependencies found" message is shown
|
||||
|
||||
### Scenario 4: Filter dependencies by relationship type
|
||||
- Given multiple relationship types exist
|
||||
- When the user filters by relationship type
|
||||
- Then only matching edges are shown
|
||||
- When the user filters by relationship type (single-select dropdown, default: "All")
|
||||
- Then only matching edges are shown (empty selection = all edges visible)
|
||||
|
||||
### Scenario 5: Only missing prerequisites
|
||||
- Given an item where all referenced targets are unresolved (no matching inventory or foundation objects)
|
||||
- When the user opens the dependencies view and selects "Outbound" or "All"
|
||||
- Then all shown edges are annotated as "Missing" with a red badge and tooltip; filtering still works and zero resolvable targets do not error
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
- FR1: Define a normalized set of relationship types.
|
||||
- FR2: Store dependency edges between inventory items and other objects (including non-inventory foundations when applicable).
|
||||
- FR3: Allow querying inbound/outbound edges for a given item.
|
||||
- FR4: Show missing prerequisites without requiring a separate “deleted” state in core inventory.
|
||||
- FR5: All dependency data is tenant-scoped and access-controlled.
|
||||
- **FR1: Relationship taxonomy**
|
||||
Define a normalized set of relationship types covering inventory→inventory and inventory→foundation edges.
|
||||
Supported types (MVP):
|
||||
- `assigned_to` (Policy → AAD Group)
|
||||
- `scoped_by` (Policy → Scope Tag)
|
||||
- `targets` (Update Policy → Device Category, conditional logic)
|
||||
- `depends_on` (Generic prerequisite, e.g., Compliance Policy referenced by Conditional Access)
|
||||
|
||||
Each type has:
|
||||
- `name` (string, e.g., "assigned_to")
|
||||
- `display_label` (string, e.g., "Assigned to")
|
||||
- `directionality` (enum: `outbound`, `inbound`, `bidirectional`)
|
||||
- `description` (brief explanation)
|
||||
|
||||
- **FR2: Dependency edge storage**
|
||||
Store edges in an `inventory_links` table with fields:
|
||||
- `id` (PK)
|
||||
- `tenant_id` (FK, indexed)
|
||||
- `source_type` (string: `inventory_item`, `foundation_object`)
|
||||
- `source_id` (UUID or stable ref)
|
||||
- `target_type` (string: `inventory_item`, `foundation_object`, `missing`)
|
||||
- `target_id` (UUID or stable ref, nullable if missing)
|
||||
- `relationship_type` (FK to taxonomy or enum)
|
||||
- `metadata` (JSONB, optional: last_known_name, raw_ref, etc.; for `target_type='foundation_object'`, `metadata.foundation_type` is required)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
**In-scope foundation object types (MVP)**:
|
||||
- AAD Groups (`aad_group`)
|
||||
- Scope Tags (`scope_tag`)
|
||||
- Device Categories (`device_category`)
|
||||
|
||||
**Out-of-scope foundation types** (for this iteration): Conditional Access Policies, Compliance Policies as foundation nodes (only as inventory items).
|
||||
|
||||
- **FR3: Query inbound/outbound edges**
|
||||
Provide service methods:
|
||||
- `getOutboundEdges(item_id, relationship_type?, limit=50)` → returns edges where item is source
|
||||
- `getInboundEdges(item_id, relationship_type?, limit=50)` → returns edges where item is target
|
||||
|
||||
Both return up to `limit` edges, ordered by `created_at DESC`.
|
||||
|
||||
UI supports filtering by `relationship_type` via a single-select dropdown (default: "All"; empty selection behaves as "All").
|
||||
|
||||
- **FR4: Missing prerequisites**
|
||||
When a target reference cannot be resolved:
|
||||
- Create edge with `target_type='missing'`, `target_id=null`
|
||||
- Store `metadata.last_known_name` and `metadata.raw_ref` if available
|
||||
- UI displays "Missing" badge + tooltip
|
||||
|
||||
No separate "deleted" or "archived" state in core inventory; missing is purely an edge property.
|
||||
|
||||
Unknown/unsupported reference shapes do not create edges; they are handled via warnings (see NFR2).
|
||||
|
||||
- **FR5: Tenant scoping and access control**
|
||||
- All edges filtered by `tenant_id` matching `Tenant::current()`
|
||||
- Read access: any authenticated tenant user
|
||||
- No cross-tenant queries allowed (enforced at query builder level)
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- NFR1: Dependency extraction must be idempotent (re-runnable without duplicating edges).
|
||||
- NFR2: Dependency extraction must not fail an inventory sync run if an unknown/unsupported reference is encountered; it should record a safe warning.
|
||||
- **NFR1: Idempotency**
|
||||
Dependency extraction must be idempotent:
|
||||
- Unique key: `(tenant_id, source_type, source_id, target_type, target_id, relationship_type)`
|
||||
- On re-run: upsert (update `updated_at`, replace `metadata` if changed)
|
||||
- Orphan edges (source/target no longer in inventory) are NOT auto-deleted; cleanup is manual or scheduled separately
|
||||
|
||||
- **NFR2: Graceful unknown-reference handling**
|
||||
If an unknown/unsupported reference shape is encountered:
|
||||
- Log warning with severity `info` (not `error`)
|
||||
- Do NOT create an edge for unsupported types (including unknown assignment target shapes)
|
||||
- Record warning in sync run metadata at `InventorySyncRun.error_context.warnings[]` with shape: `{type: 'unsupported_reference', policy_id, raw_ref, reason}`
|
||||
- Sync run continues without failure
|
||||
|
||||
## Graph Traversal & Cycles (Out of Scope for MVP)
|
||||
|
||||
- Depth > 1 traversal (transitive “blast radius”) is out of scope for this iteration.
|
||||
- The UI shows only direct inbound/outbound edges.
|
||||
- Future work may add depth-capped traversal with cycle handling and explicit cycle visualization.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- SC1: Admins can determine prerequisites and blast radius for an item in under 2 minutes.
|
||||
- SC2: For supported relationship types, dependency edges are consistent across re-runs (deterministic output).
|
||||
- **SC1: Blast radius determination**
|
||||
Admins can determine prerequisites (inbound edges) and blast radius (outbound edges; direct only) for any item in under 2 minutes:
|
||||
- Measured from: clicking "View Dependencies" on an item detail page
|
||||
- To: able to answer "What would break if I delete this?" and "What does this depend on?"
|
||||
- Acceptance: <2s page load, ≤50 edges per direction shown initially (≤100 total when showing both directions), clear visual grouping by relationship type
|
||||
|
||||
- **SC2: Deterministic output**
|
||||
For supported relationship types, dependency edges are consistent across re-runs:
|
||||
- Given identical inventory state (same items, same Graph API responses)
|
||||
- Edge set equality: same `(source, target, relationship_type)` tuples (order-independent)
|
||||
- Acceptance: automated test re-runs extraction twice on fixed test data; assert edge sets match (ignoring `updated_at`)
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
- All data is tenant-scoped; no cross-tenant queries or joins.
|
||||
- Foundation object visibility:
|
||||
- Display name shown only if available from tenant-authorized sources (inventory metadata or prior sync payloads).
|
||||
- If not available, show a masked or abbreviated identifier (e.g., first 6 characters of ID) with no external lookup.
|
||||
- Stored metadata for edges must avoid PII beyond display names surfaced by Graph within the tenant; raw references may be stored but not enriched from outside the tenant scope.
|
||||
|
||||
## Traceability
|
||||
|
||||
- FR1 (taxonomy) → SC2 (deterministic types), tests: unit taxonomy load/assert
|
||||
- FR2 (storage) → SC2 (edge equality), tests: feature upsert and equality
|
||||
- FR3 (queries) → SC1 (answer in <2 min), tests: service returns inbound/outbound within limits
|
||||
- FR4 (missing) → SC1 (clear prerequisite view), tests: feature missing badge/tooltip
|
||||
- FR5 (tenant scope) → SC1/SC2 (correct data, deterministic set), tests: tenant isolation
|
||||
|
||||
## Out of Scope
|
||||
|
||||
|
||||
@ -1,7 +1,198 @@
|
||||
# Tasks: Inventory Dependencies Graph
|
||||
# Tasks: Inventory Dependencies Graph (042)
|
||||
|
||||
- [ ] T001 Define relationship taxonomy
|
||||
- [ ] T002 Add dependency edge storage and indexes
|
||||
- [ ] T003 Extraction pipeline (idempotent)
|
||||
- [ ] T004 Item-level dependencies UI
|
||||
- [ ] T005 Tests for edge determinism and tenant scoping
|
||||
**Input**: Design documents in `specs/042-inventory-dependencies-graph/` (plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md)
|
||||
|
||||
**Notes**
|
||||
- Tasks are grouped by user story so each story is independently testable.
|
||||
- Tests are included because this feature affects runtime behavior.
|
||||
- MVP constraints (direct only, limit-only, 50 per direction, warnings-on-run-record, no warnings tables) must remain enforced.
|
||||
|
||||
## Phase 1: Setup (Shared)
|
||||
|
||||
**Purpose**: Ensure feature docs and scope constraints are locked before code changes.
|
||||
|
||||
- [x] T001 Validate MVP constraints in `specs/042-inventory-dependencies-graph/plan.md` remain aligned with `specs/042-inventory-dependencies-graph/spec.md`
|
||||
- [x] T002 Validate scope + NFR checkboxes in `specs/042-inventory-dependencies-graph/checklists/requirements.md` cover all accepted MVP constraints
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Storage + extraction + query services that all UI stories rely on.
|
||||
|
||||
**Checkpoint**: After Phase 2, edges can be extracted and queried tenant-safely with limits.
|
||||
|
||||
- [ ] T003 [P] Ensure relationship types and labels are centralized in `app/Support/Enums/RelationshipType.php`
|
||||
- [ ] T004 Ensure `inventory_links` schema (unique key + indexes) matches spec in `database/migrations/2026_01_07_150000_create_inventory_links_table.php`
|
||||
- [ ] T005 [P] Ensure `InventoryLink` casts and tenant-safe query patterns in `app/Models/InventoryLink.php`
|
||||
- [ ] T006 [P] Ensure factory coverage for dependency edges in `database/factories/InventoryLinkFactory.php`
|
||||
- [ ] T007 Align idempotent upsert semantics for edges in `app/Services/Inventory/DependencyExtractionService.php`
|
||||
- [ ] T008 Implement warning-only handling for unknown/unsupported reference shapes in `app/Services/Inventory/DependencyExtractionService.php`
|
||||
- [ ] T009 Persist warnings on the sync run record at `InventorySyncRun.error_context.warnings[]` via `app/Services/Inventory/InventorySyncService.php`
|
||||
- [ ] T010 Implement limit-only inbound/outbound queries with optional relationship filter in `app/Services/Inventory/DependencyQueryService.php` (ordered by `created_at DESC`)
|
||||
- [ ] T011 [P] Add determinism + idempotency tests in `tests/Unit/DependencyExtractionServiceTest.php`
|
||||
- [ ] T012 [P] Add tenant isolation tests for queries in `tests/Feature/DependencyExtractionFeatureTest.php`
|
||||
- [x] T003 [P] Ensure relationship types and labels are centralized in `app/Support/Enums/RelationshipType.php`
|
||||
- [x] T004 Ensure `inventory_links` schema (unique key + indexes) matches spec in `database/migrations/2026_01_07_150000_create_inventory_links_table.php`
|
||||
- [x] T005 [P] Ensure `InventoryLink` casts and tenant-safe query patterns in `app/Models/InventoryLink.php`
|
||||
- [x] T006 [P] Ensure factory coverage for dependency edges in `database/factories/InventoryLinkFactory.php`
|
||||
- [x] T007 Align idempotent upsert semantics for edges in `app/Services/Inventory/DependencyExtractionService.php`
|
||||
- [x] T008 Implement warning-only handling for unknown/unsupported reference shapes in `app/Services/Inventory/DependencyExtractionService.php`
|
||||
- [x] T009 Persist warnings on the sync run record at `InventorySyncRun.error_context.warnings[]` via `app/Services/Inventory/InventorySyncService.php`
|
||||
- [x] T010 Implement limit-only inbound/outbound queries with optional relationship filter in `app/Services/Inventory/DependencyQueryService.php` (ordered by `created_at DESC`)
|
||||
- [x] T011 [P] Add determinism + idempotency tests in `tests/Unit/DependencyExtractionServiceTest.php`
|
||||
- [x] T012 [P] Add tenant isolation tests for queries in `tests/Feature/DependencyExtractionFeatureTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — View Dependencies (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: As an admin, I can view direct inbound/outbound dependencies for an inventory item.
|
||||
|
||||
**Independent Test**: Opening an Inventory Item shows a Dependencies section that renders within limits and supports direction filtering.
|
||||
|
||||
- [ ] T013 [P] [US1] Wire dependencies section into Filament item view in `app/Filament/Resources/InventoryItemResource.php`
|
||||
- [ ] T014 [P] [US1] Render edges grouped by relationship type in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- [ ] T015 [US1] Add direction filter (querystring-backed) in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- [ ] T016 [US1] Parse/validate `direction` and pass to query service in `app/Filament/Resources/InventoryItemResource.php`
|
||||
- [ ] T017 [US1] Add UI smoke test for dependencies rendering + direction filter in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
- [x] T013 [P] [US1] Wire dependencies section into Filament item view in `app/Filament/Resources/InventoryItemResource.php`
|
||||
- [x] T014 [P] [US1] Render edges grouped by relationship type in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- [x] T015 [US1] Add direction filter (querystring-backed) in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- [x] T016 [US1] Parse/validate `direction` and pass to query service in `app/Filament/Resources/InventoryItemResource.php`
|
||||
- [x] T017 [US1] Add UI smoke test for dependencies rendering + direction filter in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Identify Missing Prerequisites (Priority: P2)
|
||||
|
||||
**Goal**: As an admin, I can clearly see when a referenced prerequisite object is missing.
|
||||
|
||||
**Independent Test**: A missing target renders a red “Missing” badge and safe tooltip using `metadata.last_known_name`/`metadata.raw_ref`.
|
||||
|
||||
- [ ] T018 [US2] Ensure unresolved targets create `target_type='missing'` edges with safe metadata in `app/Services/Inventory/DependencyExtractionService.php`
|
||||
- [ ] T019 [US2] Display “Missing” badge + tooltip for missing edges in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- [ ] T020 [US2] Add feature test for missing edge creation + metadata in `tests/Feature/DependencyExtractionFeatureTest.php`
|
||||
- [ ] T021 [US2] Add UI test asserting missing badge/tooltip is visible in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
- [x] T018 [US2] Ensure unresolved targets create `target_type='missing'` edges with safe metadata in `app/Services/Inventory/DependencyExtractionService.php`
|
||||
- [x] T019 [US2] Display “Missing” badge + tooltip for missing edges in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- [x] T020 [US2] Add feature test for missing edge creation + metadata in `tests/Feature/DependencyExtractionFeatureTest.php`
|
||||
- [x] T021 [US2] Add UI test asserting missing badge/tooltip is visible in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Filter By Relationship Type (Priority: P2)
|
||||
|
||||
**Goal**: As an admin, I can filter dependencies by relationship type to reduce noise.
|
||||
|
||||
**Independent Test**: Selecting a relationship type shows only matching edges; default “All” shows everything.
|
||||
|
||||
- [ ] T022 [US3] Add relationship-type dropdown filter (querystring-backed) in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- [ ] T023 [US3] Parse/validate `relationship_type` and pass to query service in `app/Filament/Resources/InventoryItemResource.php`
|
||||
- [ ] T024 [US3] Add UI smoke test verifying relationship filter limits edges in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
- [x] T022 [US3] Add relationship-type dropdown filter (querystring-backed) in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- [x] T023 [US3] Parse/validate `relationship_type` and pass to query service in `app/Filament/Resources/InventoryItemResource.php`
|
||||
- [x] T024 [US3] Add UI smoke test verifying relationship filter limits edges in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Zero Dependencies (Priority: P3)
|
||||
|
||||
**Goal**: As an admin, I get a clear empty state when no dependencies exist.
|
||||
|
||||
**Independent Test**: When queries return zero edges, the UI shows “No dependencies found” and does not error.
|
||||
|
||||
- [ ] T025 [US4] Render zero-state message in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- [ ] T026 [US4] Add UI test for zero-state rendering in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
- [x] T025 [US4] Render zero-state message in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- [x] T026 [US4] Add UI test for zero-state rendering in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Tighten docs/contracts and run quality gates.
|
||||
|
||||
- [ ] T027 [P] Align filter docs + URLs in `specs/042-inventory-dependencies-graph/quickstart.md`
|
||||
- [ ] T028 [P] Ensure schema reflects metadata requirements in `specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json`
|
||||
- [ ] T029 Update requirement-gate checkboxes in `specs/042-inventory-dependencies-graph/checklists/pr-gate.md`
|
||||
- [ ] T030 Run Pint and fix formatting in `app/`, `resources/views/filament/components/`, and `tests/` (touching `app/Support/Enums/RelationshipType.php`, `resources/views/filament/components/dependency-edges.blade.php`, `tests/Feature/InventoryItemDependenciesTest.php`)
|
||||
- [ ] T031 Run targeted tests: `tests/Feature/InventoryItemDependenciesTest.php`, `tests/Feature/DependencyExtractionFeatureTest.php`, `tests/Unit/DependencyExtractionServiceTest.php`
|
||||
- [x] T027 [P] Align filter docs + URLs in `specs/042-inventory-dependencies-graph/quickstart.md`
|
||||
- [x] T028 [P] Ensure schema reflects metadata requirements in `specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json`
|
||||
- [x] T029 Update requirement-gate checkboxes in `specs/042-inventory-dependencies-graph/checklists/pr-gate.md`
|
||||
- [x] T030 Run Pint and fix formatting in `app/`, `resources/views/filament/components/`, and `tests/` (touching `app/Support/Enums/RelationshipType.php`, `resources/views/filament/components/dependency-edges.blade.php`, `tests/Feature/InventoryItemDependenciesTest.php`)
|
||||
- [x] T031 Run targeted tests: `tests/Feature/InventoryItemDependenciesTest.php`, `tests/Feature/DependencyExtractionFeatureTest.php`, `tests/Unit/DependencyExtractionServiceTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Consistency & Security Coverage (Cross-Cutting)
|
||||
|
||||
**Purpose**: Close remaining spec→tasks gaps (ordering, masking, auth expectations, logging severity).
|
||||
|
||||
- [ ] T032 [P] Add test asserting inbound/outbound query ordering by `created_at DESC` + limit-only behavior in `tests/Feature/DependencyExtractionFeatureTest.php` (or `tests/Unit/DependencyExtractionServiceTest.php` if more appropriate)
|
||||
- [ ] T033 [US2] Implement masked/abbreviated identifier rendering when `metadata.last_known_name` is null in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- [ ] T034 [US2] Add UI test for masked identifier behavior in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
- [ ] T035 [P] Add auth behavior test for the inventory item dependencies view (guest blocked; authenticated tenant user allowed) in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
- [ ] T036 [P] Ensure unknown/unsupported reference warnings are logged at `info` severity in `app/Services/Inventory/DependencyExtractionService.php` and add a unit test using `Log::fake()` in `tests/Unit/DependencyExtractionServiceTest.php`
|
||||
- [ ] T037 Document how to verify the <2s dependency section goal (manual acceptance check) in `specs/042-inventory-dependencies-graph/quickstart.md`
|
||||
- [x] T032 [P] Add test asserting inbound/outbound query ordering by `created_at DESC` + limit-only behavior in `tests/Feature/DependencyExtractionFeatureTest.php` (or `tests/Unit/DependencyExtractionServiceTest.php` if more appropriate)
|
||||
- [x] T033 [US2] Implement masked/abbreviated identifier rendering when `metadata.last_known_name` is null in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
- [x] T034 [US2] Add UI test for masked identifier behavior in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
- [x] T035 [P] Add auth behavior test for the inventory item dependencies view (guest blocked; authenticated tenant user allowed) in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
- [x] T036 [P] Ensure unknown/unsupported reference warnings are logged at `info` severity in `app/Services/Inventory/DependencyExtractionService.php` and add a unit test using `Log::fake()` in `tests/Unit/DependencyExtractionServiceTest.php`
|
||||
- [x] T037 Document how to verify the <2s dependency section goal (manual acceptance check) in `specs/042-inventory-dependencies-graph/quickstart.md`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- Phase 1 (Setup) → Phase 2 (Foundational) → US1 (MVP) → US2/US3 → US4 → Polish
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- US1 depends on Phase 2.
|
||||
- US2 depends on Phase 2 and US1 (needs the Dependencies view).
|
||||
- US3 depends on Phase 2 and US1 (needs the Dependencies view).
|
||||
- US4 depends on US1 (zero-state is part of the Dependencies view).
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### Phase 2 (Foundational)
|
||||
|
||||
- [P] `app/Support/Enums/RelationshipType.php` and `database/migrations/2026_01_07_150000_create_inventory_links_table.php`
|
||||
- [P] `database/factories/InventoryLinkFactory.php` and `tests/Unit/DependencyExtractionServiceTest.php`
|
||||
|
||||
### User Story 1 (US1)
|
||||
|
||||
- [P] Update `app/Filament/Resources/InventoryItemResource.php` while implementing rendering in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
|
||||
### User Story 2 (US2)
|
||||
|
||||
- [P] Implement missing-edge extraction in `app/Services/Inventory/DependencyExtractionService.php` while updating UI rendering in `resources/views/filament/components/dependency-edges.blade.php`
|
||||
|
||||
### User Story 3 (US3)
|
||||
|
||||
- [P] Implement relationship dropdown in `resources/views/filament/components/dependency-edges.blade.php` while wiring query parsing in `app/Filament/Resources/InventoryItemResource.php`
|
||||
|
||||
### User Story 4 (US4)
|
||||
|
||||
- [P] Implement zero-state UI in `resources/views/filament/components/dependency-edges.blade.php` while writing the UI assertion in `tests/Feature/InventoryItemDependenciesTest.php`
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (US1 Only)
|
||||
|
||||
1. Complete Phase 1 and Phase 2.
|
||||
2. Implement US1 and validate with `tests/Feature/InventoryItemDependenciesTest.php`.
|
||||
3. Stop and demo MVP UI before proceeding to US2/US3.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Phase 1 + Phase 2 → foundation ready.
|
||||
2. US1 → demo dependency view.
|
||||
3. US2 → add missing-prerequisite trust signals.
|
||||
4. US3 → add relationship filtering for readability.
|
||||
5. US4 → refine empty-state UX.
|
||||
|
||||
275
tests/Feature/DependencyExtractionFeatureTest.php
Normal file
275
tests/Feature/DependencyExtractionFeatureTest.php
Normal file
@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventoryLink;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Inventory\DependencyQueryService;
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
|
||||
class FakeGraphClientForDeps implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, [
|
||||
[
|
||||
'id' => 'pol-1',
|
||||
'displayName' => 'Test Policy',
|
||||
'assignments' => [
|
||||
['target' => ['groupId' => 'group-1']],
|
||||
['target' => ['groupId' => 'group-2']],
|
||||
],
|
||||
'roleScopeTagIds' => ['scope-tag-1', 'scope-tag-2'],
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, [], 200);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, [], 200);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, [], 200);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, [], 200);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, [], 200);
|
||||
}
|
||||
}
|
||||
|
||||
it('extracts edges during inventory sync and marks missing appropriately', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$this->app->bind(GraphClientInterface::class, fn () => new FakeGraphClientForDeps);
|
||||
|
||||
$svc = app(InventorySyncService::class);
|
||||
|
||||
$run = $svc->syncNow($tenant, [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'categories' => [],
|
||||
'include_foundations' => false,
|
||||
'include_dependencies' => true,
|
||||
]);
|
||||
|
||||
expect($run->status)->toBe('success');
|
||||
|
||||
$edges = InventoryLink::query()->where('tenant_id', $tenant->getKey())->get();
|
||||
// 2 assigned_to + 2 scoped_by = 4
|
||||
expect($edges->count())->toBe(4);
|
||||
});
|
||||
|
||||
it('respects 50-edge limit for outbound extraction', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
// Fake client returning 60 group assignments
|
||||
$this->app->bind(GraphClientInterface::class, function () {
|
||||
return new class implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
$assignments = [];
|
||||
for ($i = 1; $i <= 60; $i++) {
|
||||
$assignments[] = ['target' => ['groupId' => 'g-'.$i]];
|
||||
}
|
||||
|
||||
return new GraphResponse(true, [[
|
||||
'id' => 'pol-2',
|
||||
'displayName' => 'Big Assignments',
|
||||
'assignments' => $assignments,
|
||||
]]);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
$svc = app(InventorySyncService::class);
|
||||
$svc->syncNow($tenant, [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'categories' => [],
|
||||
'include_foundations' => false,
|
||||
'include_dependencies' => true,
|
||||
]);
|
||||
|
||||
$count = InventoryLink::query()->where('tenant_id', $tenant->getKey())->count();
|
||||
expect($count)->toBe(50);
|
||||
});
|
||||
|
||||
it('persists unsupported reference warnings on the sync run record', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$this->app->bind(GraphClientInterface::class, function () {
|
||||
return new class implements GraphClientInterface
|
||||
{
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, [[
|
||||
'id' => 'pol-warn-1',
|
||||
'displayName' => 'Unsupported Assignment Target',
|
||||
'assignments' => [
|
||||
['target' => ['filterId' => 'filter-only-no-group']],
|
||||
],
|
||||
]]);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
$svc = app(InventorySyncService::class);
|
||||
$run = $svc->syncNow($tenant, [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'categories' => [],
|
||||
'include_foundations' => false,
|
||||
'include_dependencies' => true,
|
||||
]);
|
||||
|
||||
$warnings = $run->error_context['warnings'] ?? null;
|
||||
expect($warnings)->toBeArray()->toHaveCount(1);
|
||||
expect($warnings[0]['type'] ?? null)->toBe('unsupported_reference');
|
||||
|
||||
expect(InventoryLink::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('orders inbound/outbound edges by created_at desc and applies limit-only behavior', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
/** @var InventoryItem $item */
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
]);
|
||||
|
||||
$svc = app(DependencyQueryService::class);
|
||||
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'relationship_type' => 'assigned_to',
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'updated_at' => now()->subMinutes(10),
|
||||
]);
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => '22222222-2222-2222-2222-222222222222',
|
||||
'relationship_type' => 'assigned_to',
|
||||
'created_at' => now()->subMinutes(5),
|
||||
'updated_at' => now()->subMinutes(5),
|
||||
]);
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => '33333333-3333-3333-3333-333333333333',
|
||||
'relationship_type' => 'assigned_to',
|
||||
'created_at' => now()->subMinutes(1),
|
||||
'updated_at' => now()->subMinutes(1),
|
||||
]);
|
||||
|
||||
$outbound = $svc->getOutboundEdges($item, null, 2);
|
||||
expect($outbound)->toHaveCount(2);
|
||||
expect($outbound[0]->target_id)->toBe('33333333-3333-3333-3333-333333333333');
|
||||
expect($outbound[1]->target_id)->toBe('22222222-2222-2222-2222-222222222222');
|
||||
expect($outbound[0]->created_at->greaterThan($outbound[1]->created_at))->toBeTrue();
|
||||
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'target_type' => 'inventory_item',
|
||||
'target_id' => $item->external_id,
|
||||
'relationship_type' => 'depends_on',
|
||||
'created_at' => now()->subMinutes(9),
|
||||
'updated_at' => now()->subMinutes(9),
|
||||
]);
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'target_type' => 'inventory_item',
|
||||
'target_id' => $item->external_id,
|
||||
'relationship_type' => 'depends_on',
|
||||
'created_at' => now()->subMinutes(2),
|
||||
'updated_at' => now()->subMinutes(2),
|
||||
]);
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
||||
'target_type' => 'inventory_item',
|
||||
'target_id' => $item->external_id,
|
||||
'relationship_type' => 'depends_on',
|
||||
'created_at' => now()->subMinutes(1),
|
||||
'updated_at' => now()->subMinutes(1),
|
||||
]);
|
||||
|
||||
$inbound = $svc->getInboundEdges($item, null, 2);
|
||||
expect($inbound)->toHaveCount(2);
|
||||
expect($inbound[0]->source_id)->toBe('cccccccc-cccc-cccc-cccc-cccccccccccc');
|
||||
expect($inbound[1]->source_id)->toBe('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb');
|
||||
expect($inbound[0]->created_at->greaterThan($inbound[1]->created_at))->toBeTrue();
|
||||
});
|
||||
45
tests/Feature/DependencyQueryServiceTest.php
Normal file
45
tests/Feature/DependencyQueryServiceTest.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventoryLink;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\DependencyQueryService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
it('returns outbound and inbound edges filtered by tenant and direction', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
/** @var InventoryItem $item */
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
// Outbound edge for this item
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => (string) Str::uuid(),
|
||||
'relationship_type' => 'assigned_to',
|
||||
]);
|
||||
|
||||
// Inbound edge pointing to this item as target
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => (string) Str::uuid(),
|
||||
'target_type' => 'inventory_item',
|
||||
'target_id' => $item->external_id,
|
||||
'relationship_type' => 'depends_on',
|
||||
]);
|
||||
|
||||
$svc = app(DependencyQueryService::class);
|
||||
|
||||
$outbound = $svc->getOutboundEdges($item);
|
||||
$inbound = $svc->getInboundEdges($item);
|
||||
|
||||
expect($outbound->count())->toBe(1);
|
||||
expect($inbound->count())->toBe(1);
|
||||
});
|
||||
43
tests/Feature/DependencyTenantIsolationTest.php
Normal file
43
tests/Feature/DependencyTenantIsolationTest.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventoryLink;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\DependencyQueryService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
it('does not leak edges across tenants in service queries', function () {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
/** @var InventoryItem $itemA */
|
||||
$itemA = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenantA->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
// Edge for tenant A
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenantA->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $itemA->external_id,
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => (string) Str::uuid(),
|
||||
'relationship_type' => 'assigned_to',
|
||||
]);
|
||||
|
||||
// Edge for tenant B with same source/target ids but different tenant
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenantB->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $itemA->external_id,
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => (string) Str::uuid(),
|
||||
'relationship_type' => 'assigned_to',
|
||||
]);
|
||||
|
||||
$svc = app(DependencyQueryService::class);
|
||||
$outboundA = $svc->getOutboundEdges($itemA);
|
||||
|
||||
expect($outboundA->count())->toBe(1);
|
||||
});
|
||||
188
tests/Feature/InventoryItemDependenciesTest.php
Normal file
188
tests/Feature/InventoryItemDependenciesTest.php
Normal file
@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventoryLink;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
it('shows zero-state when no dependencies and shows missing badge when applicable', function () {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
/** @var InventoryItem $item */
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
// Zero state
|
||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
|
||||
$this->get($url)->assertOk()->assertSee('No dependencies found');
|
||||
|
||||
// Create a missing edge and assert badge appears
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'missing',
|
||||
'target_id' => null,
|
||||
'relationship_type' => 'assigned_to',
|
||||
'metadata' => [
|
||||
'last_known_name' => 'Ghost Target',
|
||||
'raw_ref' => ['example' => 'ref'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->get($url)
|
||||
->assertOk()
|
||||
->assertSee('Missing')
|
||||
->assertSee('Last known: Ghost Target');
|
||||
});
|
||||
|
||||
it('direction filter limits to outbound or inbound', function () {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
/** @var InventoryItem $item */
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
// Outbound only
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => (string) Str::uuid(),
|
||||
'relationship_type' => 'assigned_to',
|
||||
]);
|
||||
|
||||
// Inbound only
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => (string) Str::uuid(),
|
||||
'target_type' => 'inventory_item',
|
||||
'target_id' => $item->external_id,
|
||||
'relationship_type' => 'depends_on',
|
||||
]);
|
||||
|
||||
$urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=outbound';
|
||||
$this->get($urlOutbound)->assertOk()->assertDontSee('No dependencies found');
|
||||
|
||||
$urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant).'?direction=inbound';
|
||||
$this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found');
|
||||
});
|
||||
|
||||
it('relationship filter limits edges by type', function () {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
/** @var InventoryItem $item */
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
// Two outbound edges with different relationship types.
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'missing',
|
||||
'target_id' => null,
|
||||
'relationship_type' => 'assigned_to',
|
||||
'metadata' => ['last_known_name' => 'Assigned Target'],
|
||||
]);
|
||||
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'missing',
|
||||
'target_id' => null,
|
||||
'relationship_type' => 'scoped_by',
|
||||
'metadata' => ['last_known_name' => 'Scoped Target'],
|
||||
]);
|
||||
|
||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant)
|
||||
.'?direction=outbound&relationship_type=scoped_by';
|
||||
|
||||
$this->get($url)
|
||||
->assertOk()
|
||||
->assertSee('Scoped Target')
|
||||
->assertDontSee('Assigned Target');
|
||||
});
|
||||
|
||||
it('does not show edges from other tenants (tenant isolation)', function () {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
/** @var InventoryItem $item */
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
// Same source_id, but different tenant_id: must not be rendered.
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $otherTenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'missing',
|
||||
'target_id' => null,
|
||||
'relationship_type' => 'assigned_to',
|
||||
'metadata' => ['last_known_name' => 'Other Tenant Edge'],
|
||||
]);
|
||||
|
||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
|
||||
$this->get($url)
|
||||
->assertOk()
|
||||
->assertDontSee('Other Tenant Edge');
|
||||
});
|
||||
|
||||
it('shows masked identifier when last known name is missing', function () {
|
||||
[$user, $tenant] = createUserWithTenant();
|
||||
$this->actingAs($user);
|
||||
|
||||
/** @var InventoryItem $item */
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => $item->external_id,
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => '12345678-1234-1234-1234-123456789012',
|
||||
'relationship_type' => 'assigned_to',
|
||||
'metadata' => [
|
||||
'last_known_name' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
|
||||
$this->get($url)
|
||||
->assertOk()
|
||||
->assertSee('ID: 123456…');
|
||||
});
|
||||
|
||||
it('blocks guest access to inventory item dependencies view', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
/** @var InventoryItem $item */
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
|
||||
$this->get($url)->assertRedirect();
|
||||
});
|
||||
82
tests/Unit/DependencyExtractionServiceTest.php
Normal file
82
tests/Unit/DependencyExtractionServiceTest.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventoryLink;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\DependencyExtractionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('extracts deterministically and enforces unique key', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
/** @var InventoryItem $item */
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$policyData = [
|
||||
'id' => $item->external_id,
|
||||
'assignments' => [
|
||||
['target' => ['groupId' => 'group-1']],
|
||||
['target' => ['groupId' => 'group-2']],
|
||||
],
|
||||
'roleScopeTagIds' => ['scope-tag-1', 'scope-tag-2'],
|
||||
];
|
||||
|
||||
$svc = app(DependencyExtractionService::class);
|
||||
|
||||
$warnings1 = $svc->extractForPolicyData($item, $policyData);
|
||||
$warnings2 = $svc->extractForPolicyData($item, $policyData); // re-run, should be idempotent
|
||||
|
||||
expect($warnings1)->toBeArray()->toBeEmpty();
|
||||
expect($warnings2)->toBeArray()->toBeEmpty();
|
||||
|
||||
$edges = InventoryLink::query()->where('tenant_id', $tenant->getKey())->get();
|
||||
expect($edges)->toHaveCount(4);
|
||||
|
||||
// Ensure uniqueness by tuple (source, target, type)
|
||||
$tuples = $edges->map(fn ($e) => implode('|', [
|
||||
$e->source_type, $e->source_id, $e->target_type, (string) $e->target_id, $e->relationship_type,
|
||||
]))->unique();
|
||||
|
||||
expect($tuples->count())->toBe(4);
|
||||
});
|
||||
|
||||
it('handles unsupported references by recording warnings (no edges)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
/** @var InventoryItem $item */
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'external_id' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$policyData = [
|
||||
'id' => $item->external_id,
|
||||
'assignments' => [
|
||||
['target' => ['filterId' => 'filter-only-no-group']], // no groupId shape → missing
|
||||
],
|
||||
];
|
||||
|
||||
Log::spy();
|
||||
|
||||
$svc = app(DependencyExtractionService::class);
|
||||
|
||||
$warnings = $svc->extractForPolicyData($item, $policyData);
|
||||
expect($warnings)->toBeArray()->toHaveCount(1);
|
||||
expect($warnings[0]['type'] ?? null)->toBe('unsupported_reference');
|
||||
expect($warnings[0]['policy_id'] ?? null)->toBe($item->external_id);
|
||||
|
||||
expect(InventoryLink::query()->count())->toBe(0);
|
||||
|
||||
Log::shouldHaveReceived('info')
|
||||
->withArgs(fn (string $message, array $context) => $message === 'Unsupported reference shape encountered'
|
||||
&& ($context['type'] ?? null) === 'unsupported_reference'
|
||||
&& ($context['policy_id'] ?? null) === $item->external_id)
|
||||
->once();
|
||||
});
|
||||
52
tests/Unit/InventoryLinkTest.php
Normal file
52
tests/Unit/InventoryLinkTest.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventoryLink;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('enforces unique constraint on inventory_links', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$data = [
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'source_type' => 'inventory_item',
|
||||
'source_id' => (string) Str::uuid(),
|
||||
'target_type' => 'foundation_object',
|
||||
'target_id' => (string) Str::uuid(),
|
||||
'relationship_type' => 'assigned_to',
|
||||
'metadata' => ['last_known_name' => 'X'],
|
||||
];
|
||||
|
||||
InventoryLink::query()->create($data);
|
||||
|
||||
expect(function () use ($data) {
|
||||
InventoryLink::query()->create($data);
|
||||
})->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('scopes edges by tenant at query level', function () {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
$sourceId = (string) Str::uuid();
|
||||
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenantA->getKey(),
|
||||
'source_id' => $sourceId,
|
||||
]);
|
||||
|
||||
InventoryLink::factory()->create([
|
||||
'tenant_id' => $tenantB->getKey(),
|
||||
'source_id' => $sourceId,
|
||||
]);
|
||||
|
||||
$edgesA = InventoryLink::query()->where('tenant_id', $tenantA->getKey())->get();
|
||||
$edgesB = InventoryLink::query()->where('tenant_id', $tenantB->getKey())->get();
|
||||
|
||||
expect($edgesA->count())->toBe(1);
|
||||
expect($edgesB->count())->toBe(1);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user