withoutVite(); } private function fixture(array $payload = [], array $changes = []): Opportunity { $payload = array_replace(['title' => 'Synthetic public offer', 'summary' => 'Approved public summary.', 'category' => 'business_sale', 'province_id' => null, 'district_id' => null, 'price_mode' => 'amount', 'amount' => '12345.67', 'currency' => 'MZN'], $payload); $hash = OfferPublicCatalogue::projectionHash($payload); $publication = array_replace_recursive(['contract' => OfferPublicCatalogue::CONTRACT, 'qa_fixture' => true, 'revision' => 1, 'payload' => $payload, 'approval' => ['state' => 'approved', 'hash' => $hash, 'actor_id' => 1, 'at' => now()->subDays(2)->toIso8601String()], 'publication' => ['state' => 'published', 'hash' => $hash, 'actor_id' => 2, 'at' => now()->subDay()->toIso8601String()]], $changes); $record = Opportunity::create(['title' => 'PRIVATE_CANARY', 'slug' => 'qa-'.Str::uuid(), 'reference' => 'QA-'.Str::uuid(), 'description' => 'PRIVATE_CANARY financial details', 'status' => 'draft']); $record->forceFill(['management_draft' => json_encode(['contact' => 'PRIVATE_CANARY', 'coordinates' => [33.123456, -18.654321]]), 'workflow_context' => ['private_offer_preparation' => ['version' => 2, 'working_state' => 'draft'], 'offer_publication' => $publication]])->save(); return $record; } private function enableSyntheticConsumer(): void { $this->app->instance(OfferPublicCatalogue::class, new OfferPublicCatalogue(true)); } public function test_effective_publication_remains_closed_even_with_a_stored_projection(): void { $offer = $this->fixture(); $this->getJson('/api/v1/offers?qa_fixture=true')->assertOk()->assertJsonPath('results.total', 0); $this->getJson('/api/v1/offers/'.$offer->id)->assertNotFound(); $this->get('/ofertas/'.$offer->id)->assertNotFound(); $this->assertStringNotContainsString('Synthetic public offer', $this->get('/api/v1/offers/export')->assertOk()->streamedContent()); $this->enableSyntheticConsumer(); $this->app['env'] = 'production'; try { $this->getJson('/api/v1/offers')->assertOk()->assertJsonPath('results.total', 0); $this->getJson('/api/v1/offers/'.$offer->id)->assertNotFound(); } finally { $this->app['env'] = 'testing'; } } public function test_pages_api_csv_and_map_only_use_the_explicit_public_projection(): void { $this->enableSyntheticConsumer(); $offer = $this->fixture(); $response = $this->getJson('/api/v1/offers')->assertOk()->assertJsonPath('results.total', 1) ->assertJsonPath('results.data.0.title', 'Synthetic public offer'); $this->assertStringNotContainsString('PRIVATE_CANARY', $response->getContent()); $this->get('/ofertas')->assertOk()->assertInertia(fn (Assert $page) => $page->component('offers/catalogue')->has('results.data', 1)); $this->get('/ofertas/'.$offer->id)->assertOk()->assertInertia(fn (Assert $page) => $page->component('offers/public-detail')->where('offer.title', 'Synthetic public offer')); $detail = $this->getJson('/api/v1/offers/'.$offer->id)->assertOk(); $this->assertSame(['id', 'title', 'summary', 'category', 'location', 'price', 'status', 'published_at', 'revision', 'url'], array_keys($detail->json('data'))); $csv = $this->get('/api/v1/offers/export')->assertOk()->streamedContent(); $this->assertStringContainsString('Synthetic public offer', $csv); $this->assertStringNotContainsString('PRIVATE_CANARY', $csv); $this->getJson('/api/v1/offers/map')->assertOk()->assertJsonPath('features', [])->assertJsonPath('exact_locations_disclosed', false); $this->getJson('/api/v1/opportunities/'.$offer->slug)->assertNotFound(); $this->assertSame('draft', $offer->fresh()->status); $this->assertNull($offer->fresh()->published_at); } public function test_invalid_unapproved_unpublished_or_sensitive_snapshots_are_not_readable(): void { $this->enableSyntheticConsumer(); foreach ([['approval' => ['state' => 'rejected']], ['publication' => ['state' => 'draft']], ['publication' => ['actor_id' => 1]], ['publication' => ['at' => now()->addDay()->toIso8601String()]], ['approval' => ['hash' => str_repeat('0', 64)]], ['payload' => ['contact' => 'PRIVATE_CANARY']], ['contract' => 'unknown'], ['qa_fixture' => false]] as $invalid) { $offer = $this->fixture([], $invalid); $this->getJson('/api/v1/offers/'.$offer->id)->assertNotFound(); } $this->getJson('/api/v1/offers')->assertOk()->assertJsonPath('results.total', 0); } public function test_pending_private_edits_do_not_replace_the_previously_approved_projection(): void { $this->enableSyntheticConsumer(); $offer = $this->fixture(); $before = $this->getJson('/api/v1/offers/'.$offer->id)->assertOk()->json('data'); $context = $offer->workflow_context; $context['private_offer_preparation']['working_state'] = 'rejected'; $context['draft'] = ['title' => 'PRIVATE_CANARY revision', 'amount' => '999999.99']; $offer->forceFill(['title' => 'PRIVATE_CANARY edited', 'workflow_context' => $context])->save(); $this->assertSame($before, $this->getJson('/api/v1/offers/'.$offer->id)->assertOk()->json('data')); $this->getJson('/api/v1/offers?q=PRIVATE_CANARY')->assertJsonPath('results.total', 0); $this->getJson('/api/v1/offers?q=Synthetic')->assertJsonPath('results.total', 1); $offer->delete(); $this->getJson('/api/v1/offers/'.$offer->id)->assertNotFound(); } public function test_prices_are_exact_and_currencies_are_never_converted(): void { $this->enableSyntheticConsumer(); $first = $this->fixture(['amount' => '9999999999999999.01', 'currency' => 'USD']); $second = $this->fixture(['amount' => '9999999999999999.02', 'currency' => 'USD']); $this->fixture(['amount' => '9999999999999999.02', 'currency' => 'MZN']); $this->fixture(['price_mode' => 'negotiable', 'amount' => null, 'currency' => null]); $this->getJson('/api/v1/offers?currency=USD&min_amount=9999999999999999.02')->assertOk()->assertJsonPath('results.total', 1)->assertJsonPath('results.data.0.id', $second->id); $this->getJson('/api/v1/offers?currency=USD&sort=amount_asc')->assertOk()->assertJsonPath('results.data.0.id', $first->id); $this->getJson('/api/v1/offers?price_mode=negotiable')->assertOk()->assertJsonPath('results.total', 1); foreach (['min_amount=1', 'currency=EUR', 'sort=amount_asc', 'currency=MZN&min_amount=2&max_amount=1', 'price_mode=negotiable¤cy=USD'] as $query) { $this->getJson('/api/v1/offers?'.$query)->assertUnprocessable(); } } public function test_csv_formula_text_is_escaped_and_http_has_no_publication_operation(): void { $this->enableSyntheticConsumer(); $offer = $this->fixture(['title' => '=SYNTHETIC()']); $csv = $this->get('/api/v1/offers/export')->assertOk()->streamedContent(); $this->assertStringContainsString("'=SYNTHETIC()", $csv); $this->postJson('/api/v1/offers/'.$offer->id)->assertMethodNotAllowed(); $this->postJson('/api/v1/offers/'.$offer->id.'/publish')->assertNotFound(); $this->assertSame('draft', $offer->fresh()->status); } public function test_location_category_and_pagination_use_only_the_published_snapshot(): void { $this->enableSyntheticConsumer(); $country = $this->territory('country'); $province = $this->territory('province', $country); $district = $this->territory('district', $province); $other = $this->territory('province', $country); $boundary = (string) Str::uuid(); DB::insert("INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,'polygon',ST_GeomFromText('POLYGON((32 -20,33 -20,33 -19,32 -19,32 -20))',4326),?::jsonb,'public')", [$boundary, $district, json_encode(['purpose' => 'administrative_boundary', 'authority' => 'Synthetic QA'])]); DB::insert("INSERT INTO geo_geometries(id,geo_location_id,geometry_type,geom,properties,data_classification) VALUES (?,?,'point',ST_GeomFromText('POINT(33.123456 -18.654321)',4326),?::jsonb,'confidential')", [(string) Str::uuid(), $district, json_encode(['purpose' => 'investment_site', 'contact' => 'PRIVATE_CANARY'])]); $offer = $this->fixture(['province_id' => $province, 'district_id' => $district]); $offer->forceFill(['primary_territory_id' => $other])->save(); $this->fixture(['title' => 'Another offer', 'province_id' => $province]); $this->getJson('/api/v1/offers?category=business_sale&province_id='.$province.'&district_id='.$district) ->assertOk()->assertJsonPath('results.total', 1)->assertJsonPath('results.data.0.id', $offer->id); $this->getJson('/api/v1/offers?province_id='.$other)->assertOk()->assertJsonPath('results.total', 0); $this->getJson('/api/v1/offers?province_id='.$other.'&district_id='.$district)->assertUnprocessable(); $this->getJson('/api/v1/offers?district_id='.$district)->assertUnprocessable(); $this->getJson('/api/v1/offers?category=invalid')->assertUnprocessable(); $this->getJson('/api/v1/offers?per_page=1&page=1&sort=title&private_flag=secret')->assertOk() ->assertJsonPath('results.total', 2)->assertJsonPath('results.data.0.title', 'Another offer'); $next = $this->getJson('/api/v1/offers?per_page=1&page=1')->json('results.next_page_url'); $this->assertStringNotContainsString('private_flag', $next); $this->getJson('/api/v1/offers?per_page=1&page=2&sort=title')->assertOk()->assertJsonPath('results.data.0.id', $offer->id); $map = $this->getJson('/api/v1/offers/map?province_id='.$province)->assertOk()->assertJsonPath('features', [])->assertJsonPath('exact_locations_disclosed', false); $this->assertContains($boundary, array_column($map->json('areas'), 'id')); $this->assertStringNotContainsString('33.123456', $map->getContent()); $this->assertStringNotContainsString('PRIVATE_CANARY', $map->getContent()); DB::table('geo_locations')->where('id', $province)->update(['data_classification' => 'internal']); $this->getJson('/api/v1/offers/'.$offer->id)->assertNotFound(); $this->getJson('/api/v1/offers')->assertOk()->assertJsonPath('results.total', 0); } private function territory(string $type, ?string $parent = null): string { $id = (string) Str::uuid(); DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'Synthetic '.$type, 'location_type' => $type, 'parent_id' => $parent, 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id, 'data_classification' => 'public']); return $id; } public function test_public_html_resolves_the_real_compiled_pages_without_private_fields(): void { $this->enableSyntheticConsumer(); $offer = $this->fixture(); $directory = getenv('EDITORIAL_QA_BUILD_DIRECTORY') ?: 'build'; $manifest = json_decode(file_get_contents(public_path($directory.'/manifest.json')), true, 512, JSON_THROW_ON_ERROR); $this->app->instance(Vite::class, (new Vite)->useBuildDirectory($directory)->useHotFile(storage_path('phpunit-offers-no-hot'))); foreach (['offers/catalogue' => '/ofertas', 'offers/public-detail' => '/ofertas/'.$offer->id] as $page => $url) { $key = 'resources/js/pages/'.$page.'.tsx'; $this->assertArrayHasKey($key, $manifest); $this->get($url, ['Accept' => 'text/html'])->assertOk()->assertSee($manifest[$key]['file'], false)->assertDontSee('PRIVATE_CANARY'); } } public function test_existing_catalogue_keeps_its_own_currency_and_price_validation_contract(): void { $filters = app(InvestmentCatalogue::class)->filters(Request::create('/oportunidades', 'GET', ['currency' => 'EUR', 'min_amount' => '10', 'max_amount' => '20'])); $this->assertSame('EUR', $filters['currency']); $this->assertSame('10', $filters['min_amount']); $this->getJson('/api/v1/offers?currency=EUR&min_amount=10')->assertUnprocessable(); } public function test_native_combined_filters_and_export_with_self_contained_synthetic_references(): void { $province = $this->territory('province', $this->territory('country')); DB::table('geo_locations')->where('id', $province)->update(['name' => 'Inhambane']); Sector::create(['slug' => 'turismo', 'name' => 'Synthetic QA tourism', 'is_active' => true]); $fixture = OpportunitySearchFixture::create(); $this->getJson('/api/v1/investment/search?max_amount=500000')->assertUnprocessable()->assertJsonValidationErrors('currency'); $filters = ['q' => $fixture['query'], 'sector' => 'turismo', 'province_id' => $province, 'category' => 'investment_project', 'preparation' => 'historical:2', 'max_amount' => '500000', 'currency' => 'USD', 'park_id' => $fixture['zone']->id]; $query = http_build_query($filters); $this->getJson('/api/v1/investment/search?'.$query)->assertOk()->assertJsonPath('total', 1)->assertJsonPath('data.0.id', $fixture['associated']->id); $this->getJson('/api/v1/investment/search?'.http_build_query([...$filters, 'max_amount' => '1']))->assertOk()->assertJsonPath('total', 0); $this->get('/oportunidades?'.$query)->assertOk()->assertInertia(fn (Assert $page) => $page->component('investment/catalogue')->where('results.total', 1)); $csv = $this->get('/api/v1/investment/export?'.$query)->assertOk()->streamedContent(); $this->assertStringContainsString($fixture['associated']->title, $csv); $this->assertStringNotContainsString($fixture['private']->title, $csv); $this->assertStringNotContainsString($fixture['nearby']->title, $csv); } }