whisper-money/tests/Feature/CategoryMonthlyBreakdownTes...

201 lines
9.1 KiB
PHP

<?php
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\Category;
use App\Models\ExchangeRate;
use App\Models\Transaction;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Testing\TestResponse;
beforeEach(function () {
Http::fake();
Carbon::setTestNow('2026-06-15');
$this->user = User::factory()->create(['currency_code' => 'USD']);
$this->actingAs($this->user);
$this->account = Account::factory()->create([
'user_id' => $this->user->id,
'currency_code' => 'USD',
]);
});
afterEach(function () {
Carbon::setTestNow();
});
function makeBreakdownTransaction(array $attributes = []): Transaction
{
return Transaction::factory()->create([
'user_id' => test()->user->id,
'account_id' => test()->account->id,
'currency_code' => 'USD',
...$attributes,
]);
}
function monthlyBreakdown(Category $category): TestResponse
{
return test()->getJson("/api/categories/{$category->id}/monthly-breakdown");
}
test('it forbids analysing a category owned by another user', function () {
$other = User::factory()->create();
$category = Category::factory()->create(['user_id' => $other->id, 'type' => CategoryType::Expense]);
monthlyBreakdown($category)->assertForbidden();
});
test('the response is private and not cached between users', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
monthlyBreakdown($category)
->assertOk()
->assertHeader('Cache-Control', 'no-store, private');
});
test('a leaf category returns a single series named after the category across twelve months', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food Delivery']);
makeBreakdownTransaction(['amount' => -4000, 'category_id' => $category->id, 'transaction_date' => '2026-06-04']);
makeBreakdownTransaction(['amount' => -1000, 'category_id' => $category->id, 'transaction_date' => '2026-05-09']);
$response = monthlyBreakdown($category)->assertOk();
expect($response->json('series'))->toBe([['key' => $category->id, 'label' => 'Food Delivery']]);
expect($response->json('months'))->toHaveCount(12);
expect($response->json('months.0.key'))->toBe('2025-07');
expect($response->json('months.11.key'))->toBe('2026-06');
expect($response->json('months.11.'.$category->id))->toBe(4000);
expect($response->json('months.10.'.$category->id))->toBe(1000);
expect($response->json('months.0.'.$category->id))->toBe(0);
});
test('spend nets within a month and a refund-dominant month dips below zero', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food Delivery']);
// -40, -40, +20 -> nets to 60 of spend.
makeBreakdownTransaction(['amount' => -4000, 'category_id' => $category->id, 'transaction_date' => '2026-06-04']);
makeBreakdownTransaction(['amount' => -4000, 'category_id' => $category->id, 'transaction_date' => '2026-06-05']);
makeBreakdownTransaction(['amount' => 2000, 'category_id' => $category->id, 'transaction_date' => '2026-06-06']);
// Refunds exceed spend this month -> the bar dips below zero.
makeBreakdownTransaction(['amount' => -1000, 'category_id' => $category->id, 'transaction_date' => '2026-05-09']);
makeBreakdownTransaction(['amount' => 3000, 'category_id' => $category->id, 'transaction_date' => '2026-05-10']);
$response = monthlyBreakdown($category)->assertOk();
expect($response->json('months.11.'.$category->id))->toBe(6000);
expect($response->json('months.10.'.$category->id))->toBe(-2000);
});
test('a parent rolls grandchildren into their immediate child and surfaces direct spend', function () {
$parent = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
$delivery = Category::factory()->childOf($parent)->create(['name' => 'Delivery']);
$grocery = Category::factory()->childOf($parent)->create(['name' => 'Grocery']);
$uberEats = Category::factory()->childOf($delivery)->create(['name' => 'Uber Eats']);
makeBreakdownTransaction(['amount' => -1000, 'category_id' => $delivery->id, 'transaction_date' => '2026-06-04']);
makeBreakdownTransaction(['amount' => -500, 'category_id' => $uberEats->id, 'transaction_date' => '2026-06-05']);
makeBreakdownTransaction(['amount' => -2000, 'category_id' => $grocery->id, 'transaction_date' => '2026-06-06']);
makeBreakdownTransaction(['amount' => -300, 'category_id' => $parent->id, 'transaction_date' => '2026-06-07']);
$response = monthlyBreakdown($parent)->assertOk();
// Richest child first (Grocery 2000, then Delivery 1500), then Direct.
expect($response->json('series'))->toBe([
['key' => $grocery->id, 'label' => 'Grocery'],
['key' => $delivery->id, 'label' => 'Delivery'],
['key' => 'direct', 'label' => 'Direct'],
]);
expect($response->json('months.11.'.$grocery->id))->toBe(2000);
expect($response->json('months.11.'.$delivery->id))->toBe(1500);
expect($response->json('months.11.direct'))->toBe(300);
});
test('children beyond the top six fold into an Other segment', function () {
$parent = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Shopping']);
foreach (range(1, 8) as $rank) {
$child = Category::factory()->childOf($parent)->create(['name' => "Child {$rank}"]);
makeBreakdownTransaction(['amount' => -1000 * $rank, 'category_id' => $child->id, 'transaction_date' => '2026-06-10']);
}
$response = monthlyBreakdown($parent)->assertOk();
$series = $response->json('series');
expect($series)->toHaveCount(7);
expect(collect($series)->pluck('key')->last())->toBe('other');
// The two smallest (1000 + 2000) are folded into Other.
expect($response->json('months.11.other'))->toBe(3000);
});
test('only the trailing twelve months are included', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
makeBreakdownTransaction(['amount' => -5000, 'category_id' => $category->id, 'transaction_date' => '2026-06-01']);
// Older than the window start (2025-07-01) -> excluded.
makeBreakdownTransaction(['amount' => -9999, 'category_id' => $category->id, 'transaction_date' => '2025-06-30']);
$response = monthlyBreakdown($category)->assertOk();
$total = collect($response->json('months'))->sum($category->id);
expect($total)->toBe(5000);
});
test('income categories keep inflows positive and net expenses against them', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Income, 'name' => 'Salary']);
makeBreakdownTransaction(['amount' => 500000, 'category_id' => $category->id, 'transaction_date' => '2026-06-01']);
makeBreakdownTransaction(['amount' => -100000, 'category_id' => $category->id, 'transaction_date' => '2026-06-02']);
$response = monthlyBreakdown($category)->assertOk();
expect($response->json('months.11.'.$category->id))->toBe(400000);
});
test('summary reports the monthly average and the half-over-half trend', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
// Earlier half (2025-07..2025-12): one month of 6000 -> average 1000.
makeBreakdownTransaction(['amount' => -6000, 'category_id' => $category->id, 'transaction_date' => '2025-09-15']);
// Recent half (2026-01..2026-06): one month of 9000 -> average 1500.
makeBreakdownTransaction(['amount' => -9000, 'category_id' => $category->id, 'transaction_date' => '2026-02-15']);
$response = monthlyBreakdown($category)->assertOk();
expect($response->json('summary.average_per_month'))->toBe(1250);
expect($response->json('summary.trend_percentage'))->toEqual(50);
});
test('summary trend is null when the earlier half has no spending', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
makeBreakdownTransaction(['amount' => -6000, 'category_id' => $category->id, 'transaction_date' => '2026-06-10']);
$response = monthlyBreakdown($category)->assertOk();
expect($response->json('summary.average_per_month'))->toBe(500);
expect($response->json('summary.trend_percentage'))->toBeNull();
});
test('foreign-currency transactions are converted to the user currency', function () {
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => '2026-06-01',
'rates' => ['eur' => 0.5],
]);
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
makeBreakdownTransaction(['amount' => -1000, 'currency_code' => 'EUR', 'category_id' => $category->id, 'transaction_date' => '2026-06-01']);
$response = monthlyBreakdown($category)->assertOk();
// 1000 EUR cents / 0.5 = 2000 USD cents.
expect($response->json('months.11.'.$category->id))->toBe(2000);
});