release/v0.8.0 #49

Merged
myrmidex merged 10 commits from release/v0.8.0 into main 2026-08-17 23:05:19 +02:00
143 changed files with 12766 additions and 609 deletions

25
.env.testing Normal file
View file

@ -0,0 +1,25 @@
APP_NAME=DishPlanner
APP_ENV=testing
APP_KEY=base64:2ADW3imsmRMo+UDw3GR6852wQu37/aNK1ooxEdaJIC0=
APP_DEBUG=true
APP_URL=http://localhost
APP_MAINTENANCE_DRIVER=file
BCRYPT_ROUNDS=4
LOG_CHANNEL=stack
LOG_STACK=single
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
SESSION_DRIVER=array
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
CACHE_STORE=array
MAIL_MAILER=array

View file

@ -0,0 +1,42 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
tags: ['v*']
jobs:
build:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up Docker Buildx
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
- name: Login to Forgejo Registry
uses: https://data.forgejo.org/docker/login-action@v3
with:
registry: forge.lvl0.xyz
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Determine tags
id: meta
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
echo "tags=forge.lvl0.xyz/lvl0/dishplanner:${TAG},forge.lvl0.xyz/lvl0/dishplanner:latest" >> $GITHUB_OUTPUT
else
echo "tags=forge.lvl0.xyz/lvl0/dishplanner:latest" >> $GITHUB_OUTPUT
fi
- name: Build and push
uses: https://data.forgejo.org/docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}

32
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,32 @@
name: CI
on:
push:
branches: ['release/*']
pull_request:
branches: [main, 'release/*']
jobs:
ci:
runs-on: docker
container:
image: forge.lvl0.xyz/lvl0/dishplanner-ci:php8.3-4
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Prepare environment
run: cp .env.testing .env
- name: Restore dependencies
run: |
cp -a /opt/deps/vendor ./vendor
composer install --no-interaction --no-progress --prefer-source
- name: Lint
run: vendor/bin/pint --test
- name: Static analysis
run: vendor/bin/phpstan analyse --memory-limit=1G
- name: Tests
run: php -d memory_limit=512M vendor/bin/phpunit

View file

@ -0,0 +1,46 @@
name: Build and Push Base Images
on:
push:
branches: [main]
paths:
- 'docker/build/**'
- 'composer.json'
- 'composer.lock'
- '.forgejo/workflows/images.yml'
workflow_dispatch:
jobs:
images:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
strategy:
matrix:
include:
- name: dishplanner-ci
file: docker/build/Dockerfile.ci
version: php8.3-4
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Set up Docker Buildx
uses: https://data.forgejo.org/docker/setup-buildx-action@v3
- name: Login to Forgejo Registry
uses: https://data.forgejo.org/docker/login-action@v3
with:
registry: forge.lvl0.xyz
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: https://data.forgejo.org/docker/build-push-action@v5
with:
context: .
file: ${{ matrix.file }}
push: true
tags: |
forge.lvl0.xyz/lvl0/${{ matrix.name }}:${{ matrix.version }}
forge.lvl0.xyz/lvl0/${{ matrix.name }}:latest
forge.lvl0.xyz/lvl0/${{ matrix.name }}:${{ github.sha }}

2
.gitattributes vendored
View file

@ -6,6 +6,4 @@
*.md diff=markdown *.md diff=markdown
*.php diff=php *.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore CHANGELOG.md export-ignore
.styleci.yml export-ignore

1
.gitignore vendored
View file

@ -1,4 +1,3 @@
/composer.lock
/.phpunit.cache /.phpunit.cache
/coverage /coverage
/node_modules /node_modules

View file

@ -5,6 +5,30 @@ # Changelog
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.8.0] - 2026-08-17
### Added
- **Forgejo CI pipeline** (#45) — added a CI workflow that runs Laravel Pint code-style checks, PHPStan static analysis, and the PHPUnit suite against SQLite in memory.
- **Static analysis** — added PHPStan via `larastan/larastan` and `phpstan/phpstan-mockery`, with `phpstan.neon` and a baseline.
- **Code style** — added Laravel Pint configuration (`pint.json`).
- **CI and app image build workflows** (#46) — automated Docker image builds for the app and the CI base image, published to the Forgejo registry.
- **CI base image** (#46) — added `docker/build/Dockerfile.ci`, a Debian-based PHP 8.3 CLI image with the project's PHP dependencies pre-loaded so CI doesn't pay a per-run Composer install.
- **Contributing guide** (#47) — added `CONTRIBUTING.md`.
- **Nix shell completion** (#44) — finished the `nix-shell` development commands.
### Changed
- **Dependencies**`composer.lock` is now committed to the repository.
- **Code style** — reformatted the codebase with Laravel Pint.
- **Documentation** (#47) — professionalized the `README.md`.
- **Git hygiene** — dropped stale `.github`/StyleCI export rules from `.gitattributes`.
### Fixed
- **CI dependency installation** (#46, #50) — retried `composer install` on transient HTTP 429s, switched to `--prefer-source` and pre-loaded dependencies to avoid `codeload.github.com` rate limits, and added the missing `zip` extension to the CI image.
- **Test suite** (#50) — fixed feature tests failing on a missing Vite manifest by disabling Vite resolution during tests.
## [0.7.0] - 2026-08-17 ## [0.7.0] - 2026-08-17
### Removed ### Removed

97
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,97 @@
# Contributing
Thanks for your interest in DishPlanner. It's a small self-hosted project —
issues and pull requests are both welcome.
## Reporting issues
Use [Issues](https://forge.lvl0.xyz/lvl0/dishplanner/issues).
For bugs, include what you expected, what happened, and enough detail to
reproduce it. Relevant log output helps; `dev-logs` follows the application log.
## Development setup
Requires PHP 8.2+ and a container runtime (Podman or Docker). The development
environment runs in containers defined by `docker-compose.yml`.
On NixOS, or anywhere with Nix installed:
```bash
git clone https://forge.lvl0.xyz/lvl0/dishplanner.git
cd dishplanner
nix-shell
```
The shell prints the available commands on entry and can start the containers
for you:
| Command | Description |
|---------|-------------|
| `dev-up` | Start the development environment |
| `dev-down` | Stop the development environment |
| `dev-restart` | Restart the containers |
| `dev-rebuild` | Full rebuild (removes volumes) |
| `dev-rebuild-quick` | Quick rebuild (keeps volumes) |
| `dev-logs [service]` | Follow logs |
| `dev-logs-db` | Tail database logs |
| `dev-shell` | Enter the app container |
| `dev-artisan <cmd>` | Run an artisan command |
| `dev-test [path]` | Run the PHPUnit suite the CI way |
| `dev-fix-permissions` | Fix Docker-created file permissions |
Once running:
| Service | URL |
|---------|-----|
| App | http://localhost:8000 |
| Vite | http://localhost:5173 |
| Mailhog | http://localhost:8025 |
| MariaDB | localhost:3306 |
Without Nix, start the same containers directly from `docker-compose.yml`.
Contributions improving the setup instructions for other platforms are welcome.
## Before opening a pull request
Three checks run in CI, and all three must pass. Run them locally first, from
inside the app container or anywhere the project's dependencies are available:
```bash
vendor/bin/pint --test # code style, Laravel preset
vendor/bin/phpstan analyse --memory-limit=1G # static analysis, level 7
php -d memory_limit=512M vendor/bin/phpunit # PHPUnit on SQLite in memory
```
Or run the test suite the CI way with `dev-test`.
Some conventions:
- **Static analysis.** PHPStan runs at level 7 with a baseline
(`phpstan-baseline.neon`) covering pre-existing findings. Don't add baseline
entries to silence errors in code you're writing — fix the cause instead. The
baseline is for cases where the analyzer or an upstream docblock is wrong,
not real bugs.
- **Tests.** New behaviour needs a test. Tests run against SQLite in memory and
must not reach for the network.
- **Dependencies.** `composer.lock` is committed. If you change dependencies,
commit the updated lockfile alongside `composer.json`.
## Commits
One commit does one thing. Keep each commit passing all three checks so history
stays bisectable. Separate renames from behaviour changes, and mechanical edits
from logic.
Commit messages are a single line, referencing the ticket they belong to:
```
45 - Add Forgejo CI workflow
```
No body, no trailers.
## License
By contributing, you agree that your contributions are licensed under the
[GNU AGPL-3.0](LICENSE.md), the same license as the project.

View file

@ -1,5 +1,8 @@
# 🍽️ Dish Planner # 🍽️ Dish Planner
[![CI](https://forge.lvl0.xyz/lvl0/dishplanner/badges/workflows/ci.yml/badge.svg)](https://forge.lvl0.xyz/lvl0/dishplanner/actions)
[![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE.md)
A Laravel-based meal planning application that helps households organize and schedule their dishes among multiple users. Built with Laravel, Livewire, and FrankenPHP for a modern, single-container deployment. A Laravel-based meal planning application that helps households organize and schedule their dishes among multiple users. Built with Laravel, Livewire, and FrankenPHP for a modern, single-container deployment.
## ✨ Features ## ✨ Features
@ -13,7 +16,7 @@ ## ✨ Features
## 🚀 Self-hosting ## 🚀 Self-hosting
The production image is available at `forge.lvl0.xyz/lvl0/dishplanner:latest`. The production image is available at `forge.lvl0.xyz/lvl0/dishplanner:latest`. See [CHANGELOG.md](CHANGELOG.md) before upgrading.
### docker-compose.yml ### docker-compose.yml
@ -102,8 +105,10 @@ #### Available Commands
| `dev-rebuild` | Full rebuild (removes volumes) | | `dev-rebuild` | Full rebuild (removes volumes) |
| `dev-rebuild-quick` | Quick rebuild (keeps volumes) | | `dev-rebuild-quick` | Quick rebuild (keeps volumes) |
| `dev-logs [service]` | Follow logs | | `dev-logs [service]` | Follow logs |
| `dev-logs-db` | Tail database logs |
| `dev-shell` | Enter app container | | `dev-shell` | Enter app container |
| `dev-artisan <cmd>` | Run artisan commands | | `dev-artisan <cmd>` | Run artisan commands |
| `dev-test [path]` | Run the PHPUnit suite the CI way |
| `dev-fix-permissions` | Fix Docker-created file permissions | | `dev-fix-permissions` | Fix Docker-created file permissions |
#### Services #### Services
@ -119,6 +124,11 @@ ### Other Platforms
Contributions welcome for development setup instructions on other platforms. Contributions welcome for development setup instructions on other platforms.
## 🤝 Contributing
Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for
the development setup, the checks that run in CI, and the commit conventions.
## 📄 License ## 📄 License
This project is open-source software licensed under the [AGPL-3.0 license](LICENSE.md). This project is open-source software licensed under the [AGPL-3.0 license](LICENSE.md).

View file

@ -17,11 +17,11 @@ public function execute(array $data): User
{ {
try { try {
// Validate required fields first // Validate required fields first
if (!isset($data['name']) || empty($data['name'])) { if (! isset($data['name']) || empty($data['name'])) {
throw new InvalidArgumentException('Name is required'); throw new InvalidArgumentException('Name is required');
} }
if (!isset($data['planner_id']) || empty($data['planner_id'])) { if (! isset($data['planner_id']) || empty($data['planner_id'])) {
throw new InvalidArgumentException('Planner ID is required'); throw new InvalidArgumentException('Planner ID is required');
} }
@ -38,7 +38,7 @@ public function execute(array $data): User
'planner_id' => $data['planner_id'], 'planner_id' => $data['planner_id'],
]); ]);
if (!$user) { if (! $user) {
throw new Exception('User creation returned null'); throw new Exception('User creation returned null');
} }
@ -50,7 +50,7 @@ public function execute(array $data): User
// Verify the user was actually created // Verify the user was actually created
$createdUser = User::find($user->id); $createdUser = User::find($user->id);
if (!$createdUser) { if (! $createdUser) {
throw new Exception('User creation did not persist to database'); throw new Exception('User creation did not persist to database');
} }

View file

@ -12,52 +12,52 @@ public function execute(User $user, array $data): bool
{ {
try { try {
DB::beginTransaction(); DB::beginTransaction();
Log::info('EditUserAction: Starting user update', [ Log::info('EditUserAction: Starting user update', [
'user_id' => $user->id, 'user_id' => $user->id,
'old_name' => $user->name, 'old_name' => $user->name,
'new_name' => $data['name'], 'new_name' => $data['name'],
'planner_id' => $user->planner_id, 'planner_id' => $user->planner_id,
]); ]);
$result = $user->update([ $result = $user->update([
'name' => $data['name'], 'name' => $data['name'],
]); ]);
Log::info('EditUserAction: Update result', [ Log::info('EditUserAction: Update result', [
'result' => $result, 'result' => $result,
'user_id' => $user->id, 'user_id' => $user->id,
]); ]);
if (!$result) { if (! $result) {
throw new \Exception('User update returned false'); throw new \Exception('User update returned false');
} }
// Verify the update actually happened // Verify the update actually happened
$user->refresh(); $user->refresh();
if ($user->name !== $data['name']) { if ($user->name !== $data['name']) {
throw new \Exception('User update did not persist to database'); throw new \Exception('User update did not persist to database');
} }
DB::commit(); DB::commit();
Log::info('EditUserAction: User successfully updated', [ Log::info('EditUserAction: User successfully updated', [
'user_id' => $user->id, 'user_id' => $user->id,
'updated_name' => $user->name, 'updated_name' => $user->name,
]); ]);
return true; return true;
} catch (\Exception $e) { } catch (\Exception $e) {
DB::rollBack(); DB::rollBack();
Log::error('EditUserAction: User update failed', [ Log::error('EditUserAction: User update failed', [
'user_id' => $user->id, 'user_id' => $user->id,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(), 'trace' => $e->getTraceAsString(),
]); ]);
throw $e; throw $e;
} }
} }
} }

View file

@ -24,6 +24,7 @@ public function handle(): int
if ($planners->isEmpty()) { if ($planners->isEmpty()) {
$this->warn('No planners found. Aborting schedule generation.'); $this->warn('No planners found. Aborting schedule generation.');
return self::FAILURE; return self::FAILURE;
} }

View file

@ -13,8 +13,7 @@ public function response(
?array $payload = null, ?array $payload = null,
array|string|null $errors = null, array|string|null $errors = null,
int $statusCode = 200, int $statusCode = 200,
): JsonResponse ): JsonResponse {
{
return response()->json(resolve(OutputService::class)->response($success, $payload, $errors), $statusCode); return response()->json(resolve(OutputService::class)->response($success, $payload, $errors), $statusCode);
} }

View file

@ -41,4 +41,4 @@ public function logout(Request $request)
return redirect('/'); return redirect('/');
} }
} }

View file

@ -34,4 +34,4 @@ public function register(Request $request)
return redirect(route('dashboard')); return redirect(route('dashboard'));
} }
} }

View file

@ -17,7 +17,7 @@ public function toArray(Request $request): array
'recurrences' => $this->recurrences->map(fn ($recurrence) => [ 'recurrences' => $this->recurrences->map(fn ($recurrence) => [
'id' => $recurrence->id, 'id' => $recurrence->id,
'type' => $recurrence->recurrence_type, 'type' => $recurrence->recurrence_type,
'value' => $recurrence->getValue() 'value' => $recurrence->getValue(),
]), ]),
]; ];
} }

