Move transaction filtering from client-side to server-side (#126)

## Summary

- **Server-side filtering/sorting/search**: With encryption removed, all
transaction filtering, sorting, and full-text search (LIKE on
description + notes) now happens via SQL queries instead of client-side
IndexedDB with decryption
- **Cursor pagination**: Transactions are returned with cursor-based
pagination (default 50 per page, "Load More" pattern) instead of loading
all into IndexedDB
- **Removed IndexedDB dependency**: The main transactions page no longer
depends on Dexie/IndexedDB for data or sync — other pages (categorize,
accounts/show, budgets/show) are unaffected and will be migrated
separately

## Changes

| File | Change |
|------|--------|
| `app/Models/Transaction.php` | Added `scopeApplyFilters` query scope |
| `app/Http/Requests/IndexTransactionRequest.php` | New form request for
filter validation |
| `app/Http/Controllers/TransactionController.php` | Server-filtered
cursor-paginated index, refactored bulkUpdate to reuse scope |
| `resources/js/pages/transactions/index.tsx` | Major rewrite —
server-driven filtering, sorting, pagination via `router.visit()` |
| `resources/js/components/transactions/transaction-filters.tsx` |
Search always enabled (removed encryption key dependency) |
| `resources/js/types/transaction.ts` | Added `ServerTransaction` type |
| `resources/js/contexts/sync-context.tsx` | Simplified — removed
transaction sync service |
| `tests/Feature/TransactionFilterTest.php` | 20 new Pest tests for all
filter scenarios |

## Test plan

- [x] Run `php artisan test` — all 523+ tests pass
- [x] Navigate to `/transactions` — paginated results load from server
- [x] Apply each filter type (date, amount, category, account, label,
search) and verify server round-trip
- [x] Test "uncategorized" category filter
- [x] Test combined filters
- [x] Test sorting by date, amount, description (ascending/descending)
- [x] Test "Load More" button for cursor pagination
- [x] Test bulk operations (select some → update, select all filtered →
update)
- [x] Verify URL persistence (refresh page with filters in URL)
- [x] Test search on both description and notes fields
This commit is contained in:
Víctor Falcón 2026-02-16 12:49:04 +01:00 committed by GitHub
parent 6abec95d0e
commit d1efc03fc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1017 additions and 957 deletions

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Http\Requests\BulkUpdateTransactionsRequest;
use App\Http\Requests\IndexTransactionRequest;
use App\Http\Requests\StoreTransactionRequest;
use App\Http\Requests\UpdateTransactionRequest;
use App\Models\Account;
@ -21,9 +22,49 @@ class TransactionController extends Controller
{
use AuthorizesRequests;
public function index(Request $request): Response
public function index(IndexTransactionRequest $request): Response
{
$user = $request->user();
$validated = $request->validated();
$perPage = (int) ($validated['per_page'] ?? 50);
$sortParam = $validated['sort'] ?? '-transaction_date';
$descending = str_starts_with($sortParam, '-');
$sortColumn = ltrim($sortParam, '-');
$sortDirection = $descending ? 'desc' : 'asc';
$filters = array_filter([
'date_from' => $validated['date_from'] ?? null,
'date_to' => $validated['date_to'] ?? null,
'amount_min' => $validated['amount_min'] ?? null,
'amount_max' => $validated['amount_max'] ?? null,
'category_ids' => $validated['category_ids'] ?? null,
'account_ids' => $validated['account_ids'] ?? null,
'label_ids' => $validated['label_ids'] ?? null,
'search' => $validated['search'] ?? null,
], fn ($value) => $value !== null);
$transactions = Transaction::query()
->where('user_id', $user->id)
->with(['account.bank:id,name,logo', 'category:id,name,icon,color', 'labels:id,name,color'])
->applyFilters($filters)
->orderBy($sortColumn, $sortDirection)
->orderBy('id', 'desc')
->cursorPaginate($perPage)
->withQueryString();
$appliedFilters = [
'date_from' => $validated['date_from'] ?? null,
'date_to' => $validated['date_to'] ?? null,
'amount_min' => $validated['amount_min'] ?? null,
'amount_max' => $validated['amount_max'] ?? null,
'category_ids' => $validated['category_ids'] ?? [],
'account_ids' => $validated['account_ids'] ?? [],
'label_ids' => $validated['label_ids'] ?? [],
'search' => $validated['search'] ?? '',
'sort' => $sortParam,
];
$categories = Category::query()
->where('user_id', $user->id)
@ -56,6 +97,8 @@ class TransactionController extends Controller
->get();
return Inertia::render('transactions/index', [
'transactions' => $transactions,
'appliedFilters' => $appliedFilters,
'categories' => $categories,
'accounts' => $accounts,
'banks' => $banks,
@ -192,30 +235,7 @@ class TransactionController extends Controller
], 403);
}
} elseif ($filters !== null) {
if (isset($filters['date_from'])) {
$query->whereDate('transaction_date', '>=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$query->whereDate('transaction_date', '<=', $filters['date_to']);
}
if (isset($filters['amount_min'])) {
$query->where('amount', '>=', $filters['amount_min'] * 100);
}
if (isset($filters['amount_max'])) {
$query->where('amount', '<=', $filters['amount_max'] * 100);
}
if (! empty($filters['category_ids'])) {
$query->whereIn('category_id', $filters['category_ids']);
}
if (! empty($filters['account_ids'])) {
$query->whereIn('account_id', $filters['account_ids']);
}
if (! empty($filters['label_ids'])) {
$query->whereHas('labels', function ($q) use ($filters) {
$q->whereIn('labels.id', $filters['label_ids']);
});
}
$query->applyFilters($filters);
$transactions = $query->get();
} else {
$transactions = $query->get();
@ -242,40 +262,17 @@ class TransactionController extends Controller
}
if (! empty($updateData)) {
$query = Transaction::query()->where('user_id', $user->id);
$updateQuery = Transaction::query()->where('user_id', $user->id);
if ($transactionIds && count($transactionIds) > 0) {
$query->whereIn('id', $transactionIds);
$updateQuery->whereIn('id', $transactionIds);
} elseif ($filters !== null) {
if (isset($filters['date_from'])) {
$query->whereDate('transaction_date', '>=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$query->whereDate('transaction_date', '<=', $filters['date_to']);
}
if (isset($filters['amount_min'])) {
$query->where('amount', '>=', $filters['amount_min'] * 100);
}
if (isset($filters['amount_max'])) {
$query->where('amount', '<=', $filters['amount_max'] * 100);
}
if (! empty($filters['category_ids'])) {
$query->whereIn('category_id', $filters['category_ids']);
}
if (! empty($filters['account_ids'])) {
$query->whereIn('account_id', $filters['account_ids']);
}
if (! empty($filters['label_ids'])) {
$query->whereHas('labels', function ($q) use ($filters) {
$q->whereIn('labels.id', $filters['label_ids']);
});
}
$updateQuery->applyFilters($filters);
}
$query->update($updateData);
$updateQuery->update($updateData);
}
if ($hasLabelUpdate) {
foreach ($transactions as $transaction) {
// Replace labels (consistent with single update behavior)
$transaction->labels()->sync($labelIds ?? []);
$transaction->save();
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class IndexTransactionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$fields = ['category_ids', 'account_ids', 'label_ids'];
foreach ($fields as $field) {
$value = $this->input($field);
if (is_string($value)) {
$this->merge([
$field => array_filter(explode(',', $value)),
]);
}
}
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'date_from' => ['nullable', 'date'],
'date_to' => ['nullable', 'date'],
'amount_min' => ['nullable', 'numeric'],
'amount_max' => ['nullable', 'numeric'],
'category_ids' => ['nullable', 'array'],
'category_ids.*' => ['string'],
'account_ids' => ['nullable', 'array'],
'account_ids.*' => ['string', 'uuid'],
'label_ids' => ['nullable', 'array'],
'label_ids.*' => ['string', 'uuid'],
'search' => ['nullable', 'string', 'max:200'],
'sort' => ['nullable', 'string', 'in:transaction_date,-transaction_date,amount,-amount,description,-description'],
'cursor' => ['nullable', 'string'],
'per_page' => ['nullable', 'integer', 'min:10', 'max:100'],
];
}
}

View File

@ -6,6 +6,7 @@ use App\Enums\TransactionSource;
use App\Events\TransactionCreated;
use App\Events\TransactionDeleted;
use App\Events\TransactionUpdated;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -79,4 +80,58 @@ class Transaction extends Model
{
return $this->hasMany(BudgetTransaction::class);
}
/**
* @param Builder<Transaction> $query
* @param array<string, mixed> $filters
* @return Builder<Transaction>
*/
public function scopeApplyFilters(Builder $query, array $filters): Builder
{
if (isset($filters['date_from'])) {
$query->whereDate('transaction_date', '>=', $filters['date_from']);
}
if (isset($filters['date_to'])) {
$query->whereDate('transaction_date', '<=', $filters['date_to']);
}
if (isset($filters['amount_min'])) {
$query->where('amount', '>=', $filters['amount_min'] * 100);
}
if (isset($filters['amount_max'])) {
$query->where('amount', '<=', $filters['amount_max'] * 100);
}
if (! empty($filters['category_ids'])) {
$ids = collect($filters['category_ids']);
$hasUncategorized = $ids->contains('uncategorized');
$realIds = $ids->reject(fn ($id) => $id === 'uncategorized')->values()->all();
$query->where(function (Builder $q) use ($realIds, $hasUncategorized) {
if (! empty($realIds)) {
$q->whereIn('category_id', $realIds);
}
if ($hasUncategorized) {
$q->orWhereNull('category_id');
}
});
}
if (! empty($filters['account_ids'])) {
$query->whereIn('account_id', $filters['account_ids']);
}
if (! empty($filters['label_ids'])) {
$query->whereHas('labels', fn (Builder $q) => $q->whereIn('labels.id', $filters['label_ids']));
}
if (! empty($filters['search'])) {
$term = '%'.$filters['search'].'%';
$query->where(fn (Builder $q) => $q->where('description', 'LIKE', $term)->orWhere('notes', 'LIKE', $term));
}
return $query;
}
}

View File

@ -2,7 +2,7 @@ import { formatDate } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { ColumnDef } from '@tanstack/react-table';
import { getYear, parseISO } from 'date-fns';
import { ArrowDown, MoreHorizontal } from 'lucide-react';
import { ArrowDown, ArrowUp, ArrowUpDown, MoreHorizontal } from 'lucide-react';
import { AccountName } from '@/components/accounts/account-name';
import { BankLogo } from '@/components/bank-logo';
@ -97,8 +97,13 @@ export function createTransactionColumns({
}
>
{__('Date')}
<ArrowDown className="h-2 w-2 opacity-25" />
{column.getIsSorted() === 'desc' ? (
<ArrowDown className="h-3 w-3" />
) : column.getIsSorted() === 'asc' ? (
<ArrowUp className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 opacity-25" />
)}
</Button>
);
},

