109 lines
5.4 KiB
PHP
109 lines
5.4 KiB
PHP
<?php
|
|
|
|
use App\Models\Account;
|
|
use App\Models\Category;
|
|
use App\Models\Transaction;
|
|
use App\Models\User;
|
|
use App\Services\Ai\RuleSuggestionGuard;
|
|
|
|
beforeEach(function () {
|
|
config()->set('ai_suggestions.confidence_floor', 0.7);
|
|
config()->set('ai_suggestions.overbroad_fraction', 0.4);
|
|
|
|
$this->user = User::factory()->create();
|
|
$this->account = Account::factory()->for($this->user)->create();
|
|
$this->guard = app(RuleSuggestionGuard::class);
|
|
|
|
$make = function (string $description, ?string $creditor, int $amount): void {
|
|
Transaction::factory()->for($this->user)->create([
|
|
'account_id' => $this->account->id,
|
|
'category_id' => null,
|
|
'description_iv' => null,
|
|
'creditor_name' => $creditor,
|
|
'debtor_name' => null,
|
|
'description' => $description,
|
|
'amount' => $amount,
|
|
]);
|
|
};
|
|
|
|
// 6 Mercadona (outflow), 4 Netflix (outflow), 10 unique "various shop" (outflow) = 20 total.
|
|
for ($i = 0; $i < 6; $i++) {
|
|
$make("MERCADONA COMPRA {$i}", 'MERCADONA', -4000);
|
|
}
|
|
for ($i = 0; $i < 4; $i++) {
|
|
$make("NETFLIX {$i}", null, -1300);
|
|
}
|
|
for ($i = 0; $i < 10; $i++) {
|
|
$make("VARIOUS SHOP {$i}", null, -1000);
|
|
}
|
|
|
|
$this->groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
|
|
$this->salary = Category::factory()->for($this->user)->create(['name' => 'Salary', 'type' => 'income']);
|
|
|
|
$this->categoryOptions = [
|
|
['id' => $this->groceries->id, 'name' => 'Groceries', 'path' => 'Groceries', 'type' => 'expense', 'direction' => 'outflow', 'is_leaf' => true],
|
|
['id' => $this->salary->id, 'name' => 'Salary', 'path' => 'Salary', 'type' => 'income', 'direction' => 'inflow', 'is_leaf' => true],
|
|
];
|
|
});
|
|
|
|
it('keeps a valid suggestion and computes group size + samples', function () {
|
|
$result = $this->guard->validate($this->user, [
|
|
['group_key' => 'mercadona', 'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'MERCADONA', 'category_id' => $this->groceries->id, 'confidence' => 0.95],
|
|
], $this->categoryOptions);
|
|
|
|
expect($result)->toHaveCount(1);
|
|
expect($result[0]['match_token'])->toBe('mercadona')
|
|
->and($result[0]['proposed_category_id'])->toBe($this->groceries->id)
|
|
->and($result[0]['group_size'])->toBe(6)
|
|
->and($result[0]['sample_descriptions'])->not->toBeEmpty();
|
|
});
|
|
|
|
it('rejects low-confidence, over-broad, absent-token and disallowed-field suggestions', function () {
|
|
$result = $this->guard->validate($this->user, [
|
|
['group_key' => 'netflix', 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'netflix', 'category_id' => $this->groceries->id, 'confidence' => 0.5],
|
|
['group_key' => 'shop', 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'shop', 'category_id' => $this->groceries->id, 'confidence' => 0.95],
|
|
['group_key' => 'ghost', 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'zzzzz', 'category_id' => $this->groceries->id, 'confidence' => 0.95],
|
|
['group_key' => 'notes', 'match_field' => 'notes', 'match_operator' => 'contains', 'match_token' => 'whatever', 'category_id' => $this->groceries->id, 'confidence' => 0.95],
|
|
], $this->categoryOptions);
|
|
|
|
expect($result)->toBeEmpty();
|
|
});
|
|
|
|
it('rejects a token that only matches a single transaction', function () {
|
|
$result = $this->guard->validate($this->user, [
|
|
['group_key' => 'one-off', 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'mercadona compra 0', 'category_id' => $this->groceries->id, 'confidence' => 0.95],
|
|
], $this->categoryOptions);
|
|
|
|
expect($result)->toBeEmpty();
|
|
});
|
|
|
|
it('rejects a suggestion whose category direction conflicts with the group', function () {
|
|
$result = $this->guard->validate($this->user, [
|
|
['group_key' => 'netflix', 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'netflix', 'category_id' => $this->salary->id, 'confidence' => 0.95],
|
|
], $this->categoryOptions);
|
|
|
|
expect($result)->toBeEmpty();
|
|
});
|
|
|
|
it('accepts a new-category proposal when no existing category is given', function () {
|
|
$result = $this->guard->validate($this->user, [
|
|
['group_key' => 'netflix', 'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'netflix', 'category_id' => '', 'new_category_name' => 'Streaming', 'confidence' => 0.9],
|
|
], $this->categoryOptions);
|
|
|
|
expect($result)->toHaveCount(1);
|
|
expect($result[0]['proposed_category_id'])->toBeNull()
|
|
->and($result[0]['new_category_name'])->toBe('Streaming')
|
|
->and($result[0]['new_category_direction'])->toBe('outflow')
|
|
->and($result[0]['group_size'])->toBe(4);
|
|
});
|
|
|
|
it('keeps only the highest-confidence suggestion per identical matcher', function () {
|
|
$result = $this->guard->validate($this->user, [
|
|
['group_key' => 'mercadona', 'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona', 'category_id' => $this->groceries->id, 'confidence' => 0.80],
|
|
['group_key' => 'mercadona', 'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona', 'category_id' => $this->groceries->id, 'confidence' => 0.97],
|
|
], $this->categoryOptions);
|
|
|
|
expect($result)->toHaveCount(1)
|
|
->and($result[0]['confidence'])->toBe(0.97);
|
|
});
|