some vibe tests

better than none
This commit is contained in:
2026-10-30 10:20:59 +01:00
parent 613154a1f9
commit d4eaa45baa
8 changed files with 596 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace Tests;
use App\AccountClass;
use App\AccountType;
use App\Booking;
use App\IOType;
use App\Person;
use App\PersonTarget;
use App\Project;
use App\Scope;
use App\SubBooking;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Contracts\Console\Kernel;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
abstract class DatabaseTestCase extends TestCase
{
use RefreshDatabase;
protected $incomeAccount;
protected $expenseAccount;
protected $project;
protected $user;
public function artisan($command, $parameters = [])
{
// Laravel 5.6's Kernel::call uses a numeric command key, which the
// locked Symfony Console reads as a string offset on PHP 7.4.
// Use its normal input handler with a named command until upgraded.
$output = new BufferedOutput;
$status = $this->app[Kernel::class]->handle(
new ArrayInput(array_merge(['command' => $command], $parameters)), $output
);
$this->assertSame(0, $status, $output->fetch());
return $status;
}
protected function setUp()
{
parent::setUp();
// Small, explicit fixtures; no dependency on demo data or fixed IDs.
$scope = $this->record(Scope::class, ['name' => 'Test scope']);
foreach ([1 => 'Einnahme', -1 => 'Ausgabe'] as $factor => $name) {
$io = $this->record(IOType::class, compact('name', 'factor'));
$class = $this->record(AccountClass::class, [
'name' => $name, 'number' => $factor > 0 ? 10 : 20,
'scope_id' => $scope->id, 'io_type_id' => $io->id,
]);
$account = $this->record(AccountType::class, [
'name' => $name, 'number' => $factor > 0 ? 100 : 200,
'account_class_id' => $class->id,
]);
if ($factor > 0) {
$this->incomeAccount = $account;
} else {
$this->expenseAccount = $account;
}
}
$this->project = $this->record(Project::class, [
'name' => 'Test project', 'start_date' => '2019-01-01', 'end_date' => '2020-12-31',
]);
$this->user = $this->record(User::class, [
'name' => 'Test user', 'email' => 'test@example.com', 'password' => bcrypt('test-password'),
]);
}
protected function record($class, array $attributes)
{
$model = new $class;
$model->forceFill($attributes)->save();
return $model;
}
protected function booking($date, $amount, $account = null, array $attributes = [])
{
$booking = $this->record(Booking::class, [
'date' => $date, 'reference' => 'Test booking', 'person_or_company' => 'Test company',
]);
$this->record(SubBooking::class, array_merge([
'booking_id' => $booking->id, 'reference' => 'Test line', 'amount' => $amount,
'account_type_id' => ($account ?: $this->incomeAccount)->id,
'project_id' => $this->project->id,
], $attributes));
return $booking;
}
protected function target($amount = 100, $account = null)
{
$person = $this->record(Person::class, ['title' => 'Firma', 'name_or_company' => 'Test company']);
return $this->record(PersonTarget::class, [
'person_id' => $person->id, 'date' => '2020-01-01', 'reference' => 'Test target',
'amount' => $amount, 'io_type_id' => ($account ?: $this->incomeAccount)->accountClass->io_type_id,
]);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Tests\Feature;
use Tests\DatabaseTestCase;
class AuthenticationTest extends DatabaseTestCase
{
/** @dataProvider protectedRoutes */
public function test_guests_are_redirected_to_login($method, $path)
{
$this->call($method, $path)->assertRedirect('/login');
}
public function protectedRoutes()
{
return [
['GET', '/bookings/year-2020'], ['GET', '/people'],
['GET', '/projects'], ['GET', '/targets'],
['GET', '/reports/balances/2020'], ['GET', '/reports/scopes/compact/2020'],
['POST', '/bookings'], ['PATCH', '/bookings/1'], ['DELETE', '/bookings/1'],
];
}
public function test_login_form_is_available()
{
$this->get('/login')->assertStatus(200)->assertSee('password');
}
public function test_guests_cannot_change_or_delete_an_existing_booking()
{
$booking = $this->booking('2020-01-15', 100);
$this->patch('/bookings/' . $booking->id, [
'booking' => ['reference' => 'Unauthorized change'],
])->assertRedirect('/login');
$this->delete('/bookings/' . $booking->id)->assertRedirect('/login');
$this->assertSame('Test booking', $booking->fresh()->reference);
$this->assertCount(1, $booking->fresh()->subBookings);
}
public function test_valid_credentials_log_in_and_redirect_to_bookings()
{
$this->post('/login', ['email' => $this->user->email, 'password' => 'test-password'])
->assertRedirect('/bookings');
$this->assertAuthenticatedAs($this->user);
}
public function test_wrong_password_is_rejected()
{
$this->from('/login')->post('/login', [
'email' => $this->user->email, 'password' => 'wrong-password',
])->assertRedirect('/login')->assertSessionHasErrors('email');
$this->assertGuest();
}
public function test_logout_ends_authentication()
{
$this->actingAs($this->user)->post('/logout')->assertRedirect('/login');
$this->assertGuest();
$this->get('/bookings/year-2020')->assertRedirect('/login');
}
}
+181
View File
@@ -0,0 +1,181 @@
<?php
namespace Tests\Feature;
use App\Booking;
use App\PersonTarget;
use App\SubBooking;
use Tests\DatabaseTestCase;
class BookingsTest extends DatabaseTestCase
{
protected function payload(array $attributes = [])
{
return array_replace([
'date' => '2020-02-15', 'reference' => 'Membership payment',
'person_or_company' => 'Test company', 'note' => 'Test note', 'amount' => '125.50',
'account_type_id' => $this->incomeAccount->id, 'project_id' => $this->project->id,
'person_target_id' => null, 'personTarget' => ['person_id' => null, 'date' => null],
], $attributes);
}
public function test_user_can_create_a_booking_with_its_account_and_project()
{
$this->actingAs($this->user)->post('/bookings', $this->payload())
->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
$booking = Booking::where('reference', 'Membership payment')->firstOrFail();
$this->assertSame('2020-02-15', $booking->date);
$this->assertSame('Test company', $booking->person_or_company);
$this->assertSame('Test note', $booking->note);
$this->assertCount(1, $booking->subBookings);
$this->assertDatabaseHas('sub_bookings', [
'booking_id' => $booking->id, 'amount' => 125.50, 'reference' => 'Membership payment',
'account_type_id' => $this->incomeAccount->id, 'project_id' => $this->project->id,
]);
}
public function test_booking_forms_render_with_account_project_and_target_choices()
{
$target = $this->target();
$booking = $this->booking('2020-01-15', 100, null, ['person_target_id' => $target->id]);
$this->actingAs($this->user)->get('/bookings/create')->assertStatus(200)
->assertSee($this->project->name)->assertSee($this->incomeAccount->name);
// A fully paid target must still be selectable when editing its booking.
$this->get('/bookings/' . $booking->id . '/edit')->assertStatus(200)
->assertSee($target->reference)->assertSee($booking->reference);
$this->get('/bookings/' . $booking->id . '/delete')->assertStatus(200);
}
/** @dataProvider invalidBookings */
public function test_invalid_bookings_do_not_write_any_records($field, $value)
{
$this->actingAs($this->user)->from('/bookings/create')
->post('/bookings', $this->payload([$field => $value]))
->assertRedirect('/bookings/create')->assertSessionHasErrors($field);
$this->assertSame(0, Booking::count());
$this->assertSame(0, SubBooking::count());
}
public function invalidBookings()
{
return [
['date', ''], ['reference', ''], ['person_or_company', ''],
['amount', ''], ['amount', 'not money'], ['amount', '-10'], ['account_type_id', ''],
];
}
public function test_booking_can_pay_an_existing_target()
{
$target = $this->target(125.50);
$this->actingAs($this->user)->post('/bookings', $this->payload(['person_target_id' => $target->id]))
->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
$this->assertDatabaseHas('sub_bookings', ['person_target_id' => $target->id, 'amount' => 125.50]);
$this->assertTrue($target->fresh()->isPaid());
$this->assertSame(1, PersonTarget::count());
}
public function test_booking_can_create_a_target_using_the_booking_date_by_default()
{
$person = $this->target()->person;
$this->actingAs($this->user)->post('/bookings', $this->payload([
'personTarget' => ['person_id' => $person->id, 'date' => null],
]))->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
$target = SubBooking::firstOrFail()->personTarget;
$this->assertSame($person->id, $target->person_id);
$this->assertSame('2020-02-15', $target->date);
$this->assertEquals(125.50, $target->amount);
$this->assertTrue($target->isPaid());
}
public function test_user_cannot_choose_an_existing_target_and_create_one_together()
{
$target = $this->target();
$this->actingAs($this->user)->post('/bookings', $this->payload([
'person_target_id' => $target->id,
'personTarget' => ['person_id' => $target->person_id, 'date' => '2020-01-01'],
]))->assertSessionHasErrors('person_target_id');
$this->assertSame(0, Booking::count());
$this->assertSame(1, PersonTarget::count());
}
public function test_user_can_update_booking_and_split_it_into_income_and_expense_lines()
{
$booking = $this->booking('2019-12-31', 100);
$line = $booking->subBookings->first();
$this->actingAs($this->user)->patch('/bookings/' . $booking->id, [
'booking' => ['date' => '2020-01-15', 'reference' => 'Corrected', 'person_or_company' => 'Updated company'],
'sub_booking' => [
$line->id => ['reference' => 'Income', 'amount' => '120.50', 'account_type_id' => $this->incomeAccount->id],
0 => ['reference' => 'Fee', 'amount' => '20.25', 'account_type_id' => $this->expenseAccount->id],
],
])->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
$booking = $booking->fresh();
$this->assertSame('Corrected', $booking->reference);
$this->assertSame('2020-01-15', $booking->date);
$this->assertSame('Updated company', $booking->person_or_company);
$this->assertCount(2, $booking->subBookings);
$this->assertEquals(100.25, $booking->signedAmount());
$this->assertDatabaseHas('sub_bookings', ['id' => $line->id, 'amount' => 120.50]);
$this->assertDatabaseHas('sub_bookings', ['booking_id' => $booking->id, 'reference' => 'Fee', 'amount' => 20.25]);
}
public function test_invalid_update_leaves_booking_and_lines_unchanged()
{
$booking = $this->booking('2020-01-15', 100);
$line = $booking->subBookings->first();
$this->actingAs($this->user)->patch('/bookings/' . $booking->id, [
'booking' => ['date' => '2020-02-01', 'reference' => 'Changed', 'person_or_company' => 'Company'],
'sub_booking' => [$line->id => ['reference' => 'Invalid', 'amount' => -1, 'account_type_id' => $this->incomeAccount->id]],
])->assertSessionHasErrors('sub_booking.' . $line->id . '.amount');
$this->assertSame('2020-01-15', $booking->fresh()->date);
$this->assertEquals(100, $line->fresh()->amount);
}
public function test_empty_line_is_removed_while_other_lines_are_kept()
{
$booking = $this->booking('2020-01-15', 100);
$line = $booking->subBookings->first();
$this->actingAs($this->user)->patch('/bookings/' . $booking->id, [
'booking' => ['date' => $booking->date, 'reference' => $booking->reference, 'person_or_company' => $booking->person_or_company],
'sub_booking' => [
$line->id => ['reference' => null, 'amount' => null, 'account_type_id' => null],
0 => ['reference' => 'Replacement', 'amount' => 75, 'account_type_id' => $this->incomeAccount->id],
],
])->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
$this->assertDatabaseMissing('sub_bookings', ['id' => $line->id]);
$this->assertCount(1, $booking->fresh()->subBookings);
$this->assertEquals(75, $booking->fresh()->signedAmount());
}
public function test_deleting_booking_cascades_to_its_lines_but_keeps_target_and_other_bookings()
{
$target = $this->target();
$booking = $this->booking('2020-01-15', 100, null, ['person_target_id' => $target->id]);
$other = $this->booking('2020-02-15', 50);
$this->actingAs($this->user)->delete('/bookings/' . $booking->id, ['date' => $booking->date])
->assertRedirect('/bookings/year-2020');
$this->assertDatabaseMissing('bookings', ['id' => $booking->id]);
$this->assertDatabaseMissing('sub_bookings', ['booking_id' => $booking->id]);
$this->assertDatabaseHas('bookings', ['id' => $other->id]);
$this->assertDatabaseHas('person_targets', ['id' => $target->id]);
$this->assertEquals(100, $target->fresh()->difference());
}
public function test_clearing_all_lines_redirects_to_delete_confirmation_without_deleting()
{
$booking = $this->booking('2020-01-15', 100);
$line = $booking->subBookings->first();
$this->actingAs($this->user)->patch('/bookings/' . $booking->id, [
'booking' => ['date' => $booking->date, 'reference' => $booking->reference, 'person_or_company' => $booking->person_or_company],
'sub_booking' => [$line->id => ['reference' => null, 'amount' => null, 'account_type_id' => null]],
])->assertRedirect('/bookings/' . $booking->id . '/delete')->assertSessionHasNoErrors();
$this->assertDatabaseHas('bookings', ['id' => $booking->id]);
$this->assertDatabaseHas('sub_bookings', ['id' => $line->id, 'amount' => 100]);
}
public function test_unknown_booking_returns_not_found()
{
$this->actingAs($this->user)->get('/bookings/999999/edit')->assertStatus(404);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature;
use App\PersonTarget;
use Tests\DatabaseTestCase;
class PersonTargetsTest extends DatabaseTestCase
{
/** @dataProvider paymentStates */
public function test_payment_states_and_outstanding_balances($expense, $payment, $difference, $state)
{
$account = $expense ? $this->expenseAccount : $this->incomeAccount;
$target = $this->target(100, $account);
if ($payment > 0) {
$this->booking('2020-01-15', $payment, $account, ['person_target_id' => $target->id]);
}
$this->assertEquals($difference, $target->difference());
$this->assertSame($state === 'paid', $target->isPaid());
$this->assertTrue(PersonTarget::$state()->contains('id', $target->id));
$this->assertSame($state !== 'paid', PersonTarget::open()->contains('id', $target->id));
$this->assertSame($difference > 0, PersonTarget::receivable()->contains('id', $target->id));
$this->assertSame($difference < 0, PersonTarget::debt()->contains('id', $target->id));
}
public function paymentStates()
{
return [
'unpaid income' => [false, 0, 100, 'underpaid'],
'partial income' => [false, 40, 60, 'underpaid'],
'paid income' => [false, 100, 0, 'paid'],
'overpaid income' => [false, 120, -20, 'overpaid'],
'unpaid expense' => [true, 0, -100, 'underpaid'],
'partial expense' => [true, 40, -60, 'underpaid'],
'paid expense' => [true, 100, 0, 'paid'],
'overpaid expense' => [true, 120, 20, 'overpaid'],
];
}
public function test_multiple_payments_accumulate_and_targets_page_displays_remaining_total()
{
$receivable = $this->target(100);
$payable = $this->target(80, $this->expenseAccount);
$paid = $this->target(25);
$this->booking('2020-01-10', 30, null, ['person_target_id' => $receivable->id]);
$this->booking('2020-01-20', 20, null, ['person_target_id' => $receivable->id]);
$this->booking('2020-01-20', 15, $this->expenseAccount, ['person_target_id' => $payable->id]);
$this->booking('2020-01-20', 25, null, ['person_target_id' => $paid->id]);
$response = $this->actingAs($this->user)->get('/targets')->assertStatus(200);
$data = $response->original->getData();
$this->assertEquals(['receivables' => 50, 'debts' => -65], $data['sums']);
$this->assertSame([$receivable->id], $data['personTargets']['receivables']->pluck('id')->all());
$this->assertSame([$payable->id], $data['personTargets']['debts']->pluck('id')->all());
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace Tests\Feature;
use App\SubBooking;
use Tests\DatabaseTestCase;
class ReportsTest extends DatabaseTestCase
{
protected function seedCashFlows()
{
$this->booking('2019-12-31', 100);
$this->booking('2020-01-01', 50.25);
$this->booking('2020-01-31', 20.10, $this->expenseAccount);
$this->booking('2020-03-01', 10, $this->expenseAccount);
$this->booking('2020-12-31', 5.50);
$this->booking('2021-01-01', 999);
$this->actingAs($this->user);
}
public function test_balance_includes_cutoff_day_and_excludes_future_bookings()
{
$this->seedCashFlows();
$this->assertEquals(0, SubBooking::balanceAt('2019-12-30'));
$this->assertEquals(100, SubBooking::balanceAt('2019-12-31'));
$this->assertEquals(130.15, SubBooking::balanceAt('2020-01-31'), '', 0.001);
$this->assertEquals(125.65, SubBooking::balanceAt('2020-12-31'), '', 0.001);
}
public function test_interval_totals_include_both_endpoints_and_sign_expenses()
{
$this->seedCashFlows();
$this->assertEquals(55.75, SubBooking::incomesInInterval('2020-01-01', '2020-12-31')->first()->incomes, '', 0.001);
$this->assertEquals(-30.10, SubBooking::expedituresInInterval('2020-01-01', '2020-12-31')->first()->expeditures, '', 0.001);
}
public function test_monthly_totals_fill_empty_months_across_a_year_boundary()
{
$this->booking('2019-12-31', 10);
$this->booking('2020-02-01', 25);
$months = SubBooking::incomesInInterval('2019-12-01', '2020-02-29', true);
$this->assertSame([[2019, 12], [2020, 1], [2020, 2]], $months->map(function ($month) {
return [(int) $month->year, (int) $month->month];
})->all());
$this->assertEquals([10, 0, 25], $months->pluck('incomes')->all());
}
public function test_monthly_balance_report_carries_opening_balance_through_empty_months()
{
$this->seedCashFlows();
$response = $this->get('/reports/balances/2020')->assertStatus(200)->assertViewIs('reports.balances');
$data = $response->original->getData();
$this->assertEquals(100, $data['startBalance']);
$balances = $data['balances'];
$this->assertCount(12, $balances);
$this->assertEquals(50.25, $balances[0]->incomes);
$this->assertEquals(-20.10, $balances[0]->expeditures, '', 0.001);
$this->assertEquals(30.15, $balances[0]->monthBalance, '', 0.001);
$this->assertEquals(130.15, $balances[0]->accountBalance, '', 0.001);
$this->assertEquals(0, $balances[1]->monthBalance);
$this->assertEquals(130.15, $balances[1]->accountBalance, '', 0.001);
$this->assertEquals(120.15, $balances[2]->accountBalance, '', 0.001);
$this->assertEquals(125.65, $balances[11]->accountBalance, '', 0.001);
$this->assertSame('01.01.2021', $balances[11]->date);
}
/** @dataProvider cashbookPaths */
public function test_cashbook_and_print_view_filter_year_and_show_correct_totals($path)
{
$this->seedCashFlows();
$response = $this->get($path)->assertStatus(200);
$data = $response->original->getData();
$this->assertCount(4, $data['bookings']->flatten());
$this->assertEquals(100, $data['sums'][0]['balance']);
$this->assertEquals(-20.10, $data['sums'][1]['expeditures'], '', 0.001);
$this->assertEquals(150.25, $data['sums'][1]['incomes'], '', 0.001);
$this->assertEquals(130.15, $data['sums'][1]['balance'], '', 0.001);
$this->assertEquals(130.15, $data['sums'][2]['balance'], '', 0.001);
$this->assertEquals(125.65, $data['sums'][12]['balance'], '', 0.001);
}
public function cashbookPaths()
{
return [['/bookings/year-2020'], ['/bookings/year-2020/print']];
}
/** @dataProvider compactReportPaths */
public function test_scope_report_totals_match_cashbook_and_exclude_other_years($path)
{
$this->seedCashFlows();
$response = $this->get($path)->assertStatus(200);
$data = $response->original->getData();
$this->assertEquals(100, $data['startBalance']);
$this->assertEquals(25.65, $data['scopes']->sum('balance'), '', 0.001);
$this->assertEquals(125.65, $data['endBalance'], '', 0.001);
}
public function compactReportPaths()
{
return [['/reports/scopes/compact/2020'], ['/reports/scopes/compact/2020/print']];
}
public function test_project_report_excludes_other_projects_and_orders_bookings_by_date()
{
$late = $this->booking('2020-03-01', 80);
$early = $this->booking('2020-01-01', 20, $this->expenseAccount);
$this->booking('2020-02-01', 999, null, ['project_id' => null]);
$response = $this->actingAs($this->user)->get('/reports/projects/' . $this->project->id)
->assertStatus(200)->assertSee($this->project->name);
$data = $response->original->getData();
$this->assertEquals(80, $data['incomes']);
$this->assertEquals(20, $data['expeditures']);
$this->assertSame([$early->id, $late->id], $data['projectBookings']->pluck('booking_id')->values()->all());
}
public function test_reports_render_with_an_empty_database()
{
$this->actingAs($this->user);
$response = $this->get('/reports/balances/2020')->assertStatus(200);
$data = $response->original->getData();
$this->assertEquals(0, $data['startBalance']);
$this->assertCount(12, $data['balances']);
foreach ($data['balances'] as $balance) {
$this->assertEquals(0, $balance->accountBalance);
}
$this->get('/reports/scopes/compact/2020')->assertStatus(200)->assertViewHas('endBalance', 0);
$this->get('/bookings/year-2020')->assertStatus(200);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Tests\Unit;
use App\AccountClass;
use App\AccountType;
use App\Booking;
use App\IOType;
use App\SubBooking;
use Illuminate\Database\Eloquent\Collection;
use PHPUnit\Framework\TestCase;
class BookingAmountsTest extends TestCase
{
public function test_split_booking_uses_account_signs_and_preserves_unsigned_total()
{
$income = $this->line(125.50, 1);
$expense = $this->line(25.25, -1);
$booking = new Booking;
$booking->setRelation('subBookings', new Collection([$income, $expense]));
$this->assertEquals(125.50, $income->signedAmount());
$this->assertEquals(-25.25, $expense->signedAmount());
$this->assertEquals(150.75, $booking->amount());
$this->assertEquals(100.25, $booking->signedAmount());
$this->assertFalse($booking->isSimple());
$this->assertNull($booking->accountType());
$this->assertNull($booking->project());
$this->assertNull($booking->personTarget());
}
protected function line($amount, $factor)
{
$io = (new IOType)->forceFill(['factor' => $factor]);
$class = (new AccountClass)->setRelation('ioType', $io);
$account = (new AccountType)->setRelation('accountClass', $class);
return (new SubBooking)->forceFill(['amount' => $amount])->setRelation('accountType', $account);
}
}