for($tenant)->create([ 'status' => Finding::STATUS_NEW, ]); $service = app(FindingWorkflowService::class); expect(fn () => $service->startProgress($finding, $tenant, $user)) ->toThrow(\InvalidArgumentException::class); expect(fn () => $service->resolve($finding, $tenant, $user, ' ')) ->toThrow(\InvalidArgumentException::class); }); it('resets due_at and sla_days when reopening and clears terminal fields', function (): void { [$user, $tenant] = createUserWithTenant(role: 'manager'); CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-02-24T10:00:00Z')); $finding = Finding::factory()->for($tenant)->create([ 'severity' => Finding::SEVERITY_HIGH, 'status' => Finding::STATUS_RESOLVED, 'resolved_at' => now()->subDay(), 'resolved_reason' => 'fixed', 'closed_at' => now()->subHours(2), 'closed_reason' => 'legacy-close', 'closed_by_user_id' => $user->getKey(), 'sla_days' => 30, 'due_at' => now()->subDays(10), ]); $reopened = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user); expect($reopened->status)->toBe(Finding::STATUS_REOPENED) ->and($reopened->reopened_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00') ->and($reopened->sla_days)->toBe(7) ->and($reopened->due_at?->toIso8601String())->toBe('2026-03-03T10:00:00+00:00') ->and($reopened->resolved_at)->toBeNull() ->and($reopened->resolved_reason)->toBeNull() ->and($reopened->closed_at)->toBeNull() ->and($reopened->closed_reason)->toBeNull() ->and($reopened->closed_by_user_id)->toBeNull(); CarbonImmutable::setTestNow(); }); it('keeps due_at stable across open workflow transitions even if severity changes while open', function (): void { [$user, $tenant] = createUserWithTenant(role: 'manager'); $dueAt = now()->addDays(14)->startOfMinute(); $finding = Finding::factory()->for($tenant)->create([ 'status' => Finding::STATUS_NEW, 'severity' => Finding::SEVERITY_MEDIUM, 'due_at' => $dueAt, 'sla_days' => 14, ]); $finding->forceFill(['severity' => Finding::SEVERITY_CRITICAL])->save(); $service = app(FindingWorkflowService::class); $service->triage($finding->refresh(), $tenant, $user); $inProgress = $service->startProgress($finding->refresh(), $tenant, $user); expect($inProgress->status)->toBe(Finding::STATUS_IN_PROGRESS) ->and($inProgress->due_at?->toIso8601String())->toBe($dueAt->toIso8601String()) ->and($inProgress->sla_days)->toBe(14); }); it('allows assigning current tenant members and rejects non-members', function (): void { [$manager, $tenant] = createUserWithTenant(role: 'manager'); $assignee = User::factory()->create(); createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator'); $outsider = User::factory()->create(); $finding = Finding::factory()->for($tenant)->create([ 'status' => Finding::STATUS_NEW, ]); $service = app(FindingWorkflowService::class); $assigned = $service->assign($finding, $tenant, $manager, (int) $assignee->getKey(), (int) $manager->getKey()); expect((int) $assigned->assignee_user_id)->toBe((int) $assignee->getKey()) ->and((int) $assigned->owner_user_id)->toBe((int) $manager->getKey()); expect(fn () => $service->assign($assigned, $tenant, $manager, (int) $outsider->getKey(), null)) ->toThrow(\InvalidArgumentException::class); });