Compare commits
8
Commits
d2bd589c96
..
benni
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2b8e62b2f | ||
|
|
d4eaa45baa | ||
|
|
613154a1f9 | ||
|
|
9fe0b2564d | ||
|
|
f2bcbae0a5 | ||
|
|
1c21ad1ef7 | ||
|
|
d1981673a2 | ||
|
|
84f5f0c788 |
@@ -1,5 +1,7 @@
|
|||||||
FROM composer:2.8 AS composer
|
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
|
FROM php:7.4-cli
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
|
|||||||
+33
-1
@@ -37,7 +37,7 @@ and loads the constant and demo data.
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `just up` | Start the app and database in the foreground |
|
| `just up` | Start the app and database in the foreground |
|
||||||
| `just start` | Start the app and database in the background |
|
| `just up -d` | Start the app and database in the background |
|
||||||
| `just down` | Stop the app and database |
|
| `just down` | Stop the app and database |
|
||||||
| `just logs` | Follow container logs |
|
| `just logs` | Follow container logs |
|
||||||
| `just artisan <command>` | Run an Artisan command |
|
| `just artisan <command>` | Run an Artisan command |
|
||||||
@@ -75,11 +75,43 @@ needed just to run the application.
|
|||||||
just test
|
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
|
## Legacy compatibility notes
|
||||||
|
|
||||||
This project uses several old dependencies, so a few small compatibility fixes
|
This project uses several old dependencies, so a few small compatibility fixes
|
||||||
are included:
|
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
|
- `phpdocumentor/phpdocumentor` 2.* was removed from `require-dev`. It requires
|
||||||
Symfony 2.x, conflicts with Laravel 5.6, and was never present in
|
Symfony 2.x, conflicts with Laravel 5.6, and was never present in
|
||||||
`composer.lock`.
|
`composer.lock`.
|
||||||
@@ -20,7 +20,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
return new CustomValidator($translator, $data, $rules, $messages);
|
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);
|
Schema::defaultStringLength(191);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ services:
|
|||||||
command: php artisan serve --host=0.0.0.0 --port=8000
|
command: php artisan serve --host=0.0.0.0 --port=8000
|
||||||
|
|
||||||
db:
|
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
|
image: mysql:5.7
|
||||||
environment:
|
environment:
|
||||||
MYSQL_DATABASE: pollux
|
MYSQL_DATABASE: pollux
|
||||||
@@ -41,5 +43,23 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 20
|
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:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
|
|||||||
@@ -39,14 +39,10 @@ setup:
|
|||||||
echo "Setup complete. Run 'just up', then open http://localhost:8000"
|
echo "Setup complete. Run 'just up', then open http://localhost:8000"
|
||||||
echo "Login: dev@pollux.local / password"
|
echo "Login: dev@pollux.local / password"
|
||||||
|
|
||||||
# Start the app and database in the foreground.
|
# Start the app and database in the foreground. Use `just up -d` for background.
|
||||||
up *args:
|
up *args:
|
||||||
docker compose up --build {{args}}
|
docker compose up --build {{args}}
|
||||||
|
|
||||||
# Start the app and database in the background.
|
|
||||||
start:
|
|
||||||
docker compose up -d --build
|
|
||||||
|
|
||||||
# Stop the app and database.
|
# Stop the app and database.
|
||||||
down:
|
down:
|
||||||
docker compose down
|
docker compose down
|
||||||
@@ -95,6 +91,7 @@ fresh:
|
|||||||
|
|
||||||
# Run the test suite.
|
# Run the test suite.
|
||||||
test *args:
|
test *args:
|
||||||
|
docker compose up -d --wait db-test
|
||||||
docker compose run --rm --no-deps app vendor/bin/phpunit {{args}}
|
docker compose run --rm --no-deps app vendor/bin/phpunit {{args}}
|
||||||
|
|
||||||
# Build frontend assets (uses the mise-pinned Node 10).
|
# Build frontend assets (uses the mise-pinned Node 10).
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
[tools]
|
[tools]
|
||||||
# Old Laravel Mix 2 / node-sass toolchain; use Node 10 for asset builds.
|
# 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"
|
node = "10.24.1"
|
||||||
just = "1.59.0"
|
just = "1.59.0"
|
||||||
|
|
||||||
|
|||||||
+15
-6
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<phpunit backupGlobals="false"
|
<phpunit backupGlobals="false"
|
||||||
backupStaticAttributes="false"
|
backupStaticAttributes="false"
|
||||||
bootstrap="vendor/autoload.php"
|
bootstrap="tests/bootstrap.php"
|
||||||
colors="true"
|
colors="true"
|
||||||
convertErrorsToExceptions="true"
|
convertErrorsToExceptions="true"
|
||||||
convertNoticesToExceptions="true"
|
convertNoticesToExceptions="true"
|
||||||
@@ -23,11 +23,20 @@
|
|||||||
</whitelist>
|
</whitelist>
|
||||||
</filter>
|
</filter>
|
||||||
<php>
|
<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="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="CACHE_DRIVER" value="array"/>
|
<env name="CACHE_DRIVER" value="array" force="true"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||||
<env name="QUEUE_DRIVER" value="sync"/>
|
<env name="QUEUE_DRIVER" value="sync" force="true"/>
|
||||||
<env name="MAIL_DRIVER" value="array"/>
|
<env name="MAIL_DRIVER" value="array" force="true"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
*
|
*
|
||||||
* This script adds support for both formats to the vendored framework file.
|
* 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.
|
* 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';
|
$file = __DIR__.'/../vendor/laravel/framework/src/Illuminate/Foundation/PackageManifest.php';
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ trait CreatesApplication
|
|||||||
|
|
||||||
$app->make(Kernel::class)->bootstrap();
|
$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;
|
return $app;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user