View file

@ -12,16 +12,20 @@ class DishesList extends Component
use WithPagination; use WithPagination;
public $showCreateModal = false; public $showCreateModal = false;
public $showEditModal = false; public $showEditModal = false;
public $showDeleteModal = false; public $showDeleteModal = false;
public $editingDish = null; public $editingDish = null;
public $deletingDish = null; public $deletingDish = null;
// Form fields // Form fields
public $name = ''; public $name = '';
public $selectedUsers = []; public $selectedUsers = [];
protected $rules = [ protected $rules = [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'selectedUsers' => 'array', 'selectedUsers' => 'array',
@ -32,14 +36,14 @@ public function render()
$dishes = Dish::with('users') $dishes = Dish::with('users')
->orderBy('name') ->orderBy('name')
->paginate(10); ->paginate(10);
$users = User::where('planner_id', auth()->id()) $users = User::where('planner_id', auth()->id())
->orderBy('name') ->orderBy('name')
->get(); ->get();
return view('livewire.dishes.dishes-list', [ return view('livewire.dishes.dishes-list', [
'dishes' => $dishes, 'dishes' => $dishes,
'users' => $users 'users' => $users,
]); ]);
} }
@ -60,13 +64,13 @@ public function store()
]); ]);
// Attach selected users // Attach selected users
if (!empty($this->selectedUsers)) { if (! empty($this->selectedUsers)) {
$dish->users()->attach($this->selectedUsers); $dish->users()->attach($this->selectedUsers);
} }
$this->showCreateModal = false; $this->showCreateModal = false;
$this->reset(['name', 'selectedUsers']); $this->reset(['name', 'selectedUsers']);
session()->flash('success', 'Dish created successfully.'); session()->flash('success', 'Dish created successfully.');
} }
@ -86,13 +90,13 @@ public function update()
$this->editingDish->update([ $this->editingDish->update([
'name' => $this->name, 'name' => $this->name,
]); ]);
// Sync users // Sync users
$this->editingDish->users()->sync($this->selectedUsers); $this->editingDish->users()->sync($this->selectedUsers);
$this->showEditModal = false; $this->showEditModal = false;
$this->reset(['name', 'selectedUsers', 'editingDish']); $this->reset(['name', 'selectedUsers', 'editingDish']);
session()->flash('success', 'Dish updated successfully.'); session()->flash('success', 'Dish updated successfully.');
} }
@ -108,7 +112,7 @@ public function delete()
$this->deletingDish->delete(); $this->deletingDish->delete();
$this->showDeleteModal = false; $this->showDeleteModal = false;
$this->deletingDish = null; $this->deletingDish = null;
session()->flash('success', 'Dish deleted successfully.'); session()->flash('success', 'Dish deleted successfully.');
} }
@ -126,7 +130,7 @@ public function toggleAllUsers(): void
if (count($this->selectedUsers) === $users->count()) { if (count($this->selectedUsers) === $users->count()) {
$this->selectedUsers = []; $this->selectedUsers = [];
} else { } else {
$this->selectedUsers = $users->pluck('id')->map(fn($id) => (string) $id)->toArray(); $this->selectedUsers = $users->pluck('id')->map(fn ($id) => (string) $id)->toArray();
} }
} }
} }

View file

