whisper-money/tests/Feature/ReEvaluateTransactionRulesT...

420 lines
15 KiB
PHP

<?php
use App\Jobs\ReEvaluateTransactionRulesJob;
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\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
$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]);
});
// ──────────────────────────────────────────────
// Single re-evaluate endpoint
// ──────────────────────────────────────────────
test('single endpoint applies matching rule and returns updated transaction', function () {
AutomationRule::factory()->create([
'user_id' => $this->user->id,
'priority' => 1,
'rules_json' => ['in' => ['grocery', ['var' => 'description']]],
'action_category_id' => $this->category->id,
]);
$transaction = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Grocery Store Purchase',
'amount' => -5000,
'category_id' => null,
]);
$response = $this->actingAs($this->user)
->postJson(route('transactions.re-evaluate-rules.single', $transaction));
$response->assertOk()
->assertJsonPath('data.category_id', $this->category->id);
expect($transaction->fresh()->category_id)->toBe($this->category->id);
});
test('single endpoint assigns labels from matching rule', function () {
$label = Label::factory()->create(['user_id' => $this->user->id]);
AutomationRule::factory()
->hasAttached($label, [], 'labels')
->create([
'user_id' => $this->user->id,
'priority' => 1,
'rules_json' => ['in' => ['netflix', ['var' => 'description']]],
'action_category_id' => null,
]);
$transaction = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Netflix subscription',
'amount' => -1500,
]);
$this->actingAs($this->user)
->postJson(route('transactions.re-evaluate-rules.single', $transaction))
->assertOk();
expect($transaction->fresh()->labels->pluck('id'))->toContain($label->id);
});
test('single endpoint applies plain action_note from matching rule', function () {
AutomationRule::factory()->create([
'user_id' => $this->user->id,
'priority' => 1,
'rules_json' => ['in' => ['spotify', ['var' => 'description']]],
'action_category_id' => null,
'action_note' => 'Streaming service',
'action_note_iv' => null,
]);
$transaction = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Spotify Premium',
'amount' => -999,
'notes' => null,
]);
$this->actingAs($this->user)
->postJson(route('transactions.re-evaluate-rules.single', $transaction))
->assertOk();
expect($transaction->fresh()->notes)->toBe('Streaming service');
});
test('single endpoint does not duplicate a note already present', function () {
AutomationRule::factory()->create([
'user_id' => $this->user->id,
'priority' => 1,
'rules_json' => ['in' => ['spotify', ['var' => 'description']]],
'action_category_id' => null,
'action_note' => 'Streaming service',
'action_note_iv' => null,
]);
$transaction = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Spotify Premium',
'amount' => -999,
'notes' => 'Streaming service',
]);
$this->actingAs($this->user)
->postJson(route('transactions.re-evaluate-rules.single', $transaction))
->assertOk();
expect($transaction->fresh()->notes)->toBe('Streaming service');
});
test('single endpoint skips encrypted transactions silently', function () {
AutomationRule::factory()->create([
'user_id' => $this->user->id,
'priority' => 1,
'rules_json' => ['in' => ['grocery', ['var' => 'description']]],
'action_category_id' => $this->category->id,
]);
$transaction = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'encrypted-blob',
'description_iv' => 'a1b2c3d4e5f60001',
'amount' => -5000,
'category_id' => null,
]);
$this->actingAs($this->user)
->postJson(route('transactions.re-evaluate-rules.single', $transaction))
->assertOk();
// Category should remain null because the backend skips encrypted transactions
expect($transaction->fresh()->category_id)->toBeNull();
});
test('single endpoint returns 403 for another user\'s transaction', function () {
$otherUser = User::factory()->onboarded()->create();
$transaction = Transaction::factory()->enableBanking()->create([
'user_id' => $otherUser->id,
'account_id' => Account::factory()->create(['user_id' => $otherUser->id])->id,
'description' => 'Some purchase',
'amount' => -1000,
]);
$this->actingAs($this->user)
->postJson(route('transactions.re-evaluate-rules.single', $transaction))
->assertForbidden();
});
// ──────────────────────────────────────────────
// Bulk re-evaluate endpoint
// ──────────────────────────────────────────────
test('bulk endpoint dispatches job and returns job_id', function () {
Queue::fake();
$response = $this->actingAs($this->user)
->postJson(route('transactions.re-evaluate-rules.bulk'));
$response->assertStatus(202)
->assertJsonStructure(['job_id']);
Queue::assertPushed(ReEvaluateTransactionRulesJob::class);
});
test('bulk endpoint dispatches job with provided transaction_ids', function () {
Queue::fake();
$transaction = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Purchase',
'amount' => -1000,
]);
$response = $this->actingAs($this->user)
->postJson(route('transactions.re-evaluate-rules.bulk'), [
'transaction_ids' => [$transaction->id],
]);
$response->assertStatus(202);
Queue::assertPushed(ReEvaluateTransactionRulesJob::class, function ($job) use ($transaction) {
return $job->transactionIds === [$transaction->id];
});
});
test('bulk endpoint sets initial pending status in cache', function () {
Queue::fake();
$response = $this->actingAs($this->user)
->postJson(route('transactions.re-evaluate-rules.bulk'));
$jobId = $response->json('job_id');
$cacheKey = ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId);
expect(Cache::get($cacheKey))->toMatchArray(['status' => 'pending']);
});
// ──────────────────────────────────────────────
// Status endpoint
// ──────────────────────────────────────────────
test('status endpoint returns progress from cache', function () {
$jobId = 'test-job-id';
$cacheKey = ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId);
Cache::put($cacheKey, [
'status' => 'processing',
'processed' => 10,
'total' => 50,
'updated' => 3,
], now()->addHour());
$this->actingAs($this->user)
->getJson(route('transactions.re-evaluate-rules.status', $jobId))
->assertOk()
->assertJson([
'status' => 'processing',
'processed' => 10,
'total' => 50,
'updated' => 3,
]);
});
test('status endpoint returns 404 for unknown job', function () {
$this->actingAs($this->user)
->getJson(route('transactions.re-evaluate-rules.status', 'non-existent-job-id'))
->assertNotFound();
});
// ──────────────────────────────────────────────
// Job execution
// ──────────────────────────────────────────────
test('job applies rules to non-encrypted transactions and tracks progress', function () {
// Create transactions BEFORE the rule so the creation listener has no rules to apply
$matchingTransaction = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Grocery Store',
'amount' => -5000,
'category_id' => null,
]);
$nonMatchingTransaction = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Coffee Shop',
'amount' => -500,
'category_id' => null,
]);
// Add the rule after creation so re-evaluation is meaningful
AutomationRule::factory()->create([
'user_id' => $this->user->id,
'priority' => 1,
'rules_json' => ['in' => ['grocery', ['var' => 'description']]],
'action_category_id' => $this->category->id,
]);
$jobId = 'test-job-'.uniqid();
$job = new ReEvaluateTransactionRulesJob($this->user, $jobId);
$job->handle(app(AutomationRuleService::class));
expect($matchingTransaction->fresh()->category_id)->toBe($this->category->id);
expect($nonMatchingTransaction->fresh()->category_id)->toBeNull();
$progress = Cache::get(ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId));
expect($progress['status'])->toBe('done');
expect($progress['processed'])->toBe(2);
expect($progress['updated'])->toBe(1);
});
test('job skips encrypted transactions', function () {
AutomationRule::factory()->create([
'user_id' => $this->user->id,
'priority' => 1,
'rules_json' => ['in' => ['grocery', ['var' => 'description']]],
'action_category_id' => $this->category->id,
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'encrypted-blob',
'description_iv' => 'a1b2c3d4e5f60001',
'amount' => -5000,
'category_id' => null,
]);
$jobId = 'test-job-'.uniqid();
$job = new ReEvaluateTransactionRulesJob($this->user, $jobId);
$job->handle(app(AutomationRuleService::class));
$progress = Cache::get(ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId));
// 0 processed because the encrypted transaction is excluded from the query
expect($progress['processed'])->toBe(0);
expect($progress['updated'])->toBe(0);
});
test('job only processes provided transaction_ids', function () {
// Create transactions BEFORE the rule so the creation listener has no rules to apply
$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 Supermarket',
'amount' => -3000,
'category_id' => null,
]);
AutomationRule::factory()->create([
'user_id' => $this->user->id,
'priority' => 1,
'rules_json' => ['in' => ['grocery', ['var' => 'description']]],
'action_category_id' => $this->category->id,
]);
$jobId = 'test-job-'.uniqid();
$job = new ReEvaluateTransactionRulesJob($this->user, $jobId, [$t1->id]);
$job->handle(app(AutomationRuleService::class));
expect($t1->fresh()->category_id)->toBe($this->category->id);
expect($t2->fresh()->category_id)->toBeNull();
$progress = Cache::get(ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId));
expect($progress['processed'])->toBe(1);
});
test('bulk endpoint dispatches job with provided filters', function () {
Queue::fake();
$response = $this->actingAs($this->user)
->postJson(route('transactions.re-evaluate-rules.bulk'), [
'filters' => [
'date_from' => '2024-01-01',
'date_to' => '2024-12-31',
],
]);
$response->assertStatus(202)
->assertJsonStructure(['job_id']);
Queue::assertPushed(ReEvaluateTransactionRulesJob::class, function ($job) {
return $job->transactionIds === null
&& $job->filters === ['date_from' => '2024-01-01', 'date_to' => '2024-12-31'];
});
});
test('job applies rules to transactions matching filters', function () {
$matchingTransaction = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Grocery Store',
'amount' => -5000,
'category_id' => null,
'transaction_date' => '2024-06-15',
]);
$outsideRangeTransaction = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Grocery Supermarket',
'amount' => -3000,
'category_id' => null,
'transaction_date' => '2023-01-01',
]);
AutomationRule::factory()->create([
'user_id' => $this->user->id,
'priority' => 1,
'rules_json' => ['in' => ['grocery', ['var' => 'description']]],
'action_category_id' => $this->category->id,
]);
$jobId = 'test-job-'.uniqid();
$job = new ReEvaluateTransactionRulesJob($this->user, $jobId, null, [
'date_from' => '2024-01-01',
'date_to' => '2024-12-31',
]);
$job->handle(app(AutomationRuleService::class));
expect($matchingTransaction->fresh()->category_id)->toBe($this->category->id);
expect($outsideRangeTransaction->fresh()->category_id)->toBeNull();
$progress = Cache::get(ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId));
expect($progress['status'])->toBe('done');
expect($progress['processed'])->toBe(1);
expect($progress['updated'])->toBe(1);
});