diff --git a/Dockerfile b/Dockerfile index 4dd70ca..1ee8972 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/README.md b/README.md index 583c811..1297f92 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e099dd5..c3657b7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); } diff --git a/compose.yaml b/compose.yaml index 475051e..9ee74a0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/justfile b/justfile index bc4da9e..eae8891 100644 --- a/justfile +++ b/justfile @@ -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). diff --git a/mise.toml b/mise.toml index 6f34dd2..5912179 100644 --- a/mise.toml +++ b/mise.toml @@ -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" diff --git a/phpunit.xml b/phpunit.xml index f564cfb..af7f2da 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ - + + + + + + + + + + - - - - + + + + diff --git a/scripts/patch-package-manifest.php b/scripts/patch-package-manifest.php index cd3070c..87b6114 100644 --- a/scripts/patch-package-manifest.php +++ b/scripts/patch-package-manifest.php @@ -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'; diff --git a/tests/CreatesApplication.php b/tests/CreatesApplication.php index 547152f..6aa30b6 100644 --- a/tests/CreatesApplication.php +++ b/tests/CreatesApplication.php @@ -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; } } diff --git a/tests/DatabaseTestCase.php b/tests/DatabaseTestCase.php new file mode 100644 index 0000000..7149e18 --- /dev/null +++ b/tests/DatabaseTestCase.php @@ -0,0 +1,107 @@ +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, + ]); + } +} diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php new file mode 100644 index 0000000..c192c41 --- /dev/null +++ b/tests/Feature/AuthenticationTest.php @@ -0,0 +1,62 @@ +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'); + } +} diff --git a/tests/Feature/BookingsTest.php b/tests/Feature/BookingsTest.php new file mode 100644 index 0000000..d3aad99 --- /dev/null +++ b/tests/Feature/BookingsTest.php @@ -0,0 +1,181 @@ + '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); + } +} diff --git a/tests/Feature/PersonTargetsTest.php b/tests/Feature/PersonTargetsTest.php new file mode 100644 index 0000000..3e1605b --- /dev/null +++ b/tests/Feature/PersonTargetsTest.php @@ -0,0 +1,55 @@ +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()); + } +} diff --git a/tests/Feature/ReportsTest.php b/tests/Feature/ReportsTest.php new file mode 100644 index 0000000..663f5ec --- /dev/null +++ b/tests/Feature/ReportsTest.php @@ -0,0 +1,129 @@ +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); + } +} diff --git a/tests/Unit/BookingAmountsTest.php b/tests/Unit/BookingAmountsTest.php new file mode 100644 index 0000000..e32ca3c --- /dev/null +++ b/tests/Unit/BookingAmountsTest.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index e9fe19c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -assertTrue(true); - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..48271c4 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,6 @@ +