whisper-money/tests/Feature/TransactionFilterTest.php

661 lines
20 KiB
PHP

<?php
use App\Models\Account;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use function Pest\Laravel\actingAs;
beforeEach(function () {
$this->user = User::factory()->onboarded()->create();
$this->account = Account::factory()->create(['user_id' => $this->user->id]);
});
test('index returns paginated transactions as Inertia props', function () {
Transaction::factory()->plaintext()->count(3)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
]);
$response = actingAs($this->user)->get(route('transactions.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('transactions/index')
->has('transactions.data', 3)
->has('appliedFilters')
->where('appliedFilters.sort', '-transaction_date')
);
});
test('transactions include eager-loaded relationships', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$label = Label::factory()->create(['user_id' => $this->user->id]);
$transaction = Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $category->id,
]);
$transaction->labels()->attach($label->id);
$response = actingAs($this->user)->get(route('transactions.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->has('transactions.data.0.account')
->has('transactions.data.0.category')
->has('transactions.data.0.labels', 1)
);
});
test('filter by date range', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'transaction_date' => '2025-06-15',
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'transaction_date' => '2025-01-01',
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'date_from' => '2025-06-01',
'date_to' => '2025-06-30',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
);
});
test('filter by amount range', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'amount' => -5000, // -$50.00
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'amount' => -20000, // -$200.00
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'amount_min' => -100,
'amount_max' => -10,
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
);
});
test('filter by category', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $category->id,
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'category_ids' => $category->id,
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
);
});
test('filtering by a parent category includes transactions from its descendants', function () {
$parent = Category::factory()->create(['user_id' => $this->user->id]);
$child = Category::factory()->childOf($parent)->create(['user_id' => $this->user->id]);
$grandchild = Category::factory()->childOf($child)->create(['user_id' => $this->user->id]);
$unrelated = Category::factory()->create(['user_id' => $this->user->id]);
foreach ([$parent, $child, $grandchild, $unrelated] as $category) {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $category->id,
]);
}
$response = actingAs($this->user)->get(route('transactions.index', [
'category_ids' => $parent->id,
]));
// Parent + child + grandchild, but not the unrelated category.
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 3)
);
});
test('filter by uncategorized', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $category->id,
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'category_ids' => 'uncategorized',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
->where('transactions.data.0.category_id', null)
);
});
test('filter by account', function () {
$otherAccount = Account::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $otherAccount->id,
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'account_ids' => $this->account->id,
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
);
});
test('filter by label', function () {
$label = Label::factory()->create(['user_id' => $this->user->id]);
$txWithLabel = Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
]);
$txWithLabel->labels()->attach($label->id);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'label_ids' => $label->id,
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
);
});
test('category and label filters combine with OR', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$label = Label::factory()->create(['user_id' => $this->user->id]);
$txWithCategory = Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $category->id,
]);
$txWithLabel = Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
]);
$txWithLabel->labels()->attach($label->id);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'category_ids' => $category->id,
'label_ids' => $label->id,
]));
$ids = collect($response->viewData('page')['props']['transactions']['data'])->pluck('id');
expect($ids)->toHaveCount(2)
->and($ids)->toContain($txWithCategory->id)
->and($ids)->toContain($txWithLabel->id);
});
test('search matches description', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Grocery Store Purchase',
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Gas Station',
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'search' => 'Grocery',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
->where('transactions.data.0.description', 'Grocery Store Purchase')
);
});
test('filter by creditor name', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'creditor_name' => 'Amazon EU',
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'creditor_name' => 'Coffee Shop',
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'creditor_name' => 'Amazon',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
->where('transactions.data.0.creditor_name', 'Amazon EU')
->where('appliedFilters.creditor_name', 'Amazon')
);
});
test('filter by debtor name', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'debtor_name' => 'Payroll GmbH',
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'debtor_name' => 'Other Sender',
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'debtor_name' => 'Payroll',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
->where('transactions.data.0.debtor_name', 'Payroll GmbH')
->where('appliedFilters.debtor_name', 'Payroll')
);
});
test('search matches counterparty names', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Card payment',
'creditor_name' => 'Amazon EU',
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Card payment',
'creditor_name' => 'Coffee Shop',
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'search' => 'Amazon',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
->where('transactions.data.0.creditor_name', 'Amazon EU')
);
});
test('search matches notes', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Some purchase',
'notes' => 'weekly groceries for the family',
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Another purchase',
'notes' => null,
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'search' => 'groceries',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
);
});
test('combined filters work', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
// Matches all filters
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $category->id,
'transaction_date' => '2025-06-15',
'amount' => -5000,
'description' => 'Target purchase',
]);
// Wrong date
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $category->id,
'transaction_date' => '2025-01-01',
'amount' => -5000,
'description' => 'Target purchase',
]);
// Wrong category
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'transaction_date' => '2025-06-15',
'amount' => -5000,
'description' => 'Target purchase',
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'date_from' => '2025-06-01',
'date_to' => '2025-06-30',
'category_ids' => $category->id,
'search' => 'Target',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
);
});
test('sort by date ascending', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'transaction_date' => '2025-06-15',
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'transaction_date' => '2025-01-01',
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'sort' => 'transaction_date',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 2)
->where('transactions.data.0.transaction_date', fn ($date) => str_contains($date, '2025-01-01'))
);
});
test('sort by date descending (default)', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'transaction_date' => '2025-06-15',
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'transaction_date' => '2025-01-01',
]);
$response = actingAs($this->user)->get(route('transactions.index'));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 2)
->where('transactions.data.0.transaction_date', fn ($date) => str_contains($date, '2025-06-15'))
);
});
test('sort by amount', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'amount' => -10000,
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'amount' => 5000,
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'sort' => 'amount',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 2)
->where('transactions.data.0.amount', -10000)
);
});
test('sort by description', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Zebra Store',
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Apple Store',
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'sort' => 'description',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 2)
->where('transactions.data.0.description', 'Apple Store')
);
});
test('cursor pagination returns correct results', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->plaintext()->count(25)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $category->id,
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'per_page' => 10,
]));
$nextCursor = null;
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 10)
->where('transactions.next_cursor', function ($cursor) use (&$nextCursor) {
$nextCursor = $cursor;
return $cursor !== null;
})
);
// Fetch the next page using the cursor
$response2 = actingAs($this->user)->get(route('transactions.index', [
'per_page' => 10,
'cursor' => $nextCursor,
]));
$response2->assertInertia(fn ($page) => $page
->has('transactions.data', 10)
);
});
test('empty results when no matches', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Coffee Shop',
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'search' => 'nonexistent_query_string',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 0)
);
});
test('user scoping - cannot see other users transactions', function () {
$otherUser = User::factory()->create();
$otherAccount = Account::factory()->create(['user_id' => $otherUser->id]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
]);
Transaction::factory()->plaintext()->create([
'user_id' => $otherUser->id,
'account_id' => $otherAccount->id,
]);
$response = actingAs($this->user)->get(route('transactions.index'));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
);
});
test('applied filters are returned in response', function () {
$response = actingAs($this->user)->get(route('transactions.index', [
'search' => 'test',
'date_from' => '2025-01-01',
'sort' => '-amount',
]));
$response->assertInertia(fn ($page) => $page
->where('appliedFilters.search', 'test')
->where('appliedFilters.date_from', '2025-01-01')
->where('appliedFilters.sort', '-amount')
);
});
test('filter by multiple categories including uncategorized', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $category->id,
]);
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'category_ids' => $category->id.',uncategorized',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 2)
);
});
test('paginates across pages when sorting by a nullable column with null values', function () {
Transaction::factory()->plaintext()->count(12)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'creditor_name' => null,
]);
Transaction::factory()->plaintext()->count(5)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'creditor_name' => 'ACME',
]);
$firstPage = actingAs($this->user)->get(route('transactions.index', [
'sort' => 'creditor_name',
'per_page' => 10,
]));
$firstPage->assertSuccessful();
$nextPageUrl = $firstPage->getOriginalContent()->getData()['page']['props']['transactions']['next_page_url'];
expect($nextPageUrl)->not->toBeNull();
actingAs($this->user)->get($nextPageUrl)->assertSuccessful();
});
test('does not expose the sort alias attribute when sorting by a nullable column', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'creditor_name' => null,
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'sort' => 'creditor_name',
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 1)
->missing('transactions.data.0.creditor_name_sort')
);
});