Files
Investidor-V2/tests/Feature/ProductExperimentsTest.php
T

273 lines
19 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\InterestRequest;
use App\Models\User;
use App\Services\ProductExperiments;
use Carbon\CarbonImmutable;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Tests\TestCase;
final class ProductExperimentsTest extends TestCase
{
use RefreshDatabase;
private User $manager;
private User $other;
private string $url = '/management/product-experiments';
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->other = User::factory()->create();
config(['portal.managers' => [$this->manager->email], 'investmoz_ai.enabled' => false, 'suitecrm.enabled' => false, 'portal.notifications_enabled' => false]);
}
protected function tearDown(): void
{
$this->travelBack();
parent::tearDown();
}
private function body(array $values = [], int $version = 0, array $extra = []): array
{
return [...['version' => $version, 'operation_key' => (string) Str::uuid(), 'reason' => $version > 0 ? 'SYNTHETIC revision, no real experiment' : '', 'values' => ['state' => 'draft', 'title' => 'PRIVATE SYNTHETIC experiment', ...$values], 'indicator_definition_version' => config('product-metrics.version')], ...$extra];
}
private function plan(string $state = 'planned'): array
{
$values = array_fill_keys(['reference', 'hypothesis', 'segment', 'control', 'variant', 'assignment', 'criterion', 'transparency', 'consent', 'security', 'equality'], 'PRIVATE SYNTHETIC description; not production evidence');
return [...$values, 'title' => 'PRIVATE SYNTHETIC plan', 'state' => $state, 'change_kind' => 'form', 'indicator_id' => 'manual', 'indicator_title' => 'SYNTHETIC proportion', 'indicator_definition' => 'SYNTHETIC numerator / denominator, only observed sample', 'indicator_unit' => '%', 'preserves_requirements' => 'yes', 'period_from' => '2026-11-02', 'period_to' => '2026-11-04'];
}
private function observation(): array
{
return ['observed_from' => '2026-11-02', 'observed_to' => '2026-11-02', 'control_result' => 'SYNTHETIC 0; not an actual user sample', 'variant_result' => 'SYNTHETIC unknown; no inferred zero', 'result' => 'SYNTHETIC inconclusive comparison', 'limitations' => 'SYNTHETIC fixture, no real allocation or causal claim', 'evidence' => 'SYNTHETIC isolated QA receipt', 'decision' => 'inconclusive', 'decision_reason' => 'No real experiment or customer acceptance is claimed.'];
}
private function create(array $body): string
{
return $this->actingAs($this->manager)->postJson($this->url, $body)->assertCreated()->json('id');
}
public function test_existing_manager_authority_private_transport_and_read_only_views_are_preserved(): void
{
$this->getJson($this->url)->assertUnauthorized();
$this->actingAs($this->other)->getJson($this->url)->assertForbidden();
$this->postJson($this->url, $this->body())->assertForbidden();
$this->actingAs($this->manager)->getJson($this->url)->assertOk()->assertHeader('Cache-Control', 'no-store, private')->assertJsonPath('total', 0);
$this->get($this->url)->assertOk()->assertInertia(fn ($p) => $p->component('management/experiments/index'));
$this->get($this->url.'/new')->assertOk()->assertInertia(fn ($p) => $p->component('management/experiments/edit'));
$this->assertDatabaseCount('product_experiments', 0);
$this->assertDatabaseCount('product_experiment_revisions', 0);
$this->assertSame(0, DB::table('audit_events')->where('source_system', 'product-experiments')->count());
config(['portal.managers' => []]);
$this->getJson($this->url)->assertForbidden();
$this->manager->forceFill(['email_verified_at' => null])->save();
config(['portal.managers' => [$this->manager->email]]);
$this->getJson($this->url)->assertForbidden();
}
public function test_incomplete_drafts_are_encrypted_replayable_and_version_conflicts_never_overwrite_history(): void
{
$first = $this->body(['hypothesis' => 'PRIVATE SYNTHETIC original']);
$id = $this->create($first);
$this->postJson($this->url, $first)->assertCreated()->assertJsonPath('replayed', true)->assertJsonPath('id', $id);
$this->postJson($this->url, [...$first, 'values' => [...$first['values'], 'title' => 'Changed']])->assertConflict();
$this->assertStringNotContainsString('PRIVATE SYNTHETIC', DB::table('product_experiment_revisions')->value('snapshot'));
$this->assertStringNotContainsString('PRIVATE SYNTHETIC', DB::table('audit_events')->where('source_system', 'product-experiments')->get()->toJson());
$changed = $this->body(['hypothesis' => 'PRIVATE SYNTHETIC changed'], 1);
$this->postJson($this->url.'/'.$id.'/revisions', $changed)->assertOk()->assertJsonPath('version', 2);
$this->postJson($this->url.'/'.$id.'/revisions', $changed)->assertOk()->assertJsonPath('replayed', true);
$this->postJson($this->url.'/'.$id.'/revisions', $this->body(['title' => 'Stale edit'], 1))->assertConflict();
$this->postJson($this->url, $first)->assertCreated()->assertJsonPath('version', 1);
$this->getJson($this->url.'/'.$id)->assertOk()->assertJsonPath('values.hypothesis', 'PRIVATE SYNTHETIC changed')->assertJsonPath('currentVersion', 2);
$this->getJson($this->url.'/'.$id.'?revision=1')->assertOk()->assertJsonPath('historical', true)->assertJsonPath('values.hypothesis', 'PRIVATE SYNTHETIC original');
$this->postJson($this->url.'/'.$id.'/revisions', $this->body($changed['values'], 2))->assertOk()->assertJsonPath('unchanged', true);
$this->postJson($this->url, $this->body(['server_snapshot' => 'FORGED']))->assertUnprocessable();
$this->assertDatabaseCount('product_experiment_revisions', 2);
$this->assertDatabaseCount('desk_cases', 0);
DB::statement('SET CONSTRAINTS product_experiment_current_revision IMMEDIATE');
}
public function test_registered_plans_require_actual_definition_and_all_four_protections_without_backdating_authorship(): void
{
$this->actingAs($this->manager);
foreach (['transparency', 'consent', 'security', 'equality', 'preserves_requirements'] as $key) {
$this->postJson($this->url, $this->body([...$this->plan(), $key => '']))->assertUnprocessable()->assertJsonValidationErrors('values.'.$key);
}
$this->postJson($this->url, $this->body(['state' => 'planned']))->assertUnprocessable();
$this->assertDatabaseCount('product_experiments', 0);
$id = $this->create($this->body($this->plan(), extra: ['actor' => ['id' => $this->other->id], 'registration' => ['first_at' => '2000-01-01']]));
$r = $this->getJson($this->url.'/'.$id)->assertOk()->json('document');
$this->assertSame('2026-11-03T12:00:00.123456+00:00', $r['registration']['first_at']);
$this->assertSame($this->manager->id, $r['actor']['id']);
$this->assertSame(0, $r['registration']['amendments']);
$this->assertSame(config('product-experiments.fields'), $r['vocabulary']['fields']);
$this->assertSame('manual', $r['indicator']['id']);
config(['product-experiments.version' => 'SYNTHETIC new registry edition', 'product-experiments.fields.title' => 'SYNTHETIC changed field label']);
$this->postJson($this->url.'/'.$id.'/revisions', $this->body([...$this->plan(), 'title' => 'SYNTHETIC title only correction'], 1))->assertOk();
$this->assertSame($r['indicator'], $this->getJson($this->url.'/'.$id)->json('document.indicator'));
$this->getJson($this->url.'/'.$id.'?revision=1')->assertOk()->assertJsonPath('labels.title', $r['vocabulary']['fields']['title'])->assertJsonPath('document.definition_version', $r['definition_version']);
}
public function test_observation_decision_and_optional_report_preserve_unknowns_and_do_not_claim_variant_measurement(): void
{
$values = [...$this->plan('reviewed'), ...$this->observation()];
$id = $this->create($this->body($values, extra: ['attach_report' => true, 'operational_report' => ['FORGED' => 99]]));
$r = $this->getJson($this->url.'/'.$id)->assertOk()->json('document');
$this->assertSame('operational_cohort_not_experiment_arm', $r['report_scope']);
$this->assertTrue($r['report_matches_observation']);
$this->assertNull(collect($r['operational_report']['metrics'])->firstWhere('id', 'submission_rate')['value']);
$this->assertSame(0, collect($r['operational_report']['metrics'])->firstWhere('id', 'requests_created')['value']);
$this->assertSame('inconclusive', $r['values']['decision']);
$this->assertArrayNotHasKey('FORGED', $r['operational_report']);
$this->travelTo(CarbonImmutable::parse('2026-11-04 12:00:00', 'UTC'));
$values['observed_to'] = '2026-11-03';
$this->postJson($this->url.'/'.$id.'/revisions', $this->body($values, 1))->assertOk();
$new = $this->getJson($this->url.'/'.$id)->assertOk()->json('document');
$this->assertSame($r['operational_report'], $new['operational_report']);
$this->assertFalse($new['report_matches_observation']);
$this->postJson($this->url.'/'.$id.'/revisions', $this->body($values, 2, ['remove_report' => true]))->assertOk();
$this->getJson($this->url.'/'.$id)->assertOk()->assertJsonPath('document.operational_report', null);
$this->getJson($this->url.'/'.$id.'?revision=1')->assertOk()->assertJsonPath('document.operational_report.filters.to', '2026-11-02');
$this->assertDatabaseCount('desk_cases', 0);
$this->assertDatabaseCount('desk_requests', 0);
$this->assertSame(0, InterestRequest::query()->count());
}
public function test_amending_a_registered_plan_keeps_prior_observations_bound_until_explicit_review(): void
{
$values = [...$this->plan('reviewed'), ...$this->observation()];
$id = $this->create($this->body($values));
$original = $this->getJson($this->url.'/'.$id)->json('document');
$values = [...$values, 'state' => 'planned', 'hypothesis' => 'SYNTHETIC changed hypothesis'];
$this->postJson($this->url.'/'.$id.'/revisions', $this->body($values, 1))->assertUnprocessable()->assertJsonValidationErrors('confirm_plan_change');
$this->postJson($this->url.'/'.$id.'/revisions', $this->body($values, 1, ['confirm_plan_change' => true]))->assertOk();
$new = $this->getJson($this->url.'/'.$id)->json('document');
$this->assertSame(1, $new['registration']['amendments']);
$this->assertTrue($new['registration']['amended_after_observation']);
$this->assertSame($original['plan_sha256'], $new['observation_plan_sha256']);
$this->assertNotSame($new['plan_sha256'], $new['observation_plan_sha256']);
$values['state'] = 'reviewed';
$this->postJson($this->url.'/'.$id.'/revisions', $this->body($values, 2))->assertUnprocessable()->assertJsonValidationErrors('values.result');
$this->postJson($this->url.'/'.$id.'/revisions', $this->body($values, 2, ['confirm_observation_plan' => true]))->assertOk();
$last = $this->getJson($this->url.'/'.$id)->json('document');
$this->assertSame($last['plan_sha256'], $last['observation_plan_sha256']);
$this->assertTrue($last['observation_reaffirmed_for_plan']);
$this->assertSame($original['registration']['first_at'], $last['registration']['first_at']);
$this->getJson($this->url.'/'.$id.'?revision=1')->assertOk()->assertJsonPath('values.hypothesis', $original['values']['hypothesis']);
}
public function test_catalogue_definitions_remain_frozen_and_refresh_requires_explicit_plan_revision(): void
{
$body = $this->body([...$this->plan(), 'indicator_id' => 'submission_rate']);
$id = $this->create($body);
$old = $this->getJson($this->url.'/'.$id)->json('document.indicator');
config(['product-metrics.version' => 'SYNTHETIC new definition edition']);
$values = [...$body['values'], 'title' => 'SYNTHETIC title correction'];
$this->postJson($this->url.'/'.$id.'/revisions', $this->body($values, 1))->assertOk();
$this->assertSame($old, $this->getJson($this->url.'/'.$id)->json('document.indicator'));
$this->postJson($this->url, $body)->assertCreated()->assertJsonPath('version', 1)->assertJsonPath('replayed', true);
$this->postJson($this->url.'/'.$id.'/revisions', $this->body($values, 2, ['refresh_indicator' => true]))->assertUnprocessable()->assertJsonValidationErrors('confirm_plan_change');
$this->postJson($this->url.'/'.$id.'/revisions', $this->body($values, 2, ['refresh_indicator' => true, 'confirm_plan_change' => true]))->assertOk();
$this->getJson($this->url.'/'.$id)->assertOk()->assertJsonPath('document.indicator.definition_version', 'SYNTHETIC new definition edition');
$this->getJson($this->url.'/'.$id.'?revision=1')->assertOk()->assertJsonPath('document.indicator.definition_version', $old['definition_version']);
}
public function test_amended_period_can_preserve_old_observation_dates_without_reclassifying_them_as_new_results(): void
{
$values = [...$this->plan('reviewed'), ...$this->observation()];
$id = $this->create($this->body($values));
$old = $this->getJson($this->url.'/'.$id)->json('document');
$values = [...$values, 'state' => 'planned', 'period_from' => '2026-11-03', 'period_to' => '2026-11-05'];
$this->postJson($this->url.'/'.$id.'/revisions', $this->body($values, 1, ['confirm_plan_change' => true]))->assertOk();
$current = $this->getJson($this->url.'/'.$id)->json('document');
$this->assertSame('2026-11-02', $current['values']['observed_from']);
$this->assertSame($old['plan_sha256'], $current['observation_plan_sha256']);
$this->assertNotSame($current['plan_sha256'], $current['observation_plan_sha256']);
$this->postJson($this->url.'/'.$id.'/revisions', $this->body([...$values, 'state' => 'reviewed'], 2, ['confirm_observation_plan' => true]))->assertUnprocessable()->assertJsonValidationErrors('values.observed_from');
$this->getJson($this->url.'/'.$id.'?revision=1')->assertOk()->assertJsonPath('values.period_from', '2026-11-02')->assertJsonPath('values.observed_from', '2026-11-02');
$this->assertDatabaseCount('product_experiment_revisions', 2);
}
public function test_invalid_dates_unknown_indicators_and_incomplete_results_cannot_be_recorded_as_complete(): void
{
$this->actingAs($this->manager);
foreach ([['period_from' => '2026-11-05'], ['indicator_id' => 'forged'], ['indicator_definition' => ''], ['state' => 'observing', 'observed_from' => '2026-11-04'], ['state' => 'reviewed'], ['observed_from' => '2026-11-01']] as $bad) {
$this->postJson($this->url, $this->body([...$this->plan(), ...$bad]))->assertUnprocessable();
}
$this->postJson($this->url, $this->body($this->plan(), extra: ['attach_report' => true]))->assertUnprocessable();
$this->postJson($this->url, $this->body([...$this->plan('reviewed'), ...$this->observation(), 'report_service' => 'invented'], extra: ['attach_report' => true]))->assertUnprocessable();
$this->assertDatabaseCount('product_experiments', 0);
$this->assertDatabaseCount('product_experiment_revisions', 0);
}
public function test_database_enforces_append_only_history_immutable_origin_and_current_revision_integrity(): void
{
$id = $this->create($this->body());
DB::statement('SET CONSTRAINTS product_experiment_current_revision IMMEDIATE');
foreach ([fn () => DB::table('product_experiment_revisions')->where('experiment_id', $id)->update(['snapshot' => 'FORGED']), fn () => DB::table('product_experiments')->where('id', $id)->delete(), fn () => DB::statement('TRUNCATE product_experiment_revisions CASCADE'), fn () => DB::table('product_experiments')->where('id', $id)->update(['version' => 9]), fn () => DB::table('product_experiments')->where('id', $id)->update(['version' => 2, 'created_by' => $this->other->id]), function () use ($id) {
DB::table('product_experiments')->where('id', $id)->update(['version' => 2]);
DB::statement('SET CONSTRAINTS product_experiment_current_revision IMMEDIATE');
}] as $mutation) {
try {
DB::transaction($mutation);
$this->fail('History/identity integrity must reject this mutation.');
} catch (QueryException $error) {
$this->assertContains($error->getCode(), ['23514', '23503']);
}
}
DB::statement('SET CONSTRAINTS product_experiment_current_revision DEFERRED');
$this->postJson($this->url.'/'.$id.'/revisions', $this->body(['title' => 'SYNTHETIC valid next version'], 1))->assertOk()->assertJsonPath('version', 2);
DB::statement('SET CONSTRAINTS product_experiment_current_revision IMMEDIATE');
$this->assertDatabaseCount('product_experiment_revisions', 2);
}
public function test_history_and_listing_are_paginated_and_historical_reads_do_not_create_revisions(): void
{
$id = $this->create($this->body());
$service = app(ProductExperiments::class);
for ($v = 1; $v < 28; $v++) {
$service->save($this->manager, $id, $this->body(['title' => 'SYNTHETIC version '.($v + 1)], $v));
}
for ($v = 1; $v < 26; $v++) {
$service->save($this->manager, null, $this->body(['title' => 'SYNTHETIC additional '.$v]));
}
$this->getJson($this->url.'/'.$id)->assertOk()->assertJsonCount(25, 'history')->assertJsonPath('historyNext', 4)->assertJsonPath('history.0.version', 28);
$this->getJson($this->url.'/'.$id.'?history_before=4&revision=1')->assertOk()->assertJsonCount(3, 'history')->assertJsonPath('historyNext', null)->assertJsonPath('historical', true);
$this->getJson($this->url.'?page=999')->assertOk()->assertJsonPath('page', 2)->assertJsonPath('total', 26)->assertJsonCount(1, 'items');
$this->getJson($this->url.'?state=planned')->assertOk()->assertJsonPath('total', 0);
$this->getJson($this->url.'/'.$id.'?revision=999')->assertNotFound();
$this->assertDatabaseCount('product_experiment_revisions', 53);
}
public function test_missing_registry_schema_does_not_disable_the_operational_metrics_or_create_records(): void
{
$this->actingAs($this->manager);
Schema::rename('product_experiment_revisions', 'product_experiment_revisions_qa_temporarily_unavailable');
try {
$this->getJson($this->url)->assertOk()->assertJsonPath('installed', false)->assertJsonCount(0, 'items');
$this->getJson($this->url.'/new')->assertOk()->assertJsonPath('installed', false)->assertJsonPath('values.state', 'draft');
$this->postJson($this->url, $this->body())->assertStatus(503);
$this->getJson('/management/product-usage')->assertOk()->assertJsonCount(13, 'report.unmeasured');
$this->assertDatabaseCount('product_experiments', 0);
} finally {
Schema::rename('product_experiment_revisions_qa_temporarily_unavailable', 'product_experiment_revisions');
}
}
}