refactor: Simplify transaction endpoints architecture (#76)

## Summary

Simplifies transaction endpoints by separating sync API (read-only) from
web routes (mutations). This creates clearer architectural boundaries
and fixes inconsistent label behavior.

## Architecture Changes

### Before
- Sync API handled both sync (GET) and mutations (POST/PATCH/DELETE)
- Frontend used sync API for all operations
- Bulk updates merged labels, single updates replaced them

### After  
- **Sync API**: Read-only GET endpoint for IndexedDB sync
- **Web Routes**: All mutations (create, update, delete, bulk
operations)
- **Consistent behavior**: All label updates replace instead of merge

## Endpoint Mapping

| Operation | Old Endpoint | New Endpoint |
|-----------|-------------|--------------|
| Fetch/Sync | `GET /api/sync/transactions` | `GET
/api/sync/transactions`  |
| Create | `POST /api/sync/transactions` | `POST /transactions` |
| Update | `PATCH /api/sync/transactions/{id}` | `PATCH
/transactions/{id}` |
| Delete | `DELETE /api/sync/transactions/{id}` | `DELETE
/transactions/{id}` |
| Bulk Update | `PATCH /transactions/bulk` | `PATCH /transactions/bulk`
 |

## Backend Changes

### TransactionSyncController
-  Simplified to read-only `index()` method
-  Removed `store()`, `update()`, `destroy()` methods
-  Added docblock clarifying purpose

### TransactionController
-  Fixed `store()` to return labels with `id, name, color`
-  Fixed `update()` to return labels with `id, name, color`  
-  Changed `bulkUpdate()` to replace labels instead of merging

### Cleanup
-  Removed `UpdateTransactionSyncRequest` (no longer needed)
-  Updated `routes/api.php` to only have GET for sync

## Frontend Changes

### transaction-sync.ts
-  Updated `create()` → `POST /transactions`
-  Updated `update()` → `PATCH /transactions/{id}`
-  Updated `delete()` → `DELETE /transactions/{id}`
-  Replaced all `fetch()` calls with `axios`
-  Removed manual CSRF token handling

## Test Changes

### TransactionSyncTest
-  Removed create/update/delete tests
-  Kept only read-only sync tests
-  Added test for labels format

### BulkUpdateTransactionsTest  
-  Added test verifying label replacement behavior

## Test Results

All tests passing! 

```
Tests:    42 passed (235 assertions)
Duration: 6.17s

✓ TransactionTest: 29 tests
✓ TransactionSyncTest: 4 tests
✓ BulkUpdateTransactionsTest: 9 tests
```

## Benefits

1. **Clear separation of concerns**: Sync API is read-only, web routes
handle mutations
2. **Consistent label behavior**: All updates replace labels (not merge)
3. **Standardized HTTP client**: Axios everywhere, automatic CSRF
handling
4. **Reduced complexity**: Removed duplicate form request class
5. **Better architecture**: Aligns with intended design

## Breaking Changes

None - All changes are internal to how the frontend calls the backend.
The functionality remains the same from the user's perspective.

## Files Changed

- `app/Http/Controllers/Sync/TransactionSyncController.php`
- `app/Http/Controllers/TransactionController.php`
- `app/Http/Requests/UpdateTransactionSyncRequest.php` (deleted)
- `resources/js/services/transaction-sync.ts`
- `routes/api.php`
- `tests/Feature/Sync/TransactionSyncTest.php`
- `tests/Feature/BulkUpdateTransactionsTest.php`
This commit is contained in:
Víctor Falcón 2026-01-25 16:15:17 +01:00 committed by GitHub
parent 91dd23edc0
commit 8a8d5962b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 88 additions and 531 deletions

View File

@ -3,14 +3,16 @@
namespace App\Http\Controllers\Sync;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTransactionRequest;
use App\Http\Requests\UpdateTransactionSyncRequest;
use App\Models\Transaction;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TransactionSyncController extends Controller
{
/**
* Fetch transactions for client-side IndexedDB sync.
* Supports delta sync via 'since' parameter.
*/
public function index(Request $request): JsonResponse
{
$query = Transaction::query()
@ -30,87 +32,4 @@ class TransactionSyncController extends Controller
'data' => $transactions,
]);
}
public function store(StoreTransactionRequest $request): JsonResponse
{
$data = $request->validated();
$labelIds = $data['label_ids'] ?? [];
unset($data['label_ids']);
// Create transaction with provided ID if available
$transaction = new Transaction([
...$data,
'user_id' => $request->user()->id,
]);
// If ID is provided, check if transaction already exists (idempotent create)
if (isset($data['id'])) {
$existing = Transaction::query()
->where('id', $data['id'])
->where('user_id', $request->user()->id)
->first();
if ($existing) {
// Transaction already exists, return it as success (idempotent)
return response()->json([
'data' => $existing->load('labels:id,name,color'),
], 200);
}
}
// If ID is provided, use it; otherwise Laravel will generate UUID v7
if (isset($data['id'])) {
$transaction->id = $data['id'];
$transaction->exists = false;
}
$transaction->save();
if (! empty($labelIds)) {
$transaction->labels()->sync($labelIds);
}
return response()->json([
'data' => $transaction->load('labels:id,name,color'),
], 201);
}
public function update(UpdateTransactionSyncRequest $request, Transaction $transaction): JsonResponse
{
// Ensure user owns this transaction
if ($transaction->user_id !== $request->user()->id) {
abort(403, 'Unauthorized');
}
$data = $request->validated();
$labelIds = $data['label_ids'] ?? null;
$hasLabelUpdate = $request->has('label_ids');
unset($data['label_ids']);
if (! empty($data)) {
$transaction->update($data);
}
if ($hasLabelUpdate) {
$transaction->labels()->sync($labelIds ?? []);
}
return response()->json([
'data' => $transaction->fresh()->load('labels:id,name,color'),
]);
}
public function destroy(Transaction $transaction): JsonResponse
{
// Ensure user owns this transaction
if ($transaction->user_id !== request()->user()->id) {
abort(403, 'Unauthorized');
}
$transaction->delete();
return response()->json([
'message' => 'Transaction deleted successfully',
]);
}
}

