diff --git a/app/Http/Controllers/Sync/TransactionSyncController.php b/app/Http/Controllers/Sync/TransactionSyncController.php index 921c8ae9..a4af9148 100644 --- a/app/Http/Controllers/Sync/TransactionSyncController.php +++ b/app/Http/Controllers/Sync/TransactionSyncController.php @@ -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', - ]); - } } diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index aa104d1c..7f313a26 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -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(); } } diff --git a/app/Http/Requests/UpdateTransactionSyncRequest.php b/app/Http/Requests/UpdateTransactionSyncRequest.php deleted file mode 100644 index ec2f926e..00000000 --- a/app/Http/Requests/UpdateTransactionSyncRequest.php +++ /dev/null @@ -1,63 +0,0 @@ - [ - '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.', - ]; - } -} diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts index 347355f6..2d2b4d91 100644 --- a/resources/js/services/transaction-sync.ts +++ b/resources/js/services/transaction-sync.ts @@ -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, ): Promise { - 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 { 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 { 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 { - await axios.delete(`/api/sync/transactions/${id}`); + await axios.delete(`/transactions/${id}`); } async updateManyIndividual( diff --git a/routes/api.php b/routes/api.php index 84a0817f..7038b600 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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 diff --git a/tests/Feature/BulkUpdateTransactionsTest.php b/tests/Feature/BulkUpdateTransactionsTest.php index d76f90b7..7ddac62f 100644 --- a/tests/Feature/BulkUpdateTransactionsTest.php +++ b/tests/Feature/BulkUpdateTransactionsTest.php @@ -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) diff --git a/tests/Feature/IdorVulnerabilityTest.php b/tests/Feature/IdorVulnerabilityTest.php index 1649ebc9..3f02ad49 100644 --- a/tests/Feature/IdorVulnerabilityTest.php +++ b/tests/Feature/IdorVulnerabilityTest.php @@ -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(); diff --git a/tests/Feature/Sync/TransactionSyncTest.php b/tests/Feature/Sync/TransactionSyncTest.php index 268ac56a..47958820 100644 --- a/tests/Feature/Sync/TransactionSyncTest.php +++ b/tests/Feature/Sync/TransactionSyncTest.php @@ -1,10 +1,9 @@ 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'); });