From 0bbb4571bd12832c156ebfeaa4de3ccb5850df94 Mon Sep 17 00:00:00 2001 From: Bob Molitor Date: Mon, 20 Oct 2025 22:14:15 +0200 Subject: [PATCH] add tests --- database/factories/CompanyFactory.php | 35 +++++++ database/factories/TransactionFactory.php | 51 ++++++++++ tests/Feature/CompanySearchTest.php | 67 +++++++++++++ tests/Feature/DashboardTest.php | 37 ++++++++ tests/Feature/TransactionReviewTest.php | 110 ++++++++++++++++++++++ 5 files changed, 300 insertions(+) create mode 100644 database/factories/CompanyFactory.php create mode 100644 database/factories/TransactionFactory.php create mode 100644 tests/Feature/TransactionReviewTest.php diff --git a/database/factories/CompanyFactory.php b/database/factories/CompanyFactory.php new file mode 100644 index 0000000..37c6680 --- /dev/null +++ b/database/factories/CompanyFactory.php @@ -0,0 +1,35 @@ + + */ +class CompanyFactory extends Factory +{ + protected $model = Company::class; + + public function definition(): array + { + return [ + 'name' => $this->faker->unique()->company() . ' ' . Str::upper(Str::random(3)), + 'legal_name' => $this->faker->company() . ' AG', + 'ticker' => Str::upper($this->faker->lexify('???')), + 'sector' => $this->faker->randomElement(['Finance', 'Technology', 'Energy']), + 'country' => $this->faker->randomElement(['DE', 'AT', 'CH']), + 'headquarters' => $this->faker->city(), + 'kyc_risk_level' => $this->faker->randomElement(['low', 'medium', 'high']), + 'summary' => $this->faker->sentence(8), + ]; + } + + public function highRisk(): static + { + return $this->state(fn () => ['kyc_risk_level' => 'high']); + } +} + diff --git a/database/factories/TransactionFactory.php b/database/factories/TransactionFactory.php new file mode 100644 index 0000000..bd9b3f8 --- /dev/null +++ b/database/factories/TransactionFactory.php @@ -0,0 +1,51 @@ + + */ +class TransactionFactory extends Factory +{ + protected $model = Transaction::class; + + public function definition(): array + { + return [ + 'company_id' => Company::factory(), + 'reference' => Str::upper($this->faker->unique()->bothify('TRX########')), + 'amount' => $this->faker->randomFloat(2, 1000, 500000), + 'currency' => 'EUR', + 'counterparty' => $this->faker->company(), + 'counterparty_country' => $this->faker->countryCode(), + 'channel' => $this->faker->randomElement(['SWIFT', 'SEPA', 'ACH']), + 'executed_at' => $this->faker->dateTimeBetween('-7 days', 'now'), + 'risk_score' => $this->faker->numberBetween(10, 99), + 'status' => $this->faker->randomElement([ + Transaction::STATUS_TRUE_POSITIVE, + Transaction::STATUS_FALSE_POSITIVE, + Transaction::STATUS_CLEARED, + ]), + 'requires_review' => $this->faker->boolean(70), + 'flagged_by' => $this->faker->randomElement(['Rule Engine', 'Sanctions Monitor', 'Analyst']), + 'flagged_reason' => $this->faker->sentence(6), + 'signals' => [$this->faker->word()], + ]; + } + + public function requiresReview(bool $value = true): static + { + return $this->state(fn () => ['requires_review' => $value]); + } + + public function status(string $status): static + { + return $this->state(fn () => ['status' => $status]); + } +} + diff --git a/tests/Feature/CompanySearchTest.php b/tests/Feature/CompanySearchTest.php index 56e867d..a80a66b 100644 --- a/tests/Feature/CompanySearchTest.php +++ b/tests/Feature/CompanySearchTest.php @@ -1,6 +1,9 @@ set('search', 'Test Firma') ->assertSet('search', 'Test Firma'); }); + +test('overview aggregates open alert metrics', function () { + $this->actingAs(User::factory()->create()); + + $acme = Company::factory()->create(['legal_name' => 'Acme Investigations AG']); + $globex = Company::factory()->create(['legal_name' => 'Globex Risk GmbH']); + + Transaction::factory()->for($acme)->requiresReview()->status(Transaction::STATUS_TRUE_POSITIVE)->create([ + 'amount' => 120000, + 'risk_score' => 90, + 'executed_at' => Carbon::parse('2024-01-10 09:00:00'), + 'signals' => ['Sanktionsliste'], + ]); + + Transaction::factory()->for($globex)->requiresReview()->status(Transaction::STATUS_FALSE_POSITIVE)->create([ + 'amount' => 45000, + 'risk_score' => 60, + 'executed_at' => Carbon::parse('2024-01-11 12:30:00'), + ]); + + Transaction::factory()->for($acme)->requiresReview(false)->status(Transaction::STATUS_FALSE_POSITIVE)->create([ + 'amount' => 8000, + 'risk_score' => 20, + 'executed_at' => Carbon::parse('2024-01-09 08:15:00'), + ]); + + $component = Volt::test('company-search'); + + $overview = $component->get('overview'); + + expect($overview['companies'])->toBe(2) + ->and($overview['open_alerts'])->toBe(2) + ->and($overview['open_volume'])->toEqualWithDelta(165000, 0.001) + ->and($overview['avg_risk'])->toEqualWithDelta(75, 0.001) + ->and($overview['watchlist_hits'])->toBe(1) + ->and($overview['automation_share'])->toBe(68); +}); + +test('search filters companies by ticker and name', function () { + $this->actingAs(User::factory()->create()); + + $match = Company::factory()->create([ + 'name' => 'Fintrac Labs', + 'legal_name' => 'Fintrac Labs AG', + 'ticker' => 'FTR', + ]); + + $other = Company::factory()->create([ + 'name' => 'Velocity Partners', + 'ticker' => 'VLP', + ]); + + Transaction::factory()->for($match)->requiresReview()->create(['amount' => 250000]); + Transaction::factory()->for($other)->requiresReview()->create(['amount' => 125000]); + + $component = Volt::test('company-search'); + + $component->set('search', 'FTR'); + + $companies = $component->get('companies'); + + expect($companies)->toHaveCount(1) + ->and($companies->first()->id)->toBe($match->id); +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index b9974a1..cc1392d 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -1,6 +1,9 @@ get(route('dashboard')); @@ -14,3 +17,37 @@ test('authenticated users can visit the dashboard', function () { $response = $this->get(route('dashboard')); $response->assertStatus(200); }); + +test('dashboard displays aggregated transaction insights', function () { + $this->actingAs(User::factory()->create()); + + $acme = Company::factory()->create(['legal_name' => 'Acme Investigations AG']); + $globex = Company::factory()->create(['legal_name' => 'Globex Surveillance GmbH']); + + Transaction::factory()->for($acme)->requiresReview()->status(Transaction::STATUS_TRUE_POSITIVE)->create([ + 'amount' => 123456.78, + 'risk_score' => 92, + 'executed_at' => Carbon::now(), + ]); + + Transaction::factory()->for($acme)->requiresReview(false)->status(Transaction::STATUS_CLEARED)->create([ + 'amount' => 6543.21, + 'risk_score' => 30, + 'executed_at' => Carbon::now()->subDay(), + ]); + + Transaction::factory()->for($globex)->requiresReview()->status(Transaction::STATUS_FALSE_POSITIVE)->create([ + 'amount' => 25000.00, + 'risk_score' => 70, + 'executed_at' => Carbon::now()->subHours(2), + ]); + + $response = $this->get(route('dashboard')); + + $response->assertOk() + ->assertSee('Acme Investigations AG') + ->assertSee('Globex Surveillance GmbH') + ->assertSee('154.999,99 €') + ->assertSee('123.456,78 €') + ->assertSee('Ø Risikoscore 92'); +}); diff --git a/tests/Feature/TransactionReviewTest.php b/tests/Feature/TransactionReviewTest.php new file mode 100644 index 0000000..92a18ba --- /dev/null +++ b/tests/Feature/TransactionReviewTest.php @@ -0,0 +1,110 @@ +get(route('transaction-review')); + $response->assertRedirect(route('login')); +}); + +test('authenticated users can view transaction review', function () { + $this->actingAs(User::factory()->create()); + + $response = $this->get(route('transaction-review')); + + $response->assertOk(); +}); + +test('transaction review filters by search and updates selection', function () { + $this->actingAs(User::factory()->create()); + + $acme = Company::factory()->create(['name' => 'Acme', 'legal_name' => 'Acme Investigations AG']); + $globex = Company::factory()->create(['name' => 'Globex', 'legal_name' => 'Globex Risk GmbH']); + + $priority = Transaction::factory()->for($acme)->requiresReview()->status(Transaction::STATUS_TRUE_POSITIVE)->create([ + 'reference' => 'REF-ACME-001', + 'counterparty' => 'Alpha Logistics', + 'amount' => 125000.75, + 'risk_score' => 95, + 'executed_at' => Carbon::parse('2024-01-11 12:00:00'), + ]); + + $match = Transaction::factory()->for($globex)->requiresReview()->status(Transaction::STATUS_FALSE_POSITIVE)->create([ + 'reference' => 'REF-GLOBEX-001', + 'counterparty' => 'Beta Holdings', + 'amount' => 45000.50, + 'risk_score' => 72, + 'executed_at' => Carbon::parse('2024-01-10 09:00:00'), + ]); + + Transaction::factory()->for($globex)->requiresReview(false)->status(Transaction::STATUS_CLEARED)->create([ + 'reference' => 'REF-GLOBEX-002', + 'counterparty' => 'Gamma Partners', + 'amount' => 32000.25, + 'risk_score' => 40, + 'executed_at' => Carbon::parse('2024-01-09 08:15:00'), + ]); + + $component = Volt::test('transaction-review'); + + expect($component->get('selectedTransactionId'))->toBe($priority->id); + + $component->set('search', 'Beta'); + + /** @var LengthAwarePaginator $transactions */ + $transactions = $component->get('transactions'); + + expect($transactions->total())->toBe(1) + ->and($transactions->items()[0]->id)->toBe($match->id) + ->and($component->get('selectedTransactionId'))->toBe($match->id); +}); + +test('transaction review metrics aggregate totals by status', function () { + $this->actingAs(User::factory()->create()); + + $acme = Company::factory()->create(); + $globex = Company::factory()->create(); + + $truePositive = Transaction::factory()->for($acme)->requiresReview()->status(Transaction::STATUS_TRUE_POSITIVE)->create([ + 'amount' => 90000.00, + 'risk_score' => 88, + 'executed_at' => Carbon::parse('2024-01-08 10:00:00'), + ]); + + $falsePositive = Transaction::factory()->for($globex)->requiresReview()->status(Transaction::STATUS_FALSE_POSITIVE)->create([ + 'amount' => 15000.00, + 'risk_score' => 65, + 'executed_at' => Carbon::parse('2024-01-08 09:00:00'), + ]); + + Transaction::factory()->for($globex)->requiresReview(false)->status(Transaction::STATUS_CLEARED)->create([ + 'amount' => 6000.00, + 'risk_score' => 35, + 'executed_at' => Carbon::parse('2024-01-07 08:00:00'), + ]); + + $component = Volt::test('transaction-review'); + + $metrics = $component->get('metrics'); + + expect($metrics['total']['count'])->toBe(3) + ->and($metrics['by_status'][Transaction::STATUS_TRUE_POSITIVE]['count'])->toBe(1) + ->and($metrics['by_status'][Transaction::STATUS_TRUE_POSITIVE]['amount'])->toEqualWithDelta(90000, 0.001) + ->and($metrics['by_status'][Transaction::STATUS_FALSE_POSITIVE]['count'])->toBe(1) + ->and($metrics['by_status'][Transaction::STATUS_FALSE_POSITIVE]['amount'])->toEqualWithDelta(15000, 0.001) + ->and($metrics['by_status'][Transaction::STATUS_CLEARED]['count'])->toBe(1); + + $component->set('status', Transaction::STATUS_FALSE_POSITIVE); + + $filteredMetrics = $component->get('metrics'); + + expect($filteredMetrics['total']['count'])->toBe(1) + ->and($filteredMetrics['total']['amount'])->toEqualWithDelta(15000, 0.001) + ->and($component->get('selectedTransactionId'))->toBe($falsePositive->id); +}); +