Compare commits

..

19 commits

Author SHA1 Message Date
a84296ae29 Merge pull request '55 - Add browser test for registration' (#62) from 55-add-browser-test-for-registration into release/v0.9.0
Some checks failed
CI / ci-image (push) Failing after 3m52s
CI / ci (push) Has been skipped
Reviewed-on: #62
2026-08-21 00:10:56 +02:00
7b14aa240c Merge branch 'release/v0.9.0' into 55-add-browser-test-for-registration
All checks were successful
CI / ci-image (pull_request) Successful in 1m1s
CI / ci (pull_request) Successful in 9m53s
2026-08-20 23:44:34 +02:00
b048aca93b 55 - Add browser test for registration
All checks were successful
CI / ci-image (pull_request) Successful in 55s
CI / ci (pull_request) Successful in 2m10s
2026-08-20 23:36:30 +02:00
9054c25d0e 55 - Move the dev image to Debian so Playwright can run
All checks were successful
CI / ci-image (push) Successful in 56s
CI / ci (push) Successful in 2m20s
2026-08-20 23:32:21 +02:00
b543f5fdb3 56 - Cover the refresh-csrf route with feature tests 2026-08-20 22:10:32 +02:00
c1d8008b5d 56 - Cover session invalidation and fix pest-browser path handling
All checks were successful
CI / ci-image (push) Successful in 2m2s
CI / ci (push) Successful in 2m53s
2026-08-20 22:05:39 +02:00
95c5a72294 56 - Add browser test for logout 2026-08-20 21:45:28 +02:00
c3a5532028 60 - Make schedule test assertion counts deterministic 2026-08-20 20:24:09 +02:00
7f7a9450bf 61 - Bake node modules and Chromium into the CI image
All checks were successful
CI / ci-image (push) Successful in 48m48s
CI / ci (push) Successful in 11m52s
2026-08-19 19:32:08 +02:00
cfad3dcb1b 61 - Authenticate composer dist downloads and drop the dead registry cache
All checks were successful
CI / ci-image (push) Successful in 25m46s
CI / ci (push) Successful in 22m39s
2026-08-19 15:49:09 +02:00
e0e3bb0936 Merge branch '35-enhance-dashboard-page' into release/v0.9.0 2026-08-19 15:43:22 +02:00
2eccf2c690 35 - Add dashboard stats for dishes, users, meals and favorites 2026-08-19 15:42:39 +02:00
1e826e7cae 54 - Skip CI image rebuild when the lockfile tag already exists
Some checks failed
CI / ci (push) Has been cancelled
CI / ci-image (push) Has been cancelled
2026-08-19 14:33:45 +02:00
cb2dbd9299 54 - Fix Pint import issues in tests/Pest.php
Some checks failed
CI / ci-image (push) Has been cancelled
CI / ci (push) Has been cancelled
2026-08-19 13:40:43 +02:00
175290b707 54 - Resolve Playwright Chromium libraries via nix-ld 2026-08-19 13:12:33 +02:00
dcfa73b7db 54 - Replace Dusk with Pest 4 browser testing 2026-08-19 02:09:42 +02:00
cca3f29823 52 - Move dev compose to docker/dev/ to match the standard layout 2026-08-19 00:13:46 +02:00
ba7df4b5ad 53 - Force phpunit.xml env vars to protect the dev database 2026-08-18 23:42:00 +02:00
01659923fa 51 - Indicate eating out when skipping a meal 2026-08-18 22:01:59 +02:00
58 changed files with 4254 additions and 3183 deletions

View file

@ -1,24 +0,0 @@
APP_NAME=DishPlanner
APP_ENV=testing
APP_KEY=base64:KSKZNT+cJuaBRBv4Y2HQqav6hzREKoLkNIKN8yszU1Q=
APP_DEBUG=true
APP_URL=http://dishplanner_app:8000
LOG_CHANNEL=single
# Test database
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=dishplanner_test
DB_USERNAME=dishplanner
DB_PASSWORD=dishplanner
BROADCAST_DRIVER=log
CACHE_DRIVER=array
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
MAIL_MAILER=array

View file

@ -29,18 +29,38 @@ jobs:
- name: Compute image tag from lockfile - name: Compute image tag from lockfile
id: meta id: meta
run: | run: |
HASH="$(sha256sum composer.lock | cut -c1-12)" HASH="$(cat composer.lock package-lock.json | sha256sum | cut -c1-12)"
echo "tag=php8.3-${HASH}" >> "$GITHUB_OUTPUT" echo "tag=php8.3-${HASH}" >> "$GITHUB_OUTPUT"
- name: Check whether image already exists
id: exists
run: |
set -euo pipefail
TOKEN="$(curl -sf "https://forge.lvl0.xyz/v2/token?service=container_registry&scope=repository:lvl0/dishplanner-ci:pull" | grep -o '"token":"[^"]*"' | head -n1 | cut -d'"' -f4)"
if [ -z "$TOKEN" ]; then
echo "Registry token fetch failed; cannot check for an existing image." >&2
exit 1
fi
CODE="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $TOKEN" \
-H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \
"https://forge.lvl0.xyz/v2/lvl0/dishplanner-ci/manifests/${{ steps.meta.outputs.tag }}")"
if [ "$CODE" = "200" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- name: Build and push CI image - name: Build and push CI image
if: steps.exists.outputs.found == 'false'
uses: https://data.forgejo.org/docker/build-push-action@v5 uses: https://data.forgejo.org/docker/build-push-action@v5
with: with:
context: . context: .
file: docker/build/Dockerfile.ci file: docker/build/Dockerfile.ci
push: true push: true
tags: forge.lvl0.xyz/lvl0/dishplanner-ci:${{ steps.meta.outputs.tag }} tags: forge.lvl0.xyz/lvl0/dishplanner-ci:${{ steps.meta.outputs.tag }}
cache-from: type=registry,ref=forge.lvl0.xyz/lvl0/dishplanner-ci:buildcache secrets: |
cache-to: type=registry,ref=forge.lvl0.xyz/lvl0/dishplanner-ci:buildcache,mode=max gh_pat=${{ secrets.GH_PAT }}
ci: ci:
needs: ci-image needs: ci-image
@ -65,4 +85,13 @@ jobs:
run: vendor/bin/phpstan analyse --memory-limit=1G run: vendor/bin/phpstan analyse --memory-limit=1G
- name: Tests - name: Tests
run: php -d memory_limit=512M vendor/bin/phpunit run: vendor/bin/pest
- name: Restore frontend dependencies
run: cp -a /opt/deps-node/node_modules ./node_modules
- name: Build frontend assets
run: npm run build
- name: Browser tests
run: vendor/bin/pest tests/Browser

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
/.phpunit.cache /.phpunit.cache
/tests/Browser/Screenshots
/coverage /coverage
/node_modules /node_modules
/public/build /public/build

View file

@ -13,7 +13,7 @@ ## Reporting issues
## Development setup ## Development setup
Requires PHP 8.2+ and a container runtime (Podman or Docker). The development Requires PHP 8.2+ and a container runtime (Podman or Docker). The development
environment runs in containers defined by `docker-compose.yml`. environment runs in containers defined by `docker/dev/docker-compose.yml`.
On NixOS, or anywhere with Nix installed: On NixOS, or anywhere with Nix installed:
@ -49,7 +49,7 @@ ## Development setup
| Mailhog | http://localhost:8025 | | Mailhog | http://localhost:8025 |
| MariaDB | localhost:3306 | | MariaDB | localhost:3306 |
Without Nix, start the same containers directly from `docker-compose.yml`. Without Nix, start the same containers directly from `docker/dev/docker-compose.yml`.
Contributions improving the setup instructions for other platforms are welcome. Contributions improving the setup instructions for other platforms are welcome.
## Before opening a pull request ## Before opening a pull request

View file

@ -1,19 +1,39 @@
# Development Dockerfile with FrankenPHP # Development Dockerfile with FrankenPHP
FROM dunglas/frankenphp:latest-php8.3-alpine #
# Debian rather than Alpine so Playwright can run its own Chromium: it treats
# debian12 as an officially supported platform and has no musl build at all.
FROM dunglas/frankenphp:latest-php8.3-bookworm
# Install system dependencies + development tools # Install system dependencies + development tools. Node comes from NodeSource
RUN apk add --no-cache \ # rather than apt: bookworm ships Node 18, below what Vite 6 targets.
nodejs \ RUN apt-get update \
npm \ && apt-get install -y --no-install-recommends \
git \ ca-certificates \
mysql-client \ curl \
vim \ gnupg \
bash \ git \
nano default-mysql-client \
vim \
nano \
bash \
unzip \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Playwright's browser lives outside the bind-mounted /app so a host node_modules
# never shadows it; --with-deps pulls the shared libraries Chromium needs.
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
COPY package.json package-lock.json /tmp/pw/
RUN cd /tmp/pw \
&& npm ci --no-audit --no-fund \
&& ./node_modules/.bin/playwright install --with-deps chromium \
&& rm -rf /tmp/pw
# Install PHP extensions including xdebug for development # Install PHP extensions including xdebug for development
RUN install-php-extensions \ RUN install-php-extensions \
pdo_mysql \ pdo_mysql \
sockets \
opcache \ opcache \
zip \ zip \
gd \ gd \

View file

@ -18,7 +18,7 @@ ## 🚀 Self-hosting
The production image is available at `forge.lvl0.xyz/lvl0/dishplanner:latest`. See [CHANGELOG.md](CHANGELOG.md) before upgrading. The production image is available at `forge.lvl0.xyz/lvl0/dishplanner:latest`. See [CHANGELOG.md](CHANGELOG.md) before upgrading.
### docker-compose.yml ### docker-compose.prod.yml
```yaml ```yaml
services: services:

View file

@ -30,6 +30,15 @@ class ScheduleCalendar extends Component
public $regenerateUserId = null; public $regenerateUserId = null;
// Skip modal
public bool $showSkipModal = false;
public ?string $skipDate = null;
public ?int $skipUserId = null;
public string $skipReason = '';
// Edit dish modal // Edit dish modal
public $showEditDishModal = false; public $showEditDishModal = false;
@ -145,26 +154,52 @@ public function confirmRegenerate(): void
} }
public function skipDay($date, $userId): void public function skipDay($date, $userId): void
{
if (! $this->authorizeUser($userId)) {
session()->flash('error', 'Unauthorized action.');
return;
}
$this->skipDate = $date;
$this->skipUserId = $userId;
$this->skipReason = '';
$this->showSkipModal = true;
}
public function confirmSkip(): void
{ {
try { try {
if (! $this->authorizeUser($userId)) { if (! $this->authorizeUser((int) $this->skipUserId)) {
session()->flash('error', 'Unauthorized action.'); session()->flash('error', 'Unauthorized action.');
return; return;
} }
$this->validate([
'skipReason' => ['nullable', 'string', 'max:255'],
]);
$reason = trim($this->skipReason) ?: null;
$action = new SkipScheduledUserDishForDateAction; $action = new SkipScheduledUserDishForDateAction;
$action->execute( $action->execute(
auth()->user(), auth()->user(),
Carbon::parse($date), Carbon::parse($this->skipDate),
$userId $this->skipUserId,
$reason
); );
$this->showSkipModal = false;
$this->skipDate = null;
$this->skipUserId = null;
$this->skipReason = '';
$this->loadCalendar(); $this->loadCalendar();
session()->flash('success', 'Day skipped successfully!'); session()->flash('success', 'Day skipped successfully!');
} catch (Exception $e) { } catch (Exception $e) {
Log::error('Skip day failed', ['exception' => $e, 'date' => $date, 'userId' => $userId]); Log::error('Skip day failed', ['exception' => $e, 'date' => $this->skipDate, 'userId' => $this->skipUserId]);
session()->flash('error', 'Unable to skip day. Please try again.'); session()->flash('error', 'Unable to skip day. Please try again.');
} }
} }
@ -181,6 +216,10 @@ public function cancel(): void
$this->showRegenerateModal = false; $this->showRegenerateModal = false;
$this->regenerateDate = null; $this->regenerateDate = null;
$this->regenerateUserId = null; $this->regenerateUserId = null;
$this->showSkipModal = false;
$this->skipDate = null;
$this->skipUserId = null;
$this->skipReason = '';
$this->showEditDishModal = false; $this->showEditDishModal = false;
$this->editDate = null; $this->editDate = null;
$this->editUserId = null; $this->editUserId = null;
@ -436,6 +475,7 @@ public function saveDish(): void
[ [
'user_dish_id' => $userDish->id, 'user_dish_id' => $userDish->id,
'is_skipped' => false, 'is_skipped' => false,
'skip_reason' => null,
] ]
); );

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
* @property string|null $skip_reason
* *
* @method static create(array $array) * @method static create(array $array)
* @method static ScheduledUserDishFactory factory($count = null, $state = []) * @method static ScheduledUserDishFactory factory($count = null, $state = [])
@ -31,6 +32,7 @@ class ScheduledUserDish extends Model
'user_id', 'user_id',
'user_dish_id', 'user_dish_id',
'is_skipped', 'is_skipped',
'skip_reason',
]; ];
protected $casts = [ protected $casts = [

View file

@ -18,14 +18,15 @@
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"larastan/larastan": "^3.10", "larastan/larastan": "^3.10",
"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", "pestphp/pest": "^4.7",
"phpunit/phpunit": "^11.0.1" "pestphp/pest-plugin-browser": "^4.3",
"pestphp/pest-plugin-laravel": "^4.0",
"phpstan/phpstan-mockery": "^2.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
@ -61,7 +62,8 @@
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite" "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
], ],
"test": [ "test": [
"@php artisan test" "@php artisan test",
"@php vendor/bin/pest tests/Browser"
], ],
"test:coverage": [ "test:coverage": [
"Composer\\Config::disableProcessTimeout", "Composer\\Config::disableProcessTimeout",

3452
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,13 +2,13 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\User; use App\Models\Planner;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/** /**
* @extends Factory<User> * @extends Factory<Planner>
*/ */
class PlannerFactory extends Factory class PlannerFactory extends Factory
{ {

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('scheduled_user_dishes', function (Blueprint $table) {
$table->string('skip_reason')->nullable()->after('is_skipped');
});
}
public function down(): void
{
Schema::table('scheduled_user_dishes', function (Blueprint $table) {
$table->dropColumn('skip_reason');
});
}
};

View file

@ -1,10 +1,13 @@
# CI image: PHP + Composer only, no runtime server or frontend toolchain. # CI image: PHP + Composer + Node/npm. Unit/feature tests run against SQLite in
# Tests run against SQLite in memory (see .env.testing), so no database client # memory (see .env.testing), so no database client or cache extension is needed.
# or cache extension is needed. # Browser tests need the `sockets` extension (pest-plugin-browser boots Laravel
# in-process) and Node/npm to drive Playwright; the Chromium binary itself is
# installed per run so it always matches the playwright version from the lockfile.
# #
# Published as dishplanner-ci:php8.3-<composer.lock hash>. The CI workflow # Published as dishplanner-ci:php8.3-<composer.lock hash>. The CI workflow
# builds and tags this image from the current lockfile, so a dependency change # builds and tags this image from the current lockfile, so a PHP dependency
# automatically yields a fresh, uniquely-tagged image (no manual revision bump). # change automatically yields a fresh, uniquely-tagged image (no manual revision
# bump).
# #
# Debian-based rather than Alpine to avoid the DNS resolution timeouts against # Debian-based rather than Alpine to avoid the DNS resolution timeouts against
# codeload.github.com that the Alpine base hit during composer install. # codeload.github.com that the Alpine base hit during composer install.
@ -15,6 +18,7 @@ COPY --from=mlocati/php-extension-installer:2 /usr/bin/install-php-extensions /u
RUN install-php-extensions \ RUN install-php-extensions \
pdo_sqlite \ pdo_sqlite \
sockets \
mbstring \ mbstring \
dom \ dom \
xml \ xml \
@ -22,23 +26,44 @@ RUN install-php-extensions \
pcntl \ pcntl \
zip zip
# git is needed by the checkout action; nodejs runs the Forgejo JavaScript # git is needed by the checkout action; nodejs+npm run the Forgejo JavaScript
# actions (checkout, cache); unzip lets Composer extract dist archives. # actions (checkout, cache) and Playwright; unzip lets Composer extract dist
# archives.
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip nodejs \ && apt-get install -y --no-install-recommends git unzip nodejs npm \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# Bake the project's PHP dependencies (dev included) into the image so CI # 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 # restores them with a local copy instead of paying a per-run composer install
# over the network. --prefer-source clones via git instead of fetching dist # over the network.
# archives, avoiding the codeload.github.com rate limits the runner hits under #
# --prefer-dist. # The token lifts GitHub's API rate limit from 60 to 5000 requests/hour, which
# is what forced --prefer-source before; dist archives need no special handling.
# #
# --no-scripts skips `php artisan package:discover` (the app isn't present # --no-scripts skips `php artisan package:discover` (the app isn't present
# here). CI runs `composer install` after restoring vendor, which regenerates # here). CI runs `composer install` after restoring vendor, which regenerates
# bootstrap/cache. # bootstrap/cache.
WORKDIR /opt/deps WORKDIR /opt/deps
COPY composer.json composer.lock ./ COPY composer.json composer.lock ./
RUN composer install --no-interaction --no-progress --prefer-source --no-scripts RUN --mount=type=secret,id=gh_pat \
if [ -s /run/secrets/gh_pat ]; then \
composer config --global github-oauth.github.com "$(cat /run/secrets/gh_pat)" || exit 1; \
fi; \
composer install --no-interaction --no-progress --no-scripts; \
STATUS=$?; \
composer config --global --unset github-oauth.github.com >/dev/null 2>&1 || true; \
exit $STATUS
# Bake the Node dependencies and the Chromium build alongside the PHP ones, so a
# run installs neither. Both are keyed to package-lock.json via the image tag.
WORKDIR /opt/deps-node
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund \
&& PLAYWRIGHT_BROWSERS_PATH=/opt/playwright ./node_modules/.bin/playwright install --with-deps chromium
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
# Cosmetic only — the runner overrides this with its own --workdir.
WORKDIR /workspace

View file

@ -1,10 +1,11 @@
# Local Development Docker Compose # Local Development Docker Compose
name: dishplanner
version: '3.8' version: '3.8'
services: services:
app: app:
build: build:
context: . context: ../..
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev
container_name: dishplanner_app container_name: dishplanner_app
restart: unless-stopped restart: unless-stopped
@ -39,9 +40,12 @@ services:
# Vite # Vite
VITE_HOST: "0.0.0.0" VITE_HOST: "0.0.0.0"
# Chromium is baked into the image outside /app; see Dockerfile.dev
PLAYWRIGHT_BROWSERS_PATH: "/opt/playwright"
volumes: volumes:
# Mount entire project for hot reload with SELinux context # Mount entire project for hot reload with SELinux context
- .:/app:Z - ../..:/app:Z
# Named volumes for performance and permission isolation # Named volumes for performance and permission isolation
- app_vendor:/app/vendor - app_vendor:/app/vendor
- app_node_modules:/app/node_modules - app_node_modules:/app/node_modules
@ -64,7 +68,7 @@ services:
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
# Initialize with SQL scripts # Initialize with SQL scripts
- ./docker/mysql-init:/docker-entrypoint-initdb.d - ../mysql-init:/docker-entrypoint-initdb.d
healthcheck: healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s interval: 10s
@ -84,21 +88,6 @@ services:
networks: networks:
- dishplanner - dishplanner
# Selenium for E2E testing with Dusk
selenium:
image: selenium/standalone-chrome:latest
container_name: dishplanner_selenium
restart: unless-stopped
ports:
- "4444:4444" # Selenium server
- "7900:7900" # VNC server for debugging
volumes:
- /dev/shm:/dev/shm
networks:
- dishplanner
environment:
- SE_VNC_PASSWORD=secret
# Optional: Redis for caching/sessions # Optional: Redis for caching/sessions
# redis: # redis:
# image: redis:alpine # image: redis:alpine

50
package-lock.json generated
View file

@ -1,5 +1,5 @@
{ {
"name": "app", "name": "dishplanner",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@ -13,6 +13,7 @@
"axios": "^1.7.4", "axios": "^1.7.4",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^1.3.0", "laravel-vite-plugin": "^1.3.0",
"playwright": "^1.62.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"vite": "^6.4.1" "vite": "^6.4.1"
@ -2220,6 +2221,53 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",

View file

@ -11,6 +11,7 @@
"axios": "^1.7.4", "axios": "^1.7.4",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^1.3.0", "laravel-vite-plugin": "^1.3.0",
"playwright": "^1.62.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"vite": "^6.4.1" "vite": "^6.4.1"

File diff suppressed because it is too large Load diff

View file

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
beStrictAboutTestsThatDoNotTestAnything="false"
colors="true"
processIsolation="false"
stopOnError="false"
stopOnFailure="false"
cacheDirectory=".phpunit.cache"
backupStaticProperties="false">
<testsuites>
<testsuite name="Browser Test Suite">
<directory suffix="Test.php">./tests/Browser</directory>
</testsuite>
</testsuites>
</phpunit>

View file

@ -24,17 +24,18 @@
</exclude> </exclude>
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <server name="APP_ENV" value="testing" force="true"/>
<env name="BCRYPT_ROUNDS" value="4"/> <server name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
<env name="CACHE_STORE" value="array"/> <server name="BCRYPT_ROUNDS" value="4" force="true"/>
<env name="MAIL_MAILER" value="array"/> <server name="CACHE_STORE" value="array" force="true"/>
<env name="PULSE_ENABLED" value="false"/> <server name="MAIL_MAILER" value="array" force="true"/>
<env name="QUEUE_CONNECTION" value="sync"/> <server name="PULSE_ENABLED" value="false" force="true"/>
<env name="SESSION_DRIVER" value="array"/> <server name="QUEUE_CONNECTION" value="sync" force="true"/>
<env name="TELESCOPE_ENABLED" value="false"/> <server name="SESSION_DRIVER" value="array" force="true"/>
<env name="DB_CONNECTION" value="sqlite"/> <server name="TELESCOPE_ENABLED" value="false" force="true"/>
<env name="DB_DATABASE" value=":memory:"/> <server name="DB_CONNECTION" value="sqlite" force="true"/>
<server name="DB_DATABASE" value=":memory:" force="true"/>
</php> </php>
</phpunit> </phpunit>

View file

@ -3,6 +3,44 @@
<div class="max-w-7xl mx-auto"> <div class="max-w-7xl mx-auto">
<h1 class="text-2xl font-syncopate text-accent-blue mb-8">DASHBOARD</h1> <h1 class="text-2xl font-syncopate text-accent-blue mb-8">DASHBOARD</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="border-2 border-secondary rounded-lg p-6">
<p class="text-4xl font-bold text-accent-blue">{{ $stats['dish_count'] }}</p>
<h3 class="text-sm font-bold uppercase tracking-wide text-gray-100 mt-2">Dishes</h3>
</div>
<div class="border-2 border-secondary rounded-lg p-6">
<p class="text-4xl font-bold text-primary">{{ $stats['user_count'] }}</p>
<h3 class="text-sm font-bold uppercase tracking-wide text-gray-100 mt-2">Users</h3>
</div>
<div class="border-2 border-secondary rounded-lg p-6">
<p class="text-4xl font-bold text-success">{{ $stats['meals_this_month'] }}</p>
<h3 class="text-sm font-bold uppercase tracking-wide text-gray-100 mt-2">Meals this month</h3>
</div>
</div>
<div class="border-2 border-secondary rounded-lg p-6 mb-8">
<h2 class="text-xl font-bold text-accent-blue mb-4">Favorite dishes</h2>
@if ($stats['favorite_dishes']->isEmpty())
<p class="text-gray-100">No users yet.</p>
@else
<ul class="space-y-3">
@foreach ($stats['favorite_dishes'] as $favorite)
<li class="flex justify-between gap-4">
<span class="font-bold text-primary">{{ $favorite['user']->name }}</span>
@if ($favorite['dish'])
<span class="text-gray-100">{{ $favorite['dish']->name }} <span class="text-gray-400">({{ $favorite['count'] }})</span></span>
@else
<span class="text-gray-400">No meals yet</span>
@endif
</li>
@endforeach
</ul>
@endif
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<a href="{{ route('users.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200"> <a href="{{ route('users.index') }}" class="border-2 border-secondary rounded-lg p-6 hover:bg-gray-700 transition-colors duration-200">
<h3 class="text-xl font-bold text-primary mb-2">Manage Users</h3> <h3 class="text-xl font-bold text-primary mb-2">Manage Users</h3>

View file

@ -68,7 +68,7 @@ class="w-5 h-5 bg-gray-600 hover:bg-primary text-gray-300 hover:text-white round
<div class="w-4 h-4 bg-white text-primary rounded-full flex items-center justify-center text-xs font-bold mr-1"> <div class="w-4 h-4 bg-white text-primary rounded-full flex items-center justify-center text-xs font-bold mr-1">
{{ strtoupper(substr($scheduled->user->name, 0, 1)) }} {{ strtoupper(substr($scheduled->user->name, 0, 1)) }}
</div> </div>
<span class="truncate">{{ $scheduled->userDish?->dish?->name ?? 'Skipped' }}</span> <span class="truncate">{{ $scheduled->userDish?->dish?->name ?? ($scheduled->skip_reason ?: 'Skipped') }}</span>
</div> </div>
<!-- Action buttons --> <!-- Action buttons -->
@ -143,7 +143,7 @@ class="w-7 h-7 bg-gray-600 hover:bg-primary text-gray-300 hover:text-white round
{{ strtoupper(substr($scheduled->user->name, 0, 1)) }} {{ strtoupper(substr($scheduled->user->name, 0, 1)) }}
</div> </div>
<div> <div>
<div class="font-medium">{{ $scheduled->userDish?->dish?->name ?? 'Skipped' }}</div> <div class="font-medium">{{ $scheduled->userDish?->dish?->name ?? ($scheduled->skip_reason ?: 'Skipped') }}</div>
<div class="text-xs opacity-75">{{ $scheduled->user->name }}</div> <div class="text-xs opacity-75">{{ $scheduled->user->name }}</div>
</div> </div>
</div> </div>
@ -212,6 +212,36 @@ class="px-4 py-2 bg-warning text-white rounded hover:bg-yellow-600 transition-co
</div> </div>
@endif @endif
<!-- Skip Modal -->
@if($showSkipModal)
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-gray-600 border-2 border-secondary rounded-lg p-6 w-full max-w-md mx-4">
<h2 class="text-xl text-accent-blue mb-4">Skip Day</h2>
<p class="text-gray-100 mb-6">
Skip this meal? Optionally add a reason, e.g. "eating out".
</p>
<div class="mb-6">
<label class="block text-sm font-medium mb-2">Reason (optional)</label>
<input type="text" wire:model="skipReason" maxlength="255"
placeholder="e.g. eating out"
class="w-full p-2 border rounded bg-gray-700 border-secondary text-gray-100 focus:bg-gray-900 focus:outline-none focus:border-accent-blue">
</div>
<div class="flex justify-end space-x-3">
<button wire:click="cancel"
class="px-4 py-2 border-2 border-secondary text-gray-100 rounded hover:bg-gray-700 transition-colors duration-200">
Cancel
</button>
<button wire:click="confirmSkip"
class="px-4 py-2 bg-warning text-white rounded hover:bg-yellow-600 transition-colors duration-200">
Skip
</button>
</div>
</div>
</div>
@endif
<!-- Edit Dish Modal --> <!-- Edit Dish Modal -->
@if($showEditDishModal) @if($showEditDishModal)
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">

View file

@ -2,6 +2,7 @@
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 DishPlanner\Dashboard\Services\DashboardStatsService;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::get('/', function () {
@ -26,7 +27,9 @@
Route::post('/logout', [LoginController::class, 'logout'])->name('logout'); Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
Route::get('/dashboard', function () { Route::get('/dashboard', function () {
return view('dashboard'); $stats = (new DashboardStatsService(auth()->user()))->stats();
return view('dashboard', ['stats' => $stats]);
})->name('dashboard'); })->name('dashboard');
Route::get('/dishes', function () { Route::get('/dishes', function () {

View file

@ -1,5 +1,17 @@
{ pkgs ? import <nixpkgs> {} }: { pkgs ? import <nixpkgs> {} }:
let
# Playwright's bundled Chromium is a prebuilt glibc binary. NixOS has no
# /usr/lib, so nix-ld (enabled system-wide) resolves its interpreter and
# NIX_LD_LIBRARY_PATH supplies the libraries below.
chromiumLibs = with pkgs; [
glib nss nspr atk at-spi2-atk at-spi2-core cups dbus expat
xorg.libxcb libxkbcommon alsa-lib libgbm mesa
xorg.libX11 xorg.libXext xorg.libXcomposite xorg.libXdamage
xorg.libXfixes xorg.libXrandr cairo pango systemd libdrm
];
in
pkgs.mkShell { pkgs.mkShell {
buildInputs = with pkgs; [ buildInputs = with pkgs; [
# PHP and tools # PHP and tools
@ -29,59 +41,103 @@ pkgs.mkShell {
# Use keep-id for proper permission mapping in rootless podman # Use keep-id for proper permission mapping in rootless podman
export PODMAN_USERNS=keep-id export PODMAN_USERNS=keep-id
export NIX_LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath chromiumLibs}:$NIX_LD_LIBRARY_PATH"
# Compose file location
COMPOSE_FILE="$PWD/docker/dev/docker-compose.yml"
# ===================
# ALIASES
# ===================
alias pc='podman-compose -f $COMPOSE_FILE'
# Define helper functions # Define helper functions
dev-rebuild() { dev-rebuild() {
echo "🔨 Rebuilding development environment..." echo "🔨 Rebuilding development environment..."
PODMAN_USERNS=keep-id podman-compose down -v PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE down -v
PODMAN_USERNS=keep-id podman-compose build --no-cache app PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE build --no-cache app
PODMAN_USERNS=keep-id podman-compose up -d PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d
echo " Rebuild complete! Check logs with: dev-logs" echo " Rebuild complete! Check logs with: dev-logs"
} }
dev-rebuild-quick() { dev-rebuild-quick() {
echo " Quick rebuild (keeping volumes)..." echo " Quick rebuild (keeping volumes)..."
PODMAN_USERNS=keep-id podman-compose down PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE down
PODMAN_USERNS=keep-id podman-compose build app PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE build app
PODMAN_USERNS=keep-id podman-compose up -d PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d
echo " Quick rebuild complete!" echo " Quick rebuild complete!"
} }
dev-up() { dev-up() {
echo "🚀 Starting development environment..." echo "🚀 Starting development environment..."
PODMAN_USERNS=keep-id podman-compose up -d PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d
echo " Dev environment started!" echo " Dev environment started!"
} }
dev-down() { dev-down() {
echo "🛑 Stopping development environment..." echo "🛑 Stopping development environment..."
podman-compose down podman-compose -f $COMPOSE_FILE down
echo " Dev environment stopped!" echo " Dev environment stopped!"
} }
dev-restart() { dev-restart() {
echo "🔄 Restarting development environment..." echo "🔄 Restarting development environment..."
podman-compose restart podman-compose -f $COMPOSE_FILE restart
echo " Dev environment restarted!" echo " Dev environment restarted!"
} }
dev-logs() { dev-logs() {
podman-compose logs -f "$@" podman-compose -f $COMPOSE_FILE logs -f "$@"
} }
dev-logs-db() { dev-logs-db() {
podman-compose logs -f db "$@" podman-compose -f $COMPOSE_FILE logs -f db "$@"
} }
dev-shell() { dev-shell() {
podman-compose exec app sh podman-compose -f $COMPOSE_FILE exec app sh
} }
dev-artisan() { dev-artisan() {
podman-compose exec app php artisan "$@" podman-compose -f $COMPOSE_FILE exec app php artisan "$@"
} }
dev-test() { dev-test() {
podman-compose exec -T app env $(grep -vE '^\s*(#|$)' .env.testing) php -d memory_limit=512M vendor/bin/phpunit "$@" podman-compose -f $COMPOSE_FILE exec -T app env $(grep -vE '^\s*(#|$)' .env.testing) php -d memory_limit=512M vendor/bin/phpunit "$@"
}
pest-browser() {
# Run Pest browser tests (Playwright) on the host. Pass --headed for a
# visible browser or --debug to pause on failure. Requires `npm run build`.
# A path argument replaces the default suite; flags alone keep it.
# Only existing paths count, so `--filter Logout` cannot silently
# unscope the run to the whole test directory.
local has_path=0
local skip_next=0
local arg
for arg in "$@"; do
if [ "$skip_next" -eq 1 ]; then
skip_next=0
continue
fi
case "$arg" in
--filter|--group|--exclude-group|--test-suffix)
skip_next=1
;;
-*) ;;
*)
if [ -e "$arg" ]; then
has_path=1
fi
;;
esac
done
if [ "$has_path" -eq 1 ]; then
vendor/bin/pest "$@"
else
vendor/bin/pest tests/Browser "$@"
fi
} }
dev-fix-permissions() { dev-fix-permissions() {
@ -161,6 +217,7 @@ pkgs.mkShell {
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-test [path] - Run PHPUnit suite (CI invocation)"
echo " pest-browser [--headed] - Run Pest browser tests (Playwright)"
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:"
@ -171,12 +228,12 @@ pkgs.mkShell {
echo "" echo ""
# Auto-start prompt # Auto-start prompt
if [ -f "docker-compose.yml" ]; then if [ -f "$COMPOSE_FILE" ]; then
read -p "Start development containers? (y/N) " -n 1 -r read -p "Start development containers? (y/N) " -n 1 -r
echo echo
if [[ $REPLY =~ ^[Yy]$ ]]; then if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Starting containers..." echo "Starting containers..."
podman-compose up -d podman-compose -f $COMPOSE_FILE up -d
# Wait a moment for containers to start # Wait a moment for containers to start
sleep 3 sleep 3
@ -188,7 +245,7 @@ pkgs.mkShell {
echo " Mailhog: http://localhost:8025" echo " Mailhog: http://localhost:8025"
echo " MariaDB: localhost:3306" echo " MariaDB: localhost:3306"
echo "" echo ""
echo "Run 'podman-compose logs -f app' to follow logs" echo "Run 'pc logs -f app' to follow logs"
fi fi
fi fi
''; '';

View file

@ -0,0 +1,114 @@
<?php
namespace DishPlanner\Dashboard\Services;
use App\Models\Dish;
use App\Models\Planner;
use App\Models\ScheduledUserDish;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class DashboardStatsService
{
public function __construct(private readonly Planner $planner) {}
/**
* @return array{
* dish_count: int,
* user_count: int,
* meals_this_month: int,
* favorite_dishes: Collection<int, array{user: User, dish: Dish|null, count: int}>
* }
*/
public function stats(): array
{
// PHPStan cannot equate the identical array shapes; see favoriteDishes().
/** @phpstan-ignore return.type */
return [
'dish_count' => $this->dishCount(),
'user_count' => $this->userCount(),
'meals_this_month' => $this->mealsThisMonth(),
'favorite_dishes' => $this->favoriteDishes(),
];
}
public function dishCount(): int
{
return Dish::where('planner_id', $this->planner->id)->count();
}
public function userCount(): int
{
return User::where('planner_id', $this->planner->id)->count();
}
public function mealsThisMonth(): int
{
$start = Carbon::now()->startOfMonth()->toDateString();
$end = Carbon::now()->endOfMonth()->toDateString();
return ScheduledUserDish::query()
->where('is_skipped', false)
->whereNotNull('user_dish_id')
->whereHas('schedule', fn ($query) => $query
->where('planner_id', $this->planner->id)
->where('is_skipped', false)
->whereBetween('date', [$start, $end]))
->count();
}
/**
* @return Collection<int, array{user: User, dish: Dish|null, count: int}>
*/
public function favoriteDishes(): Collection
{
$users = User::where('planner_id', $this->planner->id)
->orderBy('name')
->get();
$favorites = ScheduledUserDish::query()
->with(['user', 'userDish.dish'])
->whereNotNull('user_dish_id')
->whereHas('schedule', fn ($query) => $query->where('planner_id', $this->planner->id))
->get()
->groupBy('user_id')
->map(fn (Collection $items) => $this->favoriteForItems($items));
// Eloquent map() returns a union type PHPStan cannot narrow to the declared shape.
/** @phpstan-ignore return.type */
return $users->map(fn (User $user) => $favorites->get($user->id) ?? [
'user' => $user,
'dish' => null,
'count' => 0,
])->toBase()->values();
}
/**
* @param Collection<int, ScheduledUserDish> $items
* @return array{user: User, dish: Dish|null, count: int}
*/
private function favoriteForItems(Collection $items): array
{
$dishCounts = $items
->groupBy(fn (ScheduledUserDish $item) => $item->userDish->dish_id)
->map(fn (Collection $dishItems) => [
'dish' => $dishItems->firstOrFail()->userDish->dish,
'count' => $dishItems->count(),
]);
$top = $dishCounts->sort(function (array $a, array $b) {
if ($a['count'] === $b['count']) {
return ($a['dish']->name ?? '') <=> ($b['dish']->name ?? '');
}
return $b['count'] <=> $a['count'];
})->first();
return [
'user' => $items->firstOrFail()->user,
'dish' => $top['dish'] ?? null,
'count' => $top['count'] ?? 0,
];
}
}

View file

@ -9,7 +9,7 @@
class SkipScheduledUserDishForDateAction class SkipScheduledUserDishForDateAction
{ {
public function execute(Planner $planner, Carbon $date, int $userId): bool public function execute(Planner $planner, Carbon $date, int $userId, ?string $reason = null): bool
{ {
$schedule = Schedule::query() $schedule = Schedule::query()
->where('planner_id', $planner->id) ->where('planner_id', $planner->id)
@ -32,6 +32,7 @@ public function execute(Planner $planner, Carbon $date, int $userId): bool
$scheduledUserDish->update([ $scheduledUserDish->update([
'is_skipped' => true, 'is_skipped' => true,
'user_dish_id' => null, 'user_dish_id' => null,
'skip_reason' => $reason,
]); ]);
return true; return true;

View file

@ -1,91 +1,39 @@
<?php <?php
namespace Tests\Browser\Auth;
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 it('logs in successfully', function () {
{ $planner = Planner::factory()->create([
protected static $testPlanner = null; 'password' => Hash::make('password'),
]);
protected static $testEmail = null; visit('/login')
->type('input[id="email"]', $planner->email)
->type('input[id="password"]', 'password')
->press('Sign In')
->assertPathIs('/dashboard');
});
protected static $testPassword = 'password'; it('rejects wrong credentials', function () {
$planner = Planner::factory()->create([
'password' => Hash::make('password'),
]);
protected function ensureTestPlannerExists(): void visit('/login')
{ ->type('input[id="email"]', $planner->email)
if (self::$testPlanner === null) { ->type('input[id="password"]', 'wrong-password')
// Generate unique email for this test run ->press('Sign In')
self::$testEmail = fake()->unique()->safeEmail(); ->assertPathIs('/login')
->assertSee('These credentials do not match our records');
});
self::$testPlanner = Planner::factory()->create([ it('requires the email and password fields', function () {
'email' => self::$testEmail, visit('/login')
'password' => Hash::make(self::$testPassword), ->assertScript("document.querySelector('input[id=\"email\"]').required")
]); ->assertScript("document.querySelector('input[id=\"password\"]').required")
} ->assertAttribute('input[id="email"]', 'type', 'email')
} ->assertAttribute('input[id="password"]', 'type', 'password')
->press('Sign In')
public function test_successful_login(): void ->assertPathIs('/login');
{ });
$this->ensureTestPlannerExists();
$this->browse(function (Browser $browser) {
$browser->driver->manage()->deleteAllCookies();
$browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', self::TIMEOUT_SHORT)
->clear('input[id="email"]')
->type('input[id="email"]', self::$testEmail)
->clear('input[id="password"]')
->type('input[id="password"]', self::$testPassword)
->press('Login')
->waitForLocation('/dashboard', self::TIMEOUT_MEDIUM)
->assertPathIs('/dashboard');
});
}
public function test_login_with_wrong_credentials(): void
{
$this->ensureTestPlannerExists();
$this->browse(function (Browser $browser) {
$browser->driver->manage()->deleteAllCookies();
$browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', self::TIMEOUT_SHORT)
->clear('input[id="email"]')
->type('input[id="email"]', self::$testEmail)
->clear('input[id="password"]')
->type('input[id="password"]', 'wrongpassword')
->press('Login')
->pause(self::PAUSE_MEDIUM)
->assertPathIs('/login')
->assertSee('These credentials do not match our records');
});
}
public function test_login_form_required_fields(): void
{
$this->browse(function (Browser $browser) {
$browser->driver->manage()->deleteAllCookies();
$browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', self::TIMEOUT_SHORT);
// Check that both fields have the required attribute
$browser->assertAttribute('input[id="email"]', 'required', 'true');
$browser->assertAttribute('input[id="password"]', 'required', 'true');
// Verify email field is type email
$browser->assertAttribute('input[id="email"]', 'type', 'email');
// Verify password field is type password
$browser->assertAttribute('input[id="password"]', 'type', 'password');
// Test that we stay on login page if we try to submit with empty fields
$browser->press('Login')
->pause(self::PAUSE_SHORT)
->assertPathIs('/login');
});
}
}

View file

@ -0,0 +1,31 @@
<?php
it('logs out and lands on the login page', function () {
$page = loginAs(createPlanner());
logout($page)
->assertPathIs('/login')
->assertSee('Sign In');
});
it('blocks protected routes after logging out', function (string $path) {
$page = loginAs(createPlanner());
logout($page)
->assertPathIs('/login')
->navigate($path)
->assertPathIs('/login')
->assertSee('Sign In');
})->with(['/dashboard', '/dishes', '/schedule', '/users']);
it('does not restore an authenticated view via the back button', function () {
$planner = createPlanner();
$page = loginAs($planner)->assertSee($planner->name);
logout($page)
->assertPathIs('/login')
->back()
->assertPathIs('/login')
->assertDontSee('Logout');
});

View file

@ -0,0 +1,49 @@
<?php
it('registers successfully', function () {
visit('/register')
->type('input[id="name"]', 'Test User')
->type('input[id="email"]', 'new@example.com')
->type('input[id="password"]', 'password123')
->type('input[id="password_confirmation"]', 'password123')
->press('Register')
->assertPathIs('/dashboard')
->assertSee('Test User');
});
it('requires all registration fields', function () {
visit('/register')
->assertScript("document.querySelector('input[id=\"name\"]').required")
->assertScript("document.querySelector('input[id=\"email\"]').required")
->assertScript("document.querySelector('input[id=\"password\"]').required")
->assertScript("document.querySelector('input[id=\"password_confirmation\"]').required")
->assertAttribute('input[id="email"]', 'type', 'email')
->assertAttribute('input[id="password"]', 'type', 'password')
->assertAttribute('input[id="password_confirmation"]', 'type', 'password')
->press('Register')
->assertPathIs('/register');
});
it('rejects a mismatched password confirmation', function () {
visit('/register')
->type('input[id="name"]', 'Test User')
->type('input[id="email"]', 'new@example.com')
->type('input[id="password"]', 'password123')
->type('input[id="password_confirmation"]', 'different123')
->press('Register')
->assertPathIs('/register')
->assertSee('confirmation does not match');
});
it('rejects a duplicate email', function () {
createPlanner('taken@example.com');
visit('/register')
->type('input[id="name"]', 'Test User')
->type('input[id="email"]', 'taken@example.com')
->type('input[id="password"]', 'password123')
->type('input[id="password_confirmation"]', 'password123')
->press('Register')
->assertPathIs('/register')
->assertSee('has already been taken');
});

View file

@ -1,105 +0,0 @@
<?php
namespace Tests\Browser\Components;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Component as BaseComponent;
class DishModal extends BaseComponent
{
protected string $mode; // 'create' or 'edit'
public function __construct(string $mode = 'create')
{
$this->mode = $mode;
}
/**
* Get the root selector for the component.
*/
public function selector(): string
{
// Livewire modals typically have a specific structure
return '[role="dialog"], .fixed.inset-0';
}
/**
* Assert that the browser page contains the component.
*/
public function assert(Browser $browser): void
{
$browser->assertVisible($this->selector());
if ($this->mode === 'create') {
$browser->assertSee('Add New Dish');
} else {
$browser->assertSee('Edit Dish');
}
}
/**
* Get the element shortcuts for the component.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@name-input' => 'input[wire\\:model="name"]',
'@description-input' => 'textarea[wire\\:model="description"]',
'@users-section' => 'div:contains("Assign to Users")',
'@submit-button' => $this->mode === 'create' ? 'button:contains("Create Dish")' : 'button:contains("Update Dish")',
'@cancel-button' => 'button:contains("Cancel")',
'@validation-error' => '.text-red-500',
];
}
/**
* Fill the dish form.
*/
public function fillForm(Browser $browser, string $name, ?string $description = null): void
{
$browser->waitFor('@name-input')
->clear('@name-input')
->type('@name-input', $name);
if ($description !== null && $browser->element('@description-input')) {
$browser->clear('@description-input')
->type('@description-input', $description);
}
}
/**
* Select users to assign the dish to.
*/
public function selectUsers(Browser $browser, array $userIds): void
{
foreach ($userIds as $userId) {
$browser->check("input[type='checkbox'][value='{$userId}']");
}
}
/**
* Submit the form.
*/
public function submit(Browser $browser): void
{
$browser->press($this->mode === 'create' ? 'Create Dish' : 'Update Dish');
}
/**
* Cancel the modal.
*/
public function cancel(Browser $browser): void
{
$browser->press('Cancel');
}
/**
* Assert validation error is shown.
*/
public function assertValidationError(Browser $browser, string $message = 'required'): void
{
$browser->assertSee($message);
}
}

View file

@ -1,89 +0,0 @@
<?php
namespace Tests\Browser\Components;
use Laravel\Dusk\Browser;
use Laravel\Dusk\Component as BaseComponent;
class LoginForm extends BaseComponent
{
/**
* Get the root selector for the component.
*/
public function selector(): string
{
return 'form[method="POST"][action*="login"]';
}
/**
* Assert that the browser page contains the component.
*/
public function assert(Browser $browser): void
{
$browser->assertVisible($this->selector())
->assertVisible('@email')
->assertVisible('@password')
->assertVisible('@submit');
}
/**
* Get the element shortcuts for the component.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@email' => 'input[id="email"]',
'@password' => 'input[id="password"]',
'@submit' => 'button[type="submit"]',
'@remember' => 'input[name="remember"]',
'@error' => '.text-red-500',
];
}
/**
* Fill in the login form.
*/
public function fillForm(Browser $browser, string $email, string $password): void
{
$browser->type('@email', $email)
->type('@password', $password);
}
/**
* Submit the login form.
*/
public function submit(Browser $browser): void
{
$browser->press('@submit');
}
/**
* Login with the given credentials.
*/
public function loginWith(Browser $browser, string $email, string $password): void
{
$this->fillForm($browser, $email, $password);
$this->submit($browser);
}
/**
* Assert that the form fields are required.
*/
public function assertFieldsRequired(Browser $browser): void
{
$browser->assertAttribute('@email', 'required', 'true')
->assertAttribute('@password', 'required', 'true')
->assertAttribute('@email', 'type', 'email')
->assertAttribute('@password', 'type', 'password');
}
/**
* Assert that the form has validation errors.
*/
public function assertHasErrors(Browser $browser): void
{
$browser->assertPresent('@error');
}
}

View file

@ -1,50 +1,8 @@
<?php <?php
namespace Tests\Browser\Dishes; it('validates the dish name field', function () {
loginAndGoToDishes()
use Laravel\Dusk\Browser; ->click('button[wire\:click="create"]')
use Tests\Browser\Components\DishModal; ->press('Create Dish')
use Tests\Browser\LoginHelpers; ->assertSee('required');
use Tests\Browser\Pages\DishesPage; });
use Tests\DuskTestCase;
class CreateDishFormValidationTest extends DuskTestCase
{
use LoginHelpers;
protected static $createDishFormValidationTestPlanner = null;
protected static $createDishFormValidationTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$createDishFormValidationTestPlanner;
self::$testEmail = self::$createDishFormValidationTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$createDishFormValidationTestPlanner = self::$testPlanner;
self::$createDishFormValidationTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_create_dish_form_validation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser);
$browser->on(new DishesPage)
->openCreateModal()
->within(new DishModal('create'), function ($browser) {
$browser->fillForm('', null)
->submit()
->pause(2000)
->assertValidationError('required');
});
});
}
}

View file

@ -1,53 +1,12 @@
<?php <?php
namespace Tests\Browser\Dishes; it('creates a dish successfully', function () {
$dishName = 'Test Dish '.uniqid();
use Laravel\Dusk\Browser; loginAndGoToDishes()
use Tests\Browser\Components\DishModal; ->click('button[wire\:click="create"]')
use Tests\Browser\LoginHelpers; ->type('input[wire\:model="name"]', $dishName)
use Tests\Browser\Pages\DishesPage; ->press('Create Dish')
use Tests\DuskTestCase; ->assertSee($dishName)
->assertSee('Dish created successfully');
class CreateDishSuccessTest extends DuskTestCase });
{
use LoginHelpers;
protected static $createDishSuccessTestPlanner = null;
protected static $createDishSuccessTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$createDishSuccessTestPlanner;
self::$testEmail = self::$createDishSuccessTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$createDishSuccessTestPlanner = self::$testPlanner;
self::$createDishSuccessTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_can_create_dish_successfully(): void
{
$this->browse(function (Browser $browser) {
$dishName = 'Test Dish '.uniqid();
$this->loginAndGoToDishes($browser);
$browser->on(new DishesPage)
->openCreateModal()
->within(new DishModal('create'), function ($browser) use ($dishName) {
$browser->fillForm($dishName)
->submit();
})
->pause(3000)
->assertDishVisible($dishName)
->assertSee('Dish created successfully');
});
}
}

View file

@ -1,47 +1,8 @@
<?php <?php
namespace Tests\Browser\Dishes; it('can access the dishes page', function () {
loginAndGoToDishes()
use Laravel\Dusk\Browser; ->assertPathIs('/dishes')
use Tests\Browser\LoginHelpers; ->assertSee('MANAGE DISHES')
use Tests\Browser\Pages\DishesPage; ->assertSee('Add Dish');
use Tests\DuskTestCase; });
class CreateDishTest extends DuskTestCase
{
use LoginHelpers;
protected static $createDishTestPlanner = null;
protected static $createDishTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$createDishTestPlanner;
self::$testEmail = self::$createDishTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$createDishTestPlanner = self::$testPlanner;
self::$createDishTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_can_access_dishes_page(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser);
$browser->on(new DishesPage)
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
});
}
// TODO: Moved to separate single-method test files to avoid static planner issues
// See: OpenCreateDishModalTest, CreateDishFormValidationTest, CancelDishCreationTest, CreateDishSuccessTest
}

View file

@ -1,77 +1,13 @@
<?php <?php
namespace Tests\Browser\Dishes; use App\Models\Dish;
use App\Models\Planner; it('exposes the delete feature', function () {
use Laravel\Dusk\Browser; $planner = createPlanner();
use Tests\Browser\LoginHelpers; Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
use Tests\DuskTestCase;
class DeleteDishTest extends DuskTestCase loginAs($planner, '/dishes')
{ ->assertPathIs('/dishes')
use LoginHelpers; ->assertSee('MANAGE DISHES')
->assertSee('Delete');
protected static $deleteDishTestPlanner = null; });
protected static $deleteDishTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$deleteDishTestPlanner;
self::$testEmail = self::$deleteDishTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$deleteDishTestPlanner = self::$testPlanner;
self::$deleteDishTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_can_access_delete_feature(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
->assertPathIs('/dishes')
->assertSee('MANAGE DISHES');
// Verify that delete functionality is available by looking for the text in the page source
$pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('Delete', $pageSource);
});
}
// TODO: Fix static planner issue causing login failures in suite runs
// These tests pass in isolation but fail when run in full suite
/*
public function testDeleteModalComponents(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
});
}
public function testDeletionSafetyFeatures(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser);
// Check that Livewire component includes all CRUD features
$pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('MANAGE DISHES', $pageSource);
$this->assertStringContainsString('Add Dish', $pageSource);
// Either we have dishes with Delete button OR "No dishes found" message
if (str_contains($pageSource, 'No dishes found')) {
$this->assertStringContainsString('No dishes found', $pageSource);
} else {
$this->assertStringContainsString('Delete', $pageSource);
}
});
}
*/
}

View file

@ -1,50 +1,13 @@
<?php <?php
namespace Tests\Browser\Dishes; use App\Models\Dish;
use Laravel\Dusk\Browser; it('shows the deletion safety features', function () {
use Tests\Browser\LoginHelpers; $planner = createPlanner();
use Tests\DuskTestCase; Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
class DishDeletionSafetyTest extends DuskTestCase loginAs($planner, '/dishes')
{ ->assertSee('MANAGE DISHES')
use LoginHelpers; ->assertSee('Add Dish')
->assertSee('Delete');
protected static $dishDeletionSafetyTestPlanner = null; });
protected static $dishDeletionSafetyTestEmail = null;
protected function setUp(): void
{
parent::setUp();
// Reset static planner for this specific test class
self::$testPlanner = self::$dishDeletionSafetyTestPlanner;
self::$testEmail = self::$dishDeletionSafetyTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$dishDeletionSafetyTestPlanner = self::$testPlanner;
self::$dishDeletionSafetyTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_deletion_safety_features(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser);
// Check that Livewire component includes all CRUD features
$pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('MANAGE DISHES', $pageSource);
$this->assertStringContainsString('Add Dish', $pageSource);
// Either we have dishes with Delete button OR "No dishes found" message
if (str_contains($pageSource, 'No dishes found')) {
$this->assertStringContainsString('No dishes found', $pageSource);
} else {
$this->assertStringContainsString('Delete', $pageSource);
}
});
}
}

View file

@ -1,74 +1,28 @@
<?php <?php
namespace Tests\Browser\Dishes; use App\Models\Dish;
use App\Models\Planner; it('exposes the edit feature', function () {
use Laravel\Dusk\Browser; $planner = createPlanner();
use Tests\Browser\LoginHelpers; Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
use Tests\DuskTestCase;
class EditDishTest extends DuskTestCase loginAs($planner, '/dishes')
{ ->assertPathIs('/dishes')
use LoginHelpers; ->assertSee('MANAGE DISHES')
->assertSee('Edit');
});
protected static $editDishTestPlanner = null; it('shows the edit modal components', function () {
loginAndGoToDishes()
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
});
protected static $editDishTestEmail = null; it('renders the dishes page structure', function () {
$planner = createPlanner();
Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
protected function setUp(): void loginAs($planner, '/dishes')
{ ->assertSee('Edit')
parent::setUp(); ->assertSee('Delete');
// Reset static planner for this specific test class });
self::$testPlanner = self::$editDishTestPlanner;
self::$testEmail = self::$editDishTestEmail;
}
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$editDishTestPlanner = self::$testPlanner;
self::$editDishTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_can_access_edit_feature(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
->assertPathIs('/dishes')
->assertSee('MANAGE DISHES');
// Verify that edit functionality is available by looking for the text in the page source
$pageSource = $browser->driver->getPageSource();
$this->assertStringContainsString('Edit', $pageSource);
});
}
public function test_edit_modal_components(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
});
}
public function test_dishes_page_structure(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToDishes($browser)
->assertSee('MANAGE DISHES')
->assertSee('Add Dish');
// Check that the dishes CRUD structure is present
$pageSource = $browser->driver->getPageSource();
// Either we have dishes with Edit/Delete buttons OR "No dishes found" message
if (str_contains($pageSource, 'No dishes found')) {
$this->assertStringContainsString('No dishes found', $pageSource);
} else {
$this->assertStringContainsString('Edit', $pageSource);
$this->assertStringContainsString('Delete', $pageSource);
}
});
}
}

View file

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

View file

@ -1,86 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
class DishesPage extends Page
{
/**
* Get the URL for the page.
*/
public function url(): string
{
return '/dishes';
}
/**
* Assert that the browser is on the page.
*/
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('MANAGE DISHES');
}
/**
* Get the element shortcuts for the page.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@add-button' => 'button[wire\\:click="create"]',
'@dishes-list' => '[wire\\:id]', // Livewire component
'@search' => 'input[type="search"]',
'@no-dishes' => '*[text*="No dishes found"]',
];
}
/**
* Open the create dish modal.
*/
public function openCreateModal(Browser $browser): void
{
$browser->waitFor('@add-button')
->click('@add-button')
->pause(1000);
}
/**
* Click edit button for a dish.
*/
public function clickEditForDish(Browser $browser, string $dishName): void
{
$browser->within("tr:contains('{$dishName}')", function ($row) {
$row->click('button.bg-accent-blue');
});
}
/**
* Click delete button for a dish.
*/
public function clickDeleteForDish(Browser $browser, string $dishName): void
{
$browser->within("tr:contains('{$dishName}')", function ($row) {
$row->click('button.bg-red-500');
});
}
/**
* Assert a dish is visible in the list.
*/
public function assertDishVisible(Browser $browser, string $dishName): void
{
$browser->assertSee($dishName);
}
/**
* Assert no dishes message is shown.
*/
public function assertNoDishes(Browser $browser): void
{
$browser->assertSee('No dishes found');
}
}

View file

@ -1,47 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
use Tests\Browser\Components\LoginForm;
class LoginPage extends Page
{
/**
* Get the URL for the page.
*/
public function url(): string
{
return '/login';
}
/**
* Assert that the browser is on the page.
*/
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('Login')
->assertPresent((new LoginForm)->selector());
}
/**
* Get the element shortcuts for the page.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@register-link' => 'a[href*="register"]',
];
}
/**
* Navigate to the registration page.
*/
public function goToRegistration(Browser $browser): void
{
$browser->click('@register-link');
}
}

View file

@ -1,21 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Page as BasePage;
abstract class Page extends BasePage
{
/**
* Get the global element shortcuts for the site.
*
* @return array<string, string>
*/
public static function siteElements(): array
{
return [
'@nav' => 'nav',
'@alert' => '[role="alert"]',
];
}
}

View file

@ -1,110 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
class SchedulePage extends Page
{
public function url(): string
{
return '/schedule';
}
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('SCHEDULE');
}
public function elements(): array
{
return [
'@generate-button' => 'button[wire\\:click="generate"]',
'@clear-month-button' => 'button[wire\\:click="clearMonth"]',
'@previous-month' => 'button[wire\\:click="previousMonth"]',
'@next-month' => 'button[wire\\:click="nextMonth"]',
'@month-select' => 'select[wire\\:model="selectedMonth"]',
'@year-select' => 'select[wire\\:model="selectedYear"]',
'@clear-existing-checkbox' => 'input[wire\\:model="clearExisting"]',
'@calendar-grid' => '.grid.grid-cols-7',
];
}
public function clickGenerate(Browser $browser): void
{
$browser->waitFor('@generate-button')
->click('@generate-button')
->pause(2000); // Wait for generation
}
public function clickClearMonth(Browser $browser): void
{
$browser->waitFor('@clear-month-button')
->click('@clear-month-button')
->pause(1000);
}
public function goToPreviousMonth(Browser $browser): void
{
$browser->waitFor('@previous-month')
->click('@previous-month')
->pause(500);
}
public function goToNextMonth(Browser $browser): void
{
$browser->waitFor('@next-month')
->click('@next-month')
->pause(500);
}
public function selectMonth(Browser $browser, int $month): void
{
$browser->waitFor('@month-select')
->select('@month-select', $month)
->pause(500);
}
public function selectYear(Browser $browser, int $year): void
{
$browser->waitFor('@year-select')
->select('@year-select', $year)
->pause(500);
}
public function toggleClearExisting(Browser $browser): void
{
$browser->waitFor('@clear-existing-checkbox')
->click('@clear-existing-checkbox');
}
public function selectUser(Browser $browser, string $userName): void
{
$browser->check("input[type='checkbox'][value]", $userName);
}
public function assertSuccessMessage(Browser $browser, ?string $message = null): void
{
if ($message) {
$browser->assertSee($message);
} else {
$browser->assertPresent('.border-success');
}
}
public function assertDishScheduled(Browser $browser, string $dishName): void
{
$browser->assertSee($dishName);
}
public function assertNoDishesScheduled(Browser $browser): void
{
$browser->assertSee('No dishes scheduled');
}
public function assertMonthDisplayed(Browser $browser, string $monthYear): void
{
$browser->assertSee($monthYear);
}
}

View file

@ -1,93 +0,0 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Browser;
class UsersPage extends Page
{
/**
* Get the URL for the page.
*/
public function url(): string
{
return '/users';
}
/**
* Assert that the browser is on the page.
*/
public function assert(Browser $browser): void
{
$browser->assertPathIs($this->url())
->assertSee('MANAGE USERS');
}
/**
* Get the element shortcuts for the page.
*
* @return array<string, string>
*/
public function elements(): array
{
return [
'@add-button' => 'button[wire\\:click="create"]',
'@users-list' => '[wire\\:id]', // Livewire component
'@no-users' => '*[text*="No users found"]',
];
}
/**
* Open the create user modal.
*/
public function openCreateModal(Browser $browser): void
{
$browser->waitFor('@add-button')
->click('@add-button')
->pause(1000);
}
/**
* Click delete button for a user.
*/
public function clickDeleteForUser(Browser $browser, string $userName): void
{
$browser->within("tr:contains('{$userName}')", function ($row) {
$row->click('button.bg-danger');
});
}
/**
* Click the first available delete button.
*/
public function clickFirstDeleteButton(Browser $browser): void
{
$browser->waitFor('button.bg-danger', 5)
->click('button.bg-danger')
->pause(1000);
}
/**
* Assert a user is visible in the list.
*/
public function assertUserVisible(Browser $browser, string $userName): void
{
$browser->assertSee($userName);
}
/**
* Assert a user is not visible in the list.
*/
public function assertUserNotVisible(Browser $browser, string $userName): void
{
$browser->assertDontSee($userName);
}
/**
* Assert success message is shown.
*/
public function assertSuccessMessage(Browser $browser, string $message): void
{
$browser->assertSee($message);
}
}

View file

@ -1,38 +1,14 @@
<?php <?php
namespace Tests\Browser; it('redirects unauthenticated users to login', function () {
visit('/dashboard')
->assertPathIs('/login')
->assertSee('Sign In');
});
use Illuminate\Foundation\Testing\DatabaseTransactions; it('loads the login page', function () {
use Laravel\Dusk\Browser; visit('/login')
use Tests\DuskTestCase; ->assertPathIs('/login')
->assertSee('Email')
class RedirectTest extends DuskTestCase ->assertSee('Password');
{ });
use DatabaseTransactions;
/**
* Test that unauthenticated users are redirected to login
*/
public function test_unauthenticated_redirects_to_login()
{
$this->browse(function (Browser $browser) {
$browser->visit('http://dishplanner_app:8000/dashboard')
->assertPathIs('/login')
->assertSee('Login');
});
}
/**
* Test that login page loads correctly
*/
public function test_login_page_loads()
{
$this->browse(function (Browser $browser) {
$browser->visit('http://dishplanner_app:8000/login')
->assertPathIs('/login')
->assertSee('Login')
->assertSee('Email')
->assertSee('Password');
});
}
}

View file

@ -1,128 +1,66 @@
<?php <?php
namespace Tests\Browser\Schedule;
use App\Models\Dish; use App\Models\Dish;
use App\Models\Planner; use App\Models\Planner;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Laravel\Dusk\Browser;
use Tests\Browser\Pages\SchedulePage;
use Tests\DuskTestCase;
class GenerateScheduleTest extends DuskTestCase /**
* @return array{0: Planner, 1: User}
*/
function makeScheduleFixture(): array
{ {
protected static $planner = null; $planner = Planner::factory()->create([
'email' => fake()->unique()->safeEmail(),
'password' => Hash::make('password'),
]);
protected static $email = null; $user = User::factory()->create([
'planner_id' => $planner->id,
'name' => 'Test User',
]);
protected static $password = 'password'; $dish = Dish::factory()->create([
'planner_id' => $planner->id,
'name' => 'Test Dish',
]);
protected static $user = null; $dish->users()->attach($user);
protected static $dish = null; return [$planner, $user];
protected function setUp(): void
{
parent::setUp();
// Create test data if not exists
if (self::$planner === null) {
self::$email = fake()->unique()->safeEmail();
self::$planner = Planner::factory()->create([
'email' => self::$email,
'password' => Hash::make(self::$password),
]);
// Create a user for this planner
self::$user = User::factory()->create([
'planner_id' => self::$planner->id,
'name' => 'Test User',
]);
// Create a dish and assign to user
self::$dish = Dish::factory()->create([
'planner_id' => self::$planner->id,
'name' => 'Test Dish',
]);
// Attach user to dish (creates UserDish)
self::$dish->users()->attach(self::$user);
}
}
protected function loginAsPlanner(Browser $browser): Browser
{
$browser->driver->manage()->deleteAllCookies();
return $browser->visit('http://dishplanner_app:8000/login')
->waitFor('input[id="email"]', DuskTestCase::TIMEOUT_SHORT)
->clear('input[id="email"]')
->type('input[id="email"]', self::$email)
->clear('input[id="password"]')
->type('input[id="password"]', self::$password)
->press('Login')
->waitForLocation('/dashboard', DuskTestCase::TIMEOUT_MEDIUM)
->pause(DuskTestCase::PAUSE_SHORT)
->visit('http://dishplanner_app:8000/schedule')
->pause(DuskTestCase::PAUSE_MEDIUM);
}
public function test_can_generate_schedule_with_user_and_dish(): void
{
$this->browse(function (Browser $browser) {
$this->loginAsPlanner($browser);
$browser->on(new SchedulePage)
->assertSee('Test User') // User should be in selection
->clickGenerate()
->pause(2000)
// Verify schedule was generated by checking dish appears on calendar
->assertSee('Test Dish');
});
}
public function test_generated_schedule_shows_dish_on_calendar(): void
{
$this->browse(function (Browser $browser) {
$this->loginAsPlanner($browser);
$browser->on(new SchedulePage)
->clickGenerate()
->pause(2000)
// The dish should appear somewhere on the calendar
->assertSee('Test Dish');
});
}
public function test_can_clear_month_schedule(): void
{
$this->browse(function (Browser $browser) {
$this->loginAsPlanner($browser);
$browser->on(new SchedulePage)
// First generate a schedule
->clickGenerate()
->pause(2000)
->assertSee('Test Dish') // Verify generated
// Then clear it
->clickClearMonth()
->pause(1000)
// After clearing, should see "No dishes scheduled" on calendar days
->assertSee('No dishes scheduled');
});
}
public function test_user_selection_affects_generation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAsPlanner($browser);
$browser->on(new SchedulePage)
// Verify the user checkbox is present
->assertSee('Test User')
// User should be selected by default
->assertChecked("input[value='".self::$user->id."']");
});
}
} }
it('can generate a schedule with a user and dish', function () {
[$planner] = makeScheduleFixture();
loginAs($planner, '/schedule')
->assertSee('Test User')
->click('button[wire\:click="generate"]')
->assertSee('Test Dish');
});
it('shows the generated dish on the calendar', function () {
[$planner] = makeScheduleFixture();
loginAs($planner, '/schedule')
->click('button[wire\:click="generate"]')
->assertSee('Test Dish');
});
it('can clear the month schedule', function () {
[$planner] = makeScheduleFixture();
loginAs($planner, '/schedule')
->click('button[wire\:click="generate"]')
->assertSee('Test Dish')
->click('button[wire\:click="clearMonth"]')
->assertSee('No dishes scheduled');
});
it('selects all users by default', function () {
[$planner, $user] = makeScheduleFixture();
loginAs($planner, '/schedule')
->assertSee('Test User')
->assertChecked('input[value="'.$user->id.'"]');
});

