240 lines
18 KiB
PHP
240 lines
18 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\InvestmentDossier;
|
|
use App\Models\Project;
|
|
use App\Models\User;
|
|
use App\Services\PartnershipExchange;
|
|
use App\Services\PartnershipInstallation;
|
|
use App\Services\PartnershipMatching;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Tests\TestCase;
|
|
|
|
class PartnershipExchangeTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function draft(User $owner, array $extra = []): object
|
|
{
|
|
return app(PartnershipExchange::class)->save($owner, array_replace_recursive(['client_key' => (string) Str::uuid(), 'title' => 'QA partnership', 'kind' => 'seeking', 'partnership_type' => 'joint_venture',
|
|
'offer' => 'QA documented capacity', 'seeking' => 'QA technical contribution', 'payload' => ['summary' => 'QA summary', 'consent' => true, 'mandatory' => ['capacity']], 'origins' => [], 'locations' => []], $extra));
|
|
}
|
|
|
|
private function publish(User $owner): array
|
|
{
|
|
$manager = User::factory()->create();
|
|
config(['portal.managers' => [$manager->email]]);
|
|
$p = $this->draft($owner);
|
|
$p = app(PartnershipExchange::class)->transition($owner, $p->id, $p->version, 'submit', 'QA submission');
|
|
$p = app(PartnershipExchange::class)->transition($manager, $p->id, $p->version, 'publish', 'QA reviewed disclosure');
|
|
|
|
return [$p, $manager];
|
|
}
|
|
|
|
private function candidature(User $candidate, object $p): object
|
|
{
|
|
return app(PartnershipExchange::class)->interest($candidate, $p->id, ['proposal' => 'QA proposal', 'payload' => ['consent' => true, 'capacity' => 'Declared capacity, not certified']]);
|
|
}
|
|
|
|
public function test_complete_local_flow_from_personal_draft_to_agreement_and_completed_milestone(): void
|
|
{
|
|
$owner = User::factory()->create(['email_verified_at' => null]);
|
|
$candidate = User::factory()->create(['email_verified_at' => null]);
|
|
[$p, $manager] = $this->publish($owner);
|
|
$this->getJson('/parcerias/'.$p->id)->assertOk()->assertJsonPath('record.status', 'published');
|
|
$i = $this->candidature($candidate, $p);
|
|
$other = $this->candidature(User::factory()->create(), $p);
|
|
$i = app(PartnershipExchange::class)->decideInterest($owner, $i->id, $i->version, 'shortlist', 'Potential match; unknown data to clarify');
|
|
$i = app(PartnershipExchange::class)->decideInterest($owner, $i->id, $i->version, 'connect', 'Private negotiation authorized');
|
|
$room = DB::table('partnership_rooms')->where('interest_id', $i->id)->first();
|
|
$this->actingAs($owner)->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 1, 'action' => 'record', 'title' => 'QA work plan', 'terms' => 'Parties, objectives, contributions, risks and exit conditions', 'reason' => 'QA instrument recorded'])->assertOk();
|
|
$this->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 2, 'action' => 'confirm', 'reason' => 'Proponent confirmation'])->assertOk();
|
|
$this->actingAs($candidate)->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 3, 'action' => 'confirm', 'reason' => 'Candidate confirmation'])->assertOk();
|
|
$this->assertDatabaseHas('partnership_rooms', ['id' => $room->id, 'status' => 'agreed']);
|
|
$this->postJson('/parcerias/salas/'.$room->id.'/marcos', ['title' => 'Pilot deliverable', 'responsible_id' => $candidate->id, 'status' => 'pending'])->assertOk();
|
|
$this->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 4, 'action' => 'start', 'reason' => 'Start pilot'])->assertOk();
|
|
$this->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 5, 'action' => 'complete', 'reason' => 'Premature completion'])->assertConflict();
|
|
$milestone = DB::table('partnership_milestones')->where('room_id', $room->id)->first();
|
|
$this->putJson('/parcerias/salas/'.$room->id.'/marcos/'.$milestone->id, ['version' => 1, 'title' => 'Pilot deliverable', 'responsible_id' => $candidate->id, 'status' => 'completed', 'evidence' => 'QA evidence reference'])->assertOk();
|
|
$this->postJson('/parcerias/salas/'.$room->id.'/acordo', ['version' => 5, 'action' => 'complete', 'reason' => 'Deliverables complete'])->assertOk();
|
|
$this->assertDatabaseHas('partnership_rooms', ['id' => $room->id, 'status' => 'closed']);
|
|
$this->assertDatabaseHas('partnership_interests', ['id' => $other->id, 'status' => 'submitted']);
|
|
$this->assertDatabaseHas('portal_partnerships', ['id' => $p->id, 'status' => 'published']);
|
|
$this->assertNotEmpty(DB::table('audit_events')->where('source_system', 'partnership-exchange')->get());
|
|
$this->assertSame('not_verified', json_decode(DB::table('partnership_rooms')->where('id', $room->id)->value('agreement'), true)['legal_signature_status']);
|
|
}
|
|
|
|
public function test_preserves_legacy_proposal_and_rejects_self_moderation_and_concurrent_updates(): void
|
|
{
|
|
$owner = User::factory()->create();
|
|
$manager = User::factory()->create();
|
|
config(['portal.managers' => [$owner->email, $manager->email]]);
|
|
$id = DB::table('portal_partnerships')->insertGetId(['user_id' => $owner->id, 'title' => 'Legacy', 'sector' => 'Original sector', 'province' => 'Original province', 'offer' => 'Original offer', 'seeking' => 'Original request', 'status' => 'pending']);
|
|
$this->actingAs($owner)->postJson('/parcerias/'.$id.'/estado', ['version' => 1, 'action' => 'publish', 'reason' => 'Self moderation'])->assertForbidden();
|
|
$this->actingAs($manager)->postJson('/parcerias/'.$id.'/estado', ['version' => 1, 'action' => 'publish', 'reason' => 'Legacy reviewed'])->assertOk();
|
|
$this->assertDatabaseHas('portal_partnerships', ['id' => $id, 'user_id' => $owner->id, 'sector' => 'Original sector', 'province' => 'Original province', 'offer' => 'Original offer']);
|
|
$this->postJson('/parcerias/'.$id.'/estado', ['version' => 1, 'action' => 'return', 'reason' => 'Stale'])->assertConflict();
|
|
}
|
|
|
|
public function test_draft_recovery_and_idempotency_do_not_require_organization_or_documents(): void
|
|
{
|
|
$owner = User::factory()->create(['email_verified_at' => null]);
|
|
$input = ['client_key' => (string) Str::uuid(), 'title' => '', 'kind' => 'seeking', 'payload' => [], 'origins' => [], 'locations' => []];
|
|
$this->actingAs($owner)->postJson('/parcerias/rascunhos', $input)->assertOk();
|
|
$this->postJson('/parcerias/rascunhos', $input)->assertOk();
|
|
$this->postJson('/parcerias/rascunhos', [...$input, 'title' => 'Different bytes'])->assertConflict();
|
|
$this->assertDatabaseCount('portal_partnerships', 1);
|
|
$p = DB::table('portal_partnerships')->first();
|
|
$this->get('/parcerias/'.$p->id.'/editar')->assertOk();
|
|
$this->postJson('/parcerias/'.$p->id.'/estado', ['version' => $p->version, 'action' => 'submit', 'reason' => 'Still incomplete'])->assertUnprocessable();
|
|
$this->actingAs(User::factory()->create())->get('/parcerias/'.$p->id.'/editar')->assertForbidden();
|
|
}
|
|
|
|
public function test_competing_candidates_and_moderator_cannot_enter_private_room_or_read_other_interests(): void
|
|
{
|
|
$owner = User::factory()->create();
|
|
$candidate = User::factory()->create();
|
|
[$p, $moderator] = $this->publish($owner);
|
|
$i = $this->candidature($candidate, $p);
|
|
$outsider = User::factory()->create();
|
|
$this->candidature($outsider, $p);
|
|
$this->actingAs($outsider)->getJson('/parcerias/'.$p->id)->assertOk()->assertJsonCount(1, 'interests');
|
|
$i = app(PartnershipExchange::class)->decideInterest($owner, $i->id, $i->version, 'shortlist', 'QA shortlist');
|
|
app(PartnershipExchange::class)->decideInterest($owner, $i->id, $i->version, 'connect', 'QA connect');
|
|
$room = DB::table('partnership_rooms')->first();
|
|
$this->getJson('/parcerias/salas/'.$room->id)->assertForbidden();
|
|
$this->actingAs($moderator)->getJson('/parcerias/salas/'.$room->id)->assertForbidden();
|
|
$this->getJson('/parcerias/'.$p->id)->assertJsonCount(0, 'interests');
|
|
$this->actingAs($candidate)->postJson('/parcerias/candidaturas/'.$i->id, ['version' => $i->version + 1, 'action' => 'assess', 'reason' => 'Self assessment', 'assessment' => ['capacity' => ['state' => 'met', 'evidence' => 'Claim']]])->assertForbidden();
|
|
$key = (string) Str::uuid();
|
|
$this->postJson('/parcerias/salas/'.$room->id.'/mensagens', ['client_key' => $key, 'body' => 'Private QA message'])->assertOk();
|
|
$this->postJson('/parcerias/salas/'.$room->id.'/mensagens', ['client_key' => $key, 'body' => 'Private QA message'])->assertOk();
|
|
$this->postJson('/parcerias/salas/'.$room->id.'/mensagens', ['client_key' => $key, 'body' => 'Changed'])->assertConflict();
|
|
$this->assertDatabaseCount('partnership_messages', 1);
|
|
}
|
|
|
|
public function test_matching_keeps_unknown_information_separate_from_proven_failure_and_desirable_criteria(): void
|
|
{
|
|
$owner = User::factory()->create();
|
|
[$p] = $this->publish($owner);
|
|
$i = $this->candidature(User::factory()->create(), $p);
|
|
$engine = app(PartnershipMatching::class);
|
|
$unknown = $engine->explain($p, $i);
|
|
$this->assertSame('unknown', $unknown['eligibility']);
|
|
$this->assertSame(100.0, $unknown['score_max']);
|
|
$failed = $engine->explain($p, $i, ['capacity' => ['state' => 'unmet', 'evidence' => 'QA documented inability']]);
|
|
$this->assertSame('unmet', $failed['eligibility']);
|
|
$desirable = $engine->explain($p, $i, ['impact' => ['state' => 'unmet', 'evidence' => 'QA reference']]);
|
|
$this->assertSame('unknown', $desirable['eligibility']);
|
|
$this->assertFalse($desirable['recommendation_is_approval']);
|
|
$all = [];
|
|
foreach (array_keys(config('partnerships.criteria')) as $code) {
|
|
$all[$code] = ['state' => 'met', 'evidence' => 'Synthetic QA source for '.$code];
|
|
}
|
|
$this->assertSame(100.0, $engine->explain($p, $i, $all)['score_min']);
|
|
}
|
|
|
|
public function test_document_sharing_reuses_private_repository_and_scan_controls(): void
|
|
{
|
|
Queue::fake();
|
|
Storage::fake('local');
|
|
$owner = User::factory()->create();
|
|
$candidate = User::factory()->create();
|
|
[$p] = $this->publish($owner);
|
|
$i = $this->candidature($candidate, $p);
|
|
$i = app(PartnershipExchange::class)->decideInterest($owner, $i->id, $i->version, 'shortlist', 'QA shortlist');
|
|
app(PartnershipExchange::class)->decideInterest($owner, $i->id, $i->version, 'connect', 'QA room');
|
|
$room = DB::table('partnership_rooms')->first();
|
|
$this->actingAs($owner)->postJson('/parcerias/salas/'.$room->id.'/documentos', ['file' => UploadedFile::fake()->create('qa.pdf', 1, 'application/pdf'), 'shared' => false])->assertOk();
|
|
$link = DB::table('partnership_documents')->first();
|
|
$this->actingAs($candidate)->getJson('/parcerias/salas/'.$room->id)->assertJsonCount(0, 'documents');
|
|
$this->get('/parcerias/salas/'.$room->id.'/documentos/'.$link->id)->assertNotFound();
|
|
$this->postJson('/parcerias/salas/'.$room->id.'/documentos/'.$link->id.'/partilha', ['shared' => true])->assertForbidden();
|
|
$this->actingAs($owner)->postJson('/parcerias/salas/'.$room->id.'/documentos/'.$link->id.'/partilha', ['shared' => true])->assertOk();
|
|
$this->actingAs($candidate);
|
|
$this->get('/parcerias/salas/'.$room->id.'/documentos/'.$link->id)->assertNotFound();
|
|
$document = DB::table('documents')->where('id', $link->document_id)->first();
|
|
DB::table('documents')->where('id', $document->id)->update(['status' => 'active', 'malware_scan_status' => 'clean', 'malware_scanned_sha256' => $document->sha256]);
|
|
$this->get('/parcerias/salas/'.$room->id.'/documentos/'.$link->id)->assertOk();
|
|
Storage::disk('local')->put($document->storage_key, 'Changed bytes');
|
|
$this->get('/parcerias/salas/'.$room->id.'/documentos/'.$link->id)->assertNotFound();
|
|
}
|
|
|
|
public function test_organization_representation_blocks_viewer_writes_and_revoked_authors(): void
|
|
{
|
|
$owner = User::factory()->create();
|
|
$representative = User::factory()->create();
|
|
$viewer = User::factory()->create();
|
|
$organization = (string) Str::uuid();
|
|
DB::table('organizations')->insert(['id' => $organization, 'name' => 'Synthetic QA organization', 'organization_type' => 'private']);
|
|
foreach ([[$owner, 'manager'], [$representative, 'officer'], [$viewer, 'viewer']] as [$user, $role]) {
|
|
DB::table('memberships')->insert(['user_id' => $user->id, 'organization_id' => $organization, 'role' => $role]);
|
|
}
|
|
$p = $this->draft($owner, ['organization_id' => $organization]);
|
|
$this->actingAs($viewer)->getJson('/parcerias/'.$p->id)->assertOk()->assertJsonPath('canEdit', false);
|
|
$this->postJson('/parcerias/'.$p->id.'/estado', ['version' => $p->version, 'action' => 'submit', 'reason' => 'Viewer attempt'])->assertForbidden();
|
|
$this->actingAs($representative)->get('/parcerias/'.$p->id.'/editar')->assertOk();
|
|
DB::table('memberships')->where('user_id', $owner->id)->update(['deleted_at' => now()]);
|
|
$this->actingAs($owner)->get('/parcerias/'.$p->id.'/editar')->assertForbidden();
|
|
$this->getJson('/parcerias/'.$p->id)->assertNotFound();
|
|
}
|
|
|
|
public function test_hierarchical_filters_and_source_links_use_only_explicit_documented_relations(): void
|
|
{
|
|
$parent = null;
|
|
$ids = [];
|
|
foreach (['country', 'province', 'district', 'admin_post', 'locality'] as $type) {
|
|
$id = (string) Str::uuid();
|
|
DB::table('geo_locations')->insert(['id' => $id, 'code' => $id, 'name' => 'QA '.$type, 'location_type' => $type, 'parent_id' => $parent,
|
|
'data_classification' => 'public', 'source_system' => 'cartografia-workbook', 'external_id' => '964a187ad32ea553b9e112cd159caa90b900050c42b7329594a75bcd798a96ff:'.$id]);
|
|
$ids[$type] = $id;
|
|
$parent = $id;
|
|
}
|
|
$owner = User::factory()->create();
|
|
$manager = User::factory()->create();
|
|
config(['portal.managers' => [$manager->email]]);
|
|
$project = Project::factory()->create();
|
|
$dossier = InvestmentDossier::create(['project_id' => $project->id, 'title' => 'QA documented anchor', 'source' => 'Synthetic QA source', 'content' => ['location' => 'QA locality'], 'publication_status' => 'published', 'published_at' => now()->subMinute(), 'approval_basis' => 'document_owner', 'approval_evidence' => 'Synthetic QA fixture disclosure only']);
|
|
DB::table('project_locations')->insert(['project_id' => $project->id, 'geo_location_id' => $ids['locality'], 'role' => 'primary', 'data_classification' => 'public']);
|
|
$p = $this->draft($owner, ['origins' => [['type' => 'project', 'target_id' => $project->id]]]);
|
|
$p = app(PartnershipExchange::class)->transition($owner, $p->id, $p->version, 'submit', 'QA submission');
|
|
$p = app(PartnershipExchange::class)->transition($manager, $p->id, $p->version, 'publish', 'QA source checked');
|
|
foreach (['province', 'district', 'admin_post', 'locality'] as $level) {
|
|
$this->getJson('/api/v1/partnerships?territory_id='.$ids[$level])->assertOk()->assertJsonPath('data.0.id', $p->id)->assertJsonPath('total', 1);
|
|
}
|
|
$this->getJson('/api/v1/partnerships/source/project/'.$project->id)->assertOk()->assertJsonPath('items.0.id', $p->id);
|
|
$this->getJson('/parcerias/'.$p->id)->assertOk()->assertJsonPath('origins.0.href', '/carteira/'.$dossier->id);
|
|
$this->assertDatabaseCount('partnership_locations', 0);
|
|
$this->assertDatabaseCount('opportunity_projects', 0);
|
|
}
|
|
|
|
public function test_authenticated_installation_preserves_legacy_rows_and_is_repeatable_in_qa(): void
|
|
{
|
|
$migration = require database_path('migrations/2026_10_07_160000_extend_internal_partnership_exchange.php');
|
|
$migration->down();
|
|
$manager = User::factory()->create();
|
|
config(['portal.managers' => [$manager->email]]);
|
|
$id = DB::table('portal_partnerships')->insertGetId(['user_id' => $manager->id, 'title' => 'Legacy before migration', 'sector' => 'Original', 'province' => 'Original', 'offer' => 'Retained', 'seeking' => 'Retained', 'status' => 'pending']);
|
|
$dir = storage_path('framework/testing/partnership-install-'.Str::uuid());
|
|
mkdir($dir, 0700, true);
|
|
config(['partnerships.proof_directory' => $dir]);
|
|
$dump = $dir.'/synthetic-unit-fixture.dump';
|
|
file_put_contents($dump, 'Isolated synthetic unit proof, not active backup');
|
|
file_put_contents($dir.'/backup-proof.json', json_encode(['restore_verified' => true, 'target' => ['database' => DB::connection()->getDatabaseName(), 'port' => 55441], 'dump_path' => $dump, 'sha256' => hash_file('sha256', $dump)]));
|
|
$installer = app(PartnershipInstallation::class);
|
|
file_put_contents($dir.'/qa-evidence.json', json_encode(['passed' => true, 'code_hash' => $installer->fingerprint(), 'database' => DB::connection()->getDatabaseName(), 'port' => 55441]));
|
|
$hash = $installer->preview()['hash'];
|
|
$this->actingAs($manager)->post('/management/partnerships-install', ['hash' => str_repeat('0', 64)])->assertConflict();
|
|
$this->post('/management/partnerships-install', ['hash' => $hash])->assertRedirect();
|
|
$this->assertDatabaseHas('portal_partnerships', ['id' => $id, 'user_id' => $manager->id, 'title' => 'Legacy before migration', 'status' => 'pending', 'offer' => 'Retained']);
|
|
$this->post('/management/partnerships-install', ['hash' => $hash])->assertRedirect();
|
|
$this->assertSame(1, DB::table('audit_events')->where('action', 'partnership_module_installed')->count());
|
|
}
|
|
}
|