whisper-money/tests/Feature/SavingsGoalTest.php

214 lines
7.3 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\LabelSource;
use App\Features\SavingsGoals;
use App\Models\Account;
use App\Models\Label;
use App\Models\SavingsGoal;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Carbon;
use Laravel\Pennant\Feature;
function onboardedSavingsUser(): User
{
return User::factory()->create(['onboarded_at' => now()]);
}
test('creating a goal creates a linked hidden label with the same name', function () {
$user = onboardedSavingsUser();
Feature::for($user)->activate(SavingsGoals::class);
$response = $this->actingAs($user)->post('/savings-goals', [
'name' => 'New car',
'target_amount' => 500000,
'target_date' => now()->addYear()->toDateString(),
]);
$response->assertRedirect();
$goal = SavingsGoal::where('user_id', $user->id)->first();
expect($goal)->not->toBeNull()
->and($goal->name)->toBe('New car')
->and($goal->target_amount)->toBe(500000);
$this->assertDatabaseHas('labels', [
'id' => $goal->label_id,
'user_id' => $user->id,
'name' => 'New car',
'source' => LabelSource::SavingsGoal->value,
]);
});
test('goal-backed labels are hidden from the labels settings screen', function () {
$user = onboardedSavingsUser();
Feature::for($user)->activate(SavingsGoals::class);
Label::factory()->create(['user_id' => $user->id, 'name' => 'Groceries']);
SavingsGoal::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get('/settings/labels');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->has('labels', 1)
->where('labels.0.name', 'Groceries')
);
});
test('saved amount is the negated net flow of tagged transactions', function () {
$user = onboardedSavingsUser();
$goal = SavingsGoal::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create(['user_id' => $user->id]);
// Outflow to savings (500) contributes +500; a withdrawal back (+200) subtracts.
$outflow = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => -50000,
]);
$withdrawal = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => 20000,
]);
$outflow->labels()->attach($goal->label_id);
$withdrawal->labels()->attach($goal->label_id);
expect($goal->savedAmountInCents())->toBe(30000);
});
test('show exposes computed progress stats', function () {
$user = onboardedSavingsUser();
Feature::for($user)->activate(SavingsGoals::class);
$goal = SavingsGoal::factory()->create([
'user_id' => $user->id,
'target_amount' => 100000,
'target_date' => null,
]);
$account = Account::factory()->create(['user_id' => $user->id]);
$transaction = Transaction::factory()->create([
'user_id' => $user->id,
'account_id' => $account->id,
'amount' => -25000,
]);
$transaction->labels()->attach($goal->label_id);
$response = $this->actingAs($user)->get("/savings-goals/{$goal->id}");
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('savings-goals/show')
->where('stats.saved', 25000)
->where('stats.target', 100000)
);
});
test('renaming the goal renames its label', function () {
$user = onboardedSavingsUser();
Feature::for($user)->activate(SavingsGoals::class);
$goal = SavingsGoal::factory()->create(['user_id' => $user->id]);
$this->actingAs($user)->patch("/savings-goals/{$goal->id}", [
'name' => 'Emergency fund',
'target_amount' => $goal->target_amount,
])->assertRedirect();
$this->assertDatabaseHas('savings_goals', ['id' => $goal->id, 'name' => 'Emergency fund']);
$this->assertDatabaseHas('labels', ['id' => $goal->label_id, 'name' => 'Emergency fund']);
});
test('deleting the goal also removes its label', function () {
$user = onboardedSavingsUser();
Feature::for($user)->activate(SavingsGoals::class);
$goal = SavingsGoal::factory()->create(['user_id' => $user->id]);
$labelId = $goal->label_id;
$this->actingAs($user)->delete("/savings-goals/{$goal->id}")->assertRedirect('/budgets');
$this->assertSoftDeleted('savings_goals', ['id' => $goal->id]);
$this->assertSoftDeleted('labels', ['id' => $labelId]);
});
test('a goal name cannot collide with an existing label', function () {
$user = onboardedSavingsUser();
Feature::for($user)->activate(SavingsGoals::class);
Label::factory()->create(['user_id' => $user->id, 'name' => 'Coche']);
$response = $this->actingAs($user)->post('/savings-goals', [
'name' => 'Coche',
'target_amount' => 500000,
]);
$response->assertSessionHasErrors('name');
expect(SavingsGoal::where('user_id', $user->id)->count())->toBe(0);
});
test('routes are gated behind the feature flag', function () {
$user = onboardedSavingsUser();
$this->actingAs($user)->post('/savings-goals', [
'name' => 'Blocked',
'target_amount' => 100000,
])->assertNotFound();
});
test('a user cannot view another users goal', function () {
$owner = onboardedSavingsUser();
$other = onboardedSavingsUser();
Feature::for($other)->activate(SavingsGoals::class);
$goal = SavingsGoal::factory()->create(['user_id' => $owner->id]);
$this->actingAs($other)->get("/savings-goals/{$goal->id}")->assertForbidden();
});
test('projection reports behind schedule and a required monthly pace', function () {
$start = Carbon::parse('2026-06-20');
$today = Carbon::parse('2026-07-20');
$targetDate = Carbon::parse('2026-09-18');
$stats = SavingsGoal::project(100000, 500000, $start, $targetDate, $today);
expect($stats['status'])->toBe('behind')
->and($stats['percentage'])->toBe(20.0)
->and($stats['required_per_month'])->toBe(200000)
->and($stats['estimated_date'])->not->toBeNull();
});
test('projection reports a completed goal', function () {
$start = Carbon::parse('2026-06-20');
$today = Carbon::parse('2026-07-20');
$targetDate = Carbon::parse('2026-09-18');
$stats = SavingsGoal::project(520000, 500000, $start, $targetDate, $today);
expect($stats['status'])->toBe('completed')
->and($stats['required_per_month'])->toBe(0);
});
test('effective start anchors on the earliest contribution predating creation', function () {
$created = Carbon::parse('2026-07-20');
// A goal created today whose money was set aside two years ago.
$start = SavingsGoal::effectiveStart($created, '2024-07-20');
expect($start->toDateString())->toBe('2024-07-20');
// The real ~2-year window keeps the rate sane instead of projecting completion
// tomorrow (which is what anchoring on created_at would produce).
$stats = SavingsGoal::project(250000, 500000, $start, null, $created);
expect($stats['rate_per_day'])->toBeLessThan(1000.0);
});
test('effective start keeps creation date when no earlier contribution exists', function () {
$created = Carbon::parse('2026-07-20');
expect(SavingsGoal::effectiveStart($created, null)->toDateString())->toBe('2026-07-20')
->and(SavingsGoal::effectiveStart($created, '2026-08-01')->toDateString())->toBe('2026-07-20');
});