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

546 lines
19 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 App\Services\AutomationRuleService;
use Illuminate\Database\Eloquent\Factories\Sequence;
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 returns transactions matching creditor name rule', function () {
$this->rule->update([
'rules_json' => ['in' => ['amazon', ['var' => 'creditor_name']]],
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Card payment',
'creditor_name' => 'Amazon EU',
'amount' => -1000,
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Card payment',
'creditor_name' => '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()->create([
'id' => '00000000-0000-0000-0000-000000000001',
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Grocery Store',
'transaction_date' => '2024-01-01',
'amount' => -1000,
]);
Transaction::factory()->enableBanking()->count(500)->sequence(
fn (Sequence $sequence): array => [
'id' => sprintf('ffffffff-ffff-ffff-ffff-%012d', $sequence->index),
],
)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Grocery Store',
'transaction_date' => '2024-01-02',
'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 batches category and label writes', function () {
Queue::fake();
$label = Label::factory()->create(['user_id' => $this->user->id]);
$this->rule->labels()->attach($label);
Transaction::factory()->enableBanking()->count(5)->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;
});
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $this->rule), [
'only_uncategorized' => true,
]);
$transactionUpdateQueries = collect($queries)
->filter(fn (string $query): bool => str_contains($query, 'update "transactions" set')
|| str_contains($query, 'update `transactions` set'));
$perTransactionPivotLookupQueries = collect($queries)
->filter(fn (string $query): bool => (str_contains($query, 'from "label_transaction"')
|| str_contains($query, 'from `label_transaction`'))
&& (str_contains($query, '"label_transaction"."transaction_id" =')
|| str_contains($query, '`label_transaction`.`transaction_id` =')));
$response->assertOk()
->assertJsonPath('status', 'done')
->assertJsonPath('applied', 5)
->assertJsonPath('updated', 5)
->assertJsonPath('total', 5);
expect($transactionUpdateQueries)->toHaveCount(1)
->and($perTransactionPivotLookupQueries)->toHaveCount(0);
Queue::assertNothingPushed();
$this->assertDatabaseCount('label_transaction', 5);
});
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 job applies the rule and records done progress', function () {
$t1 = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Grocery Store',
'amount' => -5000,
'category_id' => null,
]);
$t2 = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Grocery Store',
'amount' => -3000,
'category_id' => null,
]);
$jobId = 'apply-run-1';
(new ApplySingleAutomationRuleJob($this->rule, $jobId, [$t1->id, $t2->id]))
->handle(app(AutomationRuleService::class));
expect($t1->fresh()->category_id)->toBe($this->category->id)
->and($t2->fresh()->category_id)->toBe($this->category->id);
$progress = Cache::get(ApplySingleAutomationRuleJob::cacheKeyForJobId($this->user->id, $jobId));
expect($progress['status'])->toBe('done')
->and($progress['total'])->toBe(2)
->and($progress['processed'])->toBe(2)
->and($progress['applied'])->toBe(2)
->and($progress['updated'])->toBe(2);
});
test('apply job marks cache as failed and preserves counts', function () {
$jobId = 'apply-failed';
Cache::put(
ApplySingleAutomationRuleJob::cacheKeyForJobId($this->user->id, $jobId),
['status' => 'processing', 'processed' => 3, 'total' => 10, 'applied' => 3, 'updated' => 2],
now()->addHour(),
);
(new ApplySingleAutomationRuleJob($this->rule, $jobId, ['x', 'y']))
->failed(new RuntimeException('boom'));
$progress = Cache::get(ApplySingleAutomationRuleJob::cacheKeyForJobId($this->user->id, $jobId));
expect($progress['status'])->toBe('failed')
->and($progress['processed'])->toBe(3)
->and($progress['total'])->toBe(10)
->and($progress['applied'])->toBe(3)
->and($progress['updated'])->toBe(2);
});
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('cannot poll apply job status belonging to another user', function () {
$jobId = 'apply-job-1';
Cache::put(
ApplySingleAutomationRuleJob::cacheKeyForJobId($this->user->id, $jobId),
['status' => 'processing', 'processed' => 1, 'total' => 4],
now()->addHour(),
);
$this->actingAs($this->user)
->getJson(route('automation-rules.apply.status', $jobId))
->assertOk()
->assertJsonPath('status', 'processing');
$otherUser = User::factory()->onboarded()->create();
$this->actingAs($otherUser)
->getJson(route('automation-rules.apply.status', $jobId))
->assertNotFound();
});
test('apply re-checks only_uncategorized at apply time and skips newly categorized transactions', function () {
$otherCategory = Category::factory()->create(['user_id' => $this->user->id]);
// Both matched the rule when the snapshot was taken, but this one has since
// been categorized (by the user, another rule, or the AI backfill).
$nowCategorized = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Grocery Store',
'amount' => -5000,
'category_id' => $otherCategory->id,
]);
$stillUncategorized = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Grocery Store',
'amount' => -3000,
'category_id' => null,
]);
$transactions = Transaction::query()
->whereIn('id', [$nowCategorized->id, $stillUncategorized->id])
->get();
$changed = app(AutomationRuleService::class)
->applyRuleActionsToTransactions($transactions, $this->rule, onlyUncategorized: true);
expect($changed)->toBe(1);
expect($nowCategorized->fresh()->category_id)->toBe($otherCategory->id);
expect($stillUncategorized->fresh()->category_id)->toBe($this->category->id);
});
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,
]);
});