whisper-money/tests/Feature/AutomationRuleApplicationTe...

349 lines
12 KiB
PHP

<?php
use App\Events\TransactionCreated;
use App\Events\TransactionUpdated;
use App\Jobs\ApplySingleAutomationRuleJob;
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
Event::fake([TransactionCreated::class, TransactionUpdated::class]);
$this->user = User::factory()->onboarded()->create();
$this->bank = Bank::factory()->create(['name' => 'Test Bank', 'user_id' => $this->user->id]);
$this->account = Account::factory()->create([
'user_id' => $this->user->id,
'bank_id' => $this->bank->id,
'name' => 'Checking Account',
'encrypted' => false,
]);
$this->category = Category::factory()->create(['user_id' => $this->user->id]);
$this->rule = AutomationRule::factory()->create([
'user_id' => $this->user->id,
'priority' => 1,
'rules_json' => ['in' => ['grocery', ['var' => 'description']]],
'action_category_id' => $this->category->id,
]);
});
test('matches endpoint returns transactions matching the rule', function () {
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Grocery Store',
'amount' => -1000,
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Coffee Shop',
'amount' => -500,
]);
$response = $this->actingAs($this->user)
->getJson(route('automation-rules.matches', $this->rule));
$response->assertOk()
->assertJsonPath('total', 1)
->assertJsonCount(1, 'data');
});
test('matches endpoint skips already categorized when only_uncategorized is true', function () {
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $this->category->id,
'description' => 'Grocery Store A',
'amount' => -1000,
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Grocery Store B',
'amount' => -1500,
]);
$response = $this->actingAs($this->user)
->getJson(route('automation-rules.matches', $this->rule).'?only_uncategorized=1');
$response->assertOk()->assertJsonPath('total', 1);
$allResponse = $this->actingAs($this->user)
->getJson(route('automation-rules.matches', $this->rule).'?only_uncategorized=0');
$allResponse->assertOk()->assertJsonPath('total', 2);
});
test('matches endpoint avoids repeated relationship queries for description-only rules', function () {
Transaction::factory()->enableBanking()->count(501)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Grocery Store',
'amount' => -1000,
]);
$queries = [];
DB::listen(function ($query) use (&$queries): void {
$queries[] = $query->sql;
});
$this->actingAs($this->user)
->getJson(route('automation-rules.matches', $this->rule))
->assertOk()
->assertJsonPath('total', 501);
$accountEagerLoadQueries = collect($queries)
->filter(fn (string $query): bool => (str_contains($query, 'from "accounts"') || str_contains($query, 'from `accounts`'))
&& (str_contains($query, '"accounts"."id" in') || str_contains($query, '`accounts`.`id` in')));
$bankEagerLoadQueries = collect($queries)
->filter(fn (string $query): bool => (str_contains($query, 'from "banks"') || str_contains($query, 'from `banks`'))
&& (str_contains($query, '"banks"."id" in') || str_contains($query, '`banks`.`id` in')));
expect($accountEagerLoadQueries)->toHaveCount(1)
->and($bankEagerLoadQueries)->toHaveCount(1);
});
test('matches endpoint deduplicates cached matching transaction ids', function () {
$transaction = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $this->category->id,
'description' => 'Grocery Store A',
'amount' => -1000,
]);
$stamp = $this->rule->updated_at?->getTimestamp() ?? 0;
Cache::put(
"automation_rule_matches:{$this->user->id}:{$this->rule->id}:0:{$stamp}",
[$transaction->id, $transaction->id],
now()->addMinutes(15),
);
$response = $this->actingAs($this->user)
->getJson(route('automation-rules.matches', $this->rule).'?only_uncategorized=0');
$response->assertOk()
->assertJsonPath('total', 1)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $transaction->id);
});
test('apply endpoint runs synchronously when matches are below threshold', function () {
Queue::fake();
Transaction::factory()->enableBanking()->count(3)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Grocery Store',
'amount' => -1000,
]);
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $this->rule), [
'only_uncategorized' => true,
]);
$response->assertOk()
->assertJsonPath('status', 'done')
->assertJsonPath('applied', 3)
->assertJsonPath('updated', 3)
->assertJsonPath('total', 3);
Queue::assertNothingPushed();
expect(
Transaction::where('user_id', $this->user->id)
->where('category_id', $this->category->id)
->count()
)->toBe(3);
});
test('apply endpoint queues a job when matches exceed threshold', function () {
Queue::fake();
Transaction::factory()->enableBanking()->count(101)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Grocery Store',
'amount' => -1000,
]);
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $this->rule), [
'only_uncategorized' => true,
]);
$response->assertStatus(202)
->assertJsonPath('total', 101)
->assertJsonStructure(['job_id']);
Queue::assertPushed(ApplySingleAutomationRuleJob::class);
});
test('apply endpoint returns done with zero matches when no transactions match', function () {
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Coffee Shop',
'amount' => -500,
]);
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $this->rule));
$response->assertOk()
->assertJsonPath('status', 'done')
->assertJsonPath('total', 0)
->assertJsonPath('updated', 0);
});
test('cannot apply rule belonging to another user', function () {
$otherUser = User::factory()->onboarded()->create();
$this->actingAs($otherUser)
->postJson(route('automation-rules.apply', $this->rule))
->assertForbidden();
$this->actingAs($otherUser)
->getJson(route('automation-rules.matches', $this->rule))
->assertForbidden();
});
test('label-only rule applies when only_uncategorized is true', function () {
$labelOnlyRule = AutomationRule::factory()->create([
'user_id' => $this->user->id,
'priority' => 2,
'rules_json' => ['in' => ['coffee', ['var' => 'description']]],
'action_category_id' => null,
]);
$label = Label::factory()->create(['user_id' => $this->user->id]);
$labelOnlyRule->labels()->attach($label);
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $this->category->id,
'description' => 'Coffee Shop',
'amount' => -500,
]);
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $labelOnlyRule), [
'only_uncategorized' => true,
]);
$response->assertOk()->assertJsonPath('applied', 1);
});
test('applying after changing the rule category reports all matches as applied', function () {
$newCategory = Category::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->enableBanking()->count(5)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $this->category->id,
'description' => 'Grocery Store',
'amount' => -1000,
]);
$this->rule->update(['action_category_id' => $newCategory->id]);
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $this->rule), [
'only_uncategorized' => false,
]);
$response->assertOk()
->assertJsonPath('applied', 5)
->assertJsonPath('updated', 5)
->assertJsonPath('total', 5);
expect(
Transaction::where('user_id', $this->user->id)
->where('category_id', $newCategory->id)
->count()
)->toBe(5);
});
test('re-applying same rule reports applied count even when nothing changes', function () {
Transaction::factory()->enableBanking()->count(4)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $this->category->id,
'description' => 'Grocery Store',
'amount' => -1000,
]);
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $this->rule), [
'only_uncategorized' => false,
]);
$response->assertOk()
->assertJsonPath('applied', 4)
->assertJsonPath('updated', 0)
->assertJsonPath('total', 4);
});
test('store flashes saved automation rule id and token', function () {
$payload = [
'title' => 'Test Rule',
'priority' => 0,
'rules_json' => json_encode(['in' => ['amazon', ['var' => 'description']]]),
'action_category_id' => $this->category->id,
'action_note' => null,
'action_note_iv' => null,
'action_label_ids' => [],
];
$response = $this->actingAs($this->user)
->post(route('automation-rules.store'), $payload);
$response->assertRedirect();
$response->assertSessionHas('saved_automation_rule_id');
$response->assertSessionHas('saved_automation_rule_token');
});
test('updating labels flashes a new saved automation rule token', function () {
$firstLabel = Label::factory()->create(['user_id' => $this->user->id]);
$secondLabel = Label::factory()->create(['user_id' => $this->user->id]);
$this->rule->labels()->attach($firstLabel);
$payload = [
'title' => $this->rule->title,
'priority' => $this->rule->priority,
'rules_json' => json_encode($this->rule->rules_json),
'action_category_id' => $this->rule->action_category_id,
'action_note' => null,
'action_note_iv' => null,
'action_label_ids' => [$secondLabel->id],
];
$response = $this->actingAs($this->user)
->patch(route('automation-rules.update', $this->rule), $payload);
$response->assertRedirect();
$response->assertSessionHas('saved_automation_rule_id', $this->rule->id);
$response->assertSessionHas('saved_automation_rule_token');
expect($this->rule->labels()->pluck('labels.id')->all())->toBe([
$secondLabel->id,
]);
});