From a5ef9961b473f28ce3769eb3cf81572c3945ae7c Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 10 Jan 2026 00:51:32 +0100 Subject: [PATCH 1/2] spec: align 042 plan/tasks and checklists --- .github/agents/copilot-instructions.md | 4 +- .../checklists/requirements.md | 42 +-- .../contracts/README.md | 5 + .../contracts/dependency-edge.schema.json | 33 +++ .../data-model.md | 72 +++++ .../042-inventory-dependencies-graph/plan.md | 265 +++++------------- .../quickstart.md | 28 ++ .../research.md | 47 ++++ .../042-inventory-dependencies-graph/tasks.md | 195 ++++++++++--- 9 files changed, 437 insertions(+), 254 deletions(-) create mode 100644 specs/042-inventory-dependencies-graph/contracts/README.md create mode 100644 specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json create mode 100644 specs/042-inventory-dependencies-graph/data-model.md create mode 100644 specs/042-inventory-dependencies-graph/quickstart.md create mode 100644 specs/042-inventory-dependencies-graph/research.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 0fb9ff8..c0e52ea 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -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 diff --git a/specs/042-inventory-dependencies-graph/checklists/requirements.md b/specs/042-inventory-dependencies-graph/checklists/requirements.md index b99f72a..ca8397e 100644 --- a/specs/042-inventory-dependencies-graph/checklists/requirements.md +++ b/specs/042-inventory-dependencies-graph/checklists/requirements.md @@ -2,36 +2,36 @@ # Requirements Checklist — Inventory Dependencies Graph (042) ## Scope -- [ ] This checklist applies only to Spec 042 (Inventory Dependencies Graph). -- [ ] MVP scope: show **direct** inbound/outbound edges only (no depth>1 traversal / transitive blast radius). +- [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 -- [ ] Inventory-first: edges derived from last-observed inventory data (no snapshot/backup side effects) -- [ ] Read/write separation: no Intune write paths introduced -- [ ] Single contract path to Graph: Graph access only via GraphClientInterface + contracts (if used) -- [ ] Tenant isolation: all reads/writes tenant-scoped -- [ ] Automation is idempotent & observable: unique key + upsert + run records + stable error codes -- [ ] Data minimization & safe logging: no secrets/tokens; avoid storing raw payloads outside allowed fields -- [ ] No new tables for warnings; warnings persist on InventorySyncRun.error_context.warnings[] +- [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 +- [ ] No new tables for warnings; warnings persist on InventorySyncRun.error_context.warnings[] (T009) ## Functional Requirements Coverage -- [ ] FR-001 Relationship taxonomy exists and is testable (labels, directionality, descriptions) -- [ ] FR-002 Dependency edges stored in `inventory_links` with unique key (idempotent upsert) -- [ ] FR-003 Inbound/outbound query services tenant-scoped, limited (MVP: limit-only unless pagination is explicitly implemented) -- [ ] FR-004 Missing prerequisites represented as `target_type='missing'` with safe metadata + UI badge/tooltip -- [ ] FR-005 Relationship-type filtering available in UI (single-select, default “All”) +- [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 -- [ ] NFR-001 Idempotency: re-running extraction does not create duplicates; updates metadata deterministically -- [ ] NFR-002 Unknown reference shapes handled gracefully: warning recorded in run metadata; does not fail sync; no edge created for unsupported types +- [x] NFR-001 Idempotency: re-running extraction does not create duplicates; updates metadata deterministically +- [ ] NFR-002 Unknown reference shapes handled gracefully: warning recorded in run metadata; does not fail sync; no edge created for unsupported types (T008, T009, T036) ## Tests (Pest) -- [ ] Extraction determinism + unique key (re-run equality) -- [ ] Missing edges show “Missing” badge and safe tooltip -- [ ] 50-edge limit enforced and truncation behavior is observable (if specified) -- [ ] Tenant isolation for queries and UI -- [ ] UI smoke: relationship-type filter limits visible edges +- [x] Extraction determinism + unique key (re-run equality) +- [ ] Missing edges show “Missing” badge and safe tooltip (T019, T021) +- [x] 50-edge limit enforced and truncation behavior is observable (if specified) +- [ ] Tenant isolation for queries and UI (T012) +- [x] UI smoke: relationship-type filter limits visible edges diff --git a/specs/042-inventory-dependencies-graph/contracts/README.md b/specs/042-inventory-dependencies-graph/contracts/README.md new file mode 100644 index 0000000..0959125 --- /dev/null +++ b/specs/042-inventory-dependencies-graph/contracts/README.md @@ -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. diff --git a/specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json b/specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json new file mode 100644 index 0000000..922a2a2 --- /dev/null +++ b/specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json @@ -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" } + } +} diff --git a/specs/042-inventory-dependencies-graph/data-model.md b/specs/042-inventory-dependencies-graph/data-model.md new file mode 100644 index 0000000..61e7b7d --- /dev/null +++ b/specs/042-inventory-dependencies-graph/data-model.md @@ -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. diff --git a/specs/042-inventory-dependencies-graph/plan.md b/specs/042-inventory-dependencies-graph/plan.md index b14919d..4bab182 100644 --- a/specs/042-inventory-dependencies-graph/plan.md +++ b/specs/042-inventory-dependencies-graph/plan.md @@ -1,219 +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`. -**MVP constraints (explicit):** -- **Limit-only** queries (no pagination/cursors in this iteration). -- UI shows up to **50 edges per direction** (up to 100 total when showing both directions). -- Unknown/unsupported reference shapes: **warning-only** (no edge created). -- Warnings are persisted on the **sync run record** at `InventorySyncRun.error_context.warnings[]` (no new tables). +## MVP Constraints (Explicit) -## Dependencies +- 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. -- Inventory items and stable identifiers (Spec 040) -- Inventory UI detail pages (Spec 041) or equivalent navigation -- Assignments payload (from Spec 006 or equivalent) for `assigned_to` relationships -- Scope tags and device categories as foundation objects +## Technical Context -## Deliverables +**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 -- Relationship taxonomy (enum + metadata) -- Persisted dependency edges (`inventory_links` table) -- Extraction pipeline (integrated into inventory sync) -- Query service methods (inbound/outbound) -- UI components (Filament section + filtering) -- Tests (unit, feature, UI smoke, tenant isolation) +## Constitution Check -## Risks +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* -- Heterogeneous reference shapes across policy types → **Mitigation**: Normalize references early; unsupported shapes → soft warning, skip edge creation -- Edge explosion for large tenants → **Mitigation**: Hard limit ≤50 edges per item per direction (enforced at extraction); pagination UI for future +- 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. -## 1. Extraction Pipeline +## Project Structure -### Timing & Integration -- **When**: Dependencies extracted **during inventory sync run** (not on-demand) -- **Hook**: After `InventorySyncService` creates/updates `InventoryItem`, call `DependencyExtractionService` -- **Idempotency**: Each sync run reconciles edges for synced items (upsert based on unique key) +### Documentation (this feature) -### Inputs -1. **InventoryItem** (`raw`, `meta`, `resource_type`, `external_id`) -2. **Assignments payload** (if available): AAD Group IDs -3. **Scope tags** (from item `meta->scopeTags` or raw payload) -4. **Device categories** (from conditional logic in raw payload, if applicable) - -### Extraction Logic -```php -DependencyExtractionService::extractForItem(InventoryItem $item): array -``` -- Parse `$item->raw` and `$item->meta` for known reference shapes -- For each reference: - - Normalize to `{type, external_id, display_name?}` - - Determine `relationship_type` (assigned_to, scoped_by, targets, depends_on) - - Resolve target: - - If `InventoryItem` exists with matching `external_id` → `target_type='inventory_item'` - - If foundation object (AAD Group, Scope Tag, Device Category) → `target_type='foundation_object'` - - If unresolvable → `target_type='missing'`, `target_id=null`, store `metadata.last_known_name` - - Create/update edge - -### Error & Warning Logic -- **Unsupported reference shape**: Log warning (`info` severity), record in sync run metadata, skip edge creation -- **Low confidence**: If reference parsing heuristic uncertain, create edge with `metadata.confidence='low'` -- **Unresolved target**: Create edge with `target_type='missing'` (not an error) - - **All targets missing**: Extraction still emits `missing` edges; UI must handle zero resolvable targets without error - -**MVP clarification:** Unknown/unsupported reference shapes are **warning-only** (no edge created). Warnings are stored at `InventorySyncRun.error_context.warnings[]`. - -### Hard Limits -- **Max 50 edges per item per direction** (outbound/inbound) -- Enforced at extraction: sort by priority (assigned_to > scoped_by > targets > depends_on), keep top 50, log truncation warning - ---- - -## 2. Storage & Upsert Details - -### Schema: `inventory_links` Table -```php -Schema::create('inventory_links', function (Blueprint $table) { - $table->id(); - $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); - $table->string('source_type'); // 'inventory_item', 'foundation_object' - $table->uuid('source_id'); // InventoryItem.id or foundation stable ID - $table->string('target_type'); // 'inventory_item', 'foundation_object', 'missing' - $table->uuid('target_id')->nullable(); // null if target_type='missing' - $table->string('relationship_type'); // 'assigned_to', 'scoped_by', etc. - $table->jsonb('metadata')->nullable(); // {last_known_name, raw_ref, confidence, ...} - $table->timestamps(); - - // Unique key (idempotency) - $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']); -}); +```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 ``` -### Upsert Strategy -- **On extraction**: `InventoryLink::upsert($edges, uniqueBy: ['tenant_id', 'source_type', 'source_id', 'target_type', 'target_id', 'relationship_type'], update: ['metadata', 'updated_at'])` -- **On item deletion**: Edges **NOT** auto-deleted (orphan cleanup is manual or scheduled job) -- **On re-run**: Existing edges updated (`updated_at` bumped), new edges created +### Source Code (repository root) -### Hard Limit Enforcement -- Extraction service limits to 50 edges per direction before upserting -- Query methods (`getOutboundEdges`, `getInboundEdges`) have default `limit=50` parameter - ---- - -## 3. UI Components (Filament) - -### Location -- **InventoryItemResource → ViewInventoryItem page** -- New section: "Dependencies" (below "Details" section) - -### Component Structure -```php -// app/Filament/Resources/InventoryItemResource.php (or dedicated Infolist) -Section::make('Dependencies') - ->schema([ - // Filter: Inbound / Outbound / All (default: All) - Forms\Components\Select::make('direction') - ->options(['all' => 'All', 'inbound' => 'Inbound', 'outbound' => 'Outbound']) - ->default('all') - ->live(), - - // Filter: Relationship Type (default: All) - Forms\Components\Select::make('relationship_type') - ->options(['all' => 'All'] + /* RelationshipType enum options */ []) - ->default('all') - ->live(), - - // Edges table (or custom Blade component) - ViewEntry::make('edges') - ->view('filament.components.dependency-edges') - ->state(fn (InventoryItem $record) => - DependencyService::getEdges($record, request('direction', 'all')) - ), - ]) +```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 ``` -### Blade View: `resources/views/filament/components/dependency-edges.blade.php` -- **Zero-state**: If `$edges->isEmpty()`: "No dependencies found" -- **Edge rendering**: - - Group by `relationship_type` (collapsible groups or headings) - - Each edge: icon + target name + link (if resolvable) + "Missing" red badge (if target_type='missing') + tooltip (metadata.last_known_name) -- **Performance**: Load edges synchronously (≤50 per direction, fast query with indexes) - - **Privacy**: If display name unavailable, render masked identifier (e.g., `ID: abcd12…`), no cross-tenant lookups +## Phase 0: Research (Output: research.md) -### Filter Behavior -- Direction: single-select dropdown; default "All" (both inbound + outbound shown); empty/null treated as "All" -- Relationship type: single-select dropdown; default "All"; empty/null treated as "All" +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) -## 4. Tests +- 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. -### Unit Tests -- `DependencyExtractionServiceTest.php`: - - `test_normalizes_references_deterministically()`: same raw input → same edges (order-independent) - - `test_respects_unique_key()`: re-extraction → no duplicate edges - - `test_handles_unsupported_references()`: logs warning, skips edge +## Phase 2: Implementation Plan (MVP) -- `InventoryLinkTest.php`: - - `test_unique_constraint_enforced()`: duplicate insert → exception or upsert - - `test_tenant_scoping()`: edges filtered by tenant_id +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. -### Feature Tests -- `DependencyExtractionFeatureTest.php`: - - `test_extraction_creates_expected_edges()`: fixture item → assert edges created (assigned_to, scoped_by) - - `test_extraction_marks_missing_targets()`: item references non-existent group → edge with target_type='missing' - - `test_extraction_respects_50_edge_limit()`: item with 60 refs → only 50 edges created - - `test_only_missing_prerequisites_scenario()`: item whose all targets are unresolved → only `missing` edges, UI renders badges +## Complexity Tracking -- `DependencyQueryServiceTest.php`: - - `test_get_outbound_edges_filters_by_tenant()`: cannot see other tenant's edges - - `test_get_inbound_edges_returns_correct_direction()` - -### UI Smoke Tests -- `InventoryItemDependenciesTest.php`: - - `test_dependencies_section_renders()`: view page → see "Dependencies" section - - `test_filter_works()`: select "Inbound" → only inbound edges shown - - `test_zero_state_shown()`: item with no edges → "No dependencies found" - - `test_missing_badge_shown()`: edge with target_type='missing' → red badge visible - - `test_masks_identifier_when_name_unavailable()`: foundation object without display name → masked ID shown - -### Security Tests -- `DependencyTenantIsolationTest.php`: - - `test_cannot_see_other_tenant_edges()`: tenant A user → cannot query tenant B edges - - `test_edges_scoped_by_tenant()`: all query methods enforce tenant_id filter - - `test_no_cross_tenant_enrichment()`: service never fetches/display names across tenants - ---- - -## Implementation Sequence - -1. **T001**: Define relationship taxonomy (enum + migration for reference data or config) -2. **T002**: Create `inventory_links` migration + model + factory -3. **T003**: Implement `DependencyExtractionService` (extraction logic + normalization) -4. **T004**: Integrate extraction into `InventorySyncService` (post-item-creation hook) -5. **T005**: Implement `DependencyQueryService` (getInbound/Outbound + tenant scoping) -6. **T006**: Add Filament "Dependencies" section to InventoryItem detail page -7. **T007**: Implement filter UI (direction select + live wire) -8. **T008**: Add Blade view for edge rendering (zero-state, missing badge, tooltip) -9. **T009**: Write unit tests (extraction, unique key, tenant scoping) -10. **T010**: Write feature tests (extraction, query, limits) -11. **T011**: Write UI smoke tests (section render, filter, zero-state) -12. **T012**: Run full test suite + Pint +None for MVP (no constitution violations). +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/042-inventory-dependencies-graph/quickstart.md b/specs/042-inventory-dependencies-graph/quickstart.md new file mode 100644 index 0000000..9acc0c1 --- /dev/null +++ b/specs/042-inventory-dependencies-graph/quickstart.md @@ -0,0 +1,28 @@ +# 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[]`. diff --git a/specs/042-inventory-dependencies-graph/research.md b/specs/042-inventory-dependencies-graph/research.md new file mode 100644 index 0000000..41e5a45 --- /dev/null +++ b/specs/042-inventory-dependencies-graph/research.md @@ -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}`. diff --git a/specs/042-inventory-dependencies-graph/tasks.md b/specs/042-inventory-dependencies-graph/tasks.md index e6cb6b7..e0fcb78 100644 --- a/specs/042-inventory-dependencies-graph/tasks.md +++ b/specs/042-inventory-dependencies-graph/tasks.md @@ -1,48 +1,163 @@ -# Tasks: Inventory Dependencies Graph +# Tasks: Inventory Dependencies Graph (042) -## Schema & Data Model -- [x] T001 Define relationship taxonomy (enum or config) with display labels, directionality, descriptions -- [x] T002 Create `inventory_links` migration with unique constraint + indexes -- [x] T003 Create `InventoryLink` model + factory +**Input**: Design documents in `specs/042-inventory-dependencies-graph/` (plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md) -## Extraction Pipeline -- [x] T004 Implement `DependencyExtractionService` (normalize references, resolve targets, create edges) -- [x] T005 Add reference parsers for `assigned_to`, `scoped_by`, `targets`, `depends_on` -- [x] T006 Integrate extraction into `InventorySyncService` (post-item-creation hook) -- [x] T007 Implement 50-edge-per-direction limit with priority sorting +**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. -## Query Services -- [x] T008 Implement `DependencyQueryService::getOutboundEdges(item, type?, limit=50)` -- [x] T009 Implement `DependencyQueryService::getInboundEdges(item, type?, limit=50)` -- [x] T010 Ensure tenant-scoping enforced at query builder level +## Phase 1: Setup (Shared) -## UI Components -- [x] T011 Add "Dependencies" section to `InventoryItemResource` ViewInventoryItem page -- [x] T012 Implement direction filter (single-select: all/inbound/outbound, default: all) -- [x] T013 Create Blade view `dependency-edges.blade.php` (zero-state, missing badge, tooltip) -- [x] T014 Add relationship-type grouping/collapsible sections +**Purpose**: Ensure feature docs and scope constraints are locked before code changes. -## Tests -- [x] T015 Unit: `DependencyExtractionServiceTest` (determinism, unique key, unsupported refs) -- [x] T016 Unit: `InventoryLinkTest` (unique constraint, tenant scoping) -- [x] T017 Feature: extraction creates expected edges + handles missing targets -- [x] T018 Feature: extraction respects 50-edge limit -- [x] T019 Feature: `DependencyQueryService` filters by tenant + direction -- [x] T020 UI Smoke: dependencies section renders + filter works + zero-state shown -- [x] T021 Security: tenant isolation (cannot see other tenant edges) +- [ ] T001 Validate MVP constraints in `specs/042-inventory-dependencies-graph/plan.md` remain aligned with `specs/042-inventory-dependencies-graph/spec.md` +- [ ] T002 Validate scope + NFR checkboxes in `specs/042-inventory-dependencies-graph/checklists/requirements.md` cover all accepted MVP constraints -## Finalization -- [x] T022 Run full test suite (`php artisan test`) -- [x] T023 Run Pint (`vendor/bin/pint`) -- [x] T024 Update checklist items in `checklists/pr-gate.md` +--- -## 追加 Tasks (MVP Remediation) -- [ ] T025 Implement relationship-type filter (single-select dropdown, default: all) -- [ ] T026 UI Smoke: relationship-type filter limits edges -- [ ] T027 Create and complete `checklists/requirements.md` (Constitution gate) +## Phase 2: Foundational (Blocking Prerequisites) -## MVP Constraints (Non-Tasks) -- MVP is limit-only (no pagination/cursors). -- Show up to 50 edges per direction (up to 100 total for "all"). -- Unknown/unsupported shapes are warning-only; persist warnings on run record (`InventorySyncRun.error_context.warnings[]`). -- No new tables for warnings. +**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` + +--- + +## 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` + +--- + +## 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` + +--- + +## 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` + +--- + +## 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` + +--- + +## 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` + +--- + +## 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` + +--- + +## 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. From 85e4bd75f815a12dfdddf96ae8a2777ff00432f9 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 10 Jan 2026 00:54:40 +0100 Subject: [PATCH 2/2] fix: harden 042 requirements gate --- .../Inventory/DependencyExtractionService.php | 30 +++++----- .../Inventory/InventorySyncService.php | 17 ++++-- .../components/dependency-edges.blade.php | 16 ++++- .../checklists/requirements.md | 8 +-- .../DependencyExtractionFeatureTest.php | 59 +++++++++++++++++++ .../Feature/InventoryItemDependenciesTest.php | 40 ++++++++++++- .../Unit/DependencyExtractionServiceTest.php | 31 +++++++--- 7 files changed, 165 insertions(+), 36 deletions(-) diff --git a/app/Services/Inventory/DependencyExtractionService.php b/app/Services/Inventory/DependencyExtractionService.php index ac84e88..54ad580 100644 --- a/app/Services/Inventory/DependencyExtractionService.php +++ b/app/Services/Inventory/DependencyExtractionService.php @@ -7,6 +7,7 @@ use App\Support\Enums\RelationshipType; use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Log; class DependencyExtractionService { @@ -16,11 +17,12 @@ class DependencyExtractionService * * @param array $policyData */ - public function extractForPolicyData(InventoryItem $item, array $policyData): void + public function extractForPolicyData(InventoryItem $item, array $policyData): array { + $warnings = []; $edges = collect(); - $edges = $edges->merge($this->extractAssignedTo($item, $policyData)); + $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 @@ -57,13 +59,15 @@ public function extractForPolicyData(InventoryItem $item, array $policyData): vo ['metadata', 'updated_at'] ); } + + return $warnings; } /** * @param array $policyData * @return Collection> */ - private function extractAssignedTo(InventoryItem $item, array $policyData): Collection + private function extractAssignedTo(InventoryItem $item, array $policyData, array &$warnings): Collection { $assignments = Arr::get($policyData, 'assignments'); if (! is_array($assignments)) { @@ -93,19 +97,15 @@ private function extractAssignedTo(InventoryItem $item, array $policyData): Coll ], ]; } else { - // Unresolved/unknown target → mark missing - $edges[] = [ - 'tenant_id' => (int) $item->tenant_id, - 'source_type' => 'inventory_item', - 'source_id' => (string) $item->external_id, - 'target_type' => 'missing', - 'target_id' => null, - 'relationship_type' => RelationshipType::AssignedTo->value, - 'metadata' => [ - 'raw_ref' => $assignment, - 'last_known_name' => null, - ], + $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); } } diff --git a/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php index 3ca113b..79da1a7 100644 --- a/app/Services/Inventory/InventorySyncService.php +++ b/app/Services/Inventory/InventorySyncService.php @@ -108,6 +108,7 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal $errors = 0; $errorCodes = []; $hadErrors = false; + $warnings = []; try { $typesConfig = $this->supportedTypeConfigByType(); @@ -186,8 +187,11 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal // Extract dependencies if requested in selection $includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true); if ($includeDeps) { - app(\App\Services\Inventory\DependencyExtractionService::class) - ->extractForPolicyData($item, $policyData); + $warnings = array_merge( + $warnings, + app(\App\Services\Inventory\DependencyExtractionService::class) + ->extractForPolicyData($item, $policyData) + ); } } } @@ -198,7 +202,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, @@ -207,11 +213,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, diff --git a/resources/views/filament/components/dependency-edges.blade.php b/resources/views/filament/components/dependency-edges.blade.php index cb2240e..fcaec87 100644 --- a/resources/views/filament/components/dependency-edges.blade.php +++ b/resources/views/filament/components/dependency-edges.blade.php @@ -38,11 +38,23 @@ $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
  • - {{ $display }} + {{ $display }} @if ($isMissing) - Missing + Missing @endif
  • @endforeach diff --git a/specs/042-inventory-dependencies-graph/checklists/requirements.md b/specs/042-inventory-dependencies-graph/checklists/requirements.md index ca8397e..0282beb 100644 --- a/specs/042-inventory-dependencies-graph/checklists/requirements.md +++ b/specs/042-inventory-dependencies-graph/checklists/requirements.md @@ -13,7 +13,7 @@ ## Constitution Gates - [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 -- [ ] No new tables for warnings; warnings persist on InventorySyncRun.error_context.warnings[] (T009) +- [x] No new tables for warnings; warnings persist on InventorySyncRun.error_context.warnings[] ## Functional Requirements Coverage @@ -26,12 +26,12 @@ ## Functional Requirements Coverage ## Non-Functional Requirements Coverage - [x] NFR-001 Idempotency: re-running extraction does not create duplicates; updates metadata deterministically -- [ ] NFR-002 Unknown reference shapes handled gracefully: warning recorded in run metadata; does not fail sync; no edge created for unsupported types (T008, T009, T036) +- [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) -- [ ] Missing edges show “Missing” badge and safe tooltip (T019, T021) +- [x] Missing edges show “Missing” badge and safe tooltip - [x] 50-edge limit enforced and truncation behavior is observable (if specified) -- [ ] Tenant isolation for queries and UI (T012) +- [x] Tenant isolation for queries and UI - [x] UI smoke: relationship-type filter limits visible edges diff --git a/tests/Feature/DependencyExtractionFeatureTest.php b/tests/Feature/DependencyExtractionFeatureTest.php index 01d3162..e45d570 100644 --- a/tests/Feature/DependencyExtractionFeatureTest.php +++ b/tests/Feature/DependencyExtractionFeatureTest.php @@ -127,3 +127,62 @@ public function request(string $method, string $path, array $options = []): Grap $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); +}); diff --git a/tests/Feature/InventoryItemDependenciesTest.php b/tests/Feature/InventoryItemDependenciesTest.php index 8af983e..5c8ba75 100644 --- a/tests/Feature/InventoryItemDependenciesTest.php +++ b/tests/Feature/InventoryItemDependenciesTest.php @@ -3,6 +3,7 @@ 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 () { @@ -27,10 +28,16 @@ 'target_type' => 'missing', 'target_id' => null, 'relationship_type' => 'assigned_to', - 'metadata' => ['last_known_name' => null], + 'metadata' => [ + 'last_known_name' => 'Ghost Target', + 'raw_ref' => ['example' => 'ref'], + ], ]); - $this->get($url)->assertOk()->assertSee('Missing'); + $this->get($url) + ->assertOk() + ->assertSee('Missing') + ->assertSee('Last known: Ghost Target'); }); it('direction filter limits to outbound or inbound', function () { @@ -109,3 +116,32 @@ ->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'); +}); diff --git a/tests/Unit/DependencyExtractionServiceTest.php b/tests/Unit/DependencyExtractionServiceTest.php index 860fa23..f0f83de 100644 --- a/tests/Unit/DependencyExtractionServiceTest.php +++ b/tests/Unit/DependencyExtractionServiceTest.php @@ -5,6 +5,7 @@ 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); @@ -29,8 +30,11 @@ $svc = app(DependencyExtractionService::class); - $svc->extractForPolicyData($item, $policyData); - $svc->extractForPolicyData($item, $policyData); // re-run, should be idempotent + $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); @@ -43,7 +47,7 @@ expect($tuples->count())->toBe(4); }); -it('handles unsupported references by creating missing edges', function () { +it('handles unsupported references by recording warnings (no edges)', function () { $tenant = Tenant::factory()->create(); /** @var InventoryItem $item */ @@ -59,11 +63,20 @@ ], ]; - $svc = app(DependencyExtractionService::class); - $svc->extractForPolicyData($item, $policyData); + Log::spy(); - $edge = InventoryLink::query()->first(); - expect($edge)->not->toBeNull(); - expect($edge->target_type)->toBe('missing'); - expect($edge->target_id)->toBeNull(); + $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(); });