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, ]); });