Sync transactions
This commit is contained in:
parent
dd0f1380d4
commit
faf6d9146e
|
|
@ -43,6 +43,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `bun run build`, `bun run dev`, or `composer run dev`. Ask them.
|
||||
- Don't run `bun run build` by yourself, the user is usually already running `bun run dev`.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
|
@ -203,7 +204,7 @@ Route::get('/users', function () {
|
|||
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `bun run build` or ask the user to run `bun run dev` or `composer run dev`.
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can ask the user to run `bun run dev` or `composer run dev`.
|
||||
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sync;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreTransactionRequest;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TransactionSyncController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Transaction::query()
|
||||
->where('user_id', $request->user()->id);
|
||||
|
||||
if ($request->has('since')) {
|
||||
$query->where('updated_at', '>', $request->input('since'));
|
||||
}
|
||||
|
||||
$transactions = $query->orderBy('updated_at', 'asc')->get();
|
||||
|
||||
return response()->json([
|
||||
'data' => $transactions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreTransactionRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
// Create transaction with provided ID if available
|
||||
$transaction = new Transaction([
|
||||
...$data,
|
||||
'user_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
// 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();
|
||||
|
||||
return response()->json([
|
||||
'data' => $transaction,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(StoreTransactionRequest $request, Transaction $transaction): JsonResponse
|
||||
{
|
||||
// Ensure user owns this transaction
|
||||
if ($transaction->user_id !== $request->user()->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$data = $request->validated();
|
||||
unset($data['id']); // Don't allow ID changes
|
||||
|
||||
$transaction->update($data);
|
||||
|
||||
return response()->json([
|
||||
'data' => $transaction->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreTransactionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => ['sometimes', 'uuid'],
|
||||
'account_id' => ['required', 'exists:accounts,id'],
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'description' => ['required', 'string'],
|
||||
'description_iv' => ['required', 'string', 'size:16'],
|
||||
'transaction_date' => ['required', 'date'],
|
||||
'amount' => ['required', 'numeric'],
|
||||
'currency_code' => ['required', 'string', 'size:3'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'notes_iv' => ['nullable', 'string', 'size:16'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'account_id.required' => 'The account is required.',
|
||||
'account_id.exists' => 'The selected account does not exist.',
|
||||
'category_id.exists' => 'The selected category does not exist.',
|
||||
'description.required' => 'The description is required.',
|
||||
'description_iv.required' => 'The description IV is required.',
|
||||
'description_iv.size' => 'The description IV must be exactly 16 characters.',
|
||||
'transaction_date.required' => 'The transaction date is required.',
|
||||
'transaction_date.date' => 'The transaction date must be a valid date.',
|
||||
'amount.required' => 'The amount is required.',
|
||||
'amount.numeric' => 'The amount must be a number.',
|
||||
'currency_code.required' => 'The currency code is required.',
|
||||
'currency_code.size' => 'The currency code must be exactly 3 characters.',
|
||||
'notes_iv.size' => 'The notes IV must be exactly 16 characters.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -9,7 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
class Transaction extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\TransactionFactory> */
|
||||
use HasFactory;
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
|
|
@ -46,4 +47,9 @@ class Transaction extends Model
|
|||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function newUniqueId(): string
|
||||
{
|
||||
return (string) \Illuminate\Support\Str::uuid7();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
bun.lock
3
bun.lock
|
|
@ -41,6 +41,7 @@
|
|||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2",
|
||||
"uuidv7": "^1.0.2",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^7.0.4",
|
||||
"xlsx": "^0.18.5",
|
||||
|
|
@ -1048,6 +1049,8 @@
|
|||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
||||
|
||||
"uuidv7": ["uuidv7@1.0.2", "", { "bin": { "uuidv7": "cli.js" } }, "sha512-8JQkH4ooXnm1JCIhqTMbtmdnYEn6oKukBxHn1Ic9878jMkL7daTI7anTExfY18VRCX7tcdn5quzvCb6EWrR8PA=="],
|
||||
|
||||
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||
|
||||
"vite": ["vite@7.1.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ=="],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Drop the existing id column and recreate it as UUID
|
||||
Schema::table('transactions', function (Blueprint $table) {
|
||||
// Drop the existing auto-increment id
|
||||
$table->dropColumn('id');
|
||||
});
|
||||
|
||||
Schema::table('transactions', function (Blueprint $table) {
|
||||
// Add new UUID id as primary key
|
||||
$table->uuid('id')->primary()->first();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('transactions', function (Blueprint $table) {
|
||||
$table->dropColumn('id');
|
||||
});
|
||||
|
||||
Schema::table('transactions', function (Blueprint $table) {
|
||||
$table->id()->first();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('transactions', function (Blueprint $table) {
|
||||
$table->dropForeign(['category_id']);
|
||||
$table->foreignId('category_id')->nullable()->change()->constrained()->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('transactions', function (Blueprint $table) {
|
||||
$table->dropForeign(['category_id']);
|
||||
$table->foreignId('category_id')->nullable(false)->change()->constrained()->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -64,6 +64,7 @@
|
|||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2",
|
||||
"uuidv7": "^1.0.2",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^7.0.4",
|
||||
"xlsx": "^0.18.5"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useOnlineStatus } from '@/hooks/use-online-status';
|
|||
import { categorySyncService } from '@/services/category-sync';
|
||||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { bankSyncService } from '@/services/bank-sync';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error';
|
||||
|
||||
|
|
@ -46,17 +47,19 @@ export function SyncProvider({ children }: { children: ReactNode }) {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const [categoriesResult, accountsResult, banksResult] =
|
||||
const [categoriesResult, accountsResult, banksResult, transactionsResult] =
|
||||
await Promise.all([
|
||||
categorySyncService.sync(),
|
||||
accountSyncService.sync(),
|
||||
bankSyncService.sync(),
|
||||
transactionSyncService.sync(),
|
||||
]);
|
||||
|
||||
const allErrors = [
|
||||
...categoriesResult.errors,
|
||||
...accountsResult.errors,
|
||||
...banksResult.errors,
|
||||
...transactionsResult.errors,
|
||||
];
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export async function resetDatabase(): Promise<void> {
|
|||
}
|
||||
|
||||
export interface IndexedDBRecord {
|
||||
id: number;
|
||||
id: number | string;
|
||||
user_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
@ -170,7 +170,7 @@ class IndexedDBService {
|
|||
|
||||
async getById<T extends IndexedDBRecord>(
|
||||
storeName: StoreName,
|
||||
id: number,
|
||||
id: number | string,
|
||||
): Promise<T | null> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -188,6 +188,13 @@ class IndexedDBService {
|
|||
});
|
||||
}
|
||||
|
||||
async get<T extends IndexedDBRecord>(
|
||||
storeName: StoreName,
|
||||
id: number | string,
|
||||
): Promise<T | null> {
|
||||
return this.getById<T>(storeName, id);
|
||||
}
|
||||
|
||||
async put<T extends IndexedDBRecord>(
|
||||
storeName: StoreName,
|
||||
record: T,
|
||||
|
|
@ -255,7 +262,7 @@ class IndexedDBService {
|
|||
});
|
||||
}
|
||||
|
||||
async delete(storeName: StoreName, id: number): Promise<void> {
|
||||
async delete(storeName: StoreName, id: number | string): Promise<void> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, 'readwrite');
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ import { SyncManager } from '@/lib/sync-manager';
|
|||
import { indexedDBService } from '@/lib/indexeddb';
|
||||
import { encrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { uuidv7 } from 'uuidv7';
|
||||
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
id: string;
|
||||
user_id: number;
|
||||
account_id: number;
|
||||
category_id: number | null;
|
||||
|
|
@ -37,8 +38,8 @@ class TransactionSyncService {
|
|||
return await this.syncManager.getAll<Transaction>();
|
||||
}
|
||||
|
||||
async getById(id: number): Promise<Transaction | null> {
|
||||
return await this.syncManager.getById<Transaction>(id);
|
||||
async getById(id: string): Promise<Transaction | null> {
|
||||
return await indexedDBService.get<Transaction>('transactions', id);
|
||||
}
|
||||
|
||||
async getByAccountId(accountId: number): Promise<Transaction[]> {
|
||||
|
|
@ -61,10 +62,9 @@ class TransactionSyncService {
|
|||
const created: Transaction[] = [];
|
||||
|
||||
for (const data of transactions) {
|
||||
const tempId = Date.now() + created.length;
|
||||
const record = {
|
||||
...data,
|
||||
id: tempId,
|
||||
id: uuidv7(),
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
} as Transaction;
|
||||
|
|
@ -86,12 +86,30 @@ class TransactionSyncService {
|
|||
}
|
||||
}
|
||||
|
||||
async update(id: number, data: Partial<Transaction>): Promise<void> {
|
||||
await this.syncManager.updateLocal<Transaction>(id, data);
|
||||
async update(id: string, data: Partial<Transaction>): Promise<void> {
|
||||
const existing = await this.getById(id);
|
||||
if (!existing) {
|
||||
throw new Error('Transaction not found');
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...existing,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await indexedDBService.put('transactions', updated);
|
||||
await indexedDBService.addPendingChange('transactions', 'update', updated);
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<void> {
|
||||
await this.syncManager.deleteLocal(id);
|
||||
async delete(id: string): Promise<void> {
|
||||
const transaction = await this.getById(id);
|
||||
if (!transaction) {
|
||||
throw new Error('Transaction not found');
|
||||
}
|
||||
|
||||
await indexedDBService.delete('transactions', id);
|
||||
await indexedDBService.addPendingChange('transactions', 'delete', transaction);
|
||||
}
|
||||
|
||||
async isDuplicate(
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ Route::middleware(['auth'])->group(function () {
|
|||
Route::get('api/sync/categories', [CategorySyncController::class, 'index']);
|
||||
Route::get('api/sync/accounts', [AccountSyncController::class, 'index']);
|
||||
Route::get('api/sync/banks', [BankSyncController::class, 'index']);
|
||||
Route::get('api/sync/transactions', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'index']);
|
||||
Route::post('api/sync/transactions', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'store']);
|
||||
Route::patch('api/sync/transactions/{transaction}', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'update']);
|
||||
Route::delete('api/sync/transactions/{transaction}', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'destroy']);
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
it('can fetch user transactions', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
$transactions = Transaction::factory()->count(3)->for($user)->for($account)->create();
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/sync/transactions');
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonCount(3, 'data')
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'user_id',
|
||||
'account_id',
|
||||
'category_id',
|
||||
'description',
|
||||
'description_iv',
|
||||
'transaction_date',
|
||||
'amount',
|
||||
'currency_code',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('only returns user own transactions', function () {
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
$otherAccount = Account::factory()->for($otherUser)->create();
|
||||
|
||||
Transaction::factory()->for($user)->for($account)->create();
|
||||
Transaction::factory()->for($otherUser)->for($otherAccount)->create();
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/sync/transactions');
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
it('can filter transactions by updated_at', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
|
||||
$oldTransaction = Transaction::factory()->for($user)->for($account)->create([
|
||||
'updated_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$newTransaction = Transaction::factory()->for($user)->for($account)->create([
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/sync/transactions?since='.now()->subDay()->toISOString());
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $newTransaction->id);
|
||||
});
|
||||
|
||||
it('can create a transaction', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
$category = Category::factory()->for($user)->create();
|
||||
|
||||
$transactionData = [
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $category->id,
|
||||
'description' => 'encrypted_description',
|
||||
'description_iv' => '1234567890123456',
|
||||
'transaction_date' => now()->toDateString(),
|
||||
'amount' => '100.50',
|
||||
'currency_code' => 'USD',
|
||||
'notes' => null,
|
||||
'notes_iv' => null,
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/sync/transactions', $transactionData);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'id',
|
||||
'user_id',
|
||||
'account_id',
|
||||
'category_id',
|
||||
'description',
|
||||
'description_iv',
|
||||
'transaction_date',
|
||||
'amount',
|
||||
'currency_code',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $category->id,
|
||||
'description' => 'encrypted_description',
|
||||
'amount' => '100.50',
|
||||
]);
|
||||
});
|
||||
|
||||
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' => '100.50',
|
||||
'currency_code' => 'USD',
|
||||
'notes' => null,
|
||||
'notes_iv' => null,
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/sync/transactions', $transactionData);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.id', $uuid);
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $uuid,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
|
||||
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']);
|
||||
});
|
||||
|
||||
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' => '100.00',
|
||||
'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' => '200.00',
|
||||
'currency_code' => $transaction->currency_code,
|
||||
'notes' => null,
|
||||
'notes_iv' => null,
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->patchJson("/api/sync/transactions/{$transaction->id}", $updateData);
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonPath('data.amount', '200.00')
|
||||
->assertJsonPath('data.description', 'new_description');
|
||||
|
||||
$this->assertDatabaseHas('transactions', [
|
||||
'id' => $transaction->id,
|
||||
'amount' => '200.00',
|
||||
'description' => 'new_description',
|
||||
]);
|
||||
});
|
||||
|
||||
it('cannot update 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();
|
||||
|
||||
$updateData = [
|
||||
'account_id' => $account->id,
|
||||
'category_id' => null,
|
||||
'description' => 'hacked',
|
||||
'description_iv' => $transaction->description_iv,
|
||||
'transaction_date' => $transaction->transaction_date->toDateString(),
|
||||
'amount' => '999.99',
|
||||
'currency_code' => $transaction->currency_code,
|
||||
'notes' => null,
|
||||
'notes_iv' => null,
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->patchJson("/api/sync/transactions/{$transaction->id}", $updateData);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
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->assertDatabaseMissing('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,
|
||||
]);
|
||||
});
|
||||
Loading…
Reference in New Issue