@ -19,25 +19,39 @@
class ScheduleCalendar extends Component class ScheduleCalendar extends Component
{ {
public $currentMonth; public $currentMonth;
public $currentYear; public $currentYear;
public $calendarDays = []; public $calendarDays = [];
public $showRegenerateModal = false; public $showRegenerateModal = false;
public $regenerateDate = null; public $regenerateDate = null;
public $regenerateUserId = null; public $regenerateUserId = null;
// Edit dish modal // Edit dish modal
public $showEditDishModal = false; public $showEditDishModal = false;
public $editDate = null; public $editDate = null;
public $editUserId = null; public $editUserId = null;
public $selectedDishId = null; public $selectedDishId = null;
public $availableDishes = []; public $availableDishes = [];
// Add dish modal // Add dish modal
public $showAddDishModal = false; public $showAddDishModal = false;
public $addDate = null; public $addDate = null;
public $addUserIds = []; public $addUserIds = [];
public $addSelectedDishId = null; public $addSelectedDishId = null;
public $addAvailableUsers = []; public $addAvailableUsers = [];
public $addAvailableDishes = []; public $addAvailableDishes = [];
public function mount(): void public function mount(): void
@ -61,7 +75,7 @@ public function refreshCalendar(): void
public function loadCalendar(): void public function loadCalendar(): void
{ {
$service = new ScheduleCalendarService(); $service = new ScheduleCalendarService;
$this->calendarDays = $service->getCalendarDays( $this->calendarDays = $service->getCalendarDays(
auth()->user(), auth()->user(),
$this->currentMonth, $this->currentMonth,
@ -93,8 +107,9 @@ public function nextMonth(): void
public function regenerateForUserDate($date, $userId): void public function regenerateForUserDate($date, $userId): void
{ {
if (!$this->authorizeUser($userId)) { if (! $this->authorizeUser($userId)) {
session()->flash('error', 'Unauthorized action.'); session()->flash('error', 'Unauthorized action.');
return; return;
} }
@ -106,12 +121,13 @@ public function regenerateForUserDate($date, $userId): void
public function confirmRegenerate(): void public function confirmRegenerate(): void
{ {
try { try {
if (!$this->authorizeUser($this->regenerateUserId)) { if (! $this->authorizeUser($this->regenerateUserId)) {
session()->flash('error', 'Unauthorized action.'); session()->flash('error', 'Unauthorized action.');
return; return;
} }
$action = new DeleteScheduledUserDishForDateAction(); $action = new DeleteScheduledUserDishForDateAction;
$action->execute( $action->execute(
auth()->user(), auth()->user(),
Carbon::parse($this->regenerateDate), Carbon::parse($this->regenerateDate),
@ -131,12 +147,13 @@ public function confirmRegenerate(): void
public function skipDay($date, $userId): void public function skipDay($date, $userId): void
{ {
try { try {
if (!$this->authorizeUser($userId)) { if (! $this->authorizeUser($userId)) {
session()->flash('error', 'Unauthorized action.'); session()->flash('error', 'Unauthorized action.');
return; return;
} }
$action = new SkipScheduledUserDishForDateAction(); $action = new SkipScheduledUserDishForDateAction;
$action->execute( $action->execute(
auth()->user(), auth()->user(),
Carbon::parse($date), Carbon::parse($date),
@ -155,6 +172,7 @@ public function skipDay($date, $userId): void
private function authorizeUser(int $userId): bool private function authorizeUser(int $userId): bool
{ {
$user = User::find($userId); $user = User::find($userId);
return $user && $user->planner_id === auth()->id(); return $user && $user->planner_id === auth()->id();
} }
@ -179,8 +197,9 @@ public function cancel(): void
public function removeDish($date, $userId): void public function removeDish($date, $userId): void
{ {
try { try {
if (!$this->authorizeUser($userId)) { if (! $this->authorizeUser($userId)) {
session()->flash('error', 'Unauthorized action.'); session()->flash('error', 'Unauthorized action.');
return; return;
} }
@ -223,7 +242,7 @@ public function toggleAllUsers(): void
if (count($this->addUserIds) === count($this->addAvailableUsers)) { if (count($this->addUserIds) === count($this->addAvailableUsers)) {
$this->addUserIds = []; $this->addUserIds = [];
} else { } else {
$this->addUserIds = $this->addAvailableUsers->pluck('id')->map(fn($id) => (string) $id)->toArray(); $this->addUserIds = $this->addAvailableUsers->pluck('id')->map(fn ($id) => (string) $id)->toArray();
} }
$this->updateAvailableDishes(); $this->updateAvailableDishes();
} }
@ -252,11 +271,13 @@ public function saveAddDish(): void
try { try {
if (empty($this->addUserIds)) { if (empty($this->addUserIds)) {
session()->flash('error', 'Please select at least one user.'); session()->flash('error', 'Please select at least one user.');
return; return;
} }
if (!$this->addSelectedDishId) { if (! $this->addSelectedDishId) {
session()->flash('error', 'Please select a dish.'); session()->flash('error', 'Please select a dish.');
return; return;
} }
@ -273,8 +294,9 @@ public function saveAddDish(): void
$skippedCount = 0; $skippedCount = 0;
foreach ($this->addUserIds as $userId) { foreach ($this->addUserIds as $userId) {
if (!$this->authorizeUser((int) $userId)) { if (! $this->authorizeUser((int) $userId)) {
$skippedCount++; $skippedCount++;
continue; continue;
} }
@ -285,6 +307,7 @@ public function saveAddDish(): void
if ($existing) { if ($existing) {
$skippedCount++; $skippedCount++;
continue; continue;
} }
@ -293,8 +316,9 @@ public function saveAddDish(): void
->where('dish_id', $this->addSelectedDishId) ->where('dish_id', $this->addSelectedDishId)
->first(); ->first();
if (!$userDish) { if (! $userDish) {
$skippedCount++; $skippedCount++;
continue; continue;
} }
@ -336,8 +360,9 @@ private function closeAddDishModal(): void
public function editDish($date, $userId): void public function editDish($date, $userId): void
{ {
if (!$this->authorizeUser($userId)) { if (! $this->authorizeUser($userId)) {
session()->flash('error', 'Unauthorized action.'); session()->flash('error', 'Unauthorized action.');
return; return;
} }
@ -370,13 +395,15 @@ public function editDish($date, $userId): void
public function saveDish(): void public function saveDish(): void
{ {
try { try {
if (!$this->authorizeUser($this->editUserId)) { if (! $this->authorizeUser($this->editUserId)) {
session()->flash('error', 'Unauthorized action.'); session()->flash('error', 'Unauthorized action.');
return; return;
} }
if (!$this->selectedDishId) { if (! $this->selectedDishId) {
session()->flash('error', 'Please select a dish.'); session()->flash('error', 'Please select a dish.');
return; return;
} }
@ -394,8 +421,9 @@ public function saveDish(): void
->where('dish_id', $this->selectedDishId) ->where('dish_id', $this->selectedDishId)
->first(); ->first();
if (!$userDish) { if (! $userDish) {
session()->flash('error', 'This dish is not assigned to this user.'); session()->flash('error', 'This dish is not assigned to this user.');
return; return;
} }
@ -427,7 +455,8 @@ public function saveDish(): void
public function getMonthNameProperty(): string public function getMonthNameProperty(): string
{ {
$service = new ScheduleCalendarService(); $service = new ScheduleCalendarService;
return $service->getMonthName($this->currentMonth, $this->currentYear); return $service->getMonthName($this->currentMonth, $this->currentYear);
} }
} }

View file

@ -7,19 +7,27 @@
use DishPlanner\Schedule\Actions\ClearScheduleForMonthAction; use DishPlanner\Schedule\Actions\ClearScheduleForMonthAction;
use DishPlanner\Schedule\Actions\GenerateScheduleForMonthAction; use DishPlanner\Schedule\Actions\GenerateScheduleForMonthAction;
use DishPlanner\Schedule\Actions\RegenerateScheduleForDateForUsersAction; use DishPlanner\Schedule\Actions\RegenerateScheduleForDateForUsersAction;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Livewire\Component; use Livewire\Component;
class ScheduleGenerator extends Component class ScheduleGenerator extends Component
{ {
private const YEARS_IN_PAST = 1; private const YEARS_IN_PAST = 1;
private const YEARS_IN_FUTURE = 5; private const YEARS_IN_FUTURE = 5;
public $selectedMonth; public $selectedMonth;
public $selectedYear; public $selectedYear;
public $selectedUsers = []; public $selectedUsers = [];
public $clearExisting = true; public $clearExisting = true;
public $showAdvancedOptions = false; public $showAdvancedOptions = false;
public $isGenerating = false; public $isGenerating = false;
public function mount(): void public function mount(): void
@ -32,7 +40,7 @@ public function mount(): void
->toArray(); ->toArray();
} }
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\Contracts\View\View public function render(): Factory|View
{ {
$users = User::where('planner_id', auth()->id()) $users = User::where('planner_id', auth()->id())
->orderBy('name') ->orderBy('name')
@ -43,7 +51,7 @@ public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\Contrac
return view('livewire.schedule.schedule-generator', [ return view('livewire.schedule.schedule-generator', [
'users' => $users, 'users' => $users,
'months' => $this->getMonthNames(), 'months' => $this->getMonthNames(),
'years' => $years 'years' => $years,
]); ]);
} }
@ -52,13 +60,13 @@ public function generate(): void
$this->validate([ $this->validate([
'selectedUsers' => 'required|array|min:1', 'selectedUsers' => 'required|array|min:1',
'selectedMonth' => 'required|integer|min:1|max:12', 'selectedMonth' => 'required|integer|min:1|max:12',
'selectedYear' => 'required|integer|min:' . (now()->year - self::YEARS_IN_PAST) . '|max:' . (now()->year + self::YEARS_IN_FUTURE), 'selectedYear' => 'required|integer|min:'.(now()->year - self::YEARS_IN_PAST).'|max:'.(now()->year + self::YEARS_IN_FUTURE),
]); ]);
$this->isGenerating = true; $this->isGenerating = true;
try { try {
$action = new GenerateScheduleForMonthAction(); $action = new GenerateScheduleForMonthAction;
$action->execute( $action->execute(
auth()->user(), auth()->user(),
$this->selectedMonth, $this->selectedMonth,
@ -70,8 +78,8 @@ public function generate(): void
$this->isGenerating = false; $this->isGenerating = false;
$this->dispatch('schedule-generated'); $this->dispatch('schedule-generated');
session()->flash('success', 'Schedule generated successfully for ' . session()->flash('success', 'Schedule generated successfully for '.
$this->getSelectedMonthName() . ' ' . $this->selectedYear); $this->getSelectedMonthName().' '.$this->selectedYear);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->isGenerating = false; $this->isGenerating = false;
@ -83,7 +91,7 @@ public function generate(): void
public function regenerateForDate($date): void public function regenerateForDate($date): void
{ {
try { try {
$action = new RegenerateScheduleForDateForUsersAction(); $action = new RegenerateScheduleForDateForUsersAction;
$action->execute( $action->execute(
auth()->user(), auth()->user(),
Carbon::parse($date), Carbon::parse($date),
@ -91,7 +99,7 @@ public function regenerateForDate($date): void
); );
$this->dispatch('schedule-generated'); $this->dispatch('schedule-generated');
session()->flash('success', 'Schedule regenerated for ' . Carbon::parse($date)->format('M d, Y')); session()->flash('success', 'Schedule regenerated for '.Carbon::parse($date)->format('M d, Y'));
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Schedule regeneration failed', ['exception' => $e, 'date' => $date]); Log::error('Schedule regeneration failed', ['exception' => $e, 'date' => $date]);
@ -102,7 +110,7 @@ public function regenerateForDate($date): void
public function clearMonth(): void public function clearMonth(): void
{ {
try { try {
$action = new ClearScheduleForMonthAction(); $action = new ClearScheduleForMonthAction;
$action->execute( $action->execute(
auth()->user(), auth()->user(),
$this->selectedMonth, $this->selectedMonth,
@ -111,8 +119,8 @@ public function clearMonth(): void
); );
$this->dispatch('schedule-generated'); $this->dispatch('schedule-generated');
session()->flash('success', 'Schedule cleared for ' . session()->flash('success', 'Schedule cleared for '.
$this->getSelectedMonthName() . ' ' . $this->selectedYear); $this->getSelectedMonthName().' '.$this->selectedYear);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Clear month failed', ['exception' => $e]); Log::error('Clear month failed', ['exception' => $e]);
@ -122,7 +130,7 @@ public function clearMonth(): void
public function toggleAdvancedOptions() public function toggleAdvancedOptions()
{ {
$this->showAdvancedOptions = !$this->showAdvancedOptions; $this->showAdvancedOptions = ! $this->showAdvancedOptions;
} }
private function getMonthNames(): array private function getMonthNames(): array
@ -130,7 +138,7 @@ private function getMonthNames(): array
return [ return [
1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April', 1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April',
5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August', 5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August',
9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December' 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December',
]; ];
} }

View file

@ -2,10 +2,10 @@
namespace App\Livewire\Users; namespace App\Livewire\Users;
use App\Models\User;
use App\Actions\User\CreateUserAction; use App\Actions\User\CreateUserAction;
use App\Actions\User\DeleteUserAction; use App\Actions\User\DeleteUserAction;
use App\Actions\User\EditUserAction; use App\Actions\User\EditUserAction;
use App\Models\User;
use Exception; use Exception;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Livewire\Component; use Livewire\Component;
@ -16,10 +16,13 @@ class UsersList extends Component
use WithPagination; use WithPagination;
public bool $showCreateModal = false; public bool $showCreateModal = false;
public bool $showEditModal = false; public bool $showEditModal = false;
public bool $showDeleteModal = false; public bool $showDeleteModal = false;
public ?User $editingUser = null; public ?User $editingUser = null;
public ?User $deletingUser = null; public ?User $deletingUser = null;
// Form fields // Form fields
@ -36,7 +39,7 @@ public function render(): View
->paginate(10); ->paginate(10);
return view('livewire.users.users-list', [ return view('livewire.users.users-list', [
'users' => $users 'users' => $users,
]); ]);
} }
@ -52,7 +55,7 @@ public function store(): void
$this->validate(); $this->validate();
try { try {
(new CreateUserAction())->execute([ (new CreateUserAction)->execute([
'name' => $this->name, 'name' => $this->name,
'planner_id' => auth()->id(), 'planner_id' => auth()->id(),
]); ]);
@ -62,7 +65,7 @@ public function store(): void
session()->flash('success', 'User created successfully.'); session()->flash('success', 'User created successfully.');
} catch (Exception $e) { } catch (Exception $e) {
session()->flash('error', 'Failed to create user: ' . $e->getMessage()); session()->flash('error', 'Failed to create user: '.$e->getMessage());
} }
} }
@ -79,7 +82,7 @@ public function update(): void
$this->validate(); $this->validate();
try { try {
(new EditUserAction())->execute($this->editingUser, ['name' => $this->name]); (new EditUserAction)->execute($this->editingUser, ['name' => $this->name]);
$this->showEditModal = false; $this->showEditModal = false;
$this->reset(['name', 'editingUser']); $this->reset(['name', 'editingUser']);
@ -89,7 +92,7 @@ public function update(): void
// Force component to re-render with fresh data // Force component to re-render with fresh data
$this->resetPage(); $this->resetPage();
} catch (Exception $e) { } catch (Exception $e) {
session()->flash('error', 'Failed to update user: ' . $e->getMessage()); session()->flash('error', 'Failed to update user: '.$e->getMessage());
} }
} }
@ -102,17 +105,17 @@ public function confirmDelete(User $user): void
public function delete(): void public function delete(): void
{ {
try { try {
(new DeleteUserAction())->execute($this->deletingUser); (new DeleteUserAction)->execute($this->deletingUser);
$this->showDeleteModal = false; $this->showDeleteModal = false;
$this->deletingUser = null; $this->deletingUser = null;
session()->flash('success', 'User deleted successfully.'); session()->flash('success', 'User deleted successfully.');
// Force component to re-render with fresh data // Force component to re-render with fresh data
$this->resetPage(); $this->resetPage();
} catch (Exception $e) { } catch (Exception $e) {
session()->flash('error', 'Failed to delete user: ' . $e->getMessage()); session()->flash('error', 'Failed to delete user: '.$e->getMessage());
} }
} }

View file

@ -21,6 +21,7 @@
* @property Carbon $updated_at * @property Carbon $updated_at
* @property Collection<User> $users * @property Collection<User> $users
* @property Collection<UserDish> $userDishes * @property Collection<UserDish> $userDishes
*
* @method static create(array $data) * @method static create(array $data)
* @method static findOrFail(int $dish_id) * @method static findOrFail(int $dish_id)
* @method static DishFactory factory($count = null, $state = []) * @method static DishFactory factory($count = null, $state = [])

View file

@ -13,6 +13,7 @@
* @property int $id * @property int $id
* @property static PlannerFactory factory($count = null, $state = []) * @property static PlannerFactory factory($count = null, $state = [])
* @property Collection<User> $users * @property Collection<User> $users
*
* @method static first() * @method static first()
* @method static create(array $array) * @method static create(array $array)
*/ */

View file

@ -22,8 +22,9 @@
* @property Dish $dish * @property Dish $dish
* @property User $user * @property User $user
* @property Carbon $date * @property Carbon $date
* @property boolean $is_skipped * @property bool $is_skipped
* @property Collection<ScheduledUserDish> $scheduledUserDishes * @property Collection<ScheduledUserDish> $scheduledUserDishes
*
* @method static create(array $array) * @method static create(array $array)
* @method static Builder where(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and') * @method static Builder where(array|Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and')
* @method static ScheduleFactory factory($count = null, $state = []) * @method static ScheduleFactory factory($count = null, $state = [])
@ -38,6 +39,8 @@ class Schedule extends Model
public $timestamps = false; public $timestamps = false;
protected $dateFormat = 'Y-m-d';
protected $fillable = ['planner_id', 'date', 'is_skipped']; protected $fillable = ['planner_id', 'date', 'is_skipped'];
protected $casts = [ protected $casts = [

View file

@ -17,6 +17,7 @@
* @property int $user_dish_id * @property int $user_dish_id
* @property UserDish $userDish * @property UserDish $userDish
* @property bool $is_skipped * @property bool $is_skipped
*
* @method static create(array $array) * @method static create(array $array)
* @method static ScheduledUserDishFactory factory($count = null, $state = []) * @method static ScheduledUserDishFactory factory($count = null, $state = [])
* @method static firstOrCreate(array $array, array $array1) * @method static firstOrCreate(array $array, array $array1)
@ -29,7 +30,7 @@ class ScheduledUserDish extends Model
'schedule_id', 'schedule_id',
'user_id', 'user_id',
'user_dish_id', 'user_dish_id',
'is_skipped' 'is_skipped',
]; ];
protected $casts = [ protected $casts = [

View file

@ -6,10 +6,10 @@
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Model;
/** /**
* @property int $id * @property int $id
@ -17,6 +17,7 @@
* @property string $name * @property string $name
* @property Collection<Dish> $dishes * @property Collection<Dish> $dishes
* @property Collection<UserDish> $userDishes * @property Collection<UserDish> $userDishes
*
* @method static User findOrFail(int $user_id) * @method static User findOrFail(int $user_id)
* @method static UserFactory factory($count = null, $state = []) * @method static UserFactory factory($count = null, $state = [])
* @method static create(array $array) * @method static create(array $array)

View file

@ -17,6 +17,7 @@
* @method static UserDish|null find(int|null $user_dish_id) * @method static UserDish|null find(int|null $user_dish_id)
* @method static create(array $array) * @method static create(array $array)
* @method static where(string $string, int $id) * @method static where(string $string, int $id)
*
* @property int $id * @property int $id
* @property int $dish_id * @property int $dish_id
* @property int $user_id * @property int $user_id

View file

@ -36,7 +36,7 @@ public function getValue(): int
return match ($this->recurrence_type) { return match ($this->recurrence_type) {
WeeklyRecurrence::class => $this->recurrence->weekday->value, WeeklyRecurrence::class => $this->recurrence->weekday->value,
MinimumRecurrence::class => $this->recurrence->days, MinimumRecurrence::class => $this->recurrence->days,
default => throw new InvalidRecurrenceTypeException() default => throw new InvalidRecurrenceTypeException
}; };
} }
} }

View file

@ -12,10 +12,11 @@
/** /**
* @property int $weekday * @property int $weekday
*
* @method static create(array $array) * @method static create(array $array)
* @method static WeeklyRecurrenceFactory factory($count = null, $state = []) * @method static WeeklyRecurrenceFactory factory($count = null, $state = [])
*/ */
class WeeklyRecurrence extends Model implements RecurrenceInterface, FixedRecurrenceInterface class WeeklyRecurrence extends Model implements FixedRecurrenceInterface, RecurrenceInterface
{ {
/** @use HasFactory<WeeklyRecurrenceFactory> */ /** @use HasFactory<WeeklyRecurrenceFactory> */
use HasFactory; use HasFactory;

View file

@ -16,7 +16,6 @@
use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Foundation\Exceptions\Handler as BaseHandler; use Illuminate\Foundation\Exceptions\Handler as BaseHandler;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Throwable; use Throwable;
@ -25,7 +24,8 @@ class AppServiceProvider extends ServiceProvider
public function register(): void public function register(): void
{ {
$this->app->bind(ExceptionHandler::class, function ($app) { $this->app->bind(ExceptionHandler::class, function ($app) {
return new class($app) extends BaseHandler { return new class($app) extends BaseHandler
{
public function render($request, Throwable $e) public function render($request, Throwable $e)
{ {
// Handle specific custom exception // Handle specific custom exception

View file

@ -2,7 +2,6 @@
namespace App\Services; namespace App\Services;
class OutputService class OutputService
{ {
public function response(bool $success = true, ?array $payload = null, array|string|null $errors = null): array public function response(bool $success = true, ?array $payload = null, array|string|null $errors = null): array

View file

@ -1,5 +1,7 @@
<?php <?php
use App\Providers\AppServiceProvider;
return [ return [
App\Providers\AppServiceProvider::class, AppServiceProvider::class,
]; ];

View file

@ -17,12 +17,14 @@
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"larastan/larastan": "^3.10",
"laravel/dusk": "^8.3", "laravel/dusk": "^8.3",
"laravel/pail": "^1.1", "laravel/pail": "^1.1",
"laravel/pint": "^1.13", "laravel/pint": "^1.13",
"laravel/sail": "^1.26", "laravel/sail": "^1.26",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.1", "nunomaduro/collision": "^8.1",
"phpstan/phpstan-mockery": "^2.0",
"phpunit/phpunit": "^11.0.1" "phpunit/phpunit": "^11.0.1"
}, },
"autoload": { "autoload": {

8923
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,7 @@
<?php <?php
use App\Models\Planner;
return [ return [
/* /*
@ -50,7 +52,7 @@
'providers' => [ 'providers' => [
'planners' => [ 'planners' => [
'driver' => 'eloquent', 'driver' => 'eloquent',
'model' => App\Models\Planner::class, 'model' => Planner::class,
], ],
// 'users' => [ // 'users' => [

View file

@ -1,5 +1,8 @@
<?php <?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
return [ return [
@ -75,9 +78,9 @@
*/ */
'middleware' => [ 'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, 'authenticate_session' => AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, 'encrypt_cookies' => EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, 'validate_csrf_token' => ValidateCsrfToken::class,
], ],
]; ];

View file

@ -3,8 +3,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Dish; use App\Models\Dish;
use App\Models\UserDish;
use App\Models\User; use App\Models\User;
use App\Models\UserDish;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**

View file

@ -2,7 +2,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents; // use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;

View file

@ -73,4 +73,4 @@ public function run(): void
$this->command->info('Development data seeded successfully!'); $this->command->info('Development data seeded successfully!');
$this->command->info('Login credentials: myrmidex@myrmidex.net / Password'); $this->command->info('Login credentials: myrmidex@myrmidex.net / Password');
} }
} }

View file

@ -14,7 +14,7 @@ public function run(): void
[ [
'name' => 'Admin', 'name' => 'Admin',
'email' => 'admin@test.com', 'email' => 'admin@test.com',
'password' => 'password' 'password' => 'password',
], ],
])->each(fn (array $data) => Planner::create([ ])->each(fn (array $data) => Planner::create([
'name' => $data['name'], 'name' => $data['name'],

View file

@ -41,17 +41,16 @@ private function createScheduleForPeriod(CarbonPeriod $period): void
$planner = Planner::all()->first() ?? Planner::factory()->create(); $planner = Planner::all()->first() ?? Planner::factory()->create();
collect($period) collect($period)
->each(fn (Carbon $date) => ->each(fn (Carbon $date) => User::query()
User::query() ->inRandomOrder()
->inRandomOrder() ->get()
->get() ->each(fn (User $user) => (new CreateScheduledUserDishAction)
->each(fn (User $user) => (new CreateScheduledUserDishAction()) ->execute(
->execute( planner: $planner,
planner: $planner, schedule: resolve(ScheduleRepository::class)->findOrCreate($planner, $date),
schedule: resolve(ScheduleRepository::class)->findOrCreate($planner, $date), userDish: $user->userDishes->random(),
userDish: $user->userDishes->random(),
)
) )
)
); );
} }
} }

View file

@ -16,7 +16,6 @@ public function run(): void
->each(fn (string $name) => User::factory()->create([ ->each(fn (string $name) => User::factory()->create([
'planner_id' => $planner->id, 'planner_id' => $planner->id,
'name' => $name, 'name' => $name,
])) ]));
;
} }
} }

View file

@ -0,0 +1,44 @@
# CI image: PHP + Composer only, no runtime server or frontend toolchain.
# Tests run against SQLite in memory (see .env.testing), so no database client
# or cache extension is needed.
#
# Published as dishplanner-ci:php8.3-<revision>. Bump the revision in the tag
# (.forgejo/workflows/images.yml) and in .forgejo/workflows/ci.yml whenever
# this file changes (runners cache mutable tags and will not re-pull them).
#
# Debian-based rather than Alpine to avoid the DNS resolution timeouts against
# codeload.github.com that the Alpine base hit during composer install.
FROM php:8.3-cli
COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions \
pdo_sqlite \
mbstring \
dom \
xml \
fileinfo \
pcntl \
zip
# git is needed by the checkout action; nodejs runs the Forgejo JavaScript
# actions (checkout, cache); unzip lets Composer extract dist archives.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip nodejs \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Bake the project's PHP dependencies (dev included) into the image so CI
# restores them with a local copy instead of paying a per-run composer install
# over the network. Build this image on a network that isn't
# codeload-rate-limited (e.g. locally) — the Forgejo runner hits codeload 429
# under --prefer-dist.
#
# --no-scripts skips `php artisan package:discover` (the app isn't present
# here). CI runs `composer install` after restoring vendor, which regenerates
# bootstrap/cache and tops up any lockfile drift between main and release/*.
WORKDIR /opt/deps
COPY composer.json composer.lock ./
RUN composer install --no-interaction --no-progress --prefer-dist --no-scripts

2839
phpstan-baseline.neon Normal file

File diff suppressed because it is too large Load diff

16
phpstan.neon Normal file
View file

@ -0,0 +1,16 @@
includes:
- vendor/larastan/larastan/extension.neon
- vendor/phpstan/phpstan-mockery/extension.neon
- phpstan-baseline.neon
parameters:
level: 7
paths:
- app/
- src/
- tests/
excludePaths:
- bootstrap/*.php
- storage/*

View file

@ -23,13 +23,6 @@
<directory>app/Providers</directory> <directory>app/Providers</directory>
</exclude> </exclude>
</source> </source>
<coverage>
<report>
<html outputDirectory="coverage"/>
<text outputFile="coverage/coverage.txt" showOnlySummary="true"/>
<clover outputFile="coverage/clover.xml"/>
</report>
</coverage>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
@ -40,12 +33,8 @@
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/> <env name="TELESCOPE_ENABLED" value="false"/>
<env name="DB_CONNECTION" value="mysql"/> <env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_HOST" value="mysql"/> <env name="DB_DATABASE" value=":memory:"/>
<env name="DB_PORT" value="3306"/>
<env name="DB_DATABASE" value="testing"/>
<env name="DB_USERNAME" value="sail"/>
<env name="DB_PASSWORD" value="password"/>
</php> </php>
</phpunit> </phpunit>

3
pint.json Normal file
View file

@ -0,0 +1,3 @@
{
"preset": "laravel"
}

View file

@ -5,12 +5,12 @@
Route::group([ Route::group([
'as' => 'api.', 'as' => 'api.',
], function () { ], function () {
require __DIR__ . '/api/auth.php'; require __DIR__.'/api/auth.php';
Route::middleware('auth:sanctum')->group(function () { Route::middleware('auth:sanctum')->group(function () {
require __DIR__ . '/api/users.php'; require __DIR__.'/api/users.php';
require __DIR__ . '/api/dishes.php'; require __DIR__.'/api/dishes.php';
require __DIR__ . '/api/schedule.php'; require __DIR__.'/api/schedule.php';
require __DIR__ . '/api/scheduledUserDishes.php'; require __DIR__.'/api/scheduledUserDishes.php';
}); });
}); });

View file

@ -18,4 +18,3 @@
->json($request->user()) ->json($request->user())
)->name('me'); )->name('me');
}); });

View file

@ -1,8 +1,8 @@
<?php <?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\RegisterController; use App\Http\Controllers\Auth\RegisterController;
use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', function () {
return redirect()->route('dashboard'); return redirect()->route('dashboard');

View file

@ -68,6 +68,10 @@ pkgs.mkShell {
podman-compose logs -f "$@" podman-compose logs -f "$@"
} }
dev-logs-db() {
podman-compose logs -f db "$@"
}
dev-shell() { dev-shell() {
podman-compose exec app sh podman-compose exec app sh
} }
@ -76,6 +80,10 @@ pkgs.mkShell {
podman-compose exec app php artisan "$@" podman-compose exec app php artisan "$@"
} }
dev-test() {
podman-compose exec -T app env $(grep -vE '^\s*(#|$)' .env.testing) php -d memory_limit=512M vendor/bin/phpunit "$@"
}
dev-fix-permissions() { dev-fix-permissions() {
echo "🔧 Fixing file permissions..." echo "🔧 Fixing file permissions..."
echo "This will require sudo to fix Docker-created files" echo "This will require sudo to fix Docker-created files"
@ -149,8 +157,10 @@ pkgs.mkShell {
echo " dev-rebuild - Full rebuild (removes volumes)" echo " dev-rebuild - Full rebuild (removes volumes)"
echo " dev-rebuild-quick - Quick rebuild (keeps volumes)" echo " dev-rebuild-quick - Quick rebuild (keeps volumes)"
echo " dev-logs [svc] - Follow logs (default: all)" echo " dev-logs [svc] - Follow logs (default: all)"
echo " dev-logs-db - Tail database logs"
echo " dev-shell - Enter app container" echo " dev-shell - Enter app container"
echo " dev-artisan - Run artisan commands" echo " dev-artisan - Run artisan commands"
echo " dev-test [path] - Run PHPUnit suite (CI invocation)"
echo " dev-fix-permissions - Fix Docker-created file permissions" echo " dev-fix-permissions - Fix Docker-created file permissions"
echo "" echo ""
echo "Production commands:" echo "Production commands:"

View file

@ -17,7 +17,7 @@ public function login(Request $request): JsonResponse
'password' => ['required'], 'password' => ['required'],
]); ]);
if (!Auth::attempt($credentials)) { if (! Auth::attempt($credentials)) {
return response()->json([ return response()->json([
'message' => 'The provided credentials are incorrect.', 'message' => 'The provided credentials are incorrect.',
], 401); ], 401);

View file

@ -31,7 +31,7 @@ public function index(): JsonResponse
public function store(StoreDishRequest $request): JsonResponse public function store(StoreDishRequest $request): JsonResponse
{ {
$dish = (new CreateDishAction())->execute($request->validated()); $dish = (new CreateDishAction)->execute($request->validated());
return $this->success(['dish' => new DishResource($dish)]); return $this->success(['dish' => new DishResource($dish)]);
} }
@ -47,7 +47,7 @@ public function update(UpdateDishRequest $request, Dish $dish): JsonResponse
{ {
Gate::authorize('update', $dish); Gate::authorize('update', $dish);
$dish = (new UpdateDishAction())->execute($dish, $request->validated()); $dish = (new UpdateDishAction)->execute($dish, $request->validated());
return $this->success(['dish' => new DishResource($dish)]); return $this->success(['dish' => new DishResource($dish)]);
} }
@ -56,14 +56,14 @@ public function destroy(Dish $dish): JsonResponse
{ {
Gate::authorize('delete', $dish); Gate::authorize('delete', $dish);
(new DeleteDishAction())->execute($dish); (new DeleteDishAction)->execute($dish);
return $this->success(null); return $this->success(null);
} }
public function syncUsers(SyncUsersRequest $request, Dish $dish): JsonResponse public function syncUsers(SyncUsersRequest $request, Dish $dish): JsonResponse
{ {
(new SyncUsersAction())->execute($dish, Arr::get($request->validated(), 'users', [])); (new SyncUsersAction)->execute($dish, Arr::get($request->validated(), 'users', []));
return $this->success(['dish' => new DishResource($dish->refresh())]); return $this->success(['dish' => new DishResource($dish->refresh())]);
} }
@ -72,14 +72,14 @@ public function addUsers(AddUsersToDishRequest $request, Dish $dish): JsonRespon
{ {
Gate::authorize('update', $dish); Gate::authorize('update', $dish);
(new AddUsersToDishAction())->execute($dish, Arr::get($request->validated(), 'users', [])); (new AddUsersToDishAction)->execute($dish, Arr::get($request->validated(), 'users', []));
return $this->success(['dish' => new DishResource($dish->refresh())]); return $this->success(['dish' => new DishResource($dish->refresh())]);
} }
public function removeUsers(RemoveUsersFromDishRequest $request, Dish $dish): JsonResponse public function removeUsers(RemoveUsersFromDishRequest $request, Dish $dish): JsonResponse
{ {
(new RemoveUsersFromDishAction())->execute($dish, Arr::get($request->validated(), 'users', [])); (new RemoveUsersFromDishAction)->execute($dish, Arr::get($request->validated(), 'users', []));
return $this->success(['dish' => new DishResource($dish->refresh())]); return $this->success(['dish' => new DishResource($dish->refresh())]);
} }

View file

@ -7,5 +7,6 @@
class InvalidDishException extends CustomException class InvalidDishException extends CustomException
{ {
protected $message = 'INVALID_DISH'; protected $message = 'INVALID_DISH';
protected $code = 422; protected $code = 422;
} }

View file

@ -11,7 +11,7 @@ class DraftScheduleForDateAction
public function execute(Schedule $schedule): Schedule public function execute(Schedule $schedule): Schedule
{ {
User::all() User::all()
->reject(fn($user) => $schedule ->reject(fn ($user) => $schedule
->scheduledUserDishes ->scheduledUserDishes
->map(fn (ScheduledUserDish $scheduledUserDish) => $scheduledUserDish->userDish?->user) ->map(fn (ScheduledUserDish $scheduledUserDish) => $scheduledUserDish->userDish?->user)
->filter() ->filter()

View file

@ -7,7 +7,6 @@
use App\Models\ScheduledUserDish; use App\Models\ScheduledUserDish;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class GenerateScheduleForMonthAction class GenerateScheduleForMonthAction
@ -84,7 +83,7 @@ private function generateSchedulesForPeriod(
); );
foreach ($userIds as $userId) { foreach ($userIds as $userId) {
if (!isset($userDishesMap[$userId]) || $userDishesMap[$userId]->isEmpty()) { if (! isset($userDishesMap[$userId]) || $userDishesMap[$userId]->isEmpty()) {
continue; continue;
} }

View file

@ -29,7 +29,7 @@ public function execute(Planner $planner, Schedule $schedule, User $user, bool $
); );
} }
if (!$overwrite && $scheduledUserDish->userDish) { if (! $overwrite && $scheduledUserDish->userDish) {
return $scheduledUserDish; return $scheduledUserDish;
} }

View file

@ -5,8 +5,6 @@
use App\Http\Controllers\Api\ApiController; use App\Http\Controllers\Api\ApiController;
use App\Models\Planner; use App\Models\Planner;
use App\Models\Schedule; use App\Models\Schedule;
use Carbon\CarbonPeriod;
use DishPlanner\Schedule\Actions\DraftScheduleForPeriodAction;
use DishPlanner\Schedule\Actions\GenerateScheduleForPeriodAction; use DishPlanner\Schedule\Actions\GenerateScheduleForPeriodAction;
use DishPlanner\Schedule\Actions\UpdateScheduleAction; use DishPlanner\Schedule\Actions\UpdateScheduleAction;
use DishPlanner\Schedule\Repositories\ScheduleRepository; use DishPlanner\Schedule\Repositories\ScheduleRepository;
@ -83,7 +81,7 @@ public function generate(GenerateScheduleRequest $request): JsonResponse
/** @var Planner $planner */ /** @var Planner $planner */
$planner = auth()->user(); $planner = auth()->user();
(new GenerateScheduleForPeriodAction())->execute($planner, $request->get('overwrite', false)); (new GenerateScheduleForPeriodAction)->execute($planner, $request->get('overwrite', false));
return $this->success(null); return $this->success(null);
} }

View file

@ -37,7 +37,7 @@ public function __invoke(ScheduleUserDishRequest $request, Carbon $date): JsonRe
->first(); ->first();
if (! $scheduledUserDish) { if (! $scheduledUserDish) {
$scheduledUserDish = new ScheduledUserDish(); $scheduledUserDish = new ScheduledUserDish;
} }
abort_if( abort_if(

View file

@ -12,7 +12,7 @@ public function rules(): array
'user_dish_id' => [ 'user_dish_id' => [
'required_without:skipped', 'required_without:skipped',
'exists:user_dishes,id', 'exists:user_dishes,id',
'nullable' 'nullable',
], ],
'user_id' => ['required', 'exists:users,id'], 'user_id' => ['required', 'exists:users,id'],
'skipped' => ['required_if:user_dish_id,null', 'boolean'], 'skipped' => ['required_if:user_dish_id,null', 'boolean'],

View file

@ -5,7 +5,7 @@
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
/** /**
* @property boolean $is_skipped * @property bool $is_skipped
*/ */
class UpdateScheduleRequest extends FormRequest class UpdateScheduleRequest extends FormRequest
{ {

View file

@ -43,7 +43,7 @@ private function buildCalendarDays(int $year, int $month, int $daysInMonth, Coll
'date' => $date, 'date' => $date,
'isToday' => $date->isToday(), 'isToday' => $date->isToday(),
'scheduledDishes' => $scheduledDishes, 'scheduledDishes' => $scheduledDishes,
'isEmpty' => $scheduledDishes->isEmpty() 'isEmpty' => $scheduledDishes->isEmpty(),
]; ];
} else { } else {
$calendarDays[] = [ $calendarDays[] = [
@ -51,7 +51,7 @@ private function buildCalendarDays(int $year, int $month, int $daysInMonth, Coll
'date' => null, 'date' => null,
'isToday' => false, 'isToday' => false,
'scheduledDishes' => collect(), 'scheduledDishes' => collect(),
'isEmpty' => true 'isEmpty' => true,
]; ];
} }
} }

View file

@ -31,7 +31,7 @@ public function generate(Planner $planner): void
$users->each(function (User $user) use ($date, $planner, $scheduleRepository, $userDishRepository) { $users->each(function (User $user) use ($date, $planner, $scheduleRepository, $userDishRepository) {
$schedule = $scheduleRepository->findOrCreate($planner, $date); $schedule = $scheduleRepository->findOrCreate($planner, $date);
(new CreateScheduledUserDishAction())->execute( (new CreateScheduledUserDishAction)->execute(
planner: $planner, planner: $planner,
schedule: $schedule, schedule: $schedule,
userDish: $userDishRepository->getRandomForDate($user, $date) userDish: $userDishRepository->getRandomForDate($user, $date)

View file

@ -16,7 +16,7 @@ class CreateScheduledUserDishAction
public function execute(Planner $planner, Schedule $schedule, UserDish $userDish): ScheduledUserDish public function execute(Planner $planner, Schedule $schedule, UserDish $userDish): ScheduledUserDish
{ {
if ($userDish->dish->planner_id !== $planner->id || $userDish->user->planner_id !== $planner->id) { if ($userDish->dish->planner_id !== $planner->id || $userDish->user->planner_id !== $planner->id) {
throw new InvalidPlannerException(); throw new InvalidPlannerException;
} }
return ScheduledUserDish::create([ return ScheduledUserDish::create([

View file

@ -36,7 +36,7 @@ public function create(CreateScheduleRequest $request): JsonResponse
$schedule = resolve(ScheduleRepository::class)->findOrCreate($planner, $date); $schedule = resolve(ScheduleRepository::class)->findOrCreate($planner, $date);
try { try {
$scheduledUserDish = (new CreateScheduledUserDishAction())->execute( $scheduledUserDish = (new CreateScheduledUserDishAction)->execute(
planner: $planner, planner: $planner,
schedule: $schedule, schedule: $schedule,
userDish: $userDish, userDish: $userDish,
@ -46,7 +46,7 @@ public function create(CreateScheduleRequest $request): JsonResponse
} }
return $this->success([ return $this->success([
'scheduled_user_dish' => new ScheduledUserDishResource($scheduledUserDish) 'scheduled_user_dish' => new ScheduledUserDishResource($scheduledUserDish),
]); ]);
} }
@ -63,7 +63,7 @@ public function update(UpdateScheduledUserDishRequest $request, ScheduledUserDis
{ {
Gate::authorize('update', $scheduledUserDish); Gate::authorize('update', $scheduledUserDish);
(new UpdateScheduledUserDishAction())->execute( (new UpdateScheduledUserDishAction)->execute(
scheduledUserDish: $scheduledUserDish, scheduledUserDish: $scheduledUserDish,
userDish: UserDish::find($request->user_dish_id), userDish: UserDish::find($request->user_dish_id),
isSkipped: $request->is_skipped ?? null, isSkipped: $request->is_skipped ?? null,
@ -78,7 +78,7 @@ public function delete(ScheduledUserDish $scheduledUserDish): JsonResponse
{ {
Gate::authorize('delete', $scheduledUserDish); Gate::authorize('delete', $scheduledUserDish);
(new DeleteScheduledUserDishAction())->execute($scheduledUserDish); (new DeleteScheduledUserDishAction)->execute($scheduledUserDish);
return $this->success(null); return $this->success(null);
} }

View file

@ -4,7 +4,6 @@
use App\Models\Planner; use App\Models\Planner;
use App\Models\ScheduledUserDish; use App\Models\ScheduledUserDish;
use DishPlanner\UserDish\Policies\UserDishPolicy;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
class ScheduledUserDishPolicy class ScheduledUserDishPolicy

View file

@ -2,7 +2,6 @@
namespace DishPlanner\User\Actions; namespace DishPlanner\User\Actions;
use App\Models\Planner;
use App\Models\User; use App\Models\User;
class DeleteUserAction class DeleteUserAction

View file

@ -2,7 +2,6 @@
namespace DishPlanner\User\Actions; namespace DishPlanner\User\Actions;
use App\Models\Planner;
use App\Models\User; use App\Models\User;
class UpdateUserAction class UpdateUserAction

View file

@ -33,7 +33,7 @@ public function create(CreateUserRequest $request): JsonResponse
$requestData = $request->validated(); $requestData = $request->validated();
$user = (new CreateUserAction()) $user = (new CreateUserAction)
->execute($planner, Arr::get($requestData, 'name')); ->execute($planner, Arr::get($requestData, 'name'));
return $this->success(['user' => new UserResource($user)]); return $this->success(['user' => new UserResource($user)]);
@ -43,7 +43,7 @@ public function update(UpdateUserRequest $request, User $user): JsonResponse
{ {
Gate::authorize('update', $user); Gate::authorize('update', $user);
$user = (new UpdateUserAction()) $user = (new UpdateUserAction)
->execute($user, Arr::get($request->validated(), 'name')); ->execute($user, Arr::get($request->validated(), 'name'));
return $this->success(['user' => new UserResource($user)]); return $this->success(['user' => new UserResource($user)]);
@ -53,7 +53,7 @@ public function delete(User $user): JsonResponse
{ {
Gate::authorize('delete', $user); Gate::authorize('delete', $user);
(new DeleteUserAction())->execute($user); (new DeleteUserAction)->execute($user);
return $this->success(null, 201); return $this->success(null, 201);
} }

View file

@ -17,8 +17,8 @@ class CreateFixedRecurrenceAction
*/ */
public function execute(UserDish $userDish, string $recurrenceType, int $value): void public function execute(UserDish $userDish, string $recurrenceType, int $value): void
{ {
if (!in_array($recurrenceType, self::FIXED_RECURRENCES)) { if (! in_array($recurrenceType, self::FIXED_RECURRENCES)) {
throw new InvalidRecurrenceTypeException(); throw new InvalidRecurrenceTypeException;
} }
$recurrence = $recurrenceType::create([ $recurrence = $recurrenceType::create([

View file

@ -15,7 +15,7 @@ class CreateMinimumRecurrenceAction
public function execute(UserDish $userDish, string $recurrenceType, int $recurrenceValue): void public function execute(UserDish $userDish, string $recurrenceType, int $recurrenceValue): void
{ {
if ($recurrenceType !== MinimumRecurrence::class) { if ($recurrenceType !== MinimumRecurrence::class) {
throw new InvalidRecurrenceTypeException(); throw new InvalidRecurrenceTypeException;
} }
$existingRecurrenceForDay = $userDish $existingRecurrenceForDay = $userDish

View file

@ -40,11 +40,11 @@ private function addRecurrences(UserDish $userDish, array $data): void
} }
if ($recurrenceType === WeeklyRecurrence::class) { if ($recurrenceType === WeeklyRecurrence::class) {
(new CreateFixedRecurrenceAction())->execute($userDish, $recurrenceType, $recurrenceValue); (new CreateFixedRecurrenceAction)->execute($userDish, $recurrenceType, $recurrenceValue);
} elseif ($recurrenceType === MinimumRecurrence::class) { } elseif ($recurrenceType === MinimumRecurrence::class) {
(new CreateMinimumRecurrenceAction())->execute($userDish, $recurrenceType, $recurrenceValue); (new CreateMinimumRecurrenceAction)->execute($userDish, $recurrenceType, $recurrenceValue);
} else { } else {
throw new InvalidRecurrenceTypeException(); throw new InvalidRecurrenceTypeException;
} }
} }
} }

View file

@ -14,7 +14,7 @@ class DeleteFixedRecurrenceAction
public function execute(RecurrenceInterface $recurrence): void public function execute(RecurrenceInterface $recurrence): void
{ {
if (! $recurrence instanceof WeeklyRecurrence) { if (! $recurrence instanceof WeeklyRecurrence) {
throw new InvalidRecurrenceTypeException(); throw new InvalidRecurrenceTypeException;
} }
$recurrence->delete(); $recurrence->delete();

View file

@ -15,7 +15,7 @@ class DeleteMinimumRecurrenceAction
public function execute(RecurrenceInterface $recurrence): void public function execute(RecurrenceInterface $recurrence): void
{ {
if (! $recurrence instanceof MinimumRecurrence) { if (! $recurrence instanceof MinimumRecurrence) {
throw new InvalidRecurrenceTypeException(); throw new InvalidRecurrenceTypeException;
} }
UserDishRecurrence::query() UserDishRecurrence::query()

View file

@ -32,9 +32,9 @@ public function execute(UserDish $userDish, Collection $recurrences): UserDish
} }
match ($recurrenceType) { match ($recurrenceType) {
WeeklyRecurrence::class => (new CreateFixedRecurrenceAction())->execute($userDish, $recurrenceType, $recurrenceValue), WeeklyRecurrence::class => (new CreateFixedRecurrenceAction)->execute($userDish, $recurrenceType, $recurrenceValue),
MinimumRecurrence::class => (new CreateMinimumRecurrenceAction())->execute($userDish, $recurrenceType, $recurrenceValue), MinimumRecurrence::class => (new CreateMinimumRecurrenceAction)->execute($userDish, $recurrenceType, $recurrenceValue),
default => throw new InvalidRecurrenceTypeException(), default => throw new InvalidRecurrenceTypeException,
}; };
}); });

View file

@ -15,7 +15,7 @@ class UpdateFixedRecurrenceAction
public function execute(RecurrenceInterface $recurrence, array $data): RecurrenceInterface public function execute(RecurrenceInterface $recurrence, array $data): RecurrenceInterface
{ {
if (! $recurrence instanceof WeeklyRecurrence) { if (! $recurrence instanceof WeeklyRecurrence) {
throw new InvalidRecurrenceTypeException(); throw new InvalidRecurrenceTypeException;
} }
$weekday = Arr::get($data, 'recurrence_data.weekday'); $weekday = Arr::get($data, 'recurrence_data.weekday');

View file

@ -15,7 +15,7 @@ class UpdateMinimumRecurrenceAction
public function execute(RecurrenceInterface $recurrence, array $data): RecurrenceInterface public function execute(RecurrenceInterface $recurrence, array $data): RecurrenceInterface
{ {
if (! $recurrence instanceof MinimumRecurrence) { if (! $recurrence instanceof MinimumRecurrence) {
throw new InvalidRecurrenceTypeException(); throw new InvalidRecurrenceTypeException;
} }
$days = Arr::get($data, 'recurrence_data.days'); $days = Arr::get($data, 'recurrence_data.days');

View file

@ -20,7 +20,7 @@ public function __invoke(Request $request)
$userDishes = $userDishRepository->getAllForPlanner($planner); $userDishes = $userDishRepository->getAllForPlanner($planner);
return $this->success([ return $this->success([
'user_dishes' => UserDishResource::collection($userDishes)->collection->toArray() 'user_dishes' => UserDishResource::collection($userDishes)->collection->toArray(),
]); ]);
} }
} }

View file

@ -48,7 +48,7 @@ public function show(User $user, Dish $dish): JsonResponse
*/ */
public function store(CreateUserDishRequest $request, User $user, Dish $dish): JsonResponse public function store(CreateUserDishRequest $request, User $user, Dish $dish): JsonResponse
{ {
$userDish = (new CreateUserDishAction())->execute($dish, $user, $request->validated()); $userDish = (new CreateUserDishAction)->execute($dish, $user, $request->validated());
return $this->success([ return $this->success([
'user_dish' => new UserDishResource($userDish), 'user_dish' => new UserDishResource($userDish),
@ -57,7 +57,7 @@ public function store(CreateUserDishRequest $request, User $user, Dish $dish): J
public function destroy(User $user, Dish $dish): JsonResponse public function destroy(User $user, Dish $dish): JsonResponse
{ {
(new DeleteUserDishAction())->execute($user, $dish); (new DeleteUserDishAction)->execute($user, $dish);
return $this->success(null); return $this->success(null);
} }

View file

@ -35,7 +35,7 @@ public function store(StoreUserDishRecurrenceRequest $request, User $user, Dish
$recurrences = collect($request->validated()); $recurrences = collect($request->validated());
(new SyncRecurrencesForUserDishAction())->execute($userDish, $recurrences); (new SyncRecurrencesForUserDishAction)->execute($userDish, $recurrences);
return $this->success([ return $this->success([
'user_dish' => new UserDishResource($userDish->refresh()), 'user_dish' => new UserDishResource($userDish->refresh()),
@ -51,9 +51,9 @@ public function update(UpdateUserDishFixedRecurrenceRequest $request, UserDish $
$recurrence = $recurrenceClass::findOrFail($recurrenceId); $recurrence = $recurrenceClass::findOrFail($recurrenceId);
if ($recurrence instanceof WeeklyRecurrence) { if ($recurrence instanceof WeeklyRecurrence) {
(new UpdateFixedRecurrenceAction())->execute($recurrence, $request->validated()); (new UpdateFixedRecurrenceAction)->execute($recurrence, $request->validated());
} elseif ($recurrenceClass === MinimumRecurrence::class) { } elseif ($recurrenceClass === MinimumRecurrence::class) {
(new UpdateMinimumRecurrenceAction())->execute($recurrence, $request->validated()); (new UpdateMinimumRecurrenceAction)->execute($recurrence, $request->validated());
} else { } else {
return $this->error('invalid recurrence type'); return $this->error('invalid recurrence type');
} }
@ -72,9 +72,9 @@ public function destroy(UserDish $userDish, string $recurrenceType, int $recurre
$recurrence = $recurrenceClass::findOrFail($recurrenceId); $recurrence = $recurrenceClass::findOrFail($recurrenceId);
if ($recurrence instanceof WeeklyRecurrence) { if ($recurrence instanceof WeeklyRecurrence) {
(new DeleteFixedRecurrenceAction())->execute($recurrence); (new DeleteFixedRecurrenceAction)->execute($recurrence);
} elseif ($recurrenceClass === MinimumRecurrence::class) { } elseif ($recurrenceClass === MinimumRecurrence::class) {
(new DeleteMinimumRecurrenceAction())->execute($recurrence); (new DeleteMinimumRecurrenceAction)->execute($recurrence);
} else { } else {
return $this->error('invalid recurrence type'); return $this->error('invalid recurrence type');
} }

View file

@ -2,5 +2,4 @@
namespace DishPlanner\UserDish\Interfaces; namespace DishPlanner\UserDish\Interfaces;
interface FixedRecurrenceInterface interface FixedRecurrenceInterface {}
{}

View file

@ -2,5 +2,4 @@
namespace DishPlanner\UserDish\Interfaces; namespace DishPlanner\UserDish\Interfaces;
interface RecurrenceInterface interface RecurrenceInterface {}
{}

View file

@ -12,7 +12,6 @@
use App\Models\WeeklyRecurrence; use App\Models\WeeklyRecurrence;
use Carbon\Carbon; use Carbon\Carbon;
use Carbon\CarbonPeriod; use Carbon\CarbonPeriod;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as SupportCollection; use Illuminate\Support\Collection as SupportCollection;
@ -70,7 +69,7 @@ public function findInterferingUserDishes(User $user, Carbon $date): Collection
->get() ->get()
->flatMap(fn (Schedule $schedule) => $schedule->scheduledUserDishes) ->flatMap(fn (Schedule $schedule) => $schedule->scheduledUserDishes)
->filter(fn (ScheduledUserDish $scheduledUserDish) => $scheduledUserDish->userDish->user_id === $user->id) ->filter(fn (ScheduledUserDish $scheduledUserDish) => $scheduledUserDish->userDish->user_id === $user->id)
->filter(fn(ScheduledUserDish $scheduledUserDish) => $scheduledUserDish->userDish->recurrences->contains('recurrence_type', MinimumRecurrence::class)) ->filter(fn (ScheduledUserDish $scheduledUserDish) => $scheduledUserDish->userDish->recurrences->contains('recurrence_type', MinimumRecurrence::class))
->filter(function (ScheduledUserDish $scheduledUserDish) use ($date) { ->filter(function (ScheduledUserDish $scheduledUserDish) use ($date) {
$minimum = $scheduledUserDish->userDish $minimum = $scheduledUserDish->userDish
->recurrences ->recurrences

View file

@ -19,7 +19,7 @@ public function rules(): array
MinimumRecurrence::class, MinimumRecurrence::class,
WeeklyRecurrence::class, WeeklyRecurrence::class,
]), ]),
'required_with:*.recurrence_value' 'required_with:*.recurrence_value',
], ],
'*.value' => ['sometimes', 'integer', 'required_with:*.recurrence_type'], '*.value' => ['sometimes', 'integer', 'required_with:*.recurrence_type'],
]; ];

View file

@ -14,14 +14,14 @@ public function rules(): array
'recurrence_type' => [ 'recurrence_type' => [
'required', 'required',
'string', 'string',
'in:' . implode(',', [ 'in:'.implode(',', [
MinimumRecurrence::class, MinimumRecurrence::class,
WeeklyRecurrence::class, WeeklyRecurrence::class,
]), ]),
], ],
'recurrence_data' => 'required|array', 'recurrence_data' => 'required|array',
'recurrence_data.days' => 'required_if:recurrence_type,' . MinimumRecurrence::class . '|integer|min:1', 'recurrence_data.days' => 'required_if:recurrence_type,'.MinimumRecurrence::class.'|integer|min:1',
'recurrence_data.weekday' => 'required_if:recurrence_type,' . WeeklyRecurrence::class . '|integer|between:0,6', 'recurrence_data.weekday' => 'required_if:recurrence_type,'.WeeklyRecurrence::class.'|integer|between:0,6',
]; ];
} }

View file

@ -2,15 +2,17 @@
namespace Tests\Browser\Auth; namespace Tests\Browser\Auth;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use App\Models\Planner; use App\Models\Planner;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
class LoginTest extends DuskTestCase class LoginTest extends DuskTestCase
{ {
protected static $testPlanner = null; protected static $testPlanner = null;
protected static $testEmail = null; protected static $testEmail = null;
protected static $testPassword = 'password'; protected static $testPassword = 'password';
protected function ensureTestPlannerExists(): void protected function ensureTestPlannerExists(): void
@ -26,49 +28,49 @@ protected function ensureTestPlannerExists(): void
} }
} }
public function testSuccessfulLogin(): void public function test_successful_login(): void
{ {
$this->ensureTestPlannerExists(); $this->ensureTestPlannerExists();
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$browser->driver->manage()->deleteAllCookies(); $browser->driver->manage()->deleteAllCookies();
$browser->visit('http://dishplanner_app:8000/login') $browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', self::TIMEOUT_SHORT) ->waitFor('input[id="email"]', self::TIMEOUT_SHORT)
->clear('input[id="email"]') ->clear('input[id="email"]')
->type('input[id="email"]', self::$testEmail) ->type('input[id="email"]', self::$testEmail)
->clear('input[id="password"]') ->clear('input[id="password"]')
->type('input[id="password"]', self::$testPassword) ->type('input[id="password"]', self::$testPassword)
->press('Login') ->press('Login')
->waitForLocation('/dashboard', self::TIMEOUT_MEDIUM) ->waitForLocation('/dashboard', self::TIMEOUT_MEDIUM)
->assertPathIs('/dashboard'); ->assertPathIs('/dashboard');
}); });
} }
public function testLoginWithWrongCredentials(): void public function test_login_with_wrong_credentials(): void
{ {
$this->ensureTestPlannerExists(); $this->ensureTestPlannerExists();
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$browser->driver->manage()->deleteAllCookies(); $browser->driver->manage()->deleteAllCookies();
$browser->visit('http://dishplanner_app:8000/login') $browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', self::TIMEOUT_SHORT) ->waitFor('input[id="email"]', self::TIMEOUT_SHORT)
->clear('input[id="email"]') ->clear('input[id="email"]')
->type('input[id="email"]', self::$testEmail) ->type('input[id="email"]', self::$testEmail)
->clear('input[id="password"]') ->clear('input[id="password"]')
->type('input[id="password"]', 'wrongpassword') ->type('input[id="password"]', 'wrongpassword')
->press('Login') ->press('Login')
->pause(self::PAUSE_MEDIUM) ->pause(self::PAUSE_MEDIUM)
->assertPathIs('/login') ->assertPathIs('/login')
->assertSee('These credentials do not match our records'); ->assertSee('These credentials do not match our records');
}); });
} }
public function testLoginFormRequiredFields(): void public function test_login_form_required_fields(): void
{ {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$browser->driver->manage()->deleteAllCookies(); $browser->driver->manage()->deleteAllCookies();
$browser->visit('http://dishplanner_app:8000/login') $browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', self::TIMEOUT_SHORT); ->waitFor('input[id="email"]', self::TIMEOUT_SHORT);
// Check that both fields have the required attribute // Check that both fields have the required attribute
$browser->assertAttribute('input[id="email"]', 'required', 'true'); $browser->assertAttribute('input[id="email"]', 'required', 'true');
@ -82,8 +84,8 @@ public function testLoginFormRequiredFields(): void
// Test that we stay on login page if we try to submit with empty fields // Test that we stay on login page if we try to submit with empty fields
$browser->press('Login') $browser->press('Login')
->pause(self::PAUSE_SHORT) ->pause(self::PAUSE_SHORT)
->assertPathIs('/login'); ->assertPathIs('/login');
}); });
} }
} }

View file

@ -29,7 +29,7 @@ public function selector(): string
public function assert(Browser $browser): void public function assert(Browser $browser): void
{ {
$browser->assertVisible($this->selector()); $browser->assertVisible($this->selector());
if ($this->mode === 'create') { if ($this->mode === 'create') {
$browser->assertSee('Add New Dish'); $browser->assertSee('Add New Dish');
} else { } else {
@ -60,12 +60,12 @@ public function elements(): array
public function fillForm(Browser $browser, string $name, ?string $description = null): void public function fillForm(Browser $browser, string $name, ?string $description = null): void
{ {
$browser->waitFor('@name-input') $browser->waitFor('@name-input')
->clear('@name-input') ->clear('@name-input')
->type('@name-input', $name); ->type('@name-input', $name);
if ($description !== null && $browser->element('@description-input')) { if ($description !== null && $browser->element('@description-input')) {
$browser->clear('@description-input') $browser->clear('@description-input')
->type('@description-input', $description); ->type('@description-input', $description);
} }
} }
@ -102,4 +102,4 @@ public function assertValidationError(Browser $browser, string $message = 'requi
{ {
$browser->assertSee($message); $browser->assertSee($message);
} }
} }

View file

@ -21,9 +21,9 @@ public function selector(): string
public function assert(Browser $browser): void public function assert(Browser $browser): void
{ {
$browser->assertVisible($this->selector()) $browser->assertVisible($this->selector())
->assertVisible('@email') ->assertVisible('@email')
->assertVisible('@password') ->assertVisible('@password')
->assertVisible('@submit'); ->assertVisible('@submit');
} }
/** /**
@ -48,7 +48,7 @@ public function elements(): array
public function fillForm(Browser $browser, string $email, string $password): void public function fillForm(Browser $browser, string $email, string $password): void
{ {
$browser->type('@email', $email) $browser->type('@email', $email)
->type('@password', $password); ->type('@password', $password);
} }
/** /**
@ -74,9 +74,9 @@ public function loginWith(Browser $browser, string $email, string $password): vo
public function assertFieldsRequired(Browser $browser): void public function assertFieldsRequired(Browser $browser): void
{ {
$browser->assertAttribute('@email', 'required', 'true') $browser->assertAttribute('@email', 'required', 'true')
->assertAttribute('@password', 'required', 'true') ->assertAttribute('@password', 'required', 'true')
->assertAttribute('@email', 'type', 'email') ->assertAttribute('@email', 'type', 'email')
->assertAttribute('@password', 'type', 'password'); ->assertAttribute('@password', 'type', 'password');
} }
/** /**
@ -86,4 +86,4 @@ public function assertHasErrors(Browser $browser): void
{ {
$browser->assertPresent('@error'); $browser->assertPresent('@error');
} }
} }

View file

@ -3,18 +3,19 @@
namespace Tests\Browser\Dishes; namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser; use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\Pages\DishesPage;
use Tests\Browser\Components\DishModal; use Tests\Browser\Components\DishModal;
use Tests\Browser\LoginHelpers; use Tests\Browser\LoginHelpers;
use Tests\Browser\Pages\DishesPage;
use Tests\DuskTestCase;
class CreateDishFormValidationTest extends DuskTestCase class CreateDishFormValidationTest extends DuskTestCase
{ {
use LoginHelpers; use LoginHelpers;
protected static $createDishFormValidationTestPlanner = null; protected static $createDishFormValidationTestPlanner = null;
protected static $createDishFormValidationTestEmail = null; protected static $createDishFormValidationTestEmail = null;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
@ -22,7 +23,7 @@ protected function setUp(): void
self::$testPlanner = self::$createDishFormValidationTestPlanner; self::$testPlanner = self::$createDishFormValidationTestPlanner;
self::$testEmail = self::$createDishFormValidationTestEmail; self::$testEmail = self::$createDishFormValidationTestEmail;
} }
protected function tearDown(): void protected function tearDown(): void
{ {
// Save the planner for next test method in this class // Save the planner for next test method in this class
@ -31,19 +32,19 @@ protected function tearDown(): void
parent::tearDown(); parent::tearDown();
} }
public function testCreateDishFormValidation(): void public function test_create_dish_form_validation(): void
{ {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser); $this->loginAndGoToDishes($browser);
$browser->on(new DishesPage) $browser->on(new DishesPage)
->openCreateModal() ->openCreateModal()
->within(new DishModal('create'), function ($browser) { ->within(new DishModal('create'), function ($browser) {
$browser->fillForm('', null) $browser->fillForm('', null)
->submit() ->submit()
->pause(2000) ->pause(2000)
->assertValidationError('required'); ->assertValidationError('required');
}); });
}); });
} }
} }

View file

@ -3,18 +3,19 @@
namespace Tests\Browser\Dishes; namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser; use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\Pages\DishesPage;
use Tests\Browser\Components\DishModal; use Tests\Browser\Components\DishModal;
use Tests\Browser\LoginHelpers; use Tests\Browser\LoginHelpers;
use Tests\Browser\Pages\DishesPage;
use Tests\DuskTestCase;
class CreateDishSuccessTest extends DuskTestCase class CreateDishSuccessTest extends DuskTestCase
{ {
use LoginHelpers; use LoginHelpers;
protected static $createDishSuccessTestPlanner = null; protected static $createDishSuccessTestPlanner = null;
protected static $createDishSuccessTestEmail = null; protected static $createDishSuccessTestEmail = null;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
@ -22,7 +23,7 @@ protected function setUp(): void
self::$testPlanner = self::$createDishSuccessTestPlanner; self::$testPlanner = self::$createDishSuccessTestPlanner;
self::$testEmail = self::$createDishSuccessTestEmail; self::$testEmail = self::$createDishSuccessTestEmail;
} }
protected function tearDown(): void protected function tearDown(): void
{ {
// Save the planner for next test method in this class // Save the planner for next test method in this class
@ -31,22 +32,22 @@ protected function tearDown(): void
parent::tearDown(); parent::tearDown();
} }
public function testCanCreateDishSuccessfully(): void public function test_can_create_dish_successfully(): void
{ {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$dishName = 'Test Dish ' . uniqid(); $dishName = 'Test Dish '.uniqid();
$this->loginAndGoToDishes($browser); $this->loginAndGoToDishes($browser);
$browser->on(new DishesPage) $browser->on(new DishesPage)
->openCreateModal() ->openCreateModal()
->within(new DishModal('create'), function ($browser) use ($dishName) { ->within(new DishModal('create'), function ($browser) use ($dishName) {
$browser->fillForm($dishName) $browser->fillForm($dishName)
->submit(); ->submit();
}) })
->pause(3000) ->pause(3000)
->assertDishVisible($dishName) ->assertDishVisible($dishName)
->assertSee('Dish created successfully'); ->assertSee('Dish created successfully');
}); });
} }
} }

View file

@ -3,18 +3,18 @@
namespace Tests\Browser\Dishes; namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser; use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\Pages\DishesPage;
use Tests\Browser\Components\DishModal;
use Tests\Browser\LoginHelpers; use Tests\Browser\LoginHelpers;
use Tests\Browser\Pages\DishesPage;
use Tests\DuskTestCase;
class CreateDishTest extends DuskTestCase class CreateDishTest extends DuskTestCase
{ {
use LoginHelpers; use LoginHelpers;
protected static $createDishTestPlanner = null; protected static $createDishTestPlanner = null;
protected static $createDishTestEmail = null; protected static $createDishTestEmail = null;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
@ -22,7 +22,7 @@ protected function setUp(): void
self::$testPlanner = self::$createDishTestPlanner; self::$testPlanner = self::$createDishTestPlanner;
self::$testEmail = self::$createDishTestEmail; self::$testEmail = self::$createDishTestEmail;
} }
protected function tearDown(): void protected function tearDown(): void
{ {
// Save the planner for next test method in this class // Save the planner for next test method in this class
@ -30,18 +30,18 @@ protected function tearDown(): void
self::$createDishTestEmail = self::$testEmail; self::$createDishTestEmail = self::$testEmail;
parent::tearDown(); parent::tearDown();
} }
public function testCanAccessDishesPage(): void public function test_can_access_dishes_page(): void
{ {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser); $this->loginAndGoToDishes($browser);
$browser->on(new DishesPage) $browser->on(new DishesPage)
->assertSee('MANAGE DISHES') ->assertSee('MANAGE DISHES')
->assertSee('Add Dish'); ->assertSee('Add Dish');
}); });
} }
// TODO: Moved to separate single-method test files to avoid static planner issues // TODO: Moved to separate single-method test files to avoid static planner issues
// See: OpenCreateDishModalTest, CreateDishFormValidationTest, CancelDishCreationTest, CreateDishSuccessTest // See: OpenCreateDishModalTest, CreateDishFormValidationTest, CancelDishCreationTest, CreateDishSuccessTest
} }

View file

@ -2,18 +2,19 @@
namespace Tests\Browser\Dishes; namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\LoginHelpers;
use App\Models\Planner; use App\Models\Planner;
use Laravel\Dusk\Browser;
use Tests\Browser\LoginHelpers;
use Tests\DuskTestCase;
class DeleteDishTest extends DuskTestCase class DeleteDishTest extends DuskTestCase
{ {
use LoginHelpers; use LoginHelpers;
protected static $deleteDishTestPlanner = null; protected static $deleteDishTestPlanner = null;
protected static $deleteDishTestEmail = null; protected static $deleteDishTestEmail = null;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
@ -21,7 +22,7 @@ protected function setUp(): void
self::$testPlanner = self::$deleteDishTestPlanner; self::$testPlanner = self::$deleteDishTestPlanner;
self::$testEmail = self::$deleteDishTestEmail; self::$testEmail = self::$deleteDishTestEmail;
} }
protected function tearDown(): void protected function tearDown(): void
{ {
// Save the planner for next test method in this class // Save the planner for next test method in this class
@ -29,14 +30,14 @@ protected function tearDown(): void
self::$deleteDishTestEmail = self::$testEmail; self::$deleteDishTestEmail = self::$testEmail;
parent::tearDown(); parent::tearDown();
} }
public function testCanAccessDeleteFeature(): void public function test_can_access_delete_feature(): void
{ {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser) $this->loginAndGoToDishes($browser)
->assertPathIs('/dishes') ->assertPathIs('/dishes')
->assertSee('MANAGE DISHES'); ->assertSee('MANAGE DISHES');
// Verify that delete functionality is available by looking for the text in the page source // Verify that delete functionality is available by looking for the text in the page source
$pageSource = $browser->driver->getPageSource(); $pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('Delete', $pageSource); $this->assertStringContainsString('Delete', $pageSource);
@ -59,7 +60,7 @@ public function testDeletionSafetyFeatures(): void
{ {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser); $this->loginAndGoToDishes($browser);
// Check that Livewire component includes all CRUD features // Check that Livewire component includes all CRUD features
$pageSource = $browser->driver->getPageSource(); $pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('MANAGE DISHES', $pageSource); $this->assertStringContainsString('MANAGE DISHES', $pageSource);
@ -73,4 +74,4 @@ public function testDeletionSafetyFeatures(): void
}); });
} }
*/ */
} }

View file

@ -3,16 +3,17 @@
namespace Tests\Browser\Dishes; namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser; use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\LoginHelpers; use Tests\Browser\LoginHelpers;
use Tests\DuskTestCase;
class DishDeletionSafetyTest extends DuskTestCase class DishDeletionSafetyTest extends DuskTestCase
{ {
use LoginHelpers; use LoginHelpers;
protected static $dishDeletionSafetyTestPlanner = null; protected static $dishDeletionSafetyTestPlanner = null;
protected static $dishDeletionSafetyTestEmail = null; protected static $dishDeletionSafetyTestEmail = null;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
@ -20,7 +21,7 @@ protected function setUp(): void
self::$testPlanner = self::$dishDeletionSafetyTestPlanner; self::$testPlanner = self::$dishDeletionSafetyTestPlanner;
self::$testEmail = self::$dishDeletionSafetyTestEmail; self::$testEmail = self::$dishDeletionSafetyTestEmail;
} }
protected function tearDown(): void protected function tearDown(): void
{ {
// Save the planner for next test method in this class // Save the planner for next test method in this class
@ -29,11 +30,11 @@ protected function tearDown(): void
parent::tearDown(); parent::tearDown();
} }
public function testDeletionSafetyFeatures(): void public function test_deletion_safety_features(): void
{ {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser); $this->loginAndGoToDishes($browser);
// Check that Livewire component includes all CRUD features // Check that Livewire component includes all CRUD features
$pageSource = $browser->driver->getPageSource(); $pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('MANAGE DISHES', $pageSource); $this->assertStringContainsString('MANAGE DISHES', $pageSource);
@ -46,4 +47,4 @@ public function testDeletionSafetyFeatures(): void
} }
}); });
} }
} }

View file

@ -2,18 +2,19 @@
namespace Tests\Browser\Dishes; namespace Tests\Browser\Dishes;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Tests\Browser\LoginHelpers;
use App\Models\Planner; use App\Models\Planner;
use Laravel\Dusk\Browser;
use Tests\Browser\LoginHelpers;
use Tests\DuskTestCase;
class EditDishTest extends DuskTestCase class EditDishTest extends DuskTestCase
{ {
use LoginHelpers; use LoginHelpers;
protected static $editDishTestPlanner = null; protected static $editDishTestPlanner = null;
protected static $editDishTestEmail = null; protected static $editDishTestEmail = null;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
@ -21,7 +22,7 @@ protected function setUp(): void
self::$testPlanner = self::$editDishTestPlanner; self::$testPlanner = self::$editDishTestPlanner;
self::$testEmail = self::$editDishTestEmail; self::$testEmail = self::$editDishTestEmail;
} }
protected function tearDown(): void protected function tearDown(): void
{ {
// Save the planner for next test method in this class // Save the planner for next test method in this class
@ -29,36 +30,36 @@ protected function tearDown(): void
self::$editDishTestEmail = self::$testEmail; self::$editDishTestEmail = self::$testEmail;
parent::tearDown(); parent::tearDown();
} }
public function testCanAccessEditFeature(): void public function test_can_access_edit_feature(): void
{ {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser) $this->loginAndGoToDishes($browser)
->assertPathIs('/dishes') ->assertPathIs('/dishes')
->assertSee('MANAGE DISHES'); ->assertSee('MANAGE DISHES');
// Verify that edit functionality is available by looking for the text in the page source // Verify that edit functionality is available by looking for the text in the page source
$pageSource = $browser->driver->getPageSource(); $pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('Edit', $pageSource); $this->assertStringContainsString('Edit', $pageSource);
}); });
} }
public function testEditModalComponents(): void public function test_edit_modal_components(): void
{ {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser) $this->loginAndGoToDishes($browser)
->assertSee('MANAGE DISHES') ->assertSee('MANAGE DISHES')
->assertSee('Add Dish'); ->assertSee('Add Dish');
}); });
} }
public function testDishesPageStructure(): void public function test_dishes_page_structure(): void
{ {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser) $this->loginAndGoToDishes($browser)
->assertSee('MANAGE DISHES') ->assertSee('MANAGE DISHES')
->assertSee('Add Dish'); ->assertSee('Add Dish');
// Check that the dishes CRUD structure is present // Check that the dishes CRUD structure is present
$pageSource = $browser->driver->getPageSource(); $pageSource = $browser->driver->getPageSource();
// Either we have dishes with Edit/Delete buttons OR "No dishes found" message // Either we have dishes with Edit/Delete buttons OR "No dishes found" message
@ -70,4 +71,4 @@ public function testDishesPageStructure(): void
} }
}); });
} }
} }

View file

@ -2,25 +2,29 @@
namespace Tests\Browser; namespace Tests\Browser;
use App\Models\Planner;
use Illuminate\Support\Facades\Hash;
use Laravel\Dusk\Browser; use Laravel\Dusk\Browser;
use Tests\DuskTestCase; use Tests\DuskTestCase;
trait LoginHelpers trait LoginHelpers
{ {
protected static $testPlanner = null; protected static $testPlanner = null;
protected static $testEmail = null; protected static $testEmail = null;
protected static $testPassword = 'password'; protected static $testPassword = 'password';
protected function ensureTestPlannerExists(): void protected function ensureTestPlannerExists(): void
{ {
// Always create a fresh planner for each test class to avoid session conflicts // Always create a fresh planner for each test class to avoid session conflicts
if (self::$testPlanner === null || !self::$testPlanner->exists) { if (self::$testPlanner === null || ! self::$testPlanner->exists) {
// Generate unique email for this test run // Generate unique email for this test run
self::$testEmail = fake()->unique()->safeEmail(); self::$testEmail = fake()->unique()->safeEmail();
self::$testPlanner = \App\Models\Planner::factory()->create([ self::$testPlanner = Planner::factory()->create([
'email' => self::$testEmail, 'email' => self::$testEmail,
'password' => \Illuminate\Support\Facades\Hash::make(self::$testPassword), 'password' => Hash::make(self::$testPassword),
]); ]);
} }
} }
@ -28,21 +32,21 @@ protected function ensureTestPlannerExists(): void
protected function loginAndNavigate(Browser $browser, string $page = '/dashboard'): Browser protected function loginAndNavigate(Browser $browser, string $page = '/dashboard'): Browser
{ {
$this->ensureTestPlannerExists(); $this->ensureTestPlannerExists();
// Clear browser session and cookies to start fresh // Clear browser session and cookies to start fresh
$browser->driver->manage()->deleteAllCookies(); $browser->driver->manage()->deleteAllCookies();
return $browser->visit('http://dishplanner_app:8000/login') return $browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', DuskTestCase::TIMEOUT_SHORT) ->waitFor('input[id="email"]', DuskTestCase::TIMEOUT_SHORT)
->clear('input[id="email"]') ->clear('input[id="email"]')
->type('input[id="email"]', self::$testEmail) ->type('input[id="email"]', self::$testEmail)
->clear('input[id="password"]') ->clear('input[id="password"]')
->type('input[id="password"]', self::$testPassword) ->type('input[id="password"]', self::$testPassword)
->press('Sign In') ->press('Sign In')
->waitForLocation('/dashboard', DuskTestCase::TIMEOUT_MEDIUM) // Wait for successful login redirect ->waitForLocation('/dashboard', DuskTestCase::TIMEOUT_MEDIUM) // Wait for successful login redirect
->pause(DuskTestCase::PAUSE_SHORT) // Brief pause for any initialization ->pause(DuskTestCase::PAUSE_SHORT) // Brief pause for any initialization
->visit('http://dishplanner_app:8000' . $page) ->visit('http://dishplanner_app:8000'.$page)
->pause(DuskTestCase::PAUSE_MEDIUM); // Let Livewire components initialize ->pause(DuskTestCase::PAUSE_MEDIUM); // Let Livewire components initialize
} }
protected function loginAndGoToDishes(Browser $browser): Browser protected function loginAndGoToDishes(Browser $browser): Browser

View file

@ -20,7 +20,7 @@ public function url(): string
public function assert(Browser $browser): void public function assert(Browser $browser): void
{ {
$browser->assertPathIs($this->url()) $browser->assertPathIs($this->url())
->assertSee('MANAGE DISHES'); ->assertSee('MANAGE DISHES');
} }
/** /**
@ -44,8 +44,8 @@ public function elements(): array
public function openCreateModal(Browser $browser): void public function openCreateModal(Browser $browser): void
{ {
$browser->waitFor('@add-button') $browser->waitFor('@add-button')
->click('@add-button') ->click('@add-button')
->pause(1000); ->pause(1000);
} }
/** /**
@ -83,4 +83,4 @@ public function assertNoDishes(Browser $browser): void
{ {
$browser->assertSee('No dishes found'); $browser->assertSee('No dishes found');
} }
} }

View file

@ -21,8 +21,8 @@ public function url(): string
public function assert(Browser $browser): void public function assert(Browser $browser): void
{ {
$browser->assertPathIs($this->url()) $browser->assertPathIs($this->url())
->assertSee('Login') ->assertSee('Login')
->assertPresent((new LoginForm)->selector()); ->assertPresent((new LoginForm)->selector());
} }
/** /**
@ -44,4 +44,4 @@ public function goToRegistration(Browser $browser): void
{ {
$browser->click('@register-link'); $browser->click('@register-link');
} }
} }

View file

@ -18,4 +18,4 @@ public static function siteElements(): array
'@alert' => '[role="alert"]', '@alert' => '[role="alert"]',
]; ];
} }
} }

View file

@ -14,7 +14,7 @@ public function url(): string
public function assert(Browser $browser): void public function assert(Browser $browser): void
{ {
$browser->assertPathIs($this->url()) $browser->assertPathIs($this->url())
->assertSee('SCHEDULE'); ->assertSee('SCHEDULE');
} }
public function elements(): array public function elements(): array
@ -34,49 +34,49 @@ public function elements(): array
public function clickGenerate(Browser $browser): void public function clickGenerate(Browser $browser): void
{ {
$browser->waitFor('@generate-button') $browser->waitFor('@generate-button')
->click('@generate-button') ->click('@generate-button')
->pause(2000); // Wait for generation ->pause(2000); // Wait for generation
} }
public function clickClearMonth(Browser $browser): void public function clickClearMonth(Browser $browser): void
{ {
$browser->waitFor('@clear-month-button') $browser->waitFor('@clear-month-button')
->click('@clear-month-button') ->click('@clear-month-button')
->pause(1000); ->pause(1000);
} }
public function goToPreviousMonth(Browser $browser): void public function goToPreviousMonth(Browser $browser): void
{ {
$browser->waitFor('@previous-month') $browser->waitFor('@previous-month')
->click('@previous-month') ->click('@previous-month')
->pause(500); ->pause(500);
} }
public function goToNextMonth(Browser $browser): void public function goToNextMonth(Browser $browser): void
{ {
$browser->waitFor('@next-month') $browser->waitFor('@next-month')
->click('@next-month') ->click('@next-month')
->pause(500); ->pause(500);
} }
public function selectMonth(Browser $browser, int $month): void public function selectMonth(Browser $browser, int $month): void
{ {
$browser->waitFor('@month-select') $browser->waitFor('@month-select')
->select('@month-select', $month) ->select('@month-select', $month)
->pause(500); ->pause(500);
} }
public function selectYear(Browser $browser, int $year): void public function selectYear(Browser $browser, int $year): void
{ {
$browser->waitFor('@year-select') $browser->waitFor('@year-select')
->select('@year-select', $year) ->select('@year-select', $year)
->pause(500); ->pause(500);
} }
public function toggleClearExisting(Browser $browser): void public function toggleClearExisting(Browser $browser): void
{ {
$browser->waitFor('@clear-existing-checkbox') $browser->waitFor('@clear-existing-checkbox')
->click('@clear-existing-checkbox'); ->click('@clear-existing-checkbox');
} }
public function selectUser(Browser $browser, string $userName): void public function selectUser(Browser $browser, string $userName): void
@ -84,7 +84,7 @@ public function selectUser(Browser $browser, string $userName): void
$browser->check("input[type='checkbox'][value]", $userName); $browser->check("input[type='checkbox'][value]", $userName);
} }
public function assertSuccessMessage(Browser $browser, string $message = null): void public function assertSuccessMessage(Browser $browser, ?string $message = null): void
{ {
if ($message) { if ($message) {
$browser->assertSee($message); $browser->assertSee($message);

View file

@ -20,7 +20,7 @@ public function url(): string
public function assert(Browser $browser): void public function assert(Browser $browser): void
{ {
$browser->assertPathIs($this->url()) $browser->assertPathIs($this->url())
->assertSee('MANAGE USERS'); ->assertSee('MANAGE USERS');
} }
/** /**
@ -43,8 +43,8 @@ public function elements(): array
public function openCreateModal(Browser $browser): void public function openCreateModal(Browser $browser): void
{ {
$browser->waitFor('@add-button') $browser->waitFor('@add-button')
->click('@add-button') ->click('@add-button')
->pause(1000); ->pause(1000);
} }
/** /**
@ -63,8 +63,8 @@ public function clickDeleteForUser(Browser $browser, string $userName): void
public function clickFirstDeleteButton(Browser $browser): void public function clickFirstDeleteButton(Browser $browser): void
{ {
$browser->waitFor('button.bg-danger', 5) $browser->waitFor('button.bg-danger', 5)
->click('button.bg-danger') ->click('button.bg-danger')
->pause(1000); ->pause(1000);
} }
/** /**
@ -90,4 +90,4 @@ public function assertSuccessMessage(Browser $browser, string $message): void
{ {
$browser->assertSee($message); $browser->assertSee($message);
} }
} }

View file

@ -2,9 +2,9 @@
namespace Tests\Browser; namespace Tests\Browser;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Laravel\Dusk\Browser; use Laravel\Dusk\Browser;
use Tests\DuskTestCase; use Tests\DuskTestCase;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class RedirectTest extends DuskTestCase class RedirectTest extends DuskTestCase
{ {
@ -13,26 +13,26 @@ class RedirectTest extends DuskTestCase
/** /**
* Test that unauthenticated users are redirected to login * Test that unauthenticated users are redirected to login
*/ */
public function testUnauthenticatedRedirectsToLogin() public function test_unauthenticated_redirects_to_login()
{ {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$browser->visit('http://dishplanner_app:8000/dashboard') $browser->visit('http://dishplanner_app:8000/dashboard')
->assertPathIs('/login') ->assertPathIs('/login')
->assertSee('Login'); ->assertSee('Login');
}); });
} }
/** /**
* Test that login page loads correctly * Test that login page loads correctly
*/ */
public function testLoginPageLoads() public function test_login_page_loads()
{ {
$this->browse(function (Browser $browser) { $this->browse(function (Browser $browser) {
$browser->visit('http://dishplanner_app:8000/login') $browser->visit('http://dishplanner_app:8000/login')
->assertPathIs('/login') ->assertPathIs('/login')
->assertSee('Login') ->assertSee('Login')
->assertSee('Email') ->assertSee('Email')
->assertSee('Password'); ->assertSee('Password');
}); });
} }
} }

Some files were not shown because too many files have changed in this diff Show more