whisper-money/tests/Feature/Ai/RuleSuggestionControllerTes...

287 lines
12 KiB
PHP

<?php
use App\Enums\SuggestionRunStatus;
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\BankingConnection;
use App\Models\Category;
use App\Models\RuleSuggestion;
use App\Models\SuggestionRun;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\Contracts\RuleSuggestionGenerator;
beforeEach(function () {
config()->set('ai_suggestions.eligibility_min_transactions', 50);
config()->set('ai_suggestions.confidence_floor', 0.7);
config()->set('ai_suggestions.overbroad_fraction', 0.4);
config()->set('ai_suggestions.min_match_count', 1);
$this->user = User::factory()->notOnboarded()->create();
$this->account = Account::factory()->for($this->user)->create();
});
function seedTransactions(User $user, Account $account, int $mercadona = 6, int $filler = 44): void
{
for ($i = 0; $i < $mercadona; $i++) {
Transaction::factory()->for($user)->create([
'account_id' => $account->id, 'category_id' => null, 'description_iv' => null,
'creditor_name' => 'MERCADONA', 'description' => "MERCADONA {$i}", 'amount' => -4000,
]);
}
for ($i = 0; $i < $filler; $i++) {
Transaction::factory()->for($user)->create([
'account_id' => $account->id, 'category_id' => null, 'description_iv' => null,
'creditor_name' => null, 'description' => "UNIQUE MERCHANT {$i}", 'amount' => -1000,
]);
}
}
function fakeGeneratorReturning(string $categoryId): void
{
app()->instance(RuleSuggestionGenerator::class, new class($categoryId) implements RuleSuggestionGenerator
{
public function __construct(private string $categoryId) {}
public function generate(array $groups, array $categoryOptions): array
{
return [[
'group_key' => 'mercadona',
'match_field' => 'creditor_name',
'match_operator' => 'equals',
'match_token' => 'mercadona',
'category_id' => $this->categoryId,
'confidence' => 0.95,
]];
}
});
}
it('blocks generation without consent', function () {
$this->actingAs($this->user)
->postJson(route('ai.rule-suggestions.generate'))
->assertForbidden();
});
it('reports ineligible users with too few transactions', function () {
$this->user->recordAiConsent();
seedTransactions($this->user, $this->account, mercadona: 3, filler: 5); // 8 total
$this->actingAs($this->user)
->postJson(route('ai.rule-suggestions.generate'))
->assertStatus(422)
->assertJson(['eligible' => false, 'transaction_count' => 8]);
});
it('generates, persists and returns suggestions for an eligible user', function () {
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
$this->user->recordAiConsent();
seedTransactions($this->user, $this->account);
fakeGeneratorReturning($groceries->id);
$response = $this->actingAs($this->user)
->postJson(route('ai.rule-suggestions.generate'))
->assertOk()
->assertJsonPath('run.status', SuggestionRunStatus::Completed->value)
->assertJsonPath('run.suggestions_count', 1)
->assertJsonPath('suggestions.0.values.0.match_token', 'mercadona');
expect($response->json('suggestions.0.proposed_category.id'))->toBe($groceries->id);
});
it('groups pending suggestions that share a category into one card', function () {
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
$this->user->recordAiConsent();
seedTransactions($this->user, $this->account); // 6 MERCADONA + 44 UNIQUE MERCHANT
$run = SuggestionRun::factory()->for($this->user)->create(['status' => SuggestionRunStatus::Completed]);
RuleSuggestion::factory()->for($run, 'run')->create([
'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona',
'proposed_category_id' => $groceries->id, 'group_size' => 6,
]);
RuleSuggestion::factory()->for($run, 'run')->create([
'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'unique merchant',
'proposed_category_id' => $groceries->id, 'group_size' => 44,
]);
$response = $this->actingAs($this->user)
->getJson(route('ai.rule-suggestions.show'))
->assertOk();
expect($response->json('suggestions'))->toHaveCount(1)
->and($response->json('suggestions.0.values'))->toHaveCount(2)
->and($response->json('suggestions.0.group_size'))->toBe(50)
->and($response->json('suggestions.0.proposed_category.id'))->toBe($groceries->id);
});
it('hides suggestions matching fewer transactions than the configured minimum', function () {
config()->set('ai_suggestions.min_match_count', 10);
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
$shopping = Category::factory()->for($this->user)->create(['name' => 'Shopping', 'type' => 'expense']);
$this->user->recordAiConsent();
seedTransactions($this->user, $this->account); // 6 MERCADONA + 44 UNIQUE MERCHANT
$run = SuggestionRun::factory()->for($this->user)->create(['status' => SuggestionRunStatus::Completed]);
RuleSuggestion::factory()->for($run, 'run')->create([
'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona',
'proposed_category_id' => $groceries->id, 'group_size' => 6,
]);
RuleSuggestion::factory()->for($run, 'run')->create([
'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'unique merchant',
'proposed_category_id' => $shopping->id, 'group_size' => 44,
]);
$response = $this->actingAs($this->user)
->getJson(route('ai.rule-suggestions.show'))
->assertOk();
// The MERCADONA card matches only 6 transactions (< 10) and is dropped.
expect($response->json('suggestions'))->toHaveCount(1)
->and($response->json('suggestions.0.group_size'))->toBe(44)
->and($response->json('suggestions.0.proposed_category.id'))->toBe($shopping->id);
});
it('shows every suggestion when the minimum match count is one', function () {
config()->set('ai_suggestions.min_match_count', 1);
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
$shopping = Category::factory()->for($this->user)->create(['name' => 'Shopping', 'type' => 'expense']);
$this->user->recordAiConsent();
seedTransactions($this->user, $this->account);
$run = SuggestionRun::factory()->for($this->user)->create(['status' => SuggestionRunStatus::Completed]);
RuleSuggestion::factory()->for($run, 'run')->create([
'match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona',
'proposed_category_id' => $groceries->id, 'group_size' => 6,
]);
RuleSuggestion::factory()->for($run, 'run')->create([
'match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'unique merchant',
'proposed_category_id' => $shopping->id, 'group_size' => 44,
]);
$response = $this->actingAs($this->user)
->getJson(route('ai.rule-suggestions.show'))
->assertOk();
expect($response->json('suggestions'))->toHaveCount(2);
});
it('reuses the latest run while throttled instead of generating again', function () {
$this->user->recordAiConsent();
seedTransactions($this->user, $this->account);
SuggestionRun::factory()->for($this->user)->create(['status' => SuggestionRunStatus::Completed]);
$this->actingAs($this->user)
->postJson(route('ai.rule-suggestions.generate'))
->assertOk()
->assertJson(['throttled' => true]);
expect($this->user->suggestionRuns()->count())->toBe(1);
});
it('previews the transactions a group of conditions would match', function () {
$this->user->recordAiConsent();
seedTransactions($this->user, $this->account);
$this->actingAs($this->user)
->postJson(route('ai.rule-suggestions.preview'), [
'conditions' => [
['match_field' => 'creditor_name', 'match_operator' => 'equals', 'match_token' => 'mercadona'],
['match_field' => 'description', 'match_operator' => 'contains', 'match_token' => 'unique merchant 43'],
],
])
->assertOk()
->assertJson(['match_count' => 7, 'total_uncategorized' => 50]);
});
it('accepts suggestions and applies them immediately during onboarding', function () {
$groceries = Category::factory()->for($this->user)->create(['name' => 'Groceries', 'type' => 'expense']);
$this->user->recordAiConsent();
seedTransactions($this->user, $this->account);
fakeGeneratorReturning($groceries->id);
$generated = $this->actingAs($this->user)->postJson(route('ai.rule-suggestions.generate'))->json();
$ids = collect($generated['suggestions'][0]['values'])->pluck('id')->all();
$this->actingAs($this->user)
->postJson(route('ai.rule-suggestions.accept'), [
'suggestions' => [[
'ids' => $ids,
'values' => [[
'match_field' => 'creditor_name',
'match_operator' => 'equals',
'match_token' => 'mercadona',
]],
'proposed_category_id' => $groceries->id,
]],
])
->assertOk()
->assertJson([
'summary' => ['rules_created' => 1, 'transactions_categorized' => 6],
'applied_to_existing' => true,
]);
expect(AutomationRule::query()->where('user_id', $this->user->id)->count())->toBe(1)
->and(Transaction::query()->where('user_id', $this->user->id)->where('creditor_name', 'MERCADONA')->whereNotNull('category_id')->count())->toBe(6);
});
it('does not flag an upgrade when subscriptions are disabled', function () {
config()->set('subscriptions.enabled', false);
$this->actingAs($this->user)
->getJson(route('ai.rule-suggestions.show'))
->assertOk()
->assertJson(['requires_upgrade' => false]);
});
it('flags an upgrade for free users without a connected account', function () {
config()->set('subscriptions.enabled', true);
$this->actingAs($this->user)
->getJson(route('ai.rule-suggestions.show'))
->assertOk()
->assertJson(['requires_upgrade' => true]);
});
it('does not flag an upgrade when the user already linked a bank', function () {
config()->set('subscriptions.enabled', true);
BankingConnection::factory()->for($this->user)->create();
$this->actingAs($this->user)
->getJson(route('ai.rule-suggestions.show'))
->assertOk()
->assertJson(['requires_upgrade' => false]);
});
it('does not flag an upgrade for subscribed users', function () {
config()->set('subscriptions.enabled', true);
$this->user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test123',
'stripe_status' => 'active',
'stripe_price' => 'price_test123',
]);
$this->actingAs($this->user)
->getJson(route('ai.rule-suggestions.show'))
->assertOk()
->assertJson(['requires_upgrade' => false]);
});
it('records and revokes consent', function () {
$this->actingAs($this->user)
->postJson(route('ai.consent.store'))
->assertOk()
->assertJson(['consented' => true]);
expect($this->user->hasActiveAiConsent())->toBeTrue();
$this->actingAs($this->user)
->deleteJson(route('ai.consent.destroy'))
->assertOk()
->assertJson(['consented' => false]);
expect($this->user->fresh()->hasActiveAiConsent())->toBeFalse();
});