View file

@ -1,108 +1,48 @@
<?php <?php
namespace Tests\Browser\Schedule; it('can access the schedule page', function () {
loginAndGoToSchedule()
->assertSee('SCHEDULE')
->assertSee('Generate Schedule');
});
use Laravel\Dusk\Browser; it('shows the month navigation', function () {
use Tests\Browser\LoginHelpers; loginAndGoToSchedule()
use Tests\Browser\Pages\SchedulePage; ->assertPresent('button[wire\:click="previousMonth"]')
use Tests\DuskTestCase; ->assertPresent('button[wire\:click="nextMonth"]')
->assertSee(now()->format('F Y'));
});
class SchedulePageTest extends DuskTestCase it('can navigate to the next month', function () {
{ $nextMonth = now()->addMonth();
use LoginHelpers;
protected static $schedulePageTestPlanner = null; loginAndGoToSchedule()
->click('button[wire\:click="nextMonth"]')
->assertSee($nextMonth->format('F Y'));
});
protected static $schedulePageTestEmail = null; it('can navigate to the previous month', function () {
$previousMonth = now()->subMonth();
protected function setUp(): void loginAndGoToSchedule()
{ ->click('button[wire\:click="previousMonth"]')
parent::setUp(); ->assertSee($previousMonth->format('F Y'));
self::$testPlanner = self::$schedulePageTestPlanner; });
self::$testEmail = self::$schedulePageTestEmail;
}
protected function tearDown(): void it('shows the user selection', function () {
{ loginAndGoToSchedule()
self::$schedulePageTestPlanner = self::$testPlanner; ->assertSee('Select Users')
self::$schedulePageTestEmail = self::$testEmail; ->assertPresent('button[wire\:click="generate"]')
parent::tearDown(); ->assertPresent('button[wire\:click="clearMonth"]');
} });
public function test_can_access_schedule_page(): void it('shows the days of the week', function () {
{ loginAndGoToSchedule()
$this->browse(function (Browser $browser) { ->assertSee('Mon')
$this->loginAndGoToSchedule($browser); ->assertSee('Tue')
->assertSee('Wed')
$browser->on(new SchedulePage) ->assertSee('Thu')
->assertSee('SCHEDULE') ->assertSee('Fri')
->assertSee('Generate Schedule'); ->assertSee('Sat')
}); ->assertSee('Sun');
} });
public function test_schedule_page_has_month_navigation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$browser->on(new SchedulePage)
->assertPresent('@previous-month')
->assertPresent('@next-month')
->assertSee(now()->format('F Y'));
});
}
public function test_can_navigate_to_next_month(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$nextMonth = now()->addMonth();
$browser->on(new SchedulePage)
->goToNextMonth()
->assertSee($nextMonth->format('F Y'));
});
}
public function test_can_navigate_to_previous_month(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$prevMonth = now()->subMonth();
$browser->on(new SchedulePage)
->goToPreviousMonth()
->assertSee($prevMonth->format('F Y'));
});
}
public function test_schedule_generator_shows_user_selection(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$browser->on(new SchedulePage)
->assertSee('Select Users')
->assertPresent('@generate-button')
->assertPresent('@clear-month-button');
});
}
public function test_calendar_displays_days_of_week(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToSchedule($browser);
$browser->on(new SchedulePage)
->assertSee('Mon')
->assertSee('Tue')
->assertSee('Wed')
->assertSee('Thu')
->assertSee('Fri')
->assertSee('Sat')
->assertSee('Sun');
});
}
}

