withoutVite(); $this->travelTo(CarbonImmutable::parse('2026-11-03 12:00:00.123456', 'UTC')); Http::preventStrayRequests(); $this->manager = User::factory()->create(); $this->owner = User::factory()->create(); config(['portal.managers' => [$this->manager->email], 'portal.notifications_enabled' => false, 'suitecrm.enabled' => false, 'investmoz_ai.enabled' => false]); $this->service = $this->service('synthetic-service', 1); } protected function tearDown(): void { $this->travelBack(); parent::tearDown(); } private function service(string $code, int $version): string { $id = (string) Str::uuid(); DB::table('desk_service_versions')->insert(['id' => $id, 'code' => $code, 'version' => $version, 'status' => 'published', 'definition' => json_encode(['title' => 'SYNTHETIC service', 'fields' => ['title' => ['type' => 'text', 'label' => 'Title', 'required' => true]]]), 'source' => '{}', 'author_id' => $this->manager->id, 'published_at' => now()->subDays(10), 'created_at' => now()->subDays(10), 'updated_at' => now()->subDays(10)]); return $id; } /** @param list}> $events */ private function request(array $events, string $created = '2026-11-02 10:00:00', ?string $service = null, ?int $version = null): string { $case = (string) Str::uuid(); $id = (string) Str::uuid(); $desk = app(VirtualDesk::class); DB::table('desk_cases')->insert(['id' => $case, 'user_id' => $this->owner->id, 'title' => 'PRIVATE SYNTHETIC title', 'client_key' => (string) Str::uuid(), 'payload' => $desk->encode(['private' => 'PRIVATE SYNTHETIC DATA']), 'workflow' => '[]', 'created_at' => $created, 'updated_at' => $created]); $latest = $events === [] ? $created : $events[array_key_last($events)][2]; DB::table('desk_requests')->insert(['id' => $id, 'case_id' => $case, 'service_version_id' => $service ?? $this->service, 'client_key' => (string) Str::uuid(), 'payload' => $desk->encode(['title' => 'PRIVATE SYNTHETIC DATA']), 'version' => $version ?? max(array_column($events, 0) ?: [1]), 'created_at' => $created, 'updated_at' => $latest]); foreach ($events as [$v, $action, $date, $payload]) { $this->event($id, $v, $action, $date, $payload ?? []); } return $id; } private function event(string $id, int $version, string $action, string $date, array $payload = []): void { DB::table('desk_events')->insert(['id' => (string) Str::uuid(), 'case_id' => DB::table('desk_requests')->where('id', $id)->value('case_id'), 'request_id' => in_array($action, ['company_comparison_saved', 'investment_guide_saved'], true) ? null : $id, 'actor_id' => $this->manager->id, 'action' => $action, 'version' => $version, 'payload' => app(VirtualDesk::class)->encode($payload), 'created_at' => $date]); } private function report(array $filters = []): array { return $this->actingAs($this->manager)->getJson('/management/product-usage?'.http_build_query(['from' => '2026-11-02', 'to' => '2026-11-02', ...$filters]))->assertOk()->assertHeader('Cache-Control', 'no-store, private')->json('report'); } private function metric(array $report, string $id): array { return collect($report['metrics'])->firstWhere('id', $id); } public function test_report_is_authorised_read_only_and_contains_no_private_records_or_invented_empty_rates(): void { $this->getJson('/management/product-usage')->assertUnauthorized(); $this->actingAs($this->owner)->getJson('/management/product-usage')->assertForbidden(); $before = DB::table('audit_events')->count(); $r = $this->report(); $this->assertSame(0, $this->metric($r, 'requests_created')['value']); $this->assertNull($this->metric($r, 'submission_rate')['value']); $this->assertSame(0, $this->metric($r, 'not_submitted')['value']); $this->assertSame($before, DB::table('audit_events')->count()); $this->assertDatabaseCount('desk_cases', 0); $this->assertDatabaseCount('desk_events', 0); $this->assertSame(13, count($r['unmeasured'])); $this->get('/management/product-usage')->assertOk()->assertInertia(fn ($page) => $page->component('management/product-usage')); config(['portal.managers' => []]); $this->getJson('/management/product-usage')->assertForbidden(); $this->manager->forceFill(['email_verified_at' => null])->save(); config(['portal.managers' => [$this->manager->email]]); $this->getJson('/management/product-usage')->assertForbidden(); } public function test_first_submission_and_return_rates_deduplicate_versions_and_exclude_incomplete_histories(): void { $id = $this->request([[1, 'draft_created', '2026-11-02 10:00:00', []], [2, 'submitted', '2026-11-02 10:01:00', []], [3, 'request_returned', '2026-11-02 10:02:00', ['return_reason_code' => 'missing_documents']], [4, 'submitted', '2026-11-02 11:00:00', []], [4, 'request_context_reviewed', '2026-11-02 11:00:00', []], [5, 'request_returned', '2026-11-02 11:01:00', []]]); // The mutable latest-submission field must never replace the first immutable event. DB::table('desk_requests')->where('id', $id)->update(['submitted_at' => '2026-11-02 11:00:00']); $this->request([[1, 'draft_created', '2026-11-02 10:00:00', []]]); $this->request([[1, 'draft_created', '2026-11-02 10:00:00', []], [3, 'submitted', '2026-11-02 10:02:00', []]]); $legacy = $this->request([[1, 'draft_created', '2026-11-02 10:00:00', []]], version: 3); DB::table('desk_requests')->where('id', $legacy)->update(['updated_at' => null]); $r = $this->report(); $this->assertSame(4, $r['coverage']['cohort_requests']); $this->assertSame(2, $r['coverage']['complete_histories']); $this->assertSame(2, $r['coverage']['unknown_histories']); $this->assertEquals(50, $this->metric($r, 'submission_rate')['value']); $this->assertEquals(100, $this->metric($r, 'return_rate')['value']); $this->assertEquals(100, $this->metric($r, 'error_document_return_rate')['value']); $this->assertEquals(60, $this->metric($r, 'first_submission_seconds')['value']); $this->assertSame('partial', $this->metric($r, 'submission_rate')['state']); $this->assertSame(1, $this->metric($r, 'not_submitted')['value']); $this->assertStringNotContainsString('PRIVATE SYNTHETIC', json_encode($r)); $this->assertStringNotContainsString($id, json_encode($r)); } public function test_historical_unclassified_returns_remain_unknown_and_free_text_does_not_classify_them(): void { $this->request([[1, 'draft_created', '2026-11-02 10:00:00', []], [2, 'submitted', '2026-11-02 10:01:00', []], [3, 'request_returned', '2026-11-02 10:02:00', ['reason' => 'Documentação em falta, erro nos dados']]]); $this->request([[1, 'draft_created', '2026-11-02 10:00:00', []], [2, 'submitted', '2026-11-02 10:01:00', []], [3, 'request_returned', '2026-11-02 10:02:00', ['return_reason_code' => 'other']]]); $r = $this->report(); $this->assertNull($this->metric($r, 'error_document_return_rate')['value']); $this->assertSame(0, $this->metric($r, 'error_document_return_rate')['numerator']); $this->assertSame(2, $this->metric($r, 'error_document_return_rate')['denominator']); $this->assertSame(1, $r['coverage']['unclassified_returned_requests']); $this->assertEquals(100, $this->metric($r, 'return_rate')['value']); } public function test_inclusive_days_cutoff_historical_service_versions_and_distinct_saved_dossiers(): void { $v2 = $this->service('synthetic-service', 2); $other = $this->service('other-service', 1); $a = $this->request([[1, 'draft_created', '2026-11-02 00:00:00', []], [2, 'submitted', '2026-11-03 00:00:00', []]], '2026-11-02 00:00:00'); $this->request([[1, 'draft_created', '2026-11-02 23:59:59', []]], '2026-11-02 23:59:59', $v2); $this->request([], '2026-11-01 23:59:59'); $this->request([], '2026-11-03 00:00:00'); $this->request([], '2026-11-02 12:00:00', $other); $this->event($a, 2, 'company_comparison_saved', '2026-11-02 11:00:00'); $this->event($a, 3, 'company_comparison_saved', '2026-11-02 12:00:00'); $this->event($a, 3, 'investment_guide_saved', '2026-11-02 13:00:00'); $r = $this->report(['service_code' => 'synthetic-service']); $this->assertSame(2, $this->metric($r, 'requests_created')['value']); $this->assertSame(2, $this->metric($r, 'dossiers_created')['value']); $this->assertSame(1, $this->metric($r, 'saved_comparison')['value']); $this->assertSame(1, $this->metric($r, 'saved_guide')['value']); $this->assertSame('2026-11-03T00:00:00.000000+00:00', $r['observed_before']); $this->assertSame(2, collect($r['services'])->firstWhere('code', 'synthetic-service')['version']); $this->assertSame(0, $this->metric($r, 'submission_rate')['numerator']); } public function test_negative_event_clock_intervals_are_excluded_without_turning_zero_duration_into_unknown(): void { $this->request([[1, 'draft_created', '2026-11-02 10:00:00', []], [2, 'submitted', '2026-11-02 09:59:59', []]]); $this->request([[1, 'draft_created', '2026-11-02 10:00:00', []], [2, 'submitted', '2026-11-02 10:00:00', []]]); $r = $this->report(); $this->assertEquals(0, $this->metric($r, 'first_submission_seconds')['value']); $this->assertSame('partial', $this->metric($r, 'first_submission_seconds')['state']); $this->assertSame(1, $r['coverage']['invalid_duration_requests']); $this->assertSame(1, $r['coverage']['valid_duration_requests']); } public function test_transition_keeps_optional_versioned_category_and_application_event_time_without_changing_authority(): void { $id = $this->request([[1, 'draft_created', '2026-11-02 10:00:00', []], [2, 'submitted', '2026-11-02 10:01:00', []]]); DB::table('desk_requests')->where('id', $id)->update(['status' => 'submitted', 'protocol' => 'SYNTHETIC-'.$id, 'submitted_snapshot' => app(VirtualDesk::class)->encode([]), 'submitted_at' => '2026-11-02 10:01:00']); $url = '/balcao-virtual/pedidos/'.$id.'/estado'; $data = ['version' => 2, 'status' => 'returned', 'reason' => 'SYNTHETIC correction', 'editable_fields' => ['title'], 'return_reason_code' => 'validation_error']; $this->actingAs($this->owner)->postJson($url, $data)->assertForbidden(); $this->actingAs($this->manager)->postJson($url, [...$data, 'return_reason_code' => 'invented'])->assertUnprocessable(); $this->postJson($url, $data)->assertOk(); $event = DB::table('desk_events')->where('request_id', $id)->where('action', 'request_returned')->first(); $p = app(VirtualDesk::class)->decode($event->payload); $this->assertSame('validation_error', $p['return_reason_code']); $this->assertSame(config('product-metrics.return_reasons.validation_error'), $p['return_reason_label']); $this->assertSame(config('product-metrics.version'), $p['return_reason_definition']); $this->assertSame(now()->toDateTimeString(), CarbonImmutable::parse($event->created_at)->toDateTimeString()); $this->postJson($url, $data)->assertConflict(); $this->assertSame(1, DB::table('desk_events')->where('action', 'request_returned')->count()); $other = $this->request([[1, 'draft_created', '2026-11-02 10:00:00', []], [2, 'submitted', '2026-11-02 10:01:00', []]]); DB::table('desk_requests')->where('id', $other)->update(['status' => 'submitted', 'protocol' => 'SYNTHETIC-'.$other, 'submitted_snapshot' => app(VirtualDesk::class)->encode([]), 'submitted_at' => '2026-11-02 10:01:00']); unset($data['return_reason_code']); $this->postJson('/balcao-virtual/pedidos/'.$other.'/estado', $data)->assertOk(); $legacy = DB::table('desk_events')->where('request_id', $other)->where('action', 'request_returned')->value('payload'); $this->assertSame('unspecified', app(VirtualDesk::class)->decode($legacy)['return_reason_code']); } public function test_report_rejects_invalid_periods_services_and_does_not_fall_back_to_another_scope(): void { $this->actingAs($this->manager); foreach ([['from' => 'bad'], ['from' => '2026-11-03', 'to' => '2026-11-02'], ['to' => '2026-11-04'], ['service_code' => 'invented']] as $bad) { $this->getJson('/management/product-usage?'.http_build_query(['from' => '2026-11-02', 'to' => '2026-11-02', ...$bad]))->assertUnprocessable(); } $r = app(ProductUsageMetrics::class)->report($this->manager, []); $this->assertSame('existing_transaction', $r['read_consistency']); $this->assertSame('2026-11-03T12:00:00.123456+00:00', $r['observed_before']); } }