Compare commits

6 Commits
Author SHA1 Message Date
paperbenni d2b8e62b2f document compatibility workarounds 2026-10-30 11:03:17 +01:00
paperbenni d4eaa45baa some vibe tests
better than none
2026-10-30 10:20:59 +01:00
paperbenni 613154a1f9 override testing env vars
shouldnt be able to accidentally use production database
2026-10-30 10:20:21 +01:00
paperbenni 9fe0b2564d make footer render in unit tests 2026-10-30 10:19:44 +01:00
paperbenni f2bcbae0a5 delete stock example test 2026-10-30 10:16:11 +01:00
paperbenni 1c21ad1ef7 add db for unit tests 2026-10-30 10:12:57 +01:00
17 changed files with 670 additions and 26 deletions
+2
View File
@@ -1,5 +1,7 @@
FROM composer:2.8 AS composer
# TODO(compat): Replace PHP 7.4 with a supported PHP release once Laravel and
# Composer dependencies support it; verify composer install and `just test`.
FROM php:7.4-cli
RUN apt-get update \
+32
View File
@@ -75,11 +75,43 @@ needed just to run the application.
just test
```
`just test` starts a separate MySQL 5.7 service (`db-test`) and runs PHPUnit in
the PHP container. The `pollux_test` database uses temporary storage, has no
published port, and is separate from the development database and its volume.
Tests migrate this database automatically and roll back their fixtures after
each test. No demo or real-data seeders are needed. Run `just setup` first on a
new checkout to build the PHP image and install dependencies.
To run a focused subset:
```sh
just test --filter BookingsTest
```
Coverage includes login/logout and guest access, booking forms and changes,
split entries, validation, deletion cascades, income/expense totals, monthly
balances, year boundaries, project and scope reports, and target payment states.
These are PHP HTTP/database tests; they do not exercise browser JavaScript.
PHPUnit forces isolated database settings, and the test bootstrap refuses to run
against another database configuration. If cached application configuration
prevents testing, run `just artisan config:clear`. `just down` also stops the test
database; its temporary data is discarded when the container stops.
## Legacy compatibility notes
This project uses several old dependencies, so a few small compatibility fixes
are included:
Temporary workarounds and legacy runtime pins are marked `TODO(compat)` in the
source, with the condition for removing or replacing each one. Search for this
marker during upgrades. In particular, remove the test `artisan()` override once
Laravel's inherited helper works, and remove both the package-manifest patch
and its Composer hook once Laravel supports Composer 2 directly.
The test startup timestamp, isolated database settings, and database safety
check are normal test infrastructure, not temporary version workarounds.
- `phpdocumentor/phpdocumentor` 2.* was removed from `require-dev`. It requires
Symfony 2.x, conflicts with Laravel 5.6, and was never present in
`composer.lock`.
+3 -1
View File
@@ -20,7 +20,9 @@ class AppServiceProvider extends ServiceProvider
return new CustomValidator($translator, $data, $rules, $messages);
});
// Set default string length in order to run on older versions of MySQL
// TODO(compat): This limits strings to 191 characters for older MySQL
// utf8mb4 index limits. Remove once every supported database handles
// indexed 255-character utf8mb4 strings; verify fresh migrations first.
Schema::defaultStringLength(191);
}
+20
View File
@@ -25,6 +25,8 @@ services:
command: php artisan serve --host=0.0.0.0 --port=8000
db:
# TODO(compat): Upgrade MySQL 5.7 after validating a restored data copy,
# migrations and reports. Update db-test to the same target version.
image: mysql:5.7
environment:
MYSQL_DATABASE: pollux
@@ -41,5 +43,23 @@ services:
timeout: 5s
retries: 20
db-test:
# TODO(compat): Replace this legacy image alongside db during the MySQL
# upgrade; keep the test database isolated and run the full test suite.
image: mysql:5.7
profiles: [testing]
environment:
MYSQL_DATABASE: pollux_test
MYSQL_USER: pollux_test
MYSQL_PASSWORD: testing
MYSQL_ROOT_PASSWORD: testing-root
tmpfs:
- /var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -ptesting-root --silent"]
interval: 2s
timeout: 5s
retries: 30
volumes:
mysql-data:
+1
View File
@@ -91,6 +91,7 @@ fresh:
# Run the test suite.
test *args:
docker compose up -d --wait db-test
docker compose run --rm --no-deps app vendor/bin/phpunit {{args}}
# Build frontend assets (uses the mise-pinned Node 10).
+2
View File
@@ -1,5 +1,7 @@
[tools]
# Old Laravel Mix 2 / node-sass toolchain; use Node 10 for asset builds.
# TODO(compat): Replace this pin with a supported Node LTS when Mix 2 and
# node-sass are replaced; verify development and production asset builds.
node = "10.24.1"
just = "1.59.0"
+15 -6
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="vendor/autoload.php"
bootstrap="tests/bootstrap.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
@@ -23,11 +23,20 @@
</whitelist>
</filter>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_ENV" value="testing" force="true"/>
<env name="APP_DEBUG" value="false" force="true"/>
<env name="APP_KEY" value="base64:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" force="true"/>
<env name="DB_CONNECTION" value="mysql" force="true"/>
<env name="DB_HOST" value="db-test" force="true"/>
<env name="DB_PORT" value="3306" force="true"/>
<env name="DB_DATABASE" value="pollux_test" force="true"/>
<env name="DB_USERNAME" value="pollux_test" force="true"/>
<env name="DB_PASSWORD" value="testing" force="true"/>
<env name="DB_SOCKET" value="" force="true"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="MAIL_DRIVER" value="array"/>
<env name="CACHE_DRIVER" value="array" force="true"/>
<env name="SESSION_DRIVER" value="array" force="true"/>
<env name="QUEUE_DRIVER" value="sync" force="true"/>
<env name="MAIL_DRIVER" value="array" force="true"/>
</php>
</phpunit>
+5
View File
@@ -7,6 +7,11 @@
*
* This script adds support for both formats to the vendored framework file.
* Composer runs it before package:discover, so it is safe to run repeatedly.
*
* TODO(compat): Once the upgraded Laravel supports Composer 2's installed
* package format itself, delete this script AND its post-autoload-dump entry
* in composer.json. Verify composer install and artisan package:discover
* succeed with a clean vendor directory and without this patch.
*/
$file = __DIR__.'/../vendor/laravel/framework/src/Illuminate/Foundation/PackageManifest.php';
+10
View File
@@ -17,6 +17,16 @@ trait CreatesApplication
$app->make(Kernel::class)->bootstrap();
// RefreshDatabase runs migrate:fresh. Refuse cached or overridden
// configuration that could point it at the application's database.
if (!$app->environment('testing')
|| config('database.default') !== 'mysql'
|| config('database.connections.mysql.host') !== 'db-test'
|| config('database.connections.mysql.database') !== 'pollux_test'
|| !empty(config('database.connections.mysql.unix_socket'))) {
throw new \RuntimeException('Tests require the isolated db-test / pollux_test database. Clear cached configuration before running tests.');
}
return $app;
}
}
+107
View File
@@ -0,0 +1,107 @@
<?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 = [])
{
// TODO(compat): 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.
// After upgrading Laravel/Symfony, remove this entire override and its
// Kernel, ArrayInput and BufferedOutput imports once `just test` passes
// using Laravel's inherited artisan() helper.
$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);
}
}
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace Tests\Unit;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function testBasicTest()
{
$this->assertTrue(true);
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
// Match the HTTP entry point: the shared footer displays elapsed request time.
define('LARAVEL_START', microtime(true));
require __DIR__.'/../vendor/autoload.php';