diff --git a/README.md b/README.md index 583c811..de048ec 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,29 @@ 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 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/tests/DatabaseTestCase.php b/tests/DatabaseTestCase.php new file mode 100644 index 0000000..203d84c --- /dev/null +++ b/tests/DatabaseTestCase.php @@ -0,0 +1,105 @@ +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); + } +}