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

229 lines
15 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Opportunity;
use App\Models\Sector;
use App\Services\InvestmentCatalogue;
use App\Services\OfferPublicCatalogue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Vite;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\Support\OpportunitySearchFixture;
use Tests\TestCase;
final class OfferPublicCatalogueTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->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&currency=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);
}
}