feat(044): add drift findings foundation
This commit is contained in:
parent
df18cb1a0d
commit
242881c04e
83
app/Filament/Pages/DriftLanding.php
Normal file
83
app/Filament/Pages/DriftLanding.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Drift\DriftRunSelector;
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class DriftLanding extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
||||
|
||||
protected static ?string $navigationLabel = 'Drift';
|
||||
|
||||
protected string $view = 'filament.pages.drift-landing';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
$latestSuccessful = InventorySyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('status', InventorySyncRun::STATUS_SUCCESS)
|
||||
->whereNotNull('finished_at')
|
||||
->orderByDesc('finished_at')
|
||||
->first();
|
||||
|
||||
if (! $latestSuccessful instanceof InventorySyncRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scopeKey = (string) $latestSuccessful->selection_hash;
|
||||
|
||||
$selector = app(DriftRunSelector::class);
|
||||
$comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
|
||||
|
||||
if ($comparison === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$baseline = $comparison['baseline'];
|
||||
$current = $comparison['current'];
|
||||
|
||||
$exists = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('scope_key', $scopeKey)
|
||||
->where('baseline_run_id', $baseline->getKey())
|
||||
->where('current_run_id', $current->getKey())
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
GenerateDriftFindingsJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
baselineRunId: (int) $baseline->getKey(),
|
||||
currentRunId: (int) $current->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
public function getFindingsUrl(): string
|
||||
{
|
||||
return FindingResource::getUrl('index', tenant: Tenant::current());
|
||||
}
|
||||
}
|
||||
65
app/Filament/Resources/FindingResource.php
Normal file
65
app/Filament/Resources/FindingResource.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class FindingResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Finding::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
||||
|
||||
protected static ?string $navigationLabel = 'Findings';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
|
||||
Tables\Columns\TextColumn::make('status')->badge(),
|
||||
Tables\Columns\TextColumn::make('severity')->badge(),
|
||||
Tables\Columns\TextColumn::make('subject_type')->label('Subject')->searchable(),
|
||||
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListFindings::route('/'),
|
||||
'view' => Pages\ViewFinding::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListFindings extends ListRecords
|
||||
{
|
||||
protected static string $resource = FindingResource::class;
|
||||
}
|
||||
11
app/Filament/Resources/FindingResource/Pages/ViewFinding.php
Normal file
11
app/Filament/Resources/FindingResource/Pages/ViewFinding.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewFinding extends ViewRecord
|
||||
{
|
||||
protected static string $resource = FindingResource::class;
|
||||
}
|
||||
30
app/Jobs/GenerateDriftFindingsJob.php
Normal file
30
app/Jobs/GenerateDriftFindingsJob.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class GenerateDriftFindingsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $baselineRunId,
|
||||
public int $currentRunId,
|
||||
public string $scopeKey,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
// Implemented in later tasks (T020/T021).
|
||||
}
|
||||
}
|
||||
65
app/Models/Finding.php
Normal file
65
app/Models/Finding.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Finding extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\FindingFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const string FINDING_TYPE_DRIFT = 'drift';
|
||||
|
||||
public const string SEVERITY_LOW = 'low';
|
||||
|
||||
public const string SEVERITY_MEDIUM = 'medium';
|
||||
|
||||
public const string SEVERITY_HIGH = 'high';
|
||||
|
||||
public const string STATUS_NEW = 'new';
|
||||
|
||||
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'acknowledged_at' => 'datetime',
|
||||
'evidence_jsonb' => 'array',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function baselineRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventorySyncRun::class, 'baseline_run_id');
|
||||
}
|
||||
|
||||
public function currentRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventorySyncRun::class, 'current_run_id');
|
||||
}
|
||||
|
||||
public function acknowledgedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'acknowledged_by_user_id');
|
||||
}
|
||||
|
||||
public function acknowledge(User $user): void
|
||||
{
|
||||
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_ACKNOWLEDGED,
|
||||
'acknowledged_at' => now(),
|
||||
'acknowledged_by_user_id' => $user->getKey(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
app/Policies/FindingPolicy.php
Normal file
66
app/Policies/FindingPolicy.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\TenantRole;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class FindingPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
public function view(User $user, Finding $finding): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $finding->tenant_id === (int) $tenant->getKey();
|
||||
}
|
||||
|
||||
public function update(User $user, Finding $finding): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $user->tenantRole($tenant);
|
||||
|
||||
return match ($role) {
|
||||
TenantRole::Owner,
|
||||
TenantRole::Manager,
|
||||
TenantRole::Operator => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -3,10 +3,14 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Policies\BackupSchedulePolicy;
|
||||
use App\Policies\BulkOperationRunPolicy;
|
||||
use App\Policies\FindingPolicy;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\MicrosoftGraphClient;
|
||||
use App\Services\Graph\NullGraphClient;
|
||||
@ -108,5 +112,6 @@ public function boot(): void
|
||||
|
||||
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
||||
Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class);
|
||||
Gate::policy(Finding::class, FindingPolicy::class);
|
||||
}
|
||||
}
|
||||
|
||||
31
app/Services/Drift/DriftEvidence.php
Normal file
31
app/Services/Drift/DriftEvidence.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
class DriftEvidence
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function sanitize(array $payload): array
|
||||
{
|
||||
$allowedKeys = [
|
||||
'change_type',
|
||||
'summary',
|
||||
'baseline',
|
||||
'current',
|
||||
'diff',
|
||||
'notes',
|
||||
];
|
||||
|
||||
$safe = [];
|
||||
foreach ($allowedKeys as $key) {
|
||||
if (array_key_exists($key, $payload)) {
|
||||
$safe[$key] = $payload[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $safe;
|
||||
}
|
||||
}
|
||||
33
app/Services/Drift/DriftHasher.php
Normal file
33
app/Services/Drift/DriftHasher.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
class DriftHasher
|
||||
{
|
||||
public function fingerprint(
|
||||
int $tenantId,
|
||||
string $scopeKey,
|
||||
string $subjectType,
|
||||
string $subjectExternalId,
|
||||
string $changeType,
|
||||
string $baselineHash,
|
||||
string $currentHash,
|
||||
): string {
|
||||
$parts = [
|
||||
(string) $tenantId,
|
||||
$this->normalize($scopeKey),
|
||||
$this->normalize($subjectType),
|
||||
$this->normalize($subjectExternalId),
|
||||
$this->normalize($changeType),
|
||||
$this->normalize($baselineHash),
|
||||
$this->normalize($currentHash),
|
||||
];
|
||||
|
||||
return hash('sha256', implode('|', $parts));
|
||||
}
|
||||
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return trim(mb_strtolower($value));
|
||||
}
|
||||
}
|
||||
40
app/Services/Drift/DriftRunSelector.php
Normal file
40
app/Services/Drift/DriftRunSelector.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
|
||||
class DriftRunSelector
|
||||
{
|
||||
/**
|
||||
* @return array{baseline:InventorySyncRun,current:InventorySyncRun}|null
|
||||
*/
|
||||
public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?array
|
||||
{
|
||||
$runs = InventorySyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_hash', $scopeKey)
|
||||
->where('status', InventorySyncRun::STATUS_SUCCESS)
|
||||
->whereNotNull('finished_at')
|
||||
->orderByDesc('finished_at')
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
if ($runs->count() < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = $runs->first();
|
||||
$baseline = $runs->last();
|
||||
|
||||
if (! $baseline instanceof InventorySyncRun || ! $current instanceof InventorySyncRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'baseline' => $baseline,
|
||||
'current' => $current,
|
||||
];
|
||||
}
|
||||
}
|
||||
13
app/Services/Drift/DriftScopeKey.php
Normal file
13
app/Services/Drift/DriftScopeKey.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
|
||||
class DriftScopeKey
|
||||
{
|
||||
public function fromRun(InventorySyncRun $run): string
|
||||
{
|
||||
return (string) $run->selection_hash;
|
||||
}
|
||||
}
|
||||
37
database/factories/FindingFactory.php
Normal file
37
database/factories/FindingFactory.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Finding>
|
||||
*/
|
||||
class FindingFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => hash('sha256', fake()->uuid()),
|
||||
'baseline_run_id' => null,
|
||||
'current_run_id' => null,
|
||||
'fingerprint' => hash('sha256', fake()->uuid()),
|
||||
'subject_type' => 'assignment',
|
||||
'subject_external_id' => fake()->uuid(),
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'acknowledged_at' => null,
|
||||
'acknowledged_by_user_id' => null,
|
||||
'evidence_jsonb' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('findings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('tenant_id')->constrained();
|
||||
|
||||
$table->string('finding_type');
|
||||
$table->string('scope_key');
|
||||
|
||||
$table->foreignId('baseline_run_id')->nullable()->constrained('inventory_sync_runs');
|
||||
$table->foreignId('current_run_id')->nullable()->constrained('inventory_sync_runs');
|
||||
|
||||
$table->string('fingerprint', 64);
|
||||
|
||||
$table->string('subject_type');
|
||||
$table->string('subject_external_id');
|
||||
|
||||
$table->string('severity');
|
||||
$table->string('status');
|
||||
|
||||
$table->timestampTz('acknowledged_at')->nullable();
|
||||
$table->foreignId('acknowledged_by_user_id')->nullable()->constrained('users');
|
||||
|
||||
$table->jsonb('evidence_jsonb')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['tenant_id', 'fingerprint']);
|
||||
|
||||
$table->index(['tenant_id', 'status']);
|
||||
$table->index(['tenant_id', 'scope_key']);
|
||||
$table->index(['tenant_id', 'baseline_run_id']);
|
||||
$table->index(['tenant_id', 'current_run_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('findings');
|
||||
}
|
||||
};
|
||||
15
resources/views/filament/pages/drift-landing.blade.php
Normal file
15
resources/views/filament/pages/drift-landing.blade.php
Normal file
@ -0,0 +1,15 @@
|
||||
<x-filament::page>
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Review new drift findings between the last two inventory sync runs for the current scope.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<x-filament::button tag="a" :href="$this->getFindingsUrl()">
|
||||
Findings
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament::page>
|
||||
60
tests/Feature/Drift/DriftBaselineSelectionTest.php
Normal file
60
tests/Feature/Drift/DriftBaselineSelectionTest.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Services\Drift\DriftRunSelector;
|
||||
|
||||
test('it selects the previous and latest successful runs for the same scope', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-a');
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_FAILED,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
$selector = app(DriftRunSelector::class);
|
||||
|
||||
$selected = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
|
||||
|
||||
expect($selected)->not->toBeNull();
|
||||
expect($selected['baseline']->getKey())->toBe($baseline->getKey());
|
||||
expect($selected['current']->getKey())->toBe($current->getKey());
|
||||
});
|
||||
|
||||
test('it returns null when fewer than two successful runs exist for scope', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-b');
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$selector = app(DriftRunSelector::class);
|
||||
|
||||
expect($selector->selectBaselineAndCurrent($tenant, $scopeKey))->toBeNull();
|
||||
});
|
||||
60
tests/Feature/Drift/DriftGenerationDispatchTest.php
Normal file
60
tests/Feature/Drift/DriftGenerationDispatchTest.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\InventorySyncRun;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('opening Drift dispatches generation when findings are missing', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-dispatch');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey): bool {
|
||||
return $job->tenantId === (int) $tenant->getKey()
|
||||
&& $job->userId === (int) $user->getKey()
|
||||
&& $job->baselineRunId === (int) $baseline->getKey()
|
||||
&& $job->currentRunId === (int) $current->getKey()
|
||||
&& $job->scopeKey === $scopeKey;
|
||||
});
|
||||
});
|
||||
|
||||
test('opening Drift does not dispatch generation when fewer than two successful runs exist', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-blocked');
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user