Initial commit: SuiviCPT personal finance tracker
Laravel 11 + Livewire 3/Volt + Alpine.js + Tailwind app for household budget planning, transaction tracking, and financial dashboards. - Multi-tenant households with member invites - Plan: monthly/yearly budget per category with overrides - Suivi: transaction log with running balance (desktop + mobile quick-entry) - Dashboard: budget vs tracked breakdown + Chart.js donuts - Flux: Sankey diagram of income allocation - WCAG 2.1 AA color tokens, dark mode, accessible tables/forms - 50 Pest tests Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Volt\Volt;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_login_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/login');
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSeeVolt('pages.auth.login');
|
||||
}
|
||||
|
||||
public function test_users_can_authenticate_using_the_login_screen(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$component = Volt::test('pages.auth.login')
|
||||
->set('form.email', $user->email)
|
||||
->set('form.password', 'password');
|
||||
|
||||
$component->call('login');
|
||||
|
||||
$component
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticated();
|
||||
}
|
||||
|
||||
public function test_users_can_not_authenticate_with_invalid_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$component = Volt::test('pages.auth.login')
|
||||
->set('form.email', $user->email)
|
||||
->set('form.password', 'wrong-password');
|
||||
|
||||
$component->call('login');
|
||||
|
||||
$component
|
||||
->assertHasErrors()
|
||||
->assertNoRedirect();
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_navigation_menu_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get('/dashboard');
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSeeVolt('layout.navigation');
|
||||
}
|
||||
|
||||
public function test_users_can_logout(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Volt::test('layout.navigation');
|
||||
|
||||
$component->call('logout');
|
||||
|
||||
$component
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect('/');
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmailVerificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_email_verification_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/verify-email');
|
||||
|
||||
$response
|
||||
->assertSeeVolt('pages.auth.verify-email')
|
||||
->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_email_can_be_verified(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
Event::fake();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
Event::assertDispatched(Verified::class);
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
public function test_email_is_not_verified_with_invalid_hash(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')]
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Volt\Volt;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordConfirmationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_confirm_password_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/confirm-password');
|
||||
|
||||
$response
|
||||
->assertSeeVolt('pages.auth.confirm-password')
|
||||
->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_password_can_be_confirmed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Volt::test('pages.auth.confirm-password')
|
||||
->set('password', 'password');
|
||||
|
||||
$component->call('confirmPassword');
|
||||
|
||||
$component
|
||||
->assertRedirect('/dashboard')
|
||||
->assertHasNoErrors();
|
||||
}
|
||||
|
||||
public function test_password_is_not_confirmed_with_invalid_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Volt::test('pages.auth.confirm-password')
|
||||
->set('password', 'wrong-password');
|
||||
|
||||
$component->call('confirmPassword');
|
||||
|
||||
$component
|
||||
->assertNoRedirect()
|
||||
->assertHasErrors('password');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Livewire\Volt\Volt;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordResetTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_reset_password_link_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/forgot-password');
|
||||
|
||||
$response
|
||||
->assertSeeVolt('pages.auth.forgot-password')
|
||||
->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_reset_password_link_can_be_requested(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
Volt::test('pages.auth.forgot-password')
|
||||
->set('email', $user->email)
|
||||
->call('sendPasswordResetLink');
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class);
|
||||
}
|
||||
|
||||
public function test_reset_password_screen_can_be_rendered(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
Volt::test('pages.auth.forgot-password')
|
||||
->set('email', $user->email)
|
||||
->call('sendPasswordResetLink');
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
||||
$response = $this->get('/reset-password/'.$notification->token);
|
||||
|
||||
$response
|
||||
->assertSeeVolt('pages.auth.reset-password')
|
||||
->assertStatus(200);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_password_can_be_reset_with_valid_token(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
Volt::test('pages.auth.forgot-password')
|
||||
->set('email', $user->email)
|
||||
->call('sendPasswordResetLink');
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||
$component = Volt::test('pages.auth.reset-password', ['token' => $notification->token])
|
||||
->set('email', $user->email)
|
||||
->set('password', 'password')
|
||||
->set('password_confirmation', 'password');
|
||||
|
||||
$component->call('resetPassword');
|
||||
|
||||
$component
|
||||
->assertRedirect('/login')
|
||||
->assertHasNoErrors();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Volt\Volt;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordUpdateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_password_can_be_updated(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Volt::test('profile.update-password-form')
|
||||
->set('current_password', 'password')
|
||||
->set('password', 'new-password')
|
||||
->set('password_confirmation', 'new-password')
|
||||
->call('updatePassword');
|
||||
|
||||
$component
|
||||
->assertHasNoErrors()
|
||||
->assertNoRedirect();
|
||||
|
||||
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_update_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Volt::test('profile.update-password-form')
|
||||
->set('current_password', 'wrong-password')
|
||||
->set('password', 'new-password')
|
||||
->set('password_confirmation', 'new-password')
|
||||
->call('updatePassword');
|
||||
|
||||
$component
|
||||
->assertHasErrors(['current_password'])
|
||||
->assertNoRedirect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Volt\Volt;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RegistrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_registration_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/register');
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSeeVolt('pages.auth.register');
|
||||
}
|
||||
|
||||
public function test_new_users_can_register(): void
|
||||
{
|
||||
$component = Volt::test('pages.auth.register')
|
||||
->set('name', 'Test User')
|
||||
->set('email', 'test@example.com')
|
||||
->set('password', 'password')
|
||||
->set('password_confirmation', 'password');
|
||||
|
||||
$component->call('register');
|
||||
|
||||
$component->assertRedirect(route('dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticated();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Categories\Index;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('lets a member add a category to their household', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Index::class)
|
||||
->set('newName.depense', 'Loyer')
|
||||
->call('add', 'depense')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($user->currentHousehold->categories()->where('name', 'Loyer')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('prevents a user from editing another household categorie', function (): void {
|
||||
$owner = User::factory()->create();
|
||||
$intruder = User::factory()->create();
|
||||
|
||||
$category = $owner->currentHousehold->categories()->create([
|
||||
'name' => 'Courses',
|
||||
'type' => 'depense',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
Livewire::actingAs($intruder)
|
||||
->test(Index::class)
|
||||
->call('startEdit', $category->id)
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('deletes a category', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$category = $user->currentHousehold->categories()->create([
|
||||
'name' => 'Assurances',
|
||||
'type' => 'depense',
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Index::class)
|
||||
->call('delete', $category->id);
|
||||
|
||||
expect(Category::find($category->id))->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Dashboard\Index;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('shows budget vs tracked totals for the selected year', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$household = $user->currentHousehold;
|
||||
$category = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||
|
||||
$category->budgetLines()->create(['household_id' => $household->id, 'year' => 2026, 'month' => null, 'amount' => 1200]);
|
||||
|
||||
$household->transactions()->create([
|
||||
'category_id' => $category->id, 'user_id' => $user->id,
|
||||
'date' => '2026-03-01', 'date_effective' => '2026-03-01', 'amount' => 1200,
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(Index::class)->set('year', 2026)->set('period', 'year');
|
||||
|
||||
$sections = $component->viewData('sections');
|
||||
|
||||
expect($sections['depense']['totals']['budget'])->toBe(14400.0); // 1200 * 12
|
||||
expect($sections['depense']['totals']['tracked'])->toBe(1200.0);
|
||||
});
|
||||
|
||||
it('defaults to the current month instead of the full year', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$component = Livewire::actingAs($user)->test(Index::class);
|
||||
|
||||
expect($component->get('period'))->toBe((string) now()->month);
|
||||
expect($component->get('year'))->toBe((int) now()->year);
|
||||
});
|
||||
|
||||
it('filters tracked and budget to a single month when a period is selected', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$household = $user->currentHousehold;
|
||||
$category = $household->categories()->create(['name' => 'Assurances', 'type' => 'depense', 'position' => 1]);
|
||||
|
||||
$category->budgetLines()->create(['household_id' => $household->id, 'year' => 2026, 'month' => null, 'amount' => 189.89]);
|
||||
$category->budgetLines()->create(['household_id' => $household->id, 'year' => 2026, 'month' => 8, 'amount' => 270]);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(Index::class)
|
||||
->set('year', 2026)
|
||||
->set('period', '8');
|
||||
|
||||
$sections = $component->viewData('sections');
|
||||
|
||||
expect($sections['depense']['totals']['budget'])->toBe(270.0);
|
||||
});
|
||||
|
||||
it('excludes zero-tracked categories from chart data and caps it at 6', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$household = $user->currentHousehold;
|
||||
|
||||
$tracked = collect();
|
||||
for ($i = 1; $i <= 8; $i++) {
|
||||
$category = $household->categories()->create(['name' => "Cat{$i}", 'type' => 'depense', 'position' => $i]);
|
||||
$household->transactions()->create([
|
||||
'category_id' => $category->id, 'user_id' => $user->id,
|
||||
'date' => '2026-01-01', 'date_effective' => '2026-01-01', 'amount' => $i * 10,
|
||||
]);
|
||||
$tracked->push($category);
|
||||
}
|
||||
|
||||
$zero = $household->categories()->create(['name' => 'CatZero', 'type' => 'depense', 'position' => 99]);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(Index::class)->set('year', 2026)->set('period', 'year');
|
||||
|
||||
$chartData = $component->viewData('chartData');
|
||||
|
||||
expect($chartData['depense']['labels'])->toHaveCount(6);
|
||||
expect($chartData['depense']['labels'])->not->toContain('CatZero');
|
||||
expect($chartData['depense']['labels']->first())->toBe('Cat8'); // highest tracked first
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Household;
|
||||
use App\Models\HouseholdInvite;
|
||||
use App\Models\User;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
it('creates a household automatically on registration', function (): void {
|
||||
Volt::test('pages.auth.register')
|
||||
->set('name', 'Jeremy')
|
||||
->set('email', 'jeremy@example.com')
|
||||
->set('password', 'password')
|
||||
->set('password_confirmation', 'password')
|
||||
->call('register');
|
||||
|
||||
$user = User::where('email', 'jeremy@example.com')->first();
|
||||
|
||||
expect($user)->not->toBeNull();
|
||||
expect($user->current_household_id)->not->toBeNull();
|
||||
expect($user->households()->wherePivot('role', 'owner')->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('allows an owner to invite a member who joins on registration', function (): void {
|
||||
$owner = User::factory()->create();
|
||||
$household = Household::create(['name' => 'Foyer Test', 'owner_id' => $owner->id]);
|
||||
$owner->households()->attach($household->id, ['role' => 'owner']);
|
||||
$owner->forceFill(['current_household_id' => $household->id])->save();
|
||||
|
||||
$invite = HouseholdInvite::create([
|
||||
'household_id' => $household->id,
|
||||
'invited_by' => $owner->id,
|
||||
'email' => 'member@example.com',
|
||||
'token' => 'test-token-123',
|
||||
'role' => 'member',
|
||||
'expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
Volt::test('pages.auth.register')
|
||||
->set('invite', $invite->token)
|
||||
->set('name', 'Member')
|
||||
->set('email', 'member@example.com')
|
||||
->set('password', 'password')
|
||||
->set('password_confirmation', 'password')
|
||||
->call('register');
|
||||
|
||||
$member = User::where('email', 'member@example.com')->first();
|
||||
|
||||
expect($member->current_household_id)->toBe($household->id);
|
||||
expect($household->fresh()->members()->wherePivot('role', 'member')->where('users.id', $member->id)->exists())->toBeTrue();
|
||||
expect($invite->fresh()->accepted_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('isolates categories between households', function (): void {
|
||||
$userA = User::factory()->create();
|
||||
$householdA = Household::create(['name' => 'A', 'owner_id' => $userA->id]);
|
||||
$userA->households()->attach($householdA->id, ['role' => 'owner']);
|
||||
|
||||
$userB = User::factory()->create();
|
||||
$householdB = Household::create(['name' => 'B', 'owner_id' => $userB->id]);
|
||||
$userB->households()->attach($householdB->id, ['role' => 'owner']);
|
||||
|
||||
$householdA->categories()->create(['name' => 'Loyer', 'type' => 'depense']);
|
||||
|
||||
expect($householdB->categories()->count())->toBe(0);
|
||||
expect($householdA->categories()->count())->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Plan\Index;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('sets a default amount that applies to all months without override', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$category = $user->currentHousehold->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Index::class)
|
||||
->call('setYear', 2026)
|
||||
->call('updateAmount', $category->id, null, '1200');
|
||||
|
||||
$line = $category->budgetLines()->where('year', 2026)->whereNull('month')->first();
|
||||
expect((float) $line->amount)->toBe(1200.0);
|
||||
});
|
||||
|
||||
it('overrides a single month while others keep the default', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$category = $user->currentHousehold->categories()->create(['name' => 'Assurances', 'type' => 'depense', 'position' => 1]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Index::class)
|
||||
->call('setYear', 2026)
|
||||
->call('updateAmount', $category->id, null, '189.89')
|
||||
->call('updateAmount', $category->id, 8, '270');
|
||||
|
||||
expect((float) $category->budgetLines()->where('year', 2026)->whereNull('month')->first()->amount)->toBe(189.89);
|
||||
expect((float) $category->budgetLines()->where('year', 2026)->where('month', 8)->first()->amount)->toBe(270.0);
|
||||
expect($category->budgetLines()->where('year', 2026)->where('month', 9)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects updating a category from another household', function (): void {
|
||||
$owner = User::factory()->create();
|
||||
$intruder = User::factory()->create();
|
||||
|
||||
$category = $owner->currentHousehold->categories()->create(['name' => 'Courses', 'type' => 'depense', 'position' => 1]);
|
||||
|
||||
Livewire::actingAs($intruder)
|
||||
->test(Index::class)
|
||||
->call('updateAmount', $category->id, null, '500')
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
it('computes remaining revenue to allocate per month after depenses and epargne', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$household = $user->currentHousehold;
|
||||
|
||||
$revenu = $household->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
|
||||
$depense = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||
$epargne = $household->categories()->create(['name' => 'Vacances', 'type' => 'epargne', 'position' => 1]);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(Index::class)->call('setYear', 2026);
|
||||
|
||||
$component->call('updateAmount', $revenu->id, null, '2900');
|
||||
$component->call('updateAmount', $depense->id, null, '1200');
|
||||
$component->call('updateAmount', $epargne->id, null, '200');
|
||||
$component->call('updateAmount', $depense->id, 8, '1500');
|
||||
|
||||
$sections = $component->viewData('sections');
|
||||
|
||||
expect($sections['revenu']['remaining'][1])->toBe(1500.0); // 2900 - 1200 - 200
|
||||
expect($sections['revenu']['remaining'][8])->toBe(1200.0); // 2900 - 1500 - 200
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Volt\Volt;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProfileTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_profile_page_is_displayed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/profile');
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSeeVolt('profile.update-profile-information-form')
|
||||
->assertSeeVolt('profile.update-password-form')
|
||||
->assertSeeVolt('profile.delete-user-form');
|
||||
}
|
||||
|
||||
public function test_profile_information_can_be_updated(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Volt::test('profile.update-profile-information-form')
|
||||
->set('name', 'Test User')
|
||||
->set('email', 'test@example.com')
|
||||
->call('updateProfileInformation');
|
||||
|
||||
$component
|
||||
->assertHasNoErrors()
|
||||
->assertNoRedirect();
|
||||
|
||||
$user->refresh();
|
||||
|
||||
$this->assertSame('Test User', $user->name);
|
||||
$this->assertSame('test@example.com', $user->email);
|
||||
$this->assertNull($user->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Volt::test('profile.update-profile-information-form')
|
||||
->set('name', 'Test User')
|
||||
->set('email', $user->email)
|
||||
->call('updateProfileInformation');
|
||||
|
||||
$component
|
||||
->assertHasNoErrors()
|
||||
->assertNoRedirect();
|
||||
|
||||
$this->assertNotNull($user->refresh()->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_user_can_delete_their_account(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Volt::test('profile.delete-user-form')
|
||||
->set('password', 'password')
|
||||
->call('deleteUser');
|
||||
|
||||
$component
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect('/');
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertNull($user->fresh());
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_delete_account(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Volt::test('profile.delete-user-form')
|
||||
->set('password', 'wrong-password')
|
||||
->call('deleteUser');
|
||||
|
||||
$component
|
||||
->assertHasErrors('password')
|
||||
->assertNoRedirect();
|
||||
|
||||
$this->assertNotNull($user->fresh());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Sankey\Index;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('builds flows from revenue to depense and epargne categories', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$household = $user->currentHousehold;
|
||||
|
||||
$revenu = $household->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
|
||||
$depense = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||
$epargne = $household->categories()->create(['name' => 'Vacances', 'type' => 'epargne', 'position' => 1]);
|
||||
|
||||
$household->transactions()->create([
|
||||
'category_id' => $revenu->id, 'user_id' => $user->id,
|
||||
'date' => '2026-01-01', 'date_effective' => '2026-01-01', 'amount' => 2000,
|
||||
]);
|
||||
$household->transactions()->create([
|
||||
'category_id' => $depense->id, 'user_id' => $user->id,
|
||||
'date' => '2026-01-02', 'date_effective' => '2026-01-02', 'amount' => 1200,
|
||||
]);
|
||||
$household->transactions()->create([
|
||||
'category_id' => $epargne->id, 'user_id' => $user->id,
|
||||
'date' => '2026-01-03', 'date_effective' => '2026-01-03', 'amount' => 300,
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(Index::class)
|
||||
->set('year', 2026)
|
||||
->set('period', 'year');
|
||||
|
||||
$flows = $component->viewData('flows');
|
||||
|
||||
expect($flows)->toHaveCount(3); // Loyer, Vacances, Non affecté
|
||||
expect($flows->firstWhere('to', 'Loyer')['flow'])->toBe(1200.0);
|
||||
expect($flows->firstWhere('to', 'Vacances')['flow'])->toBe(300.0);
|
||||
expect($flows->firstWhere('to', 'Non affecté')['flow'])->toBe(500.0);
|
||||
|
||||
expect($component->viewData('totalRevenue'))->toBe(2000.0);
|
||||
expect($component->viewData('hasData'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('omits the unallocated flow when all revenue is spent or saved', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$household = $user->currentHousehold;
|
||||
|
||||
$revenu = $household->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
|
||||
$depense = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||
|
||||
$household->transactions()->create([
|
||||
'category_id' => $revenu->id, 'user_id' => $user->id,
|
||||
'date' => '2026-01-01', 'date_effective' => '2026-01-01', 'amount' => 1000,
|
||||
]);
|
||||
$household->transactions()->create([
|
||||
'category_id' => $depense->id, 'user_id' => $user->id,
|
||||
'date' => '2026-01-02', 'date_effective' => '2026-01-02', 'amount' => 1000,
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(Index::class)
|
||||
->set('year', 2026)
|
||||
->set('period', 'year');
|
||||
|
||||
$flows = $component->viewData('flows');
|
||||
|
||||
expect($flows)->toHaveCount(1);
|
||||
expect($flows->firstWhere('to', 'Non affecté'))->toBeNull();
|
||||
});
|
||||
|
||||
it('shows no data state when there is no tracked revenue', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$component = Livewire::actingAs($user)->test(Index::class);
|
||||
|
||||
expect($component->viewData('hasData'))->toBeFalse();
|
||||
expect($component->viewData('flows'))->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('does not leak another household categories into the flows', function (): void {
|
||||
$ownerA = User::factory()->create();
|
||||
$ownerB = User::factory()->create();
|
||||
|
||||
$revenuA = $ownerA->currentHousehold->categories()->create(['name' => 'Salaire A', 'type' => 'revenu', 'position' => 1]);
|
||||
$depenseB = $ownerB->currentHousehold->categories()->create(['name' => 'Loyer B', 'type' => 'depense', 'position' => 1]);
|
||||
|
||||
$ownerA->currentHousehold->transactions()->create([
|
||||
'category_id' => $revenuA->id, 'user_id' => $ownerA->id,
|
||||
'date' => now(), 'date_effective' => now(), 'amount' => 1000,
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($ownerA)->test(Index::class);
|
||||
|
||||
expect($component->viewData('flows')->pluck('to'))->not->toContain('Loyer B');
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Suivi\Index;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('records a transaction for the current household', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$category = $user->currentHousehold->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Index::class)
|
||||
->set('categoryId', $category->id)
|
||||
->set('amount', '2900')
|
||||
->set('details', 'Paie juillet')
|
||||
->call('addTransaction')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($user->currentHousehold->transactions()->count())->toBe(1);
|
||||
expect((float) $user->currentHousehold->transactions()->first()->amount)->toBe(2900.0);
|
||||
});
|
||||
|
||||
it('computes a running balance that subtracts depenses and adds revenus', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$household = $user->currentHousehold;
|
||||
$revenu = $household->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
|
||||
$depense = $household->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||
|
||||
$household->transactions()->create([
|
||||
'category_id' => $revenu->id, 'user_id' => $user->id,
|
||||
'date' => '2026-01-01', 'date_effective' => '2026-01-01', 'amount' => 2900,
|
||||
]);
|
||||
$household->transactions()->create([
|
||||
'category_id' => $depense->id, 'user_id' => $user->id,
|
||||
'date' => '2026-01-02', 'date_effective' => '2026-01-02', 'amount' => 1200,
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(Index::class);
|
||||
|
||||
$running = $component->viewData('running');
|
||||
$transactions = $component->viewData('transactions');
|
||||
|
||||
$ordered = $transactions->sortBy('date')->values();
|
||||
expect($running[$ordered[0]->id])->toBe(2900.0);
|
||||
expect($running[$ordered[1]->id])->toBe(1700.0);
|
||||
});
|
||||
|
||||
it('subtracts epargne contributions from the running balance', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$household = $user->currentHousehold;
|
||||
$revenu = $household->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
|
||||
$epargne = $household->categories()->create(['name' => 'Vacances', 'type' => 'epargne', 'position' => 1]);
|
||||
|
||||
$household->transactions()->create([
|
||||
'category_id' => $revenu->id, 'user_id' => $user->id,
|
||||
'date' => '2026-01-01', 'date_effective' => '2026-01-01', 'amount' => 2900,
|
||||
]);
|
||||
$household->transactions()->create([
|
||||
'category_id' => $epargne->id, 'user_id' => $user->id,
|
||||
'date' => '2026-01-02', 'date_effective' => '2026-01-02', 'amount' => 200,
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($user)->test(Index::class);
|
||||
|
||||
$running = $component->viewData('running');
|
||||
$transactions = $component->viewData('transactions');
|
||||
|
||||
$ordered = $transactions->sortBy('date')->values();
|
||||
expect($running[$ordered[0]->id])->toBe(2900.0);
|
||||
expect($running[$ordered[1]->id])->toBe(2700.0);
|
||||
});
|
||||
|
||||
it('dispatches a transaction-added event after successfully adding a transaction', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$category = $user->currentHousehold->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(Index::class)
|
||||
->set('categoryId', $category->id)
|
||||
->set('amount', '1200')
|
||||
->call('addTransaction')
|
||||
->assertDispatched('transaction-added');
|
||||
});
|
||||
|
||||
it('resets the form to defaults after adding a transaction', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$category = $user->currentHousehold->categories()->create(['name' => 'Loyer', 'type' => 'depense', 'position' => 1]);
|
||||
|
||||
$component = Livewire::actingAs($user)
|
||||
->test(Index::class)
|
||||
->set('categoryId', $category->id)
|
||||
->set('amount', '1200')
|
||||
->set('details', 'Août')
|
||||
->call('addTransaction');
|
||||
|
||||
expect($component->get('categoryId'))->toBeNull();
|
||||
expect($component->get('amount'))->toBe('');
|
||||
expect($component->get('details'))->toBe('');
|
||||
});
|
||||
|
||||
it('does not show another household transactions', function (): void {
|
||||
$ownerA = User::factory()->create();
|
||||
$ownerB = User::factory()->create();
|
||||
|
||||
$categoryA = $ownerA->currentHousehold->categories()->create(['name' => 'Salaire', 'type' => 'revenu', 'position' => 1]);
|
||||
$ownerA->currentHousehold->transactions()->create([
|
||||
'category_id' => $categoryA->id, 'user_id' => $ownerA->id,
|
||||
'date' => now(), 'date_effective' => now(), 'amount' => 1000,
|
||||
]);
|
||||
|
||||
$component = Livewire::actingAs($ownerB)->test(Index::class);
|
||||
|
||||
expect($component->viewData('transactions'))->toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Test Case
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The closure you provide to your test functions is always bound to a specific PHPUnit test
|
||||
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
|
||||
| need to change it using the "pest()" function to bind a different classes or traits.
|
||||
|
|
||||
*/
|
||||
|
||||
pest()->extend(TestCase::class)
|
||||
->use(RefreshDatabase::class)
|
||||
->in('Feature');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expectations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When you're writing tests, you often need to check that values meet certain conditions. The
|
||||
| "expect()" function gives you access to a set of "expectations" methods that you can use
|
||||
| to assert different things. Of course, you may extend the Expectation API at any time.
|
||||
|
|
||||
*/
|
||||
|
||||
expect()->extend('toBeOne', function () {
|
||||
return $this->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Functions
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
|
||||
| project that you don't want to repeat in every file. Here you can also expose helpers as
|
||||
| global functions to help you to reduce the number of lines of code in your test files.
|
||||
|
|
||||
*/
|
||||
|
||||
function something()
|
||||
{
|
||||
// ..
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user