View file

@ -1,104 +1,43 @@
<?php <?php
namespace Tests\Browser\Users; it('can access the users page', function () {
loginAndGoToUsers()
->assertSee('MANAGE USERS')
->assertSee('Add User');
});
use Laravel\Dusk\Browser; it('can open the create user modal', function () {
use Tests\Browser\LoginHelpers; loginAndGoToUsers()
use Tests\Browser\Pages\UsersPage; ->click('button[wire\:click="create"]')
use Tests\DuskTestCase; ->assertSee('Add New User')
->assertSee('Name')
->assertSee('Cancel')
->assertSee('Create User');
});
class CreateUserTest extends DuskTestCase it('validates the user name field', function () {
{ loginAndGoToUsers()
use LoginHelpers; ->click('button[wire\:click="create"]')
->press('Create User')
->assertSee('The name field is required');
});
protected static $createUserTestPlanner = null; it('can create a user', function () {
$userName = 'TestCreate_'.uniqid();
protected static $createUserTestEmail = null; loginAndGoToUsers()
->click('button[wire\:click="create"]')
->type('input[wire\:model="name"]', $userName)
->press('Create User')
->assertSee('User created successfully')
->assertSee($userName);
});
protected function setUp(): void it('can cancel user creation', function () {
{ loginAndGoToUsers()
parent::setUp(); ->click('button[wire\:click="create"]')
// Reset static planner for this specific test class ->type('input[wire\:model="name"]', 'Test Cancel User')
self::$testPlanner = self::$createUserTestPlanner; ->press('Cancel')
self::$testEmail = self::$createUserTestEmail; ->assertSee('MANAGE USERS')
} ->assertDontSee('Add New User');
});
protected function tearDown(): void
{
// Save the planner for next test method in this class
self::$createUserTestPlanner = self::$testPlanner;
self::$createUserTestEmail = self::$testEmail;
parent::tearDown();
}
public function test_can_access_users_page(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->assertSee('MANAGE USERS')
->assertSee('Add User');
});
}
public function test_can_open_create_user_modal(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->openCreateModal()
->assertSee('Add New User')
->assertSee('Name')
->assertSee('Cancel')
->assertSee('Create User');
});
}
public function test_create_user_form_validation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->openCreateModal()
->press('Create User')
->pause(self::PAUSE_MEDIUM)
->assertSee('The name field is required');
});
}
public function test_can_create_user(): void
{
$this->browse(function (Browser $browser) {
$userName = 'TestCreate_'.uniqid();
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->openCreateModal()
->type('input[wire\\:model="name"]', $userName)
->press('Create User')
->pause(self::PAUSE_MEDIUM)
->assertSee('User created successfully')
->assertSee($userName);
});
}
public function test_can_cancel_user_creation(): void
{
$this->browse(function (Browser $browser) {
$this->loginAndGoToUsers($browser);
$browser->on(new UsersPage)
->openCreateModal()
->type('input[wire\\:model="name"]', 'Test Cancel User')
->press('Cancel')
->pause(self::PAUSE_SHORT)
// Modal should be closed, we should be back on users page
->assertSee('MANAGE USERS')
->assertDontSee('Add New User');
});
}
}

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