View File

@ -116,7 +116,7 @@ class TransactionController extends Controller
$transaction->save();
return response()->json([
'data' => $transaction,
'data' => $transaction->load('labels:id,name,color'),
], 201);
}
@ -152,7 +152,7 @@ class TransactionController extends Controller
}
return response()->json([
'data' => $transaction->fresh(['labels']),
'data' => $transaction->fresh()->load('labels:id,name,color'),
]);
}
@ -268,16 +268,8 @@ class TransactionController extends Controller
if ($hasLabelUpdate) {
foreach ($transactions as $transaction) {
if (empty($labelIds)) {
// If labelIds is empty, remove all labels
$transaction->labels()->sync([]);
} else {
// Otherwise, merge with existing labels (don't detach existing ones)
$existingLabelIds = $transaction->labels()->pluck('labels.id')->toArray();
$mergedLabelIds = array_unique(array_merge($existingLabelIds, $labelIds));
$transaction->labels()->sync($mergedLabelIds);
}
// Replace labels (consistent with single update behavior)
$transaction->labels()->sync($labelIds ?? []);
$transaction->save();
}
}

View File

@ -1,63 +0,0 @@
<?php
namespace App\Http\Requests;
use App\Enums\TransactionSource;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateTransactionSyncRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'account_id' => [
'sometimes',
Rule::exists('accounts', 'id')->where(function ($query) {
$query->where('user_id', $this->user()->id);
}),
],
'category_id' => [
'nullable',
Rule::exists('categories', 'id')->where(function ($query) {
$query->where('user_id', $this->user()->id);
}),
],
'description' => ['sometimes', 'string'],
'description_iv' => ['sometimes', 'string', 'size:16'],
'transaction_date' => ['sometimes', 'date'],
'amount' => ['sometimes', 'integer'],
'currency_code' => ['sometimes', 'string', 'size:3'],
'notes' => ['nullable', 'string'],
'notes_iv' => ['nullable', 'string', 'size:16'],
'source' => ['sometimes', Rule::enum(TransactionSource::class)],
'label_ids' => ['nullable', 'array'],
'label_ids.*' => [
'string',
'uuid',
Rule::exists('labels', 'id')->where(function ($query) {
$query->where('user_id', $this->user()->id);
}),
],
];
}
public function messages(): array
{
return [
'account_id.exists' => 'The selected account does not exist or does not belong to you.',
'category_id.exists' => 'The selected category does not exist or does not belong to you.',
'label_ids.*.exists' => 'One or more selected labels do not exist or do not belong to you.',
'description_iv.size' => 'The description IV must be exactly 16 characters.',
'transaction_date.date' => 'The transaction date must be a valid date.',
'amount.integer' => 'The amount must be an integer.',
'currency_code.size' => 'The currency code must be exactly 3 characters.',
'notes_iv.size' => 'The notes IV must be exactly 16 characters.',
];
}
}