View File

@ -45,7 +45,6 @@ export function TransactionFilters({
categories,
labels,
accounts,
isKeySet,
actions,
hideAccountFilter = false,
}: TransactionFiltersProps) {
@ -80,7 +79,7 @@ export function TransactionFilters({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.searchText]);
function handleCategoryToggle(categoryId: number) {
function handleCategoryToggle(categoryId: string) {
const newCategoryIds = filters.categoryIds.includes(categoryId)
? filters.categoryIds.filter((id) => id !== categoryId)
: [...filters.categoryIds, categoryId];
@ -132,14 +131,9 @@ export function TransactionFilters({
<div className="flex flex-col items-center gap-3 lg:flex-row">
<div className="flex w-full flex-row items-center gap-2 lg:w-auto">
<Input
placeholder={
isKeySet
? __('Search description or notes...')
: __('Search disabled (encryption key not set)')
}
placeholder={__('Search description or notes...')}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
disabled={!isKeySet}
className="max-w-sm flex-1 md:max-w-full md:min-w-[350px]"
/>
@ -567,4 +561,4 @@ export function TransactionFilters({
);
}
const UNCATEGORIZED_CATEGORY_ID = -1;
const UNCATEGORIZED_CATEGORY_ID = 'uncategorized';

View File

@ -1,6 +1,5 @@
import { useOnlineStatus } from '@/hooks/use-online-status';
import { identifyUser, resetPostHog } from '@/lib/posthog';
import { transactionSyncService } from '@/services/transaction-sync';
import type { User } from '@/types/index.d';
import type { Page } from '@inertiajs/core';
import { router } from '@inertiajs/react';
@ -27,31 +26,6 @@ interface SyncContextType {
const SyncContext = createContext<SyncContextType | undefined>(undefined);
function formatErrorMessage(error: string): string {
if (error.includes('status code 5')) {
return 'Server is temporarily unavailable. Please try again later.';
}
if (
error.includes('status code 401') ||
error.includes('status code 403')
) {
return 'Your session has expired. Please refresh the page.';
}
if (error.includes('status code 4')) {
return 'Something went wrong. Please try again.';
}
if (error.includes('Network Error') || error.includes('network')) {
return 'Unable to connect. Check your internet connection.';
}
if (error.includes('timeout') || error.includes('Timeout')) {
return 'The request took too long. Please try again.';
}
if (error === 'Sync already in progress') {
return 'Sync is already running. Please wait.';
}
return 'Sync failed. Please try again.';
}
interface SyncProviderProps {
children: ReactNode;
initialIsAuthenticated: boolean;
@ -72,7 +46,6 @@ export function SyncProvider({
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(null);
const [wasOffline, setWasOffline] = useState(!isOnline);
const syncInProgressRef = useRef(false);
const lastUserIdRef = useRef<string | null>(null);
useEffect(() => {
@ -102,55 +75,16 @@ export function SyncProvider({
}, [isAuthenticated]);
const sync = useCallback(async () => {
if (!isAuthenticated) {
if (!isAuthenticated || !isOnline) {
return;
}
if (!isOnline) {
setError(
'Unable to sync while offline. Connect to the internet and try again.',
);
return;
}
setSyncStatus('success');
setLastSyncTime(new Date());
if (syncInProgressRef.current) {
return;
}
syncInProgressRef.current = true;
setSyncStatus('syncing');
setError(null);
try {
const result = await transactionSyncService.sync();
if (result.errors.length > 0) {
const uniqueFormattedErrors = [
...new Set(result.errors.map(formatErrorMessage)),
];
setError(uniqueFormattedErrors.join(' '));
setSyncStatus('error');
} else {
setSyncStatus('success');
setLastSyncTime(new Date());
setTimeout(() => {
setSyncStatus('idle');
}, 3000);
}
} catch (err) {
console.error('Sync failed:', err);
const errorMessage =
err instanceof Error ? err.message : 'Unknown sync error';
setError(formatErrorMessage(errorMessage));
setSyncStatus('error');
setTimeout(() => {
setSyncStatus('idle');
}, 5000);
} finally {
syncInProgressRef.current = false;
}
setTimeout(() => {
setSyncStatus('idle');
}, 3000);
}, [isAuthenticated, isOnline]);
useEffect(() => {
@ -166,10 +100,6 @@ export function SyncProvider({
return;
}
// If user changed, clear transactions
if (lastUserIdRef.current && lastUserIdRef.current !== currentUser.id) {
transactionSyncService.clearAll();
}
lastUserIdRef.current = currentUser.id;
identifyUser(currentUser.id, {

File diff suppressed because it is too large Load Diff

View File

@ -23,6 +23,12 @@ export interface Transaction {
updated_at: string;
}
export interface ServerTransaction extends Transaction {
account?: Account;
category?: Category | null;
labels?: Label[];
}
export interface DecryptedTransaction extends Transaction {
decryptedDescription: string;
decryptedNotes: string | null;

View File

@ -0,0 +1,484 @@
<?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('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('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('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)
);
});

View File

@ -25,6 +25,9 @@ test('authenticated users can access transactions page', function () {
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('transactions/index')
->has('transactions')
->has('transactions.data')
->has('appliedFilters')
->has('categories')
->has('accounts')
->has('banks')