View file

@ -1,2 +0,0 @@
*
!.gitignore

12
tests/BrowserTestCase.php Normal file
View file

@ -0,0 +1,12 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class BrowserTestCase extends BaseTestCase
{
// Intentionally does NOT call withoutVite(): browser tests drive the real
// frontend (Livewire + Alpine) through the in-process server, so Vite must
// resolve to the built assets rather than be stubbed out.
}

View file

@ -1,55 +0,0 @@
<?php
namespace Tests;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Laravel\Dusk\TestCase as BaseTestCase;
use PHPUnit\Framework\Attributes\BeforeClass;
abstract class DuskTestCase extends BaseTestCase
{
// Timeout constants for consistent timing across all Dusk tests
public const TIMEOUT_SHORT = 2; // 2 seconds max for most operations
public const TIMEOUT_MEDIUM = 3; // 3 seconds for slower operations
public const PAUSE_SHORT = 500; // 0.5 seconds for quick pauses
public const PAUSE_MEDIUM = 1000; // 1 second for medium pauses
/**
* Prepare for Dusk test execution.
*/
#[BeforeClass]
public static function prepare(): void
{
// Don't start ChromeDriver - we're using Selenium
}
/**
* Create the RemoteWebDriver instance.
*/
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions)->addArguments([
'--window-size=1920,1080',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--headless=new',
'--disable-extensions',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
]);
return RemoteWebDriver::create(
'http://selenium:4444/wd/hub', // Connect to Selenium container
DesiredCapabilities::chrome()->setCapability(
ChromeOptions::CAPABILITY, $options
)
);
}
}

