'Fornecedor existente', 'nuit' => '123456789', 'contact' => 'Contacto comercial', 'email' => 'fornecedor@example.test', 'phone' => '123456789', 'address' => 'Maputo', 'category' => 'Material', 'beneficiaries' => 'Beneficiário de teste', 'document_references' => 'Documentos verificados no processo de teste.', 'diligence_notes' => 'Documentação conferida para testes de manutenção.', 'blocked' => false, 'next_review' => now()->addYear()->format('Y-m-d')]; } beforeEach(function () { Storage::fake('supplier_documents'); $this->tenant = Tenant::query()->create(['name' => 'Manutenção A']); $department = Department::query()->create(['tenant_id' => $this->tenant->id, 'name' => 'Operações']); $this->buyer = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Procurement]); $this->finance = User::factory()->create(['tenant_id' => $this->tenant->id, 'department_id' => $department->id, 'purchasing_role' => PurchasingRole::Finance]); $this->actingAs($this->buyer); $this->supplier = Supplier::query()->create([...maintenanceSupplierData(), 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'status' => 'active', 'reviewed_by' => $this->finance->id, 'review_comment' => 'Validação anterior preservada em auditoria.']); $this->url = '/fornecedores/'.$this->supplier->id; }); test('procurement can edit and renew a supplier but cannot forge validation or ownership', function () { $this->get($this->url.'/editar')->assertInertia(fn (Assert $page) => $page->component('suppliers/edit')->where('supplier.version', 1)); $this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'name' => 'Nome actualizado', 'tenant_id' => 999, 'created_by' => 999, 'status' => 'active', 'reviewed_by' => 999])->assertSessionHasNoErrors()->assertRedirect($this->url); $supplier = $this->supplier->refresh(); expect($supplier->name)->toBe('Nome actualizado')->and($supplier->status)->toBe('pending')->and($supplier->version)->toBe(2); expect($supplier->tenant_id)->toBe($this->tenant->id)->and($supplier->created_by)->toBe($this->buyer->id)->and($supplier->reviewed_by)->toBeNull()->and($supplier->last_changed_by)->toBe($this->buyer->id); expect($supplier->eligible())->toBeFalse(); $audit = AuditLog::query()->where('action', 'supplier.updated')->firstOrFail(); expect($audit->before['name'])->toBe('Fornecedor existente')->and($audit->after['name'])->toBe('Nome actualizado'); }); test('unchanged edits preserve validation and do not create audit noise', function () { $this->put($this->url, [...maintenanceSupplierData(), 'version' => 1])->assertSessionHasNoErrors(); expect($this->supplier->refresh()->status)->toBe('active')->and($this->supplier->version)->toBe(1); expect(AuditLog::query()->where('action', 'supplier.updated')->count())->toBe(0); }); test('stale edits cannot overwrite newer supplier data', function () { $this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'name' => 'Actualização concorrente'])->assertSessionHasNoErrors(); $this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'name' => 'Ficha antiga'])->assertSessionHasErrors('version'); expect($this->supplier->refresh()->name)->toBe('Actualização concorrente'); }); test('editing validates nuit uniqueness within company and renewal date', function () { Supplier::query()->create([...maintenanceSupplierData(), 'nuit' => '987654321', 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id]); $this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'nuit' => '987654321'])->assertSessionHasErrors('nuit'); $this->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'next_review' => now()->subDay()->format('Y-m-d')])->assertSessionHasErrors('next_review'); }); test('non procurement roles cannot change suppliers or upload documents', function (PurchasingRole $role) { $user = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => $role]); $this->actingAs($user)->get($this->url.'/editar')->assertForbidden(); $this->put($this->url, [...maintenanceSupplierData(), 'version' => 1])->assertForbidden(); $this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 10, 'application/pdf')])->assertForbidden(); expect(SupplierDocument::query()->count())->toBe(0); })->with([PurchasingRole::Finance, PurchasingRole::Admin, PurchasingRole::Requester, PurchasingRole::DepartmentHead, PurchasingRole::Warehouse]); test('private upload saves metadata invalidates validation and audits without leaking storage paths', function () { $this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 10, 'application/pdf')])->assertSessionHasNoErrors()->assertRedirect(); $document = SupplierDocument::query()->firstOrFail(); Storage::disk('supplier_documents')->assertExists($document->path); expect($document->path)->toStartWith($this->tenant->id.'/'.$this->supplier->id.'/'); expect($document->original_name)->toBe('alvara.pdf')->and(strlen($document->sha256))->toBe(64); expect($document->toArray())->not->toHaveKey('path'); expect($this->supplier->refresh()->status)->toBe('pending')->and($this->supplier->version)->toBe(2); expect(AuditLog::query()->where('action', 'supplier.document_uploaded')->firstOrFail()->after['document'])->not->toHaveKey('path'); $this->get($this->url)->assertInertia(fn (Assert $page) => $page->component('suppliers/show')->has('documents', 1)->missing('documents.0.path')->where('canEdit', true)->where('canDocuments', true)); $this->get('/storage/'.$document->path)->assertForbidden(); }); test('authorised downloads are attachments and recorded in audit', function (PurchasingRole $role) { $this->post($this->url.'/documentos', ['document_type' => 'tax', 'file' => UploadedFile::fake()->create('certidao.pdf', 10, 'application/pdf')]); $document = SupplierDocument::query()->firstOrFail(); $user = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => $role]); $this->actingAs($user)->get($this->url.'/documentos/'.$document->id)->assertOk()->assertDownload('certidao.pdf')->assertHeader('X-Content-Type-Options', 'nosniff')->assertHeader('Content-Type', 'application/octet-stream'); expect(AuditLog::query()->where('action', 'supplier.document_downloaded')->count())->toBe(1); })->with([PurchasingRole::Admin, PurchasingRole::Procurement, PurchasingRole::Finance]); test('heads see the supplier but cannot inspect or download confidential documents', function () { $this->post($this->url.'/documentos', ['document_type' => 'conflict', 'file' => UploadedFile::fake()->create('conflitos.pdf', 10, 'application/pdf')]); $document = SupplierDocument::query()->firstOrFail(); $head = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::DepartmentHead]); $this->actingAs($head)->get($this->url)->assertInertia(fn (Assert $page) => $page->has('documents', 0)->where('canDocuments', false)); $this->get($this->url.'/documentos/'.$document->id)->assertForbidden(); }); test('uploads validate contents extension size and document classification', function () { $this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('script.php', 1, 'text/x-php')])->assertSessionHasErrors('file'); $this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('script.php', 1, 'application/pdf')])->assertSessionHasErrors('file'); $this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('grande.pdf', 2049, 'application/pdf')])->assertSessionHasErrors('file'); $this->post($this->url.'/documentos', ['document_type' => 'invalid', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')])->assertSessionHasErrors('document_type'); expect(SupplierDocument::query()->count())->toBe(0)->and(Storage::disk('supplier_documents')->allFiles())->toBe([]); expect($this->supplier->refresh()->status)->toBe('active'); }); test('same company document cannot be downloaded under another supplier', function () { $this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')]); $document = SupplierDocument::query()->firstOrFail(); $other = Supplier::query()->create([...maintenanceSupplierData(), 'nuit' => '987654321', 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id]); $this->get('/fornecedores/'.$other->id.'/documentos/'.$document->id)->assertNotFound(); expect(AuditLog::query()->where('action', 'supplier.document_downloaded')->count())->toBe(0); }); test('all maintenance and attachment routes reject other companies', function () { $this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')]); $document = SupplierDocument::query()->firstOrFail(); $other = Tenant::query()->create(['name' => 'Empresa B']); $user = User::factory()->create(['tenant_id' => $other->id, 'purchasing_role' => PurchasingRole::Procurement]); $this->actingAs($user)->get($this->url)->assertNotFound(); $this->get($this->url.'/editar')->assertNotFound(); $this->put($this->url, [...maintenanceSupplierData(), 'version' => 2])->assertNotFound(); $this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')])->assertNotFound(); $this->get($this->url.'/documentos/'.$document->id)->assertNotFound(); }); test('review of a stale version is rejected after a new document upload', function () { $this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')]); $this->actingAs($this->finance)->post($this->url.'/validar', ['status' => 'active', 'comment' => 'Revisão de ficha antiga não permitida.', 'version' => 1])->assertSessionHasErrors('version'); $this->post($this->url.'/validar', ['status' => 'active', 'comment' => 'Dados e documento actual conferidos.', 'version' => 2])->assertSessionHasNoErrors(); expect($this->supplier->refresh()->status)->toBe('active')->and($this->supplier->version)->toBe(3); }); test('last editor cannot validate the supplier after changing to finance role', function () { $editor = User::factory()->create(['tenant_id' => $this->tenant->id, 'purchasing_role' => PurchasingRole::Procurement]); $this->actingAs($editor)->put($this->url, [...maintenanceSupplierData(), 'version' => 1, 'contact' => 'Novo contacto'])->assertSessionHasNoErrors(); $editor->forceFill(['purchasing_role' => PurchasingRole::Finance])->save(); $this->actingAs($editor)->post($this->url.'/validar', ['status' => 'active', 'comment' => 'Tentativa de validar alteração própria.', 'version' => 2])->assertForbidden(); }); test('file is cleaned up and database rolled back if audit cannot be written', function () { $this->mock(RequisitionWorkflow::class, function ($mock) { $mock->shouldReceive('audit')->once()->andThrow(new RuntimeException('Falha de auditoria simulada.')); }); $this->post($this->url.'/documentos', ['document_type' => 'license', 'file' => UploadedFile::fake()->create('alvara.pdf', 1, 'application/pdf')])->assertStatus(500); expect(SupplierDocument::query()->count())->toBe(0)->and(Storage::disk('supplier_documents')->allFiles())->toBe([]); expect($this->supplier->refresh()->status)->toBe('active')->and($this->supplier->version)->toBe(1); }); test('supplier task connects independent review current forms and versioned edits without writing on reads', function () { $this->supplier->update(['status' => 'pending']); supplyGrant($this->buyer, ['procurement', 'finance']); supplyGrant($this->finance, ['finance']); $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); $this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canReview', false)); $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) ->where('tasks.data.0.task', 'supplier_review')->where('tasks.data.0.href', $this->url.'#supplier-review') ->missing('tasks.data.0.email')->missing('tasks.data.0.beneficiaries')->where('tasks.data.0.due_on', null)); $before = [Supplier::query()->count(), AuditLog::query()->count()]; $this->get($this->url)->assertHeader('Cache-Control', 'max-age=0, no-store, private')->assertInertia(fn (Assert $page) => $page->where('canReview', true)->where('supplier.version', 1)); expect([Supplier::query()->count(), AuditLog::query()->count()])->toBe($before); $this->post($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'Conferência independente da ficha sintética.'])->assertRedirect(); $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); $this->actingAs($this->buyer)->put($this->url, [...maintenanceSupplierData(), 'name' => 'Ficha revista para novo uso', 'version' => 2])->assertRedirect(); $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1) ->where('tasks.data.0.id', 'supplier:'.$this->supplier->id.':review:3')); $this->postJson($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'Tentativa antiga que não deve sobrescrever a ficha.'])->assertUnprocessable(); }); test('supplier task excludes the last editor and preserves activation checks for blocked or expired sources', function () { $this->supplier->update(['status' => 'pending', 'last_changed_by' => $this->finance->id]); $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); $this->post($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'Não pode conferir a alteração que efectuou.'])->assertForbidden(); $this->supplier->update(['last_changed_by' => $this->buyer->id, 'blocked' => true]); $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 1)); $this->postJson($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'O impedimento não pode ser ignorado na activação.'])->assertUnprocessable(); $this->supplier->update(['blocked' => false, 'next_review' => today()->subDay()]); $this->postJson($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'A vigência deve ser renovada na ficha de origem.'])->assertUnprocessable(); $this->post($this->url.'/validar', ['version' => 1, 'status' => 'suspended', 'comment' => 'Suspensão sintética sem forçar a activação vencida.'])->assertRedirect(); $this->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); }); test('supplier task retains older pending records across all task pages and direct source links', function () { $this->supplier->update(['status' => 'pending']); for ($index = 1; $index < 105; $index++) { Supplier::query()->create([...maintenanceSupplierData(), 'tenant_id' => $this->tenant->id, 'created_by' => $this->buyer->id, 'last_changed_by' => $this->buyer->id, 'name' => 'Ficha sintética '.$index, 'nuit' => str_pad((string) $index, 9, '0', STR_PAD_LEFT), 'status' => 'pending']); } $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 25) ->where('tasks.data.0.href', $this->url.'#supplier-review')); $this->get('/requisicoes/tarefas?module=purchases&page=5')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 105)->has('tasks.data', 5)); $this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canReview', true)); }); test('supplier task preserves tenant maintenance expiry and fresh actor authority on native commands', function () { $this->supplier->update(['status' => 'pending']); $foreignTenant = Tenant::query()->create(['name' => 'Outra empresa do ensaio']); $foreign = User::factory()->create(['tenant_id' => $foreignTenant->id, 'purchasing_role' => PurchasingRole::Finance]); $this->actingAs($foreign)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); $this->get($this->url)->assertNotFound(); supplyGrant($this->finance, ['finance']); supplyGrant($this->buyer, ['procurement']); DB::table('organization_memberships')->where('user_id', $this->finance->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertInertia(fn (Assert $page) => $page->where('tasks.total', 0)); $this->get($this->url)->assertInertia(fn (Assert $page) => $page->where('canReview', false)); $this->post($this->url.'/validar', ['version' => 1, 'status' => 'active', 'comment' => 'Manutenção não deve decidir no percurso de negócio.'])->assertForbidden(); DB::table('organization_memberships')->where('user_id', $this->buyer->id)->update(['maintenance' => true, 'expires_at' => now()->addHour()]); $this->actingAs($this->buyer)->put($this->url, [...maintenanceSupplierData(), 'version' => 1])->assertForbidden(); DB::table('organization_memberships')->where('user_id', $this->finance->id)->update(['expires_at' => now()->subMinute()]); $this->actingAs($this->finance)->get('/requisicoes/tarefas?module=purchases')->assertForbidden(); expect(app(SupplierParticipation::class)->canReview($this->finance, $this->supplier))->toBeFalse(); }); test('supplier task preparation cannot borrow a cached role revoked after form validation', function () { $input = SaveSupplierRequest::create('/fornecedores', 'POST', [...maintenanceSupplierData(), 'nuit' => '987654321']); $input->setContainer($this->app)->setRedirector($this->app['redirect']); $input->setUserResolver(fn () => $this->buyer); $input->validateResolved(); DB::table('users')->where('id', $this->buyer->id)->update(['purchasing_role' => PurchasingRole::Requester->value]); $before = [Supplier::query()->count(), AuditLog::query()->count()]; expect(fn () => app(SupplierController::class)->store($input, app(RequisitionWorkflow::class))) ->toThrow(HttpException::class); expect([Supplier::query()->count(), AuditLog::query()->count()])->toBe($before); });