Some checks failed
Main Confidence / confidence (push) Failing after 1m20s
## 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
233 lines
9.4 KiB
PHP
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,
|
|
]);
|
|
});
|