View file

@ -90,4 +90,39 @@ public function test_users_can_logout(): void
$response->assertRedirect('/'); $response->assertRedirect('/');
$this->assertGuest(); $this->assertGuest();
} }
public function test_refresh_csrf_returns_the_current_token_to_a_guest(): void
{
$response = $this->getJson('/refresh-csrf');
$response->assertStatus(200);
$response->assertJson(['token' => csrf_token()]);
}
public function test_refresh_csrf_returns_the_current_token_to_an_authenticated_user(): void
{
$user = Planner::factory()->create();
$response = $this->actingAs($user)->getJson('/refresh-csrf');
$response->assertStatus(200);
$response->assertJson(['token' => csrf_token()]);
}
public function test_logout_invalidates_the_session(): void
{
$user = Planner::factory()->create();
// Boots the session store so getId() below reflects a real value.
$this->actingAs($user)->get('/dashboard');
session()->put('scratch', 'value');
$sessionId = session()->getId();
$this->actingAs($user)->post('/logout');
$this->assertNotSame($sessionId, session()->getId());
$this->assertFalse(session()->has('scratch'));
}
} }

View file

@ -0,0 +1,131 @@
<?php
namespace Tests\Feature;
use App\Models\Dish;
use App\Models\Planner;
use App\Models\Schedule;
use App\Models\ScheduledUserDish;
use App\Models\User;
use App\Models\UserDish;
use DishPlanner\Schedule\Actions\UpdateScheduleAction;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DashboardTest extends TestCase
{
use RefreshDatabase;
public function test_dashboard_renders_with_zero_stats_for_an_empty_planner(): void
{
$planner = Planner::factory()->create();
$this->actingAs($planner)
->get('/dashboard')
->assertOk()
->assertViewIs('dashboard')
->assertSee('DASHBOARD')
->assertSee('Dishes')
->assertSee('Users')
->assertSee('Meals this month')
->assertSee('No users yet.')
->assertViewHas('stats', fn (array $stats) => $stats['dish_count'] === 0
&& $stats['user_count'] === 0
&& $stats['meals_this_month'] === 0
&& $stats['favorite_dishes']->isEmpty());
}
public function test_dashboard_shows_dish_and_user_counts(): void
{
$planner = Planner::factory()->create();
Dish::factory()->planner($planner)->count(3)->create();
User::factory()->planner($planner)->count(2)->create();
$this->actingAs($planner)
->get('/dashboard')
->assertOk()
->assertSee('Favorite dishes')
->assertSee('No meals yet')
->assertViewHas('stats', fn (array $stats) => $stats['dish_count'] === 3
&& $stats['user_count'] === 2
&& $stats['favorite_dishes']->count() === 2
&& $stats['favorite_dishes']->every(fn (array $favorite) => $favorite['dish'] === null && $favorite['count'] === 0));
}
public function test_dashboard_counts_only_non_skipped_meals_this_month(): void
{
$planner = Planner::factory()->create();
$user = User::factory()->planner($planner)->create();
$dish = Dish::factory()->planner($planner)->create();
$userDish = UserDish::factory()->user($user)->dish($dish)->create();
$date = now()->startOfMonth()->addDays(5);
$scheduled = Schedule::factory()->planner($planner)->date($date)->create();
$skipped = Schedule::factory()->planner($planner)->date($date->copy()->addDay())->create();
ScheduledUserDish::factory()->schedule($scheduled)->user($user)->userDish($userDish)->create();
ScheduledUserDish::factory()->schedule($skipped)->user($user)->userDish($userDish)->skipped()->create();
$this->actingAs($planner)
->get('/dashboard')
->assertOk()
->assertViewHas('stats', fn (array $stats) => $stats['meals_this_month'] === 1);
}
public function test_dashboard_shows_the_most_scheduled_dish_per_user(): void
{
$planner = Planner::factory()->create();
$user = User::factory()->planner($planner)->create(['name' => 'Ada']);
$pizza = Dish::factory()->planner($planner)->create(['name' => 'Pizza']);
$tacos = Dish::factory()->planner($planner)->create(['name' => 'Tacos']);
$pizzaDish = UserDish::factory()->user($user)->dish($pizza)->create();
$tacosDish = UserDish::factory()->user($user)->dish($tacos)->create();
ScheduledUserDish::factory()
->schedule(Schedule::factory()->planner($planner)->date(now())->create())
->user($user)
->userDish($pizzaDish)
->create();
ScheduledUserDish::factory()
->schedule(Schedule::factory()->planner($planner)->date(now()->addDay())->create())
->user($user)
->userDish($pizzaDish)
->create();
ScheduledUserDish::factory()
->schedule(Schedule::factory()->planner($planner)->date(now()->addDays(2))->create())
->user($user)
->userDish($tacosDish)
->create();
$this->actingAs($planner)
->get('/dashboard')
->assertOk()
->assertSee('Pizza')
->assertViewHas('stats', function (array $stats) {
$favorite = $stats['favorite_dishes']->first();
return $favorite['user']->name === 'Ada'
&& $favorite['dish']->name === 'Pizza'
&& $favorite['count'] === 2;
});
}
public function test_dashboard_excludes_meals_from_a_skipped_schedule(): void
{
$planner = Planner::factory()->create();
$user = User::factory()->planner($planner)->create();
$dish = Dish::factory()->planner($planner)->create();
$userDish = UserDish::factory()->user($user)->dish($dish)->create();
$date = now()->startOfMonth()->addDays(5);
$schedule = Schedule::factory()->planner($planner)->date($date)->create();
ScheduledUserDish::factory()->schedule($schedule)->user($user)->userDish($userDish)->create();
(new UpdateScheduleAction)->execute($schedule, true);
$this->actingAs($planner)
->get('/dashboard')
->assertOk()
->assertViewHas('stats', fn (array $stats) => $stats['meals_this_month'] === 0);
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace Tests\Feature\Schedule;
use App\Livewire\Schedule\ScheduleCalendar;
use App\Models\Dish;
use App\Models\Planner;
use App\Models\Schedule;
use App\Models\ScheduledUserDish;
use App\Models\User;
use App\Models\UserDish;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class ScheduleCalendarTest extends TestCase
{
use RefreshDatabase;
protected Planner $planner;
protected function setUp(): void
{
parent::setUp();
/** @var Planner $planner */
$planner = Planner::factory()->create();
$this->planner = $planner;
}
public function test_skip_day_opens_modal_and_persists_reason(): void
{
$planner = $this->planner;
$user = User::factory()->planner($planner)->create();
$dish = Dish::factory()->planner($planner)->create();
$dish->users()->attach($user);
$date = now();
$schedule = Schedule::create([
'planner_id' => $planner->id,
'date' => $date->format('Y-m-d'),
'is_skipped' => false,
]);
$userDish = UserDish::query()->where('user_id', $user->id)->firstOrFail();
ScheduledUserDish::create([
'schedule_id' => $schedule->id,
'user_id' => $user->id,
'user_dish_id' => $userDish->id,
'is_skipped' => false,
]);
$this->actingAs($planner);
Livewire::test(ScheduleCalendar::class)
->call('skipDay', $date->format('Y-m-d'), $user->id)
->assertSet('showSkipModal', true)
->assertSet('skipDate', $date->format('Y-m-d'))
->set('skipReason', 'eating out')
->call('confirmSkip')
->assertSee('eating out');
$this->assertDatabaseHas(ScheduledUserDish::class, [
'schedule_id' => $schedule->id,
'user_id' => $user->id,
'is_skipped' => true,
'user_dish_id' => null,
'skip_reason' => 'eating out',
]);
}
public function test_skip_day_without_reason_persists_null(): void
{
$planner = $this->planner;
$user = User::factory()->planner($planner)->create();
$dish = Dish::factory()->planner($planner)->create();
$dish->users()->attach($user);
$date = now();
$schedule = Schedule::create([
'planner_id' => $planner->id,
'date' => $date->format('Y-m-d'),
'is_skipped' => false,
]);
$userDish = UserDish::query()->where('user_id', $user->id)->firstOrFail();
ScheduledUserDish::create([
'schedule_id' => $schedule->id,
'user_id' => $user->id,
'user_dish_id' => $userDish->id,
'is_skipped' => false,
]);
$this->actingAs($planner);
Livewire::test(ScheduleCalendar::class)
->call('skipDay', $date->format('Y-m-d'), $user->id)
->call('confirmSkip');
$this->assertDatabaseHas(ScheduledUserDish::class, [
'schedule_id' => $schedule->id,
'user_id' => $user->id,
'is_skipped' => true,
'skip_reason' => null,
]);
}
}

77
tests/Pest.php Normal file
View file

@ -0,0 +1,77 @@
<?php
use App\Models\Planner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Pest\Browser\Api\AwaitableWebpage;
use Pest\Browser\Api\Webpage;
use Tests\BrowserTestCase;
use Tests\TestCase;
uses(TestCase::class)->in('Feature', 'Unit');
uses(BrowserTestCase::class, RefreshDatabase::class)->in('Browser');
// Livewire round-trips can be slower than the default 5s auto-wait budget.
pest()->browser()->timeout(10000);
/**
* Create a planner with a known password for browser logins.
*/
function createPlanner(?string $email = null): Planner
{
return Planner::factory()->create([
'email' => $email ?? fake()->unique()->safeEmail(),
'password' => Hash::make('password'),
]);
}
/**
* Log in as the given planner and navigate to the target page.
*
* @return Webpage
*/
function loginAs(Planner $planner, string $page = '/dashboard')
{
return visit('/login')
->type('input[id="email"]', $planner->email)
->type('input[id="password"]', 'password')
->press('Sign In')
->assertPathIs('/dashboard')
->navigate($page);
}
/**
* @return Webpage
*/
function loginAndGoToDishes()
{
return loginAs(createPlanner(), '/dishes');
}
/**
* @return Webpage
*/
function loginAndGoToUsers()
{
return loginAs(createPlanner(), '/users');
}
/**
* @return Webpage
*/
function loginAndGoToSchedule()
{
return loginAs(createPlanner(), '/schedule');
}
/**
* Open the account dropdown and submit the logout form.
*
* @return Webpage
*/
function logout(Webpage|AwaitableWebpage $page)
{
return $page
->click('button[\@click="open = !open"]')
->click('.sm\:flex form[action$="/logout"] button[type="submit"]');
}

View file

@ -12,7 +12,6 @@
use App\WeekdaysEnum; use App\WeekdaysEnum;
use DishPlanner\Schedule\Services\ScheduleGenerator; use DishPlanner\Schedule\Services\ScheduleGenerator;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase; use Tests\TestCase;
use Tests\Traits\HasPlanner; use Tests\Traits\HasPlanner;
@ -128,19 +127,23 @@ public function test_it_takes_minimum_recurrences_into_account(): void
$this->assertTrue(Schedule::all()->isNotEmpty()); $this->assertTrue(Schedule::all()->isNotEmpty());
Schedule::all() $recurringDates = Schedule::all()
->filter(fn (Schedule $schedule) => $schedule->scheduledUserDishes()->first()->userDish->dish->id === $dishRecurring->id) ->filter(fn (Schedule $schedule) => $schedule->scheduledUserDishes
->contains(fn ($scheduledUserDish) => $scheduledUserDish->userDish->dish->id === $dishRecurring->id)
)
->map(fn (Schedule $schedule) => $schedule->date) ->map(fn (Schedule $schedule) => $schedule->date)
->reduce(function (?Carbon $previousDate, Carbon $currentDate) use ($recurringMinimum) { ->sort()
if (! is_null($previousDate)) { ->values();
$this->assertGreaterThanOrEqual(
$recurringMinimum,
$previousDate->diffInDays($currentDate),
'Dates are not spaced properly'
);
}
return $currentDate; $this->assertGreaterThan(1, $recurringDates->count(), 'Recurring dish was not scheduled often enough to verify spacing');
});
$gaps = $recurringDates
->sliding(2)
->map(fn ($pair) => (int) $pair->first()->diffInDays($pair->last()));
$this->assertEmpty(
$gaps->reject(fn (int $gap) => $gap >= $recurringMinimum)->all(),
'Dates are not spaced properly'
);
} }
} }

