229 lines
15 KiB
PHP
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¤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);
|
|
}
|
|
}
|