View File

@ -20,15 +20,6 @@ interface TransactionFilters {
searchText?: string;
}
function getCsrfToken(): string {
return decodeURIComponent(
document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='))
?.split('=')[1] || '',
);
}
class TransactionSyncService {
private syncManager: TransactionSyncManager;
@ -70,7 +61,7 @@ class TransactionSyncService {
async create(
data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>,
): Promise<Transaction> {
const response = await axios.post('/api/sync/transactions', data);
const response = await axios.post('/transactions', data);
const serverData = response.data.data;
const label_ids = serverData.labels?.map((l: { id: string }) => l.id);
@ -103,27 +94,12 @@ class TransactionSyncService {
): Promise<Transaction> {
const { label_ids, ...transactionData } = data;
const response = await fetch(`/api/sync/transactions/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({
...transactionData,
label_ids,
}),
const response = await axios.patch(`/transactions/${id}`, {
...transactionData,
label_ids,
});
if (!response.ok) {
throw new Error('Failed to update transaction');
}
const result = await response.json();
const serverData = result.data;
const serverData = response.data.data;
const serverLabelIds = serverData.labels?.map(
(l: { id: string }) => l.id,
@ -144,25 +120,11 @@ class TransactionSyncService {
): Promise<void> {
const { label_ids, ...transactionData } = data;
const response = await fetch('/transactions/bulk', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({
transaction_ids: ids,
label_ids: label_ids,
...transactionData,
}),
await axios.patch('/transactions/bulk', {
transaction_ids: ids,
label_ids: label_ids,
...transactionData,
});
if (!response.ok) {
throw new Error('Failed to bulk update transactions');
}
}
async updateByFilters(
@ -196,32 +158,17 @@ class TransactionSyncService {
requestFilters.label_ids = filters.labelIds;
}
const response = await fetch('/transactions/bulk', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': getCsrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({
filters: requestFilters,
label_ids: label_ids,
...transactionData,
}),
const response = await axios.patch('/transactions/bulk', {
filters: requestFilters,
label_ids: label_ids,
...transactionData,
});
if (!response.ok) {
throw new Error('Failed to bulk update transactions by filters');
}
const result = await response.json();
return result.count || 0;
return response.data.count || 0;
}
async delete(id: string): Promise<void> {
await axios.delete(`/api/sync/transactions/${id}`);
await axios.delete(`/transactions/${id}`);
}
async updateManyIndividual(

View File

@ -16,12 +16,9 @@ Route::middleware(['web', 'auth'])->group(function () {
// Import Data (for import drawers)
Route::get('import/data', [ImportDataController::class, 'index']);
// Transaction Sync (for IndexedDB client-side search)
// Transaction Sync (read-only, for IndexedDB client-side sync)
Route::prefix('sync')->group(function () {
Route::get('transactions', [TransactionSyncController::class, 'index']);
Route::post('transactions', [TransactionSyncController::class, 'store']);
Route::patch('transactions/{transaction}', [TransactionSyncController::class, 'update']);
Route::delete('transactions/{transaction}', [TransactionSyncController::class, 'destroy']);
});
// Account Balances

View File

@ -229,6 +229,41 @@ it('can bulk update transactions with labels by IDs', function () {
}
});
it('bulk update replaces labels instead of merging them', function () {
$label1 = Label::factory()->create(['user_id' => $this->user->id, 'name' => 'Old Label 1']);
$label2 = Label::factory()->create(['user_id' => $this->user->id, 'name' => 'Old Label 2']);
$label3 = Label::factory()->create(['user_id' => $this->user->id, 'name' => 'New Label']);
$transactions = Transaction::factory()
->count(2)
->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
]);
// Attach existing labels
foreach ($transactions as $transaction) {
$transaction->labels()->attach([$label1->id, $label2->id]);
}
// Bulk update with new label should replace, not merge
$response = $this->actingAs($this->user)->patchJson('/transactions/bulk', [
'transaction_ids' => $transactions->pluck('id')->toArray(),
'label_ids' => [$label3->id],
]);
$response->assertSuccessful();
// Verify labels were replaced, not merged
foreach ($transactions as $transaction) {
$labelIds = $transaction->fresh()->labels->pluck('id')->toArray();
expect($labelIds)->toHaveCount(1);
expect($labelIds)->toEqual([$label3->id]);
expect($labelIds)->not->toContain($label1->id);
expect($labelIds)->not->toContain($label2->id);
}
});
it('can update all transactions when no filters or IDs are provided', function () {
Transaction::factory()
->count(3)

View File

@ -468,14 +468,14 @@ describe('Sync Endpoints IDOR Protection', function () {
expect($transactionIds)->not->toContain($victimTransaction->id);
});
it('cannot create transaction for another user account via sync endpoint', function () {
it('cannot create transaction for another user account via web endpoint', function () {
$attacker = User::factory()->onboarded()->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
actingAs($attacker);
$response = $this->postJson('/api/sync/transactions', [
$response = $this->postJson('/transactions', [
'account_id' => $victimAccount->id,
'description' => 'Malicious transaction',
'description_iv' => str_repeat('a', 16),
@ -489,11 +489,8 @@ describe('Sync Endpoints IDOR Protection', function () {
expect($response->json('errors.account_id'))->toBeArray();
});
it('cannot create transaction using another user transaction ID via sync endpoint', function () {
it('cannot update transaction from another user via web endpoint', function () {
$attacker = User::factory()->onboarded()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$attackerCategory = Category::factory()->for($attacker)->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimCategory = Category::factory()->for($victim)->create();
@ -505,88 +502,9 @@ describe('Sync Endpoints IDOR Protection', function () {
actingAs($attacker);
$response = $this->postJson('/api/sync/transactions', [
'id' => $victimTransaction->id,
'account_id' => $attackerAccount->id,
'description' => 'Attempted ID reuse',
'description_iv' => str_repeat('a', 16),
'transaction_date' => now()->format('Y-m-d'),
'amount' => -50000,
'currency_code' => 'USD',
'source' => 'manually_created',
]);
$response->assertStatus(500);
$this->assertDatabaseHas('transactions', [
'id' => $victimTransaction->id,
'user_id' => $victim->id,
]);
$this->assertDatabaseMissing('transactions', [
'id' => $victimTransaction->id,
'user_id' => $attacker->id,
]);
});
it('idempotent create returns existing transaction when same user provides same ID', function () {
$user = User::factory()->onboarded()->create();
$account = Account::factory()->for($user)->create();
$category = Category::factory()->for($user)->create();
$existingTransaction = Transaction::factory()
->for($user)
->for($account)
->for($category)
->create([
'description' => 'Original transaction',
'description_iv' => str_repeat('a', 16),
]);
actingAs($user);
$response = $this->postJson('/api/sync/transactions', [
'id' => $existingTransaction->id,
'account_id' => $account->id,
'description' => 'Attempted duplicate',
'description_iv' => str_repeat('b', 16),
'transaction_date' => now()->format('Y-m-d'),
'amount' => -50000,
'currency_code' => 'USD',
'source' => 'manually_created',
]);
$response->assertSuccessful();
expect($response->json('data.id'))->toBe($existingTransaction->id);
expect($response->json('data.description'))->toBe('Original transaction');
$this->assertDatabaseHas('transactions', [
'id' => $existingTransaction->id,
'description' => 'Original transaction',
]);
});
it('cannot update transaction from another user via sync endpoint', function () {
$attacker = User::factory()->onboarded()->create();
$attackerAccount = Account::factory()->for($attacker)->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
$victimCategory = Category::factory()->for($victim)->create();
$victimTransaction = Transaction::factory()
->for($victim)
->for($victimAccount)
->for($victimCategory)
->create();
actingAs($attacker);
$response = $this->patchJson("/api/sync/transactions/{$victimTransaction->id}", [
'account_id' => $attackerAccount->id,
'description' => 'Hacked transaction',
'description_iv' => str_repeat('b', 16),
'transaction_date' => $victimTransaction->transaction_date->format('Y-m-d'),
'amount' => $victimTransaction->amount,
'currency_code' => $victimTransaction->currency_code,
'source' => $victimTransaction->source->value,
$response = $this->patchJson("/transactions/{$victimTransaction->id}", [
'notes' => 'Hacked notes',
'notes_iv' => str_repeat('b', 16),
]);
$response->assertForbidden();
@ -598,7 +516,7 @@ describe('Sync Endpoints IDOR Protection', function () {
]);
});
it('cannot delete transaction from another user via sync endpoint', function () {
it('cannot delete transaction from another user via web endpoint', function () {
$attacker = User::factory()->onboarded()->create();
$victim = User::factory()->onboarded()->create();
$victimAccount = Account::factory()->for($victim)->create();
@ -609,7 +527,7 @@ describe('Sync Endpoints IDOR Protection', function () {
actingAs($attacker);
$response = $this->deleteJson("/api/sync/transactions/{$victimTransaction->id}");
$response = $this->deleteJson("/transactions/{$victimTransaction->id}");
$response->assertForbidden();

View File

@ -1,10 +1,9 @@
<?php
use App\Models\Account;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Str;
it('can fetch user transactions', function () {
$user = User::factory()->create();
@ -49,7 +48,7 @@ it('only returns user own transactions', function () {
->assertJsonCount(1, 'data');
});
it('can filter transactions by updated_at', function () {
it('can filter transactions by updated_at for delta sync', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
@ -68,218 +67,31 @@ it('can filter transactions by updated_at', function () {
->assertJsonPath('data.0.id', $newTransaction->id);
});
it('can create a transaction', function () {
it('includes labels with id, name, and color', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$category = Category::factory()->for($user)->create();
$label1 = Label::factory()->create(['user_id' => $user->id, 'name' => 'Important', 'color' => '#ff0000']);
$label2 = Label::factory()->create(['user_id' => $user->id, 'name' => 'Work', 'color' => '#00ff00']);
$transactionData = [
'account_id' => $account->id,
'category_id' => $category->id,
'description' => 'encrypted_description',
'description_iv' => '1234567890123456',
'transaction_date' => now()->toDateString(),
'amount' => 10050,
'currency_code' => 'USD',
'notes' => null,
'notes_iv' => null,
'source' => 'manually_created',
];
$transaction = Transaction::factory()->for($user)->for($account)->create();
$transaction->labels()->attach([$label1->id, $label2->id]);
$response = $this->actingAs($user)->postJson('/api/sync/transactions', $transactionData);
$response = $this->actingAs($user)->getJson('/api/sync/transactions');
$response->assertCreated()
$response->assertSuccessful()
->assertJsonCount(1, 'data')
->assertJsonStructure([
'data' => [
'id',
'user_id',
'account_id',
'category_id',
'description',
'description_iv',
'transaction_date',
'amount',
'currency_code',
'source',
'created_at',
'updated_at',
'*' => [
'id',
'labels' => [
'*' => ['id', 'name', 'color'],
],
],
],
]);
$this->assertDatabaseHas('transactions', [
'user_id' => $user->id,
'account_id' => $account->id,
'category_id' => $category->id,
'description' => 'encrypted_description',
'amount' => 10050,
'source' => 'manually_created',
]);
});
it('can create a transaction with a UUID', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$uuid = (string) Str::uuid7();
$transactionData = [
'id' => $uuid,
'account_id' => $account->id,
'category_id' => null,
'description' => 'encrypted_description',
'description_iv' => '1234567890123456',
'transaction_date' => now()->toDateString(),
'amount' => 10050,
'currency_code' => 'USD',
'notes' => null,
'notes_iv' => null,
'source' => 'imported',
];
$response = $this->actingAs($user)->postJson('/api/sync/transactions', $transactionData);
$response->assertCreated()
->assertJsonPath('data.id', $uuid);
$this->assertDatabaseHas('transactions', [
'id' => $uuid,
'user_id' => $user->id,
'source' => 'imported',
]);
});
it('validates required fields when creating a transaction', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/sync/transactions', []);
$response->assertUnprocessable()
->assertJsonValidationErrors(['account_id', 'description', 'description_iv', 'transaction_date', 'amount', 'currency_code', 'source']);
});
it('can update a transaction', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$transaction = Transaction::factory()->for($user)->for($account)->create([
'amount' => 10000,
'description' => 'old_description',
]);
$updateData = [
'account_id' => $account->id,
'category_id' => null,
'description' => 'new_description',
'description_iv' => $transaction->description_iv,
'transaction_date' => $transaction->transaction_date->toDateString(),
'amount' => 20000,
'currency_code' => $transaction->currency_code,
'notes' => null,
'notes_iv' => null,
'source' => 'manually_created',
];
$response = $this->actingAs($user)->patchJson("/api/sync/transactions/{$transaction->id}", $updateData);
$response->assertSuccessful()
->assertJsonPath('data.amount', 20000)
->assertJsonPath('data.description', 'new_description');
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'amount' => 20000,
'description' => 'new_description',
]);
});
it('cannot update another user transaction', function () {
$user = User::factory()->create();
$userAccount = Account::factory()->for($user)->create();
$otherUser = User::factory()->create();
$otherAccount = Account::factory()->for($otherUser)->create();
$transaction = Transaction::factory()->for($otherUser)->for($otherAccount)->create();
$updateData = [
'account_id' => $userAccount->id,
'category_id' => null,
'description' => 'hacked',
'description_iv' => $transaction->description_iv,
'transaction_date' => $transaction->transaction_date->toDateString(),
'amount' => 99999,
'currency_code' => $transaction->currency_code,
'notes' => null,
'notes_iv' => null,
'source' => 'manually_created',
];
$response = $this->actingAs($user)->patchJson("/api/sync/transactions/{$transaction->id}", $updateData);
$response->assertForbidden();
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'user_id' => $otherUser->id,
'account_id' => $otherAccount->id,
]);
});
it('can delete a transaction', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$transaction = Transaction::factory()->for($user)->for($account)->create();
$response = $this->actingAs($user)->deleteJson("/api/sync/transactions/{$transaction->id}");
$response->assertSuccessful();
$this->assertSoftDeleted('transactions', [
'id' => $transaction->id,
]);
});
it('cannot delete another user transaction', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$account = Account::factory()->for($otherUser)->create();
$transaction = Transaction::factory()->for($otherUser)->for($account)->create();
$response = $this->actingAs($user)->deleteJson("/api/sync/transactions/{$transaction->id}");
$response->assertForbidden();
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
]);
});
it('can partially update a transaction with only category and notes', function () {
$user = User::factory()->create();
$account = Account::factory()->for($user)->create();
$category = Category::factory()->for($user)->create();
$transaction = Transaction::factory()->for($user)->for($account)->create([
'amount' => 10000,
'description' => 'original_description',
'category_id' => null,
'notes' => null,
]);
$updateData = [
'category_id' => $category->id,
'notes' => 'encrypted_notes',
'notes_iv' => str_repeat('n', 16),
];
$response = $this->actingAs($user)->patchJson("/api/sync/transactions/{$transaction->id}", $updateData);
$response->assertSuccessful()
->assertJsonPath('data.category_id', $category->id)
->assertJsonPath('data.notes', 'encrypted_notes')
->assertJsonPath('data.amount', 10000)
->assertJsonPath('data.description', 'original_description');
$this->assertDatabaseHas('transactions', [
'id' => $transaction->id,
'category_id' => $category->id,
'notes' => 'encrypted_notes',
'amount' => 10000,
'description' => 'original_description',
]);
])
->assertJsonPath('data.0.labels.0.name', 'Important')
->assertJsonPath('data.0.labels.0.color', '#ff0000')
->assertJsonPath('data.0.labels.1.name', 'Work')
->assertJsonPath('data.0.labels.1.color', '#00ff00');
});