Compare commits

16 Commits
Author SHA1 Message Date
paperbenni d4eaa45baa some vibe tests
better than none
2026-10-30 10:20:59 +01:00
paperbenni 613154a1f9 override testing env vars
shouldnt be able to accidentally use production database
2026-10-30 10:20:21 +01:00
paperbenni 9fe0b2564d make footer render in unit tests 2026-10-30 10:19:44 +01:00
paperbenni f2bcbae0a5 delete stock example test 2026-10-30 10:16:11 +01:00
paperbenni 1c21ad1ef7 add db for unit tests 2026-10-30 10:12:57 +01:00
paperbenni d1981673a2 remove redundant justfile recipe 2026-10-30 09:53:07 +01:00
paperbenni 84f5f0c788 rename readme 2026-10-30 09:45:34 +01:00
paperbenni d2bd589c96 init AI slop readmes 2026-10-30 09:43:48 +01:00
paperbenni 97e2c9e192 init justfile 2026-10-30 09:43:43 +01:00
paperbenni 23f9154eb6 remove unused dep 2026-10-30 09:43:27 +01:00
paperbenni 9ddcc0adb5 compat shim with modern composer 2026-10-30 09:41:48 +01:00
paperbenni a2ed9a9838 add dev user sheed 2026-10-30 09:40:42 +01:00
paperbenni 937004a48c fix migration incompatibility 2026-10-30 09:40:22 +01:00
paperbenni bd6e311a73 correct stale test 2026-10-30 09:39:28 +01:00
paperbenni a628dc1424 init docker stuff 2026-10-30 09:39:05 +01:00
paperbenni 7fa56f7584 init mise toml 2026-10-30 09:37:04 +01:00
24 changed files with 1035 additions and 113 deletions
+2
View File
@@ -0,0 +1,2 @@
*
!Dockerfile
+39
View File
@@ -0,0 +1,39 @@
APP_NAME=Pollux
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost:8000
LOG_CHANNEL=stack
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=pollux
DB_USERNAME=pollux
DB_PASSWORD=secret
BROADCAST_DRIVER=log
CACHE_DRIVER=file
SESSION_DRIVER=file
SESSION_LIFETIME=120
QUEUE_DRIVER=sync
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
+12
View File
@@ -0,0 +1,12 @@
FROM composer:2.8 AS composer
FROM php:7.4-cli
RUN apt-get update \
&& apt-get install -y --no-install-recommends libonig-dev libzip-dev \
&& docker-php-ext-install -j"$(nproc)" mbstring pdo_mysql zip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer /usr/bin/composer /usr/bin/composer
WORKDIR /var/www
+119
View File
@@ -0,0 +1,119 @@
# Pollux
Pollux is a small Laravel 5.6 bookkeeping application for managing bookings,
projects, people, targets, and reports.
## Local development
The application targets PHP 7.4 and MySQL 5.7. Those old runtimes are run in
Docker so they do not have to be installed on the host. [mise](https://mise.jdx.dev/)
manages the host-side tools: Just and Node 10 (required by the old Laravel Mix 2
asset pipeline).
### Prerequisites
- Docker with Compose v2 (`docker compose`)
- [mise](https://mise.jdx.dev/)
### First run
```sh
mise install
just setup
just up
```
Then open <http://localhost:8000>. A development login is created automatically:
- **Email:** `dev@pollux.local`
- **Password:** `password`
`just setup` builds the PHP image, installs Composer dependencies, creates `.env`
from `.env.docker.example`, generates `APP_KEY`, starts MySQL, runs migrations,
and loads the constant and demo data.
### Everyday commands
| Command | Description |
| --- | --- |
| `just up` | Start the app and database in the foreground |
| `just up -d` | Start the app and database in the background |
| `just down` | Stop the app and database |
| `just logs` | Follow container logs |
| `just artisan <command>` | Run an Artisan command |
| `just composer <command>` | Run a Composer command |
| `just shell` | Open a shell in the app container |
| `just db` | Open a MySQL shell |
| `just migrate` | Run migrations |
| `just seed` | Add the demo/test dataset to an existing database |
| `just seed-real` | Recreate the database and load the full dataset from `database/seeds/data/real` |
| `just fresh` | Drop and recreate the database, then load demo data |
| `just test` | Run PHPUnit |
| `just assets` | Install npm packages and build frontend assets |
| `just watch` | Rebuild frontend assets on change |
Run `just --list` to see the full list.
### Database
MySQL is exposed on host port **3307** so it does not clash with a local MySQL
on 3306. The database is `pollux` with username `pollux` and password `secret`.
The app container connects to the `db` service on port 3306.
Data lives in the Docker volume `pollux_mysql-data`. `just down` keeps the data;
use `docker compose down -v` if you want to remove it as well.
### Frontend assets
Node 10 is pinned in `mise.toml` because Laravel Mix 2 and `node-sass` do not run
on current Node versions. Prebuilt assets are committed, so `just assets` is not
needed just to run the application.
### Tests
```sh
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:
- `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`.
- `scripts/patch-package-manifest.php` runs after Composer's autoload dump. It
adds Composer 2 support to Laravel 5.6's package discovery, which otherwise
fails with `Undefined index: name`.
- `fzaninotto/faker` was updated to 1.9.2 for PHP 7.4 compatibility.
- `bookings.note` is created as `TEXT` because the full dataset in
`database/seeds/data/real` contains notes longer than the default
191-character string length.
## License
Pollux is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
+61
View File
@@ -0,0 +1,61 @@
name: pollux
services:
app:
build: .
working_dir: /var/www
user: "${UID:-1000}:${GID:-1000}"
environment:
HOME: /tmp
COMPOSER_HOME: /tmp/composer
APP_URL: http://localhost:8000
DB_CONNECTION: mysql
DB_HOST: db
DB_PORT: "3306"
DB_DATABASE: pollux
DB_USERNAME: pollux
DB_PASSWORD: secret
volumes:
- .:/var/www
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
command: php artisan serve --host=0.0.0.0 --port=8000
db:
image: mysql:5.7
environment:
MYSQL_DATABASE: pollux
MYSQL_USER: pollux
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: root
volumes:
- mysql-data:/var/lib/mysql
ports:
- "3307:3306"
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
interval: 5s
timeout: 5s
retries: 20
db-test:
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:
+2 -2
View File
@@ -22,8 +22,7 @@
"fzaninotto/faker": "^1.4", "fzaninotto/faker": "^1.4",
"mockery/mockery": "^1.0", "mockery/mockery": "^1.0",
"nunomaduro/collision": "^2.0", "nunomaduro/collision": "^2.0",
"phpunit/phpunit": "^7.0", "phpunit/phpunit": "^7.0"
"phpdocumentor/phpdocumentor": "2.*"
}, },
"autoload": { "autoload": {
"files": [ "files": [
@@ -57,6 +56,7 @@
], ],
"post-autoload-dump": [ "post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php scripts/patch-package-manifest.php",
"@php artisan package:discover" "@php artisan package:discover"
] ]
}, },
Generated
+24 -18
View File
@@ -1,7 +1,7 @@
{ {
"_readme": [ "_readme": [
"This file locks the dependencies of your project to a known state", "This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "68e5550c8775420cfdaef12858446b78", "content-hash": "68e5550c8775420cfdaef12858446b78",
@@ -1118,12 +1118,12 @@
"version": "1.25.0", "version": "1.25.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/briannesbitt/Carbon.git", "url": "https://github.com/CarbonPHP/carbon.git",
"reference": "cbcf13da0b531767e39eb86e9687f5deba9857b4" "reference": "cbcf13da0b531767e39eb86e9687f5deba9857b4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/cbcf13da0b531767e39eb86e9687f5deba9857b4", "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/cbcf13da0b531767e39eb86e9687f5deba9857b4",
"reference": "cbcf13da0b531767e39eb86e9687f5deba9857b4", "reference": "cbcf13da0b531767e39eb86e9687f5deba9857b4",
"shasum": "" "shasum": ""
}, },
@@ -2604,12 +2604,12 @@
"version": "v2.5.1", "version": "v2.5.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/vlucas/phpdotenv.git", "url": "https://github.com/phpdotenv/phpdotenv.git",
"reference": "8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e" "reference": "8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e", "url": "https://api.github.com/repos/phpdotenv/phpdotenv/zipball/8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e",
"reference": "8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e", "reference": "8abb4f9aa89ddea9d52112c65bbe8d0125e2fa8e",
"shasum": "" "shasum": ""
}, },
@@ -2656,12 +2656,12 @@
"version": "v3.2.0", "version": "v3.2.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/barryvdh/laravel-debugbar.git", "url": "https://github.com/fruitcake/laravel-debugbar.git",
"reference": "5b68f3972083a7eeec0d6f161962fcda71a127c0" "reference": "5b68f3972083a7eeec0d6f161962fcda71a127c0"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-debugbar/zipball/5b68f3972083a7eeec0d6f161962fcda71a127c0", "url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/5b68f3972083a7eeec0d6f161962fcda71a127c0",
"reference": "5b68f3972083a7eeec0d6f161962fcda71a127c0", "reference": "5b68f3972083a7eeec0d6f161962fcda71a127c0",
"shasum": "" "shasum": ""
}, },
@@ -2836,16 +2836,16 @@
}, },
{ {
"name": "fzaninotto/faker", "name": "fzaninotto/faker",
"version": "v1.8.0", "version": "v1.9.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/fzaninotto/Faker.git", "url": "https://github.com/fzaninotto/Faker.git",
"reference": "f72816b43e74063c8b10357394b6bba8cb1c10de" "reference": "848d8125239d7dbf8ab25cb7f054f1a630e68c2e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/fzaninotto/Faker/zipball/f72816b43e74063c8b10357394b6bba8cb1c10de", "url": "https://api.github.com/repos/fzaninotto/Faker/zipball/848d8125239d7dbf8ab25cb7f054f1a630e68c2e",
"reference": "f72816b43e74063c8b10357394b6bba8cb1c10de", "reference": "848d8125239d7dbf8ab25cb7f054f1a630e68c2e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2854,12 +2854,12 @@
"require-dev": { "require-dev": {
"ext-intl": "*", "ext-intl": "*",
"phpunit/phpunit": "^4.8.35 || ^5.7", "phpunit/phpunit": "^4.8.35 || ^5.7",
"squizlabs/php_codesniffer": "^1.5" "squizlabs/php_codesniffer": "^2.9.2"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "1.8-dev" "dev-master": "1.9-dev"
} }
}, },
"autoload": { "autoload": {
@@ -2882,7 +2882,12 @@
"faker", "faker",
"fixtures" "fixtures"
], ],
"time": "2018-07-12T10:23:15+00:00" "support": {
"issues": "https://github.com/fzaninotto/Faker/issues",
"source": "https://github.com/fzaninotto/Faker/tree/v1.9.2"
},
"abandoned": true,
"time": "2020-12-11T09:56:16+00:00"
}, },
{ {
"name": "hamcrest/hamcrest-php", "name": "hamcrest/hamcrest-php",
@@ -4431,12 +4436,12 @@
"version": "1.3.0", "version": "1.3.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/webmozart/assert.git", "url": "https://github.com/webmozarts/assert.git",
"reference": "0df1908962e7a3071564e857d86874dad1ef204a" "reference": "0df1908962e7a3071564e857d86874dad1ef204a"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/webmozart/assert/zipball/0df1908962e7a3071564e857d86874dad1ef204a", "url": "https://api.github.com/repos/webmozarts/assert/zipball/0df1908962e7a3071564e857d86874dad1ef204a",
"reference": "0df1908962e7a3071564e857d86874dad1ef204a", "reference": "0df1908962e7a3071564e857d86874dad1ef204a",
"shasum": "" "shasum": ""
}, },
@@ -4479,11 +4484,12 @@
], ],
"aliases": [], "aliases": [],
"minimum-stability": "dev", "minimum-stability": "dev",
"stability-flags": [], "stability-flags": {},
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": "^7.1.3" "php": "^7.1.3"
}, },
"platform-dev": [] "platform-dev": {},
"plugin-api-version": "2.6.0"
} }
@@ -25,8 +25,9 @@ class CreateBookingsTable extends Migration
// The name of the person or company which the booking belongs to // The name of the person or company which the booking belongs to
$table->string('person_or_company'); $table->string('person_or_company');
// An additional note to the booking // An additional note to the booking. The real dataset contains
$table->string('note')->nullable(); // notes longer than the 191-character default string length.
$table->text('note')->nullable();
$table->timestamps(); $table->timestamps();
}); });
+25
View File
@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Seeder;
class DevUserSeeder extends Seeder
{
/**
* Create a local development login.
*
* Email: dev@pollux.local
* Password: password
*
* @return void
*/
public function run()
{
App\User::updateOrCreate(
['email' => 'dev@pollux.local'],
[
'name' => 'Dev User',
'password' => bcrypt('password'),
]
);
}
}
+104
View File
@@ -0,0 +1,104 @@
set shell := ["bash", "-uc"]
# Keep files created by containers owned by the host user.
export UID := `id -u`
export GID := `id -g`
default:
@just --list
# First-time setup: build, install PHP deps, create .env, migrate and seed.
setup:
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f .env ]; then
cp .env.docker.example .env
fi
docker compose build
docker compose run --rm --no-deps app composer install
if ! grep -q '^APP_KEY=base64:' .env; then
docker compose run --rm --no-deps app php artisan key:generate --force
fi
docker compose up -d --wait db
docker compose run --rm app php artisan migrate --force
# Only seed the demo data on a fresh database.
if [ "$(docker compose exec -T db mysql -N -B -upollux -psecret pollux -e 'SELECT COUNT(*) FROM scopes' 2>/dev/null || echo 0)" = "0" ]; then
docker compose run --rm app php artisan db:seed --class=ConstantsSeeder --force
docker compose run --rm app php artisan db:seed --class=TestDataSeeder --force
else
echo "Database already seeded; skipping demo data."
fi
docker compose run --rm app php artisan db:seed --class=DevUserSeeder --force
echo "Setup complete. Run 'just up', then open http://localhost:8000"
echo "Login: dev@pollux.local / password"
# Start the app and database in the foreground. Use `just up -d` for background.
up *args:
docker compose up --build {{args}}
# Stop the app and database.
down:
docker compose down
# Tail logs. Optionally pass a service name: just logs app
logs *args:
docker compose logs -f {{args}}
# Run an artisan command: just artisan route:list
artisan *args:
docker compose run --rm app php artisan {{args}}
# Run a composer command: just composer require foo/bar
composer *args:
docker compose run --rm app composer {{args}}
# Open a shell in the app container.
shell:
docker compose run --rm app bash
# Open a MySQL shell.
db:
docker compose up -d --wait db
docker compose exec db mysql -upollux -psecret pollux
# Run migrations.
migrate:
docker compose run --rm app php artisan migrate
# Add the demo/test dataset to an existing database.
seed:
docker compose run --rm app php artisan db:seed --class=TestDataSeeder
# Recreate the database and load the full dataset from database/seeds/data/real.
seed-real:
docker compose run --rm app php artisan migrate:fresh
docker compose run --rm app php artisan db:seed --force
docker compose run --rm app php artisan db:seed --class=DevUserSeeder --force
# Recreate the database and load test data.
fresh:
docker compose run --rm app php artisan migrate:fresh
docker compose run --rm app php artisan db:seed --class=ConstantsSeeder
docker compose run --rm app php artisan db:seed --class=TestDataSeeder
docker compose run --rm app php artisan db:seed --class=DevUserSeeder
# 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).
assets:
mise exec -- npm install
mise exec -- npm run dev
# Watch frontend assets.
watch:
mise exec -- npm run watch
+7
View File
@@ -0,0 +1,7 @@
[tools]
# Old Laravel Mix 2 / node-sass toolchain; use Node 10 for asset builds.
node = "10.24.1"
just = "1.59.0"
[env]
COMPOSE_PROJECT_NAME = "pollux"
+15 -6
View File
@@ -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>
-65
View File
@@ -1,65 +0,0 @@
<p align="center"><img src="https://laravel.com/assets/img/components/logo-laravel.svg"></p>
<p align="center">
<a href="https://travis-ci.org/laravel/framework"><img src="https://travis-ci.org/laravel/framework.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/d/total.svg" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/v/stable.svg" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://poser.pugx.org/laravel/framework/license.svg" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel attempts to take the pain out of development by easing common tasks used in the majority of web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, yet powerful, providing tools needed for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of any modern web application framework, making it a breeze to get started learning the framework.
If you're not in the mood to read, [Laracasts](https://laracasts.com) contains over 1100 video tutorials on a range of topics including Laravel, modern PHP, unit testing, JavaScript, and more. Boost the skill level of yourself and your entire team by digging into our comprehensive video library.
## Laravel Sponsors
We would like to extend our thanks to the following sponsors for helping fund on-going Laravel development. If you are interested in becoming a sponsor, please visit the Laravel [Patreon page](https://patreon.com/taylorotwell):
- **[Vehikl](https://vehikl.com/)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[Cubet Techno Labs](https://cubettech.com)**
- **[British Software Development](https://www.britishsoftware.co)**
- **[Webdock, Fast VPS Hosting](https://www.webdock.io/en)**
- [UserInsights](https://userinsights.com)
- [Fragrantica](https://www.fragrantica.com)
- [SOFTonSOFA](https://softonsofa.com/)
- [User10](https://user10.com)
- [Soumettre.fr](https://soumettre.fr/)
- [CodeBrisk](https://codebrisk.com)
- [1Forge](https://1forge.com)
- [TECPRESSO](https://tecpresso.co.jp/)
- [Runtime Converter](http://runtimeconverter.com/)
- [WebL'Agence](https://weblagence.com/)
- [Invoice Ninja](https://www.invoiceninja.com)
- [iMi digital](https://www.imi-digital.de/)
- [Earthlink](https://www.earthlink.ro/)
- [Steadfast Collective](https://steadfastcollective.com/)
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
+33
View File
@@ -0,0 +1,33 @@
<?php
/*
* Laravel 5.6's PackageManifest predates Composer 2. Composer 2 stores the
* installed package list under a "packages" key, which makes Laravel's
* package:discover command fail with "Undefined index: name".
*
* 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.
*/
$file = __DIR__.'/../vendor/laravel/framework/src/Illuminate/Foundation/PackageManifest.php';
if (! file_exists($file)) {
fwrite(STDERR, "PackageManifest.php not found; skipping Composer 2 patch.\n");
exit(0);
}
$contents = file_get_contents($file);
if (strpos($contents, "\$packages = \$packages['packages'] ?? \$packages;") !== false) {
exit(0);
}
$needle = '$packages = json_decode($this->files->get($path), true);';
$replacement = $needle."\n\n \$packages = \$packages['packages'] ?? \$packages;";
if (strpos($contents, $needle) === false) {
fwrite(STDERR, "Could not find PackageManifest build() snippet; skipping patch.\n");
exit(0);
}
file_put_contents($file, str_replace($needle, $replacement, $contents));
+10
View File
@@ -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;
} }
} }
+105
View File
@@ -0,0 +1,105 @@
<?php
namespace Tests;
use App\AccountClass;
use App\AccountType;
use App\Booking;
use App\IOType;
use App\Person;
use App\PersonTarget;
use App\Project;
use App\Scope;
use App\SubBooking;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Contracts\Console\Kernel;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
abstract class DatabaseTestCase extends TestCase
{
use RefreshDatabase;
protected $incomeAccount;
protected $expenseAccount;
protected $project;
protected $user;
public function artisan($command, $parameters = [])
{
// Laravel 5.6's Kernel::call uses a numeric command key, which the
// locked Symfony Console reads as a string offset on PHP 7.4.
// Use its normal input handler with a named command until upgraded.
$output = new BufferedOutput;
$status = $this->app[Kernel::class]->handle(
new ArrayInput(array_merge(['command' => $command], $parameters)), $output
);
$this->assertSame(0, $status, $output->fetch());
return $status;
}
protected function setUp()
{
parent::setUp();
// Small, explicit fixtures; no dependency on demo data or fixed IDs.
$scope = $this->record(Scope::class, ['name' => 'Test scope']);
foreach ([1 => 'Einnahme', -1 => 'Ausgabe'] as $factor => $name) {
$io = $this->record(IOType::class, compact('name', 'factor'));
$class = $this->record(AccountClass::class, [
'name' => $name, 'number' => $factor > 0 ? 10 : 20,
'scope_id' => $scope->id, 'io_type_id' => $io->id,
]);
$account = $this->record(AccountType::class, [
'name' => $name, 'number' => $factor > 0 ? 100 : 200,
'account_class_id' => $class->id,
]);
if ($factor > 0) {
$this->incomeAccount = $account;
} else {
$this->expenseAccount = $account;
}
}
$this->project = $this->record(Project::class, [
'name' => 'Test project', 'start_date' => '2019-01-01', 'end_date' => '2020-12-31',
]);
$this->user = $this->record(User::class, [
'name' => 'Test user', 'email' => 'test@example.com', 'password' => bcrypt('test-password'),
]);
}
protected function record($class, array $attributes)
{
$model = new $class;
$model->forceFill($attributes)->save();
return $model;
}
protected function booking($date, $amount, $account = null, array $attributes = [])
{
$booking = $this->record(Booking::class, [
'date' => $date, 'reference' => 'Test booking', 'person_or_company' => 'Test company',
]);
$this->record(SubBooking::class, array_merge([
'booking_id' => $booking->id, 'reference' => 'Test line', 'amount' => $amount,
'account_type_id' => ($account ?: $this->incomeAccount)->id,
'project_id' => $this->project->id,
], $attributes));
return $booking;
}
protected function target($amount = 100, $account = null)
{
$person = $this->record(Person::class, ['title' => 'Firma', 'name_or_company' => 'Test company']);
return $this->record(PersonTarget::class, [
'person_id' => $person->id, 'date' => '2020-01-01', 'reference' => 'Test target',
'amount' => $amount, 'io_type_id' => ($account ?: $this->incomeAccount)->accountClass->io_type_id,
]);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Tests\Feature;
use Tests\DatabaseTestCase;
class AuthenticationTest extends DatabaseTestCase
{
/** @dataProvider protectedRoutes */
public function test_guests_are_redirected_to_login($method, $path)
{
$this->call($method, $path)->assertRedirect('/login');
}
public function protectedRoutes()
{
return [
['GET', '/bookings/year-2020'], ['GET', '/people'],
['GET', '/projects'], ['GET', '/targets'],
['GET', '/reports/balances/2020'], ['GET', '/reports/scopes/compact/2020'],
['POST', '/bookings'], ['PATCH', '/bookings/1'], ['DELETE', '/bookings/1'],
];
}
public function test_login_form_is_available()
{
$this->get('/login')->assertStatus(200)->assertSee('password');
}
public function test_guests_cannot_change_or_delete_an_existing_booking()
{
$booking = $this->booking('2020-01-15', 100);
$this->patch('/bookings/' . $booking->id, [
'booking' => ['reference' => 'Unauthorized change'],
])->assertRedirect('/login');
$this->delete('/bookings/' . $booking->id)->assertRedirect('/login');
$this->assertSame('Test booking', $booking->fresh()->reference);
$this->assertCount(1, $booking->fresh()->subBookings);
}
public function test_valid_credentials_log_in_and_redirect_to_bookings()
{
$this->post('/login', ['email' => $this->user->email, 'password' => 'test-password'])
->assertRedirect('/bookings');
$this->assertAuthenticatedAs($this->user);
}
public function test_wrong_password_is_rejected()
{
$this->from('/login')->post('/login', [
'email' => $this->user->email, 'password' => 'wrong-password',
])->assertRedirect('/login')->assertSessionHasErrors('email');
$this->assertGuest();
}
public function test_logout_ends_authentication()
{
$this->actingAs($this->user)->post('/logout')->assertRedirect('/login');
$this->assertGuest();
$this->get('/bookings/year-2020')->assertRedirect('/login');
}
}
+181
View File
@@ -0,0 +1,181 @@
<?php
namespace Tests\Feature;
use App\Booking;
use App\PersonTarget;
use App\SubBooking;
use Tests\DatabaseTestCase;
class BookingsTest extends DatabaseTestCase
{
protected function payload(array $attributes = [])
{
return array_replace([
'date' => '2020-02-15', 'reference' => 'Membership payment',
'person_or_company' => 'Test company', 'note' => 'Test note', 'amount' => '125.50',
'account_type_id' => $this->incomeAccount->id, 'project_id' => $this->project->id,
'person_target_id' => null, 'personTarget' => ['person_id' => null, 'date' => null],
], $attributes);
}
public function test_user_can_create_a_booking_with_its_account_and_project()
{
$this->actingAs($this->user)->post('/bookings', $this->payload())
->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
$booking = Booking::where('reference', 'Membership payment')->firstOrFail();
$this->assertSame('2020-02-15', $booking->date);
$this->assertSame('Test company', $booking->person_or_company);
$this->assertSame('Test note', $booking->note);
$this->assertCount(1, $booking->subBookings);
$this->assertDatabaseHas('sub_bookings', [
'booking_id' => $booking->id, 'amount' => 125.50, 'reference' => 'Membership payment',
'account_type_id' => $this->incomeAccount->id, 'project_id' => $this->project->id,
]);
}
public function test_booking_forms_render_with_account_project_and_target_choices()
{
$target = $this->target();
$booking = $this->booking('2020-01-15', 100, null, ['person_target_id' => $target->id]);
$this->actingAs($this->user)->get('/bookings/create')->assertStatus(200)
->assertSee($this->project->name)->assertSee($this->incomeAccount->name);
// A fully paid target must still be selectable when editing its booking.
$this->get('/bookings/' . $booking->id . '/edit')->assertStatus(200)
->assertSee($target->reference)->assertSee($booking->reference);
$this->get('/bookings/' . $booking->id . '/delete')->assertStatus(200);
}
/** @dataProvider invalidBookings */
public function test_invalid_bookings_do_not_write_any_records($field, $value)
{
$this->actingAs($this->user)->from('/bookings/create')
->post('/bookings', $this->payload([$field => $value]))
->assertRedirect('/bookings/create')->assertSessionHasErrors($field);
$this->assertSame(0, Booking::count());
$this->assertSame(0, SubBooking::count());
}
public function invalidBookings()
{
return [
['date', ''], ['reference', ''], ['person_or_company', ''],
['amount', ''], ['amount', 'not money'], ['amount', '-10'], ['account_type_id', ''],
];
}
public function test_booking_can_pay_an_existing_target()
{
$target = $this->target(125.50);
$this->actingAs($this->user)->post('/bookings', $this->payload(['person_target_id' => $target->id]))
->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
$this->assertDatabaseHas('sub_bookings', ['person_target_id' => $target->id, 'amount' => 125.50]);
$this->assertTrue($target->fresh()->isPaid());
$this->assertSame(1, PersonTarget::count());
}
public function test_booking_can_create_a_target_using_the_booking_date_by_default()
{
$person = $this->target()->person;
$this->actingAs($this->user)->post('/bookings', $this->payload([
'personTarget' => ['person_id' => $person->id, 'date' => null],
]))->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
$target = SubBooking::firstOrFail()->personTarget;
$this->assertSame($person->id, $target->person_id);
$this->assertSame('2020-02-15', $target->date);
$this->assertEquals(125.50, $target->amount);
$this->assertTrue($target->isPaid());
}
public function test_user_cannot_choose_an_existing_target_and_create_one_together()
{
$target = $this->target();
$this->actingAs($this->user)->post('/bookings', $this->payload([
'person_target_id' => $target->id,
'personTarget' => ['person_id' => $target->person_id, 'date' => '2020-01-01'],
]))->assertSessionHasErrors('person_target_id');
$this->assertSame(0, Booking::count());
$this->assertSame(1, PersonTarget::count());
}
public function test_user_can_update_booking_and_split_it_into_income_and_expense_lines()
{
$booking = $this->booking('2019-12-31', 100);
$line = $booking->subBookings->first();
$this->actingAs($this->user)->patch('/bookings/' . $booking->id, [
'booking' => ['date' => '2020-01-15', 'reference' => 'Corrected', 'person_or_company' => 'Updated company'],
'sub_booking' => [
$line->id => ['reference' => 'Income', 'amount' => '120.50', 'account_type_id' => $this->incomeAccount->id],
0 => ['reference' => 'Fee', 'amount' => '20.25', 'account_type_id' => $this->expenseAccount->id],
],
])->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
$booking = $booking->fresh();
$this->assertSame('Corrected', $booking->reference);
$this->assertSame('2020-01-15', $booking->date);
$this->assertSame('Updated company', $booking->person_or_company);
$this->assertCount(2, $booking->subBookings);
$this->assertEquals(100.25, $booking->signedAmount());
$this->assertDatabaseHas('sub_bookings', ['id' => $line->id, 'amount' => 120.50]);
$this->assertDatabaseHas('sub_bookings', ['booking_id' => $booking->id, 'reference' => 'Fee', 'amount' => 20.25]);
}
public function test_invalid_update_leaves_booking_and_lines_unchanged()
{
$booking = $this->booking('2020-01-15', 100);
$line = $booking->subBookings->first();
$this->actingAs($this->user)->patch('/bookings/' . $booking->id, [
'booking' => ['date' => '2020-02-01', 'reference' => 'Changed', 'person_or_company' => 'Company'],
'sub_booking' => [$line->id => ['reference' => 'Invalid', 'amount' => -1, 'account_type_id' => $this->incomeAccount->id]],
])->assertSessionHasErrors('sub_booking.' . $line->id . '.amount');
$this->assertSame('2020-01-15', $booking->fresh()->date);
$this->assertEquals(100, $line->fresh()->amount);
}
public function test_empty_line_is_removed_while_other_lines_are_kept()
{
$booking = $this->booking('2020-01-15', 100);
$line = $booking->subBookings->first();
$this->actingAs($this->user)->patch('/bookings/' . $booking->id, [
'booking' => ['date' => $booking->date, 'reference' => $booking->reference, 'person_or_company' => $booking->person_or_company],
'sub_booking' => [
$line->id => ['reference' => null, 'amount' => null, 'account_type_id' => null],
0 => ['reference' => 'Replacement', 'amount' => 75, 'account_type_id' => $this->incomeAccount->id],
],
])->assertRedirect('/bookings/year-2020')->assertSessionHasNoErrors();
$this->assertDatabaseMissing('sub_bookings', ['id' => $line->id]);
$this->assertCount(1, $booking->fresh()->subBookings);
$this->assertEquals(75, $booking->fresh()->signedAmount());
}
public function test_deleting_booking_cascades_to_its_lines_but_keeps_target_and_other_bookings()
{
$target = $this->target();
$booking = $this->booking('2020-01-15', 100, null, ['person_target_id' => $target->id]);
$other = $this->booking('2020-02-15', 50);
$this->actingAs($this->user)->delete('/bookings/' . $booking->id, ['date' => $booking->date])
->assertRedirect('/bookings/year-2020');
$this->assertDatabaseMissing('bookings', ['id' => $booking->id]);
$this->assertDatabaseMissing('sub_bookings', ['booking_id' => $booking->id]);
$this->assertDatabaseHas('bookings', ['id' => $other->id]);
$this->assertDatabaseHas('person_targets', ['id' => $target->id]);
$this->assertEquals(100, $target->fresh()->difference());
}
public function test_clearing_all_lines_redirects_to_delete_confirmation_without_deleting()
{
$booking = $this->booking('2020-01-15', 100);
$line = $booking->subBookings->first();
$this->actingAs($this->user)->patch('/bookings/' . $booking->id, [
'booking' => ['date' => $booking->date, 'reference' => $booking->reference, 'person_or_company' => $booking->person_or_company],
'sub_booking' => [$line->id => ['reference' => null, 'amount' => null, 'account_type_id' => null]],
])->assertRedirect('/bookings/' . $booking->id . '/delete')->assertSessionHasNoErrors();
$this->assertDatabaseHas('bookings', ['id' => $booking->id]);
$this->assertDatabaseHas('sub_bookings', ['id' => $line->id, 'amount' => 100]);
}
public function test_unknown_booking_returns_not_found()
{
$this->actingAs($this->user)->get('/bookings/999999/edit')->assertStatus(404);
}
}
+1 -1
View File
@@ -16,6 +16,6 @@ class ExampleTest extends TestCase
{ {
$response = $this->get('/'); $response = $this->get('/');
$response->assertStatus(200); $response->assertRedirect('/bookings');
} }
} }
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace Tests\Feature;
use App\PersonTarget;
use Tests\DatabaseTestCase;
class PersonTargetsTest extends DatabaseTestCase
{
/** @dataProvider paymentStates */
public function test_payment_states_and_outstanding_balances($expense, $payment, $difference, $state)
{
$account = $expense ? $this->expenseAccount : $this->incomeAccount;
$target = $this->target(100, $account);
if ($payment > 0) {
$this->booking('2020-01-15', $payment, $account, ['person_target_id' => $target->id]);
}
$this->assertEquals($difference, $target->difference());
$this->assertSame($state === 'paid', $target->isPaid());
$this->assertTrue(PersonTarget::$state()->contains('id', $target->id));
$this->assertSame($state !== 'paid', PersonTarget::open()->contains('id', $target->id));
$this->assertSame($difference > 0, PersonTarget::receivable()->contains('id', $target->id));
$this->assertSame($difference < 0, PersonTarget::debt()->contains('id', $target->id));
}
public function paymentStates()
{
return [
'unpaid income' => [false, 0, 100, 'underpaid'],
'partial income' => [false, 40, 60, 'underpaid'],
'paid income' => [false, 100, 0, 'paid'],
'overpaid income' => [false, 120, -20, 'overpaid'],
'unpaid expense' => [true, 0, -100, 'underpaid'],
'partial expense' => [true, 40, -60, 'underpaid'],
'paid expense' => [true, 100, 0, 'paid'],
'overpaid expense' => [true, 120, 20, 'overpaid'],
];
}
public function test_multiple_payments_accumulate_and_targets_page_displays_remaining_total()
{
$receivable = $this->target(100);
$payable = $this->target(80, $this->expenseAccount);
$paid = $this->target(25);
$this->booking('2020-01-10', 30, null, ['person_target_id' => $receivable->id]);
$this->booking('2020-01-20', 20, null, ['person_target_id' => $receivable->id]);
$this->booking('2020-01-20', 15, $this->expenseAccount, ['person_target_id' => $payable->id]);
$this->booking('2020-01-20', 25, null, ['person_target_id' => $paid->id]);
$response = $this->actingAs($this->user)->get('/targets')->assertStatus(200);
$data = $response->original->getData();
$this->assertEquals(['receivables' => 50, 'debts' => -65], $data['sums']);
$this->assertSame([$receivable->id], $data['personTargets']['receivables']->pluck('id')->all());
$this->assertSame([$payable->id], $data['personTargets']['debts']->pluck('id')->all());
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace Tests\Feature;
use App\SubBooking;
use Tests\DatabaseTestCase;
class ReportsTest extends DatabaseTestCase
{
protected function seedCashFlows()
{
$this->booking('2019-12-31', 100);
$this->booking('2020-01-01', 50.25);
$this->booking('2020-01-31', 20.10, $this->expenseAccount);
$this->booking('2020-03-01', 10, $this->expenseAccount);
$this->booking('2020-12-31', 5.50);
$this->booking('2021-01-01', 999);
$this->actingAs($this->user);
}
public function test_balance_includes_cutoff_day_and_excludes_future_bookings()
{
$this->seedCashFlows();
$this->assertEquals(0, SubBooking::balanceAt('2019-12-30'));
$this->assertEquals(100, SubBooking::balanceAt('2019-12-31'));
$this->assertEquals(130.15, SubBooking::balanceAt('2020-01-31'), '', 0.001);
$this->assertEquals(125.65, SubBooking::balanceAt('2020-12-31'), '', 0.001);
}
public function test_interval_totals_include_both_endpoints_and_sign_expenses()
{
$this->seedCashFlows();
$this->assertEquals(55.75, SubBooking::incomesInInterval('2020-01-01', '2020-12-31')->first()->incomes, '', 0.001);
$this->assertEquals(-30.10, SubBooking::expedituresInInterval('2020-01-01', '2020-12-31')->first()->expeditures, '', 0.001);
}
public function test_monthly_totals_fill_empty_months_across_a_year_boundary()
{
$this->booking('2019-12-31', 10);
$this->booking('2020-02-01', 25);
$months = SubBooking::incomesInInterval('2019-12-01', '2020-02-29', true);
$this->assertSame([[2019, 12], [2020, 1], [2020, 2]], $months->map(function ($month) {
return [(int) $month->year, (int) $month->month];
})->all());
$this->assertEquals([10, 0, 25], $months->pluck('incomes')->all());
}
public function test_monthly_balance_report_carries_opening_balance_through_empty_months()
{
$this->seedCashFlows();
$response = $this->get('/reports/balances/2020')->assertStatus(200)->assertViewIs('reports.balances');
$data = $response->original->getData();
$this->assertEquals(100, $data['startBalance']);
$balances = $data['balances'];
$this->assertCount(12, $balances);
$this->assertEquals(50.25, $balances[0]->incomes);
$this->assertEquals(-20.10, $balances[0]->expeditures, '', 0.001);
$this->assertEquals(30.15, $balances[0]->monthBalance, '', 0.001);
$this->assertEquals(130.15, $balances[0]->accountBalance, '', 0.001);
$this->assertEquals(0, $balances[1]->monthBalance);
$this->assertEquals(130.15, $balances[1]->accountBalance, '', 0.001);
$this->assertEquals(120.15, $balances[2]->accountBalance, '', 0.001);
$this->assertEquals(125.65, $balances[11]->accountBalance, '', 0.001);
$this->assertSame('01.01.2021', $balances[11]->date);
}
/** @dataProvider cashbookPaths */
public function test_cashbook_and_print_view_filter_year_and_show_correct_totals($path)
{
$this->seedCashFlows();
$response = $this->get($path)->assertStatus(200);
$data = $response->original->getData();
$this->assertCount(4, $data['bookings']->flatten());
$this->assertEquals(100, $data['sums'][0]['balance']);
$this->assertEquals(-20.10, $data['sums'][1]['expeditures'], '', 0.001);
$this->assertEquals(150.25, $data['sums'][1]['incomes'], '', 0.001);
$this->assertEquals(130.15, $data['sums'][1]['balance'], '', 0.001);
$this->assertEquals(130.15, $data['sums'][2]['balance'], '', 0.001);
$this->assertEquals(125.65, $data['sums'][12]['balance'], '', 0.001);
}
public function cashbookPaths()
{
return [['/bookings/year-2020'], ['/bookings/year-2020/print']];
}
/** @dataProvider compactReportPaths */
public function test_scope_report_totals_match_cashbook_and_exclude_other_years($path)
{
$this->seedCashFlows();
$response = $this->get($path)->assertStatus(200);
$data = $response->original->getData();
$this->assertEquals(100, $data['startBalance']);
$this->assertEquals(25.65, $data['scopes']->sum('balance'), '', 0.001);
$this->assertEquals(125.65, $data['endBalance'], '', 0.001);
}
public function compactReportPaths()
{
return [['/reports/scopes/compact/2020'], ['/reports/scopes/compact/2020/print']];
}
public function test_project_report_excludes_other_projects_and_orders_bookings_by_date()
{
$late = $this->booking('2020-03-01', 80);
$early = $this->booking('2020-01-01', 20, $this->expenseAccount);
$this->booking('2020-02-01', 999, null, ['project_id' => null]);
$response = $this->actingAs($this->user)->get('/reports/projects/' . $this->project->id)
->assertStatus(200)->assertSee($this->project->name);
$data = $response->original->getData();
$this->assertEquals(80, $data['incomes']);
$this->assertEquals(20, $data['expeditures']);
$this->assertSame([$early->id, $late->id], $data['projectBookings']->pluck('booking_id')->values()->all());
}
public function test_reports_render_with_an_empty_database()
{
$this->actingAs($this->user);
$response = $this->get('/reports/balances/2020')->assertStatus(200);
$data = $response->original->getData();
$this->assertEquals(0, $data['startBalance']);
$this->assertCount(12, $data['balances']);
foreach ($data['balances'] as $balance) {
$this->assertEquals(0, $balance->accountBalance);
}
$this->get('/reports/scopes/compact/2020')->assertStatus(200)->assertViewHas('endBalance', 0);
$this->get('/bookings/year-2020')->assertStatus(200);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace Tests\Unit;
use App\AccountClass;
use App\AccountType;
use App\Booking;
use App\IOType;
use App\SubBooking;
use Illuminate\Database\Eloquent\Collection;
use PHPUnit\Framework\TestCase;
class BookingAmountsTest extends TestCase
{
public function test_split_booking_uses_account_signs_and_preserves_unsigned_total()
{
$income = $this->line(125.50, 1);
$expense = $this->line(25.25, -1);
$booking = new Booking;
$booking->setRelation('subBookings', new Collection([$income, $expense]));
$this->assertEquals(125.50, $income->signedAmount());
$this->assertEquals(-25.25, $expense->signedAmount());
$this->assertEquals(150.75, $booking->amount());
$this->assertEquals(100.25, $booking->signedAmount());
$this->assertFalse($booking->isSimple());
$this->assertNull($booking->accountType());
$this->assertNull($booking->project());
$this->assertNull($booking->personTarget());
}
protected function line($amount, $factor)
{
$io = (new IOType)->forceFill(['factor' => $factor]);
$class = (new AccountClass)->setRelation('ioType', $io);
$account = (new AccountType)->setRelation('accountClass', $class);
return (new SubBooking)->forceFill(['amount' => $amount])->setRelation('accountType', $account);
}
}
-19
View File
@@ -1,19 +0,0 @@
<?php
namespace Tests\Unit;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ExampleTest extends TestCase
{
/**
* A basic test example.
*
* @return void
*/
public function testBasicTest()
{
$this->assertTrue(true);
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
// Match the HTTP entry point: the shared footer displays elapsed request time.
define('LARAVEL_START', microtime(true));
require __DIR__.'/../vendor/autoload.php';