Files
Investidor-V2/tests/Feature/ProductUsageMetricsTest.php

207 lines
13 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\ProductUsageMetrics;
use App\Services\VirtualDesk;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\TestCase;
final class ProductUsageMetricsTest extends TestCase
{
use RefreshDatabase;
private User $manager;
private User $owner;
private string $service;
protected function setUp(): void
{
parent::setUp();
$this->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<array{0:int,1:string,2:string,3?:array<string,mixed>}> $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']);
}
}