View file

@ -55,19 +55,20 @@ public function test_includes_correct_day_numbers(): void
public function test_marks_today_correctly(): void public function test_marks_today_correctly(): void
{ {
$this->travelTo(Carbon::createFromDate(2026, 3, 15)->startOfDay());
$planner = $this->planner; $planner = $this->planner;
$today = now();
$calendarDays = $this->service->getCalendarDays($planner, $today->month, $today->year); $calendarDays = $this->service->getCalendarDays($planner, 3, 2026);
$todayIndex = $today->day - 1; $todayIndex = 14;
$this->assertTrue($calendarDays[$todayIndex]['isToday']); $this->assertTrue($calendarDays[$todayIndex]['isToday']);
foreach ($calendarDays as $index => $day) { $otherDaysMarkedToday = collect($calendarDays)
if ($index !== $todayIndex && $day['day'] !== null) { ->filter(fn (array $day, int $index) => $index !== $todayIndex && $day['day'] !== null)
$this->assertFalse($day['isToday']); ->filter(fn (array $day) => $day['isToday']);
}
} $this->assertCount(0, $otherDaysMarkedToday);
} }
public function test_includes_scheduled_dishes(): void public function test_includes_scheduled_dishes(): void

View file

@ -6,6 +6,7 @@
use App\Models\Schedule; use App\Models\Schedule;
use App\Models\ScheduledUserDish; use App\Models\ScheduledUserDish;
use App\Models\User; use App\Models\User;
use App\Models\UserDish;
use Carbon\Carbon; use Carbon\Carbon;
use DishPlanner\ScheduledUserDish\Actions\SkipScheduledUserDishForDateAction; use DishPlanner\ScheduledUserDish\Actions\SkipScheduledUserDishForDateAction;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -59,6 +60,40 @@ public function test_skips_scheduled_user_dish(): void
]); ]);
} }
public function test_skips_with_reason_stores_reason(): void
{
$planner = $this->planner;
$user = User::factory()->planner($planner)->create();
$dish = Dish::factory()->planner($planner)->create();
$dish->users()->attach($user);
$date = Carbon::parse('2026-01-15');
$schedule = Schedule::create([
'planner_id' => $planner->id,
'date' => $date->format('Y-m-d'),
'is_skipped' => false,
]);
$userDish = UserDish::query()->where('user_id', $user->id)->firstOrFail();
ScheduledUserDish::create([
'schedule_id' => $schedule->id,
'user_id' => $user->id,
'user_dish_id' => $userDish->id,
'is_skipped' => false,
]);
$result = $this->action->execute($planner, $date, $user->id, 'eating out');
$this->assertTrue($result);
$this->assertDatabaseHas(ScheduledUserDish::class, [
'schedule_id' => $schedule->id,
'user_id' => $user->id,
'is_skipped' => true,
'user_dish_id' => null,
'skip_reason' => 'eating out',
]);
}
public function test_returns_false_when_schedule_does_not_exist(): void public function test_returns_false_when_schedule_does_not_exist(): void
{ {
$planner = $this->planner; $planner = $this->planner;