TenantAtlas/apps/platform/tests/Feature/Findings/FindingsAssignmentHygieneClassificationTest.php
ahmido 12fb5ebb30
Some checks failed
Main Confidence / confidence (push) Failing after 1m20s
feat: add findings hygiene report and control catalog layering (#264)
## Summary
- add the workspace-scoped findings hygiene report, overview signal, and supporting classification service for broken assignments and stale in-progress work
- add Spec 225 artifacts and focused findings hygiene test coverage alongside the new Filament page and workspace overview wiring
- align product roadmap and spec candidates around the layered canonical control catalog, CIS library, and readiness model
- extend SpecKit constitution and templates with the XCUT-001 shared-pattern reuse guidance

## Notes
- validation commands and implementation close-out notes are documented in `specs/225-assignment-hygiene/plan.md` and `specs/225-assignment-hygiene/quickstart.md`
- this PR targets `dev` from `225-assignment-hygiene`

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #264
2026-04-22 12:26:18 +00:00

233 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Findings\FindingAssignmentHygieneService;
use App\Support\Audit\AuditActionId;
use Carbon\CarbonImmutable;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function assignmentHygieneServiceContext(string $role = 'readonly', string $workspaceRole = 'readonly'): array
{
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
return [
app(FindingAssignmentHygieneService::class),
$user,
$tenant->workspace()->firstOrFail(),
$tenant,
];
}
function assignmentHygieneFinding(Tenant $tenant, array $attributes = []): Finding
{
return Finding::factory()->for($tenant)->create(array_merge([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'subject_external_id' => fake()->uuid(),
], $attributes));
}
function recordAssignmentHygieneWorkflowAudit(Finding $finding, string $action, CarbonImmutable $recordedAt): AuditLog
{
return AuditLog::query()->create([
'workspace_id' => (int) $finding->workspace_id,
'tenant_id' => (int) $finding->tenant_id,
'action' => $action,
'status' => 'success',
'resource_type' => 'finding',
'resource_id' => (string) $finding->getKey(),
'summary' => 'Test workflow activity',
'recorded_at' => $recordedAt,
]);
}
it('classifies broken assignments from current tenant entitlement loss and soft-deleted assignees', function (): void {
[$service, $viewer, $workspace, $tenant] = assignmentHygieneServiceContext();
$lostMember = User::factory()->create(['name' => 'Lost Member']);
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $lostMember->getKey())
->delete();
$softDeletedAssignee = User::factory()->create(['name' => 'Deleted Member']);
createUserWithTenant($tenant, $softDeletedAssignee, role: 'readonly', workspaceRole: 'readonly');
$softDeletedAssignee->delete();
$healthyAssignee = User::factory()->create(['name' => 'Healthy Assignee']);
createUserWithTenant($tenant, $healthyAssignee, role: 'readonly', workspaceRole: 'readonly');
$brokenByMembership = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'status' => Finding::STATUS_TRIAGED,
'subject_external_id' => 'broken-membership',
]);
$brokenBySoftDelete = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $softDeletedAssignee->getKey(),
'status' => Finding::STATUS_NEW,
'subject_external_id' => 'broken-soft-delete',
]);
$healthyAssigned = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $healthyAssignee->getKey(),
'status' => Finding::STATUS_NEW,
'subject_external_id' => 'healthy-assigned',
]);
$ordinaryIntake = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => null,
'status' => Finding::STATUS_NEW,
'subject_external_id' => 'ordinary-intake',
]);
$issues = $service->issueQuery($workspace, $viewer)->get()->keyBy('id');
$summary = $service->summary($workspace, $viewer);
expect($issues->keys()->all())
->toContain((int) $brokenByMembership->getKey(), (int) $brokenBySoftDelete->getKey())
->not->toContain((int) $healthyAssigned->getKey(), (int) $ordinaryIntake->getKey())
->and($service->reasonLabelsFor($issues[$brokenByMembership->getKey()]))
->toBe(['Broken assignment'])
->and($service->reasonLabelsFor($issues[$brokenBySoftDelete->getKey()]))
->toBe(['Broken assignment'])
->and($issues[$brokenBySoftDelete->getKey()]->assigneeUser?->name)
->toBe('Deleted Member')
->and($summary)
->toBe([
'unique_issue_count' => 2,
'broken_assignment_count' => 2,
'stale_in_progress_count' => 0,
]);
});
it('classifies stale in-progress work from meaningful workflow activity and excludes recently advanced or merely overdue work', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
[$service, $viewer, $workspace, $tenant] = assignmentHygieneServiceContext();
$staleFinding = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(10),
'subject_external_id' => 'stale-finding',
]);
recordAssignmentHygieneWorkflowAudit($staleFinding, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(10));
$recentlyAssigned = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(10),
'subject_external_id' => 'recently-assigned',
]);
recordAssignmentHygieneWorkflowAudit($recentlyAssigned, AuditActionId::FindingAssigned->value, CarbonImmutable::now()->subDays(2));
$recentlyReopened = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(10),
'reopened_at' => now()->subDay(),
'subject_external_id' => 'recently-reopened',
]);
recordAssignmentHygieneWorkflowAudit($recentlyReopened, AuditActionId::FindingReopened->value, CarbonImmutable::now()->subDay());
$overdueButActive = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(10),
'due_at' => now()->subDay(),
'subject_external_id' => 'overdue-but-active',
]);
recordAssignmentHygieneWorkflowAudit($overdueButActive, AuditActionId::FindingAssigned->value, CarbonImmutable::now()->subHours(12));
$issues = $service->issueQuery(
$workspace,
$viewer,
reasonFilter: FindingAssignmentHygieneService::REASON_STALE_IN_PROGRESS,
)->get();
$summary = $service->summary($workspace, $viewer);
expect($issues->pluck('id')->all())
->toBe([(int) $staleFinding->getKey()])
->and($service->reasonLabelsFor($issues->firstOrFail()))
->toBe(['Stale in progress'])
->and($service->lastWorkflowActivityAt($issues->firstOrFail())?->toIso8601String())
->toBe(CarbonImmutable::now()->subDays(10)->toIso8601String())
->and($summary)
->toBe([
'unique_issue_count' => 1,
'broken_assignment_count' => 0,
'stale_in_progress_count' => 1,
]);
});
it('counts multi-reason findings once while excluding healthy assigned work and ordinary intake backlog', function (): void {
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
[$service, $viewer, $workspace, $tenant] = assignmentHygieneServiceContext();
$lostMember = User::factory()->create(['name' => 'Lost Worker']);
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $lostMember->getKey())
->delete();
$brokenAndStale = assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $lostMember->getKey(),
'status' => Finding::STATUS_IN_PROGRESS,
'in_progress_at' => now()->subDays(8),
'subject_external_id' => 'broken-and-stale',
]);
recordAssignmentHygieneWorkflowAudit($brokenAndStale, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(8));
assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => (int) $viewer->getKey(),
'status' => Finding::STATUS_TRIAGED,
'subject_external_id' => 'healthy-assigned',
]);
assignmentHygieneFinding($tenant, [
'owner_user_id' => (int) $viewer->getKey(),
'assignee_user_id' => null,
'status' => Finding::STATUS_NEW,
'subject_external_id' => 'ordinary-intake',
]);
$issues = $service->issueQuery($workspace, $viewer)->get();
$summary = $service->summary($workspace, $viewer);
expect($issues)->toHaveCount(1)
->and((int) $issues->firstOrFail()->getKey())->toBe((int) $brokenAndStale->getKey())
->and($service->reasonLabelsFor($issues->firstOrFail()))
->toBe(['Broken assignment', 'Stale in progress'])
->and($summary)
->toBe([
'unique_issue_count' => 1,
'broken_assignment_count' => 1,
'stale_in_progress_count' => 1,
]);
});