Compare commits
6 commits
d8c1830c71
...
cb2dbd9299
| Author | SHA1 | Date | |
|---|---|---|---|
| cb2dbd9299 | |||
| 175290b707 | |||
| dcfa73b7db | |||
| cca3f29823 | |||
| ba7df4b5ad | |||
| 01659923fa |
49 changed files with 3726 additions and 3139 deletions
|
|
@ -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
|
||||
|
|
@ -65,4 +65,16 @@ jobs:
|
|||
run: vendor/bin/phpstan analyse --memory-limit=1G
|
||||
|
||||
- name: Tests
|
||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||
run: vendor/bin/pest
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build frontend assets
|
||||
run: npm run build
|
||||
|
||||
- name: Browser tests
|
||||
run: vendor/bin/pest tests/Browser
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
/.phpunit.cache
|
||||
/tests/Browser/Screenshots
|
||||
/coverage
|
||||
/node_modules
|
||||
/public/build
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ ## Reporting issues
|
|||
## Development setup
|
||||
|
||||
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:
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ ## Development setup
|
|||
| Mailhog | http://localhost:8025 |
|
||||
| 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.
|
||||
|
||||
## Before opening a pull request
|
||||
|
|
|
|||
|
|
@ -9,11 +9,23 @@ RUN apk add --no-cache \
|
|||
mysql-client \
|
||||
vim \
|
||||
bash \
|
||||
nano
|
||||
nano \
|
||||
chromium \
|
||||
nss \
|
||||
freetype \
|
||||
harfbuzz \
|
||||
ttf-freefont \
|
||||
font-noto-emoji
|
||||
|
||||
# Playwright ships glibc-linked browser builds that do not run on Alpine (musl),
|
||||
# so drive Alpine's own Chromium instead of downloading one.
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
|
||||
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
# Install PHP extensions including xdebug for development
|
||||
RUN install-php-extensions \
|
||||
pdo_mysql \
|
||||
sockets \
|
||||
opcache \
|
||||
zip \
|
||||
gd \
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
### docker-compose.yml
|
||||
### docker-compose.prod.yml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
|
|
|
|||
|
|
@ -30,6 +30,15 @@ class ScheduleCalendar extends Component
|
|||
|
||||
public $regenerateUserId = null;
|
||||
|
||||
// Skip modal
|
||||
public bool $showSkipModal = false;
|
||||
|
||||
public ?string $skipDate = null;
|
||||
|
||||
public ?int $skipUserId = null;
|
||||
|
||||
public string $skipReason = '';
|
||||
|
||||
// Edit dish modal
|
||||
public $showEditDishModal = false;
|
||||
|
||||
|
|
@ -145,26 +154,52 @@ public function confirmRegenerate(): 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 {
|
||||
if (! $this->authorizeUser($userId)) {
|
||||
if (! $this->authorizeUser((int) $this->skipUserId)) {
|
||||
session()->flash('error', 'Unauthorized action.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'skipReason' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$reason = trim($this->skipReason) ?: null;
|
||||
|
||||
$action = new SkipScheduledUserDishForDateAction;
|
||||
$action->execute(
|
||||
auth()->user(),
|
||||
Carbon::parse($date),
|
||||
$userId
|
||||
Carbon::parse($this->skipDate),
|
||||
$this->skipUserId,
|
||||
$reason
|
||||
);
|
||||
|
||||
$this->showSkipModal = false;
|
||||
$this->skipDate = null;
|
||||
$this->skipUserId = null;
|
||||
$this->skipReason = '';
|
||||
|
||||
$this->loadCalendar();
|
||||
|
||||
session()->flash('success', 'Day skipped successfully!');
|
||||
} 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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -181,6 +216,10 @@ public function cancel(): void
|
|||
$this->showRegenerateModal = false;
|
||||
$this->regenerateDate = null;
|
||||
$this->regenerateUserId = null;
|
||||
$this->showSkipModal = false;
|
||||
$this->skipDate = null;
|
||||
$this->skipUserId = null;
|
||||
$this->skipReason = '';
|
||||
$this->showEditDishModal = false;
|
||||
$this->editDate = null;
|
||||
$this->editUserId = null;
|
||||
|
|
@ -436,6 +475,7 @@ public function saveDish(): void
|
|||
[
|
||||
'user_dish_id' => $userDish->id,
|
||||
'is_skipped' => false,
|
||||
'skip_reason' => null,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
* @property int $user_dish_id
|
||||
* @property UserDish $userDish
|
||||
* @property bool $is_skipped
|
||||
* @property string|null $skip_reason
|
||||
*
|
||||
* @method static create(array $array)
|
||||
* @method static ScheduledUserDishFactory factory($count = null, $state = [])
|
||||
|
|
@ -31,6 +32,7 @@ class ScheduledUserDish extends Model
|
|||
'user_id',
|
||||
'user_dish_id',
|
||||
'is_skipped',
|
||||
'skip_reason',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
|
|||
|
|
@ -18,14 +18,15 @@
|
|||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"larastan/larastan": "^3.10",
|
||||
"laravel/dusk": "^8.3",
|
||||
"laravel/pail": "^1.1",
|
||||
"laravel/pint": "^1.13",
|
||||
"laravel/sail": "^1.26",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.1",
|
||||
"phpstan/phpstan-mockery": "^2.0",
|
||||
"phpunit/phpunit": "^11.0.1"
|
||||
"pestphp/pest": "^4.7",
|
||||
"pestphp/pest-plugin-browser": "^4.3",
|
||||
"pestphp/pest-plugin-laravel": "^4.0",
|
||||
"phpstan/phpstan-mockery": "^2.0"
|
||||
},
|
||||
"autoload": {
|
||||
"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"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan test"
|
||||
"@php artisan test",
|
||||
"@php vendor/bin/pest tests/Browser"
|
||||
],
|
||||
"test:coverage": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
|
|
|
|||
3452
composer.lock
generated
3452
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Planner;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<User>
|
||||
* @extends Factory<Planner>
|
||||
*/
|
||||
class PlannerFactory extends Factory
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
# CI image: PHP + Composer only, no runtime server or frontend toolchain.
|
||||
# Tests run against SQLite in memory (see .env.testing), so no database client
|
||||
# or cache extension is needed.
|
||||
# CI image: PHP + Composer + Node/npm. Unit/feature tests run against SQLite in
|
||||
# memory (see .env.testing), so no database client 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
|
||||
# builds and tags this image from the current lockfile, so a dependency change
|
||||
# automatically yields a fresh, uniquely-tagged image (no manual revision bump).
|
||||
# builds and tags this image from the current lockfile, so a PHP dependency
|
||||
# change automatically yields a fresh, uniquely-tagged image (no manual revision
|
||||
# bump).
|
||||
#
|
||||
# Debian-based rather than Alpine to avoid the DNS resolution timeouts against
|
||||
# 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 \
|
||||
pdo_sqlite \
|
||||
sockets \
|
||||
mbstring \
|
||||
dom \
|
||||
xml \
|
||||
|
|
@ -22,10 +26,11 @@ RUN install-php-extensions \
|
|||
pcntl \
|
||||
zip
|
||||
|
||||
# git is needed by the checkout action; nodejs runs the Forgejo JavaScript
|
||||
# actions (checkout, cache); unzip lets Composer extract dist archives.
|
||||
# git is needed by the checkout action; nodejs+npm run the Forgejo JavaScript
|
||||
# actions (checkout, cache) and Playwright; unzip lets Composer extract dist
|
||||
# archives.
|
||||
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/*
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
# Local Development Docker Compose
|
||||
name: dishplanner
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
context: ../..
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: dishplanner_app
|
||||
restart: unless-stopped
|
||||
|
|
@ -39,9 +40,13 @@ services:
|
|||
|
||||
# Vite
|
||||
VITE_HOST: "0.0.0.0"
|
||||
|
||||
# Playwright drives Alpine's system Chromium (musl); see Dockerfile.dev
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
|
||||
PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: "/usr/bin/chromium-browser"
|
||||
volumes:
|
||||
# Mount entire project for hot reload with SELinux context
|
||||
- .:/app:Z
|
||||
- ../..:/app:Z
|
||||
# Named volumes for performance and permission isolation
|
||||
- app_vendor:/app/vendor
|
||||
- app_node_modules:/app/node_modules
|
||||
|
|
@ -64,7 +69,7 @@ services:
|
|||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
# Initialize with SQL scripts
|
||||
- ./docker/mysql-init:/docker-entrypoint-initdb.d
|
||||
- ../mysql-init:/docker-entrypoint-initdb.d
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
|
|
@ -84,21 +89,6 @@ services:
|
|||
networks:
|
||||
- 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
|
||||
# redis:
|
||||
# image: redis:alpine
|
||||
50
package-lock.json
generated
50
package-lock.json
generated
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "app",
|
||||
"name": "dishplanner",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"axios": "^1.7.4",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^1.3.0",
|
||||
"playwright": "^1.62.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^6.4.1"
|
||||
|
|
@ -2220,6 +2221,53 @@
|
|||
"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": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"axios": "^1.7.4",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^1.3.0",
|
||||
"playwright": "^1.62.1",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"vite": "^6.4.1"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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>
|
||||
23
phpunit.xml
23
phpunit.xml
|
|
@ -24,17 +24,18 @@
|
|||
</exclude>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="APP_ENV" value="testing" force="true"/>
|
||||
<server name="APP_ENV" value="testing" force="true"/>
|
||||
<server name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||
<server name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||
<server name="CACHE_STORE" value="array" force="true"/>
|
||||
<server name="MAIL_MAILER" value="array" force="true"/>
|
||||
<server name="PULSE_ENABLED" value="false" force="true"/>
|
||||
<server name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||
<server name="SESSION_DRIVER" value="array" force="true"/>
|
||||
<server name="TELESCOPE_ENABLED" value="false" force="true"/>
|
||||
<server name="DB_CONNECTION" value="sqlite" force="true"/>
|
||||
<server name="DB_DATABASE" value=":memory:" force="true"/>
|
||||
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{{ strtoupper(substr($scheduled->user->name, 0, 1)) }}
|
||||
</div>
|
||||
<span class="truncate">{{ $scheduled->userDish?->dish?->name ?? 'Skipped' }}</span>
|
||||
<span class="truncate">{{ $scheduled->userDish?->dish?->name ?? ($scheduled->skip_reason ?: 'Skipped') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 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)) }}
|
||||
</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>
|
||||
</div>
|
||||
|
|
@ -212,6 +212,36 @@ class="px-4 py-2 bg-warning text-white rounded hover:bg-yellow-600 transition-co
|
|||
</div>
|
||||
@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 -->
|
||||
@if($showEditDishModal)
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
|
|
|
|||
63
shell.nix
63
shell.nix
|
|
@ -1,5 +1,17 @@
|
|||
{ 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 {
|
||||
buildInputs = with pkgs; [
|
||||
# PHP and tools
|
||||
|
|
@ -29,59 +41,75 @@ pkgs.mkShell {
|
|||
# Use keep-id for proper permission mapping in rootless podman
|
||||
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
|
||||
dev-rebuild() {
|
||||
echo "🔨 Rebuilding development environment..."
|
||||
PODMAN_USERNS=keep-id podman-compose down -v
|
||||
PODMAN_USERNS=keep-id podman-compose build --no-cache app
|
||||
PODMAN_USERNS=keep-id podman-compose up -d
|
||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE down -v
|
||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE build --no-cache app
|
||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d
|
||||
echo "✅ Rebuild complete! Check logs with: dev-logs"
|
||||
}
|
||||
|
||||
dev-rebuild-quick() {
|
||||
echo "⚡ Quick rebuild (keeping volumes)..."
|
||||
PODMAN_USERNS=keep-id podman-compose down
|
||||
PODMAN_USERNS=keep-id podman-compose build app
|
||||
PODMAN_USERNS=keep-id podman-compose up -d
|
||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE down
|
||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE build app
|
||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d
|
||||
echo "✅ Quick rebuild complete!"
|
||||
}
|
||||
|
||||
dev-up() {
|
||||
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!"
|
||||
}
|
||||
|
||||
dev-down() {
|
||||
echo "🛑 Stopping development environment..."
|
||||
podman-compose down
|
||||
podman-compose -f $COMPOSE_FILE down
|
||||
echo "✅ Dev environment stopped!"
|
||||
}
|
||||
|
||||
dev-restart() {
|
||||
echo "🔄 Restarting development environment..."
|
||||
podman-compose restart
|
||||
podman-compose -f $COMPOSE_FILE restart
|
||||
echo "✅ Dev environment restarted!"
|
||||
}
|
||||
|
||||
dev-logs() {
|
||||
podman-compose logs -f "$@"
|
||||
podman-compose -f $COMPOSE_FILE logs -f "$@"
|
||||
}
|
||||
|
||||
dev-logs-db() {
|
||||
podman-compose logs -f db "$@"
|
||||
podman-compose -f $COMPOSE_FILE logs -f db "$@"
|
||||
}
|
||||
|
||||
dev-shell() {
|
||||
podman-compose exec app sh
|
||||
podman-compose -f $COMPOSE_FILE exec app sh
|
||||
}
|
||||
|
||||
dev-artisan() {
|
||||
podman-compose exec app php artisan "$@"
|
||||
podman-compose -f $COMPOSE_FILE exec app php artisan "$@"
|
||||
}
|
||||
|
||||
dev-test() {
|
||||
podman-compose exec -T app env $(grep -vE '^\s*(#|$)' .env.testing) php -d memory_limit=512M vendor/bin/phpunit "$@"
|
||||
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`.
|
||||
vendor/bin/pest tests/Browser "$@"
|
||||
}
|
||||
|
||||
dev-fix-permissions() {
|
||||
|
|
@ -161,6 +189,7 @@ pkgs.mkShell {
|
|||
echo " dev-shell - Enter app container"
|
||||
echo " dev-artisan - Run artisan commands"
|
||||
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 ""
|
||||
echo "Production commands:"
|
||||
|
|
@ -171,12 +200,12 @@ pkgs.mkShell {
|
|||
echo ""
|
||||
|
||||
# Auto-start prompt
|
||||
if [ -f "docker-compose.yml" ]; then
|
||||
if [ -f "$COMPOSE_FILE" ]; then
|
||||
read -p "Start development containers? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Starting containers..."
|
||||
podman-compose up -d
|
||||
podman-compose -f $COMPOSE_FILE up -d
|
||||
|
||||
# Wait a moment for containers to start
|
||||
sleep 3
|
||||
|
|
@ -188,7 +217,7 @@ pkgs.mkShell {
|
|||
echo " Mailhog: http://localhost:8025"
|
||||
echo " MariaDB: localhost:3306"
|
||||
echo ""
|
||||
echo "Run 'podman-compose logs -f app' to follow logs"
|
||||
echo "Run 'pc logs -f app' to follow logs"
|
||||
fi
|
||||
fi
|
||||
'';
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
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()
|
||||
->where('planner_id', $planner->id)
|
||||
|
|
@ -32,6 +32,7 @@ public function execute(Planner $planner, Carbon $date, int $userId): bool
|
|||
$scheduledUserDish->update([
|
||||
'is_skipped' => true,
|
||||
'user_dish_id' => null,
|
||||
'skip_reason' => $reason,
|
||||
]);
|
||||
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -1,91 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Auth;
|
||||
|
||||
use App\Models\Planner;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class LoginTest extends DuskTestCase
|
||||
{
|
||||
protected static $testPlanner = null;
|
||||
it('logs in successfully', function () {
|
||||
$planner = Planner::factory()->create([
|
||||
'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
|
||||
{
|
||||
if (self::$testPlanner === null) {
|
||||
// Generate unique email for this test run
|
||||
self::$testEmail = fake()->unique()->safeEmail();
|
||||
visit('/login')
|
||||
->type('input[id="email"]', $planner->email)
|
||||
->type('input[id="password"]', 'wrong-password')
|
||||
->press('Sign In')
|
||||
->assertPathIs('/login')
|
||||
->assertSee('These credentials do not match our records');
|
||||
});
|
||||
|
||||
self::$testPlanner = Planner::factory()->create([
|
||||
'email' => self::$testEmail,
|
||||
'password' => Hash::make(self::$testPassword),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_successful_login(): 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"]', 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
it('requires the email and password fields', function () {
|
||||
visit('/login')
|
||||
->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')
|
||||
->assertPathIs('/login');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Dishes;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Components\DishModal;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
it('validates the dish name field', function () {
|
||||
loginAndGoToDishes()
|
||||
->click('button[wire\:click="create"]')
|
||||
->press('Create Dish')
|
||||
->assertSee('required');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,53 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Dishes;
|
||||
it('creates a dish successfully', function () {
|
||||
$dishName = 'Test Dish '.uniqid();
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\Components\DishModal;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
use Tests\Browser\Pages\DishesPage;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
loginAndGoToDishes()
|
||||
->click('button[wire\:click="create"]')
|
||||
->type('input[wire\:model="name"]', $dishName)
|
||||
->press('Create Dish')
|
||||
->assertSee($dishName)
|
||||
->assertSee('Dish created successfully');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,47 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Dishes;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
use Tests\Browser\Pages\DishesPage;
|
||||
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
|
||||
}
|
||||
it('can access the dishes page', function () {
|
||||
loginAndGoToDishes()
|
||||
->assertPathIs('/dishes')
|
||||
->assertSee('MANAGE DISHES')
|
||||
->assertSee('Add Dish');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,77 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Dishes;
|
||||
use App\Models\Dish;
|
||||
|
||||
use App\Models\Planner;
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
use Tests\DuskTestCase;
|
||||
it('exposes the delete feature', function () {
|
||||
$planner = createPlanner();
|
||||
Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
|
||||
|
||||
class DeleteDishTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
*/
|
||||
}
|
||||
loginAs($planner, '/dishes')
|
||||
->assertPathIs('/dishes')
|
||||
->assertSee('MANAGE DISHES')
|
||||
->assertSee('Delete');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,50 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Dishes;
|
||||
use App\Models\Dish;
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
use Tests\DuskTestCase;
|
||||
it('shows the deletion safety features', function () {
|
||||
$planner = createPlanner();
|
||||
Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
|
||||
|
||||
class DishDeletionSafetyTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
loginAs($planner, '/dishes')
|
||||
->assertSee('MANAGE DISHES')
|
||||
->assertSee('Add Dish')
|
||||
->assertSee('Delete');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,74 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Dishes;
|
||||
use App\Models\Dish;
|
||||
|
||||
use App\Models\Planner;
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
use Tests\DuskTestCase;
|
||||
it('exposes the edit feature', function () {
|
||||
$planner = createPlanner();
|
||||
Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
|
||||
|
||||
class EditDishTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
loginAs($planner, '/dishes')
|
||||
->assertPathIs('/dishes')
|
||||
->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
|
||||
{
|
||||
parent::setUp();
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
loginAs($planner, '/dishes')
|
||||
->assertSee('Edit')
|
||||
->assertSee('Delete');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser;
|
||||
it('redirects unauthenticated users to login', function () {
|
||||
visit('/dashboard')
|
||||
->assertPathIs('/login')
|
||||
->assertSee('Sign In');
|
||||
});
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\DuskTestCase;
|
||||
|
||||
class RedirectTest extends DuskTestCase
|
||||
{
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
it('loads the login page', function () {
|
||||
visit('/login')
|
||||
->assertPathIs('/login')
|
||||
->assertSee('Email')
|
||||
->assertSee('Password');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,128 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Schedule;
|
||||
|
||||
use App\Models\Dish;
|
||||
use App\Models\Planner;
|
||||
use App\Models\User;
|
||||
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;
|
||||
|
||||
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."']");
|
||||
});
|
||||
}
|
||||
return [$planner, $user];
|
||||
}
|
||||
|
||||
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.'"]');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,108 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Schedule;
|
||||
it('can access the schedule page', function () {
|
||||
loginAndGoToSchedule()
|
||||
->assertSee('SCHEDULE')
|
||||
->assertSee('Generate Schedule');
|
||||
});
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
use Tests\Browser\Pages\SchedulePage;
|
||||
use Tests\DuskTestCase;
|
||||
it('shows the month navigation', function () {
|
||||
loginAndGoToSchedule()
|
||||
->assertPresent('button[wire\:click="previousMonth"]')
|
||||
->assertPresent('button[wire\:click="nextMonth"]')
|
||||
->assertSee(now()->format('F Y'));
|
||||
});
|
||||
|
||||
class SchedulePageTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
it('can navigate to the next month', function () {
|
||||
$nextMonth = now()->addMonth();
|
||||
|
||||
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
|
||||
{
|
||||
parent::setUp();
|
||||
self::$testPlanner = self::$schedulePageTestPlanner;
|
||||
self::$testEmail = self::$schedulePageTestEmail;
|
||||
}
|
||||
loginAndGoToSchedule()
|
||||
->click('button[wire\:click="previousMonth"]')
|
||||
->assertSee($previousMonth->format('F Y'));
|
||||
});
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
self::$schedulePageTestPlanner = self::$testPlanner;
|
||||
self::$schedulePageTestEmail = self::$testEmail;
|
||||
parent::tearDown();
|
||||
}
|
||||
it('shows the user selection', function () {
|
||||
loginAndGoToSchedule()
|
||||
->assertSee('Select Users')
|
||||
->assertPresent('button[wire\:click="generate"]')
|
||||
->assertPresent('button[wire\:click="clearMonth"]');
|
||||
});
|
||||
|
||||
public function test_can_access_schedule_page(): void
|
||||
{
|
||||
$this->browse(function (Browser $browser) {
|
||||
$this->loginAndGoToSchedule($browser);
|
||||
|
||||
$browser->on(new SchedulePage)
|
||||
->assertSee('SCHEDULE')
|
||||
->assertSee('Generate Schedule');
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
it('shows the days of the week', function () {
|
||||
loginAndGoToSchedule()
|
||||
->assertSee('Mon')
|
||||
->assertSee('Tue')
|
||||
->assertSee('Wed')
|
||||
->assertSee('Thu')
|
||||
->assertSee('Fri')
|
||||
->assertSee('Sat')
|
||||
->assertSee('Sun');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,104 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Browser\Users;
|
||||
it('can access the users page', function () {
|
||||
loginAndGoToUsers()
|
||||
->assertSee('MANAGE USERS')
|
||||
->assertSee('Add User');
|
||||
});
|
||||
|
||||
use Laravel\Dusk\Browser;
|
||||
use Tests\Browser\LoginHelpers;
|
||||
use Tests\Browser\Pages\UsersPage;
|
||||
use Tests\DuskTestCase;
|
||||
it('can open the create user modal', function () {
|
||||
loginAndGoToUsers()
|
||||
->click('button[wire\:click="create"]')
|
||||
->assertSee('Add New User')
|
||||
->assertSee('Name')
|
||||
->assertSee('Cancel')
|
||||
->assertSee('Create User');
|
||||
});
|
||||
|
||||
class CreateUserTest extends DuskTestCase
|
||||
{
|
||||
use LoginHelpers;
|
||||
it('validates the user name field', function () {
|
||||
loginAndGoToUsers()
|
||||
->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
|
||||
{
|
||||
parent::setUp();
|
||||
// Reset static planner for this specific test class
|
||||
self::$testPlanner = self::$createUserTestPlanner;
|
||||
self::$testEmail = self::$createUserTestEmail;
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
it('can cancel user creation', function () {
|
||||
loginAndGoToUsers()
|
||||
->click('button[wire\:click="create"]')
|
||||
->type('input[wire\:model="name"]', 'Test Cancel User')
|
||||
->press('Cancel')
|
||||
->assertSee('MANAGE USERS')
|
||||
->assertDontSee('Add New User');
|
||||
});
|
||||
|
|
|
|||
2
tests/Browser/console/.gitignore
vendored
2
tests/Browser/console/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
2
tests/Browser/screenshots/.gitignore
vendored
2
tests/Browser/screenshots/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
2
tests/Browser/source/.gitignore
vendored
2
tests/Browser/source/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
*
|
||||
!.gitignore
|
||||
12
tests/BrowserTestCase.php
Normal file
12
tests/BrowserTestCase.php
Normal 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.
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
107
tests/Feature/Schedule/ScheduleCalendarTest.php
Normal file
107
tests/Feature/Schedule/ScheduleCalendarTest.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
64
tests/Pest.php
Normal file
64
tests/Pest.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Planner;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
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');
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
use App\Models\Schedule;
|
||||
use App\Models\ScheduledUserDish;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDish;
|
||||
use Carbon\Carbon;
|
||||
use DishPlanner\ScheduledUserDish\Actions\SkipScheduledUserDishForDateAction;
|
||||
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
|
||||
{
|
||||
$planner = $this->planner;
|
||||
|
|
|
|||
Loading…
Reference in a new issue