Compare commits
No commits in common. "cb2dbd9299e1cfd5ca7a7b4e3fa961c5b0821618" and "d8c1830c71fb08696e4dfc56595c0d445ef6f5d0" have entirely different histories.
cb2dbd9299
...
d8c1830c71
49 changed files with 3137 additions and 3724 deletions
24
.env.dusk.local
Normal file
24
.env.dusk.local
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
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,16 +65,4 @@ jobs:
|
||||||
run: vendor/bin/phpstan analyse --memory-limit=1G
|
run: vendor/bin/phpstan analyse --memory-limit=1G
|
||||||
|
|
||||||
- name: Tests
|
- name: Tests
|
||||||
run: vendor/bin/pest
|
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||||
|
|
||||||
- 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,5 +1,4 @@
|
||||||
/.phpunit.cache
|
/.phpunit.cache
|
||||||
/tests/Browser/Screenshots
|
|
||||||
/coverage
|
/coverage
|
||||||
/node_modules
|
/node_modules
|
||||||
/public/build
|
/public/build
|
||||||
|
|
|
||||||
|
|
@ -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/dev/docker-compose.yml`.
|
environment runs in containers defined by `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/dev/docker-compose.yml`.
|
Without Nix, start the same containers directly from `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
|
||||||
|
|
|
||||||
|
|
@ -9,23 +9,11 @@ RUN apk add --no-cache \
|
||||||
mysql-client \
|
mysql-client \
|
||||||
vim \
|
vim \
|
||||||
bash \
|
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
|
# 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 \
|
||||||
|
|
|
||||||
|
|
@ -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.prod.yml
|
### docker-compose.yml
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
|
|
|
||||||
|
|
@ -30,15 +30,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -155,51 +146,25 @@ public function confirmRegenerate(): void
|
||||||
|
|
||||||
public function skipDay($date, $userId): void
|
public function skipDay($date, $userId): void
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
if (! $this->authorizeUser($userId)) {
|
if (! $this->authorizeUser($userId)) {
|
||||||
session()->flash('error', 'Unauthorized action.');
|
session()->flash('error', 'Unauthorized action.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->skipDate = $date;
|
|
||||||
$this->skipUserId = $userId;
|
|
||||||
$this->skipReason = '';
|
|
||||||
$this->showSkipModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function confirmSkip(): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
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 = new SkipScheduledUserDishForDateAction;
|
||||||
$action->execute(
|
$action->execute(
|
||||||
auth()->user(),
|
auth()->user(),
|
||||||
Carbon::parse($this->skipDate),
|
Carbon::parse($date),
|
||||||
$this->skipUserId,
|
$userId
|
||||||
$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' => $this->skipDate, 'userId' => $this->skipUserId]);
|
Log::error('Skip day failed', ['exception' => $e, 'date' => $date, 'userId' => $userId]);
|
||||||
session()->flash('error', 'Unable to skip day. Please try again.');
|
session()->flash('error', 'Unable to skip day. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -216,10 +181,6 @@ 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;
|
||||||
|
|
@ -475,7 +436,6 @@ public function saveDish(): void
|
||||||
[
|
[
|
||||||
'user_dish_id' => $userDish->id,
|
'user_dish_id' => $userDish->id,
|
||||||
'is_skipped' => false,
|
'is_skipped' => false,
|
||||||
'skip_reason' => null,
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
* @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 = [])
|
||||||
|
|
@ -32,7 +31,6 @@ class ScheduledUserDish extends Model
|
||||||
'user_id',
|
'user_id',
|
||||||
'user_dish_id',
|
'user_dish_id',
|
||||||
'is_skipped',
|
'is_skipped',
|
||||||
'skip_reason',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,14 @@
|
||||||
"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",
|
||||||
"pestphp/pest": "^4.7",
|
"phpstan/phpstan-mockery": "^2.0",
|
||||||
"pestphp/pest-plugin-browser": "^4.3",
|
"phpunit/phpunit": "^11.0.1"
|
||||||
"pestphp/pest-plugin-laravel": "^4.0",
|
|
||||||
"phpstan/phpstan-mockery": "^2.0"
|
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|
@ -62,8 +61,7 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
3450
composer.lock
generated
3450
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,13 +2,13 @@
|
||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
use App\Models\Planner;
|
use App\Models\User;
|
||||||
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<Planner>
|
* @extends Factory<User>
|
||||||
*/
|
*/
|
||||||
class PlannerFactory extends Factory
|
class PlannerFactory extends Factory
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<?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,11 +1,10 @@
|
||||||
# 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
|
||||||
|
|
@ -40,13 +39,9 @@ services:
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
VITE_HOST: "0.0.0.0"
|
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:
|
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
|
||||||
|
|
@ -69,7 +64,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/mysql
|
- db_data:/var/lib/mysql
|
||||||
# Initialize with SQL scripts
|
# Initialize with SQL scripts
|
||||||
- ../mysql-init:/docker-entrypoint-initdb.d
|
- ./docker/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
|
||||||
|
|
@ -89,6 +84,21 @@ 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
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
# CI image: PHP + Composer + Node/npm. Unit/feature tests run against SQLite in
|
# CI image: PHP + Composer only, no runtime server or frontend toolchain.
|
||||||
# memory (see .env.testing), so no database client or cache extension is needed.
|
# Tests run against SQLite in memory (see .env.testing), so no database client
|
||||||
# Browser tests need the `sockets` extension (pest-plugin-browser boots Laravel
|
# or cache extension is needed.
|
||||||
# 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 PHP dependency
|
# builds and tags this image from the current lockfile, so a dependency change
|
||||||
# change automatically yields a fresh, uniquely-tagged image (no manual revision
|
# automatically yields a fresh, uniquely-tagged image (no manual revision bump).
|
||||||
# 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.
|
||||||
|
|
@ -18,7 +15,6 @@ 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 \
|
||||||
|
|
@ -26,11 +22,10 @@ RUN install-php-extensions \
|
||||||
pcntl \
|
pcntl \
|
||||||
zip
|
zip
|
||||||
|
|
||||||
# git is needed by the checkout action; nodejs+npm run the Forgejo JavaScript
|
# git is needed by the checkout action; nodejs runs the Forgejo JavaScript
|
||||||
# actions (checkout, cache) and Playwright; unzip lets Composer extract dist
|
# actions (checkout, cache); unzip lets Composer extract dist archives.
|
||||||
# archives.
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends git unzip nodejs npm \
|
&& apt-get install -y --no-install-recommends git unzip nodejs \
|
||||||
&& 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
|
||||||
|
|
|
||||||
50
package-lock.json
generated
50
package-lock.json
generated
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "dishplanner",
|
"name": "app",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
"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"
|
||||||
|
|
@ -2221,53 +2220,6 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@
|
||||||
"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
15
phpunit.dusk.xml
Normal file
15
phpunit.dusk.xml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?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,18 +24,17 @@
|
||||||
</exclude>
|
</exclude>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing" force="true"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<server name="APP_ENV" value="testing" force="true"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<server name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<server name="BCRYPT_ROUNDS" value="4" force="true"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<server name="CACHE_STORE" value="array" force="true"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<server name="MAIL_MAILER" value="array" force="true"/>
|
<env name="PULSE_ENABLED" value="false"/>
|
||||||
<server name="PULSE_ENABLED" value="false" force="true"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<server name="QUEUE_CONNECTION" value="sync" force="true"/>
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
<server name="SESSION_DRIVER" value="array" force="true"/>
|
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||||
<server name="TELESCOPE_ENABLED" value="false" force="true"/>
|
<env name="DB_CONNECTION" value="sqlite"/>
|
||||||
<server name="DB_CONNECTION" value="sqlite" force="true"/>
|
<env name="DB_DATABASE" value=":memory:"/>
|
||||||
<server name="DB_DATABASE" value=":memory:" force="true"/>
|
|
||||||
|
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</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">
|
<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 ?? ($scheduled->skip_reason ?: 'Skipped') }}</span>
|
<span class="truncate">{{ $scheduled->userDish?->dish?->name ?? '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 ?? ($scheduled->skip_reason ?: 'Skipped') }}</div>
|
<div class="font-medium">{{ $scheduled->userDish?->dish?->name ?? '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,36 +212,6 @@ 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">
|
||||||
|
|
|
||||||
63
shell.nix
63
shell.nix
|
|
@ -1,17 +1,5 @@
|
||||||
{ 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
|
||||||
|
|
@ -41,75 +29,59 @@ 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 -f $COMPOSE_FILE down -v
|
PODMAN_USERNS=keep-id podman-compose down -v
|
||||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE build --no-cache app
|
PODMAN_USERNS=keep-id podman-compose build --no-cache app
|
||||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d
|
PODMAN_USERNS=keep-id podman-compose 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 -f $COMPOSE_FILE down
|
PODMAN_USERNS=keep-id podman-compose down
|
||||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE build app
|
PODMAN_USERNS=keep-id podman-compose build app
|
||||||
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d
|
PODMAN_USERNS=keep-id podman-compose 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 -f $COMPOSE_FILE up -d
|
PODMAN_USERNS=keep-id podman-compose up -d
|
||||||
echo "✅ Dev environment started!"
|
echo "✅ Dev environment started!"
|
||||||
}
|
}
|
||||||
|
|
||||||
dev-down() {
|
dev-down() {
|
||||||
echo "🛑 Stopping development environment..."
|
echo "🛑 Stopping development environment..."
|
||||||
podman-compose -f $COMPOSE_FILE down
|
podman-compose down
|
||||||
echo "✅ Dev environment stopped!"
|
echo "✅ Dev environment stopped!"
|
||||||
}
|
}
|
||||||
|
|
||||||
dev-restart() {
|
dev-restart() {
|
||||||
echo "🔄 Restarting development environment..."
|
echo "🔄 Restarting development environment..."
|
||||||
podman-compose -f $COMPOSE_FILE restart
|
podman-compose restart
|
||||||
echo "✅ Dev environment restarted!"
|
echo "✅ Dev environment restarted!"
|
||||||
}
|
}
|
||||||
|
|
||||||
dev-logs() {
|
dev-logs() {
|
||||||
podman-compose -f $COMPOSE_FILE logs -f "$@"
|
podman-compose logs -f "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
dev-logs-db() {
|
dev-logs-db() {
|
||||||
podman-compose -f $COMPOSE_FILE logs -f db "$@"
|
podman-compose logs -f db "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
dev-shell() {
|
dev-shell() {
|
||||||
podman-compose -f $COMPOSE_FILE exec app sh
|
podman-compose exec app sh
|
||||||
}
|
}
|
||||||
|
|
||||||
dev-artisan() {
|
dev-artisan() {
|
||||||
podman-compose -f $COMPOSE_FILE exec app php artisan "$@"
|
podman-compose exec app php artisan "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
dev-test() {
|
dev-test() {
|
||||||
podman-compose -f $COMPOSE_FILE exec -T app env $(grep -vE '^\s*(#|$)' .env.testing) php -d memory_limit=512M vendor/bin/phpunit "$@"
|
podman-compose 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() {
|
dev-fix-permissions() {
|
||||||
|
|
@ -189,7 +161,6 @@ 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:"
|
||||||
|
|
@ -200,12 +171,12 @@ pkgs.mkShell {
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Auto-start prompt
|
# Auto-start prompt
|
||||||
if [ -f "$COMPOSE_FILE" ]; then
|
if [ -f "docker-compose.yml" ]; 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 -f $COMPOSE_FILE up -d
|
podman-compose up -d
|
||||||
|
|
||||||
# Wait a moment for containers to start
|
# Wait a moment for containers to start
|
||||||
sleep 3
|
sleep 3
|
||||||
|
|
@ -217,7 +188,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 'pc logs -f app' to follow logs"
|
echo "Run 'podman-compose logs -f app' to follow logs"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
'';
|
'';
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
class SkipScheduledUserDishForDateAction
|
class SkipScheduledUserDishForDateAction
|
||||||
{
|
{
|
||||||
public function execute(Planner $planner, Carbon $date, int $userId, ?string $reason = null): bool
|
public function execute(Planner $planner, Carbon $date, int $userId): bool
|
||||||
{
|
{
|
||||||
$schedule = Schedule::query()
|
$schedule = Schedule::query()
|
||||||
->where('planner_id', $planner->id)
|
->where('planner_id', $planner->id)
|
||||||
|
|
@ -32,7 +32,6 @@ public function execute(Planner $planner, Carbon $date, int $userId, ?string $re
|
||||||
$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;
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,91 @@
|
||||||
<?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;
|
||||||
|
|
||||||
it('logs in successfully', function () {
|
class LoginTest extends DuskTestCase
|
||||||
$planner = Planner::factory()->create([
|
{
|
||||||
'password' => Hash::make('password'),
|
protected static $testPlanner = null;
|
||||||
|
|
||||||
|
protected static $testEmail = null;
|
||||||
|
|
||||||
|
protected static $testPassword = 'password';
|
||||||
|
|
||||||
|
protected function ensureTestPlannerExists(): void
|
||||||
|
{
|
||||||
|
if (self::$testPlanner === null) {
|
||||||
|
// 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),
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
visit('/login')
|
public function test_successful_login(): void
|
||||||
->type('input[id="email"]', $planner->email)
|
{
|
||||||
->type('input[id="password"]', 'password')
|
$this->ensureTestPlannerExists();
|
||||||
->press('Sign In')
|
|
||||||
|
$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');
|
->assertPathIs('/dashboard');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('rejects wrong credentials', function () {
|
public function test_login_with_wrong_credentials(): void
|
||||||
$planner = Planner::factory()->create([
|
{
|
||||||
'password' => Hash::make('password'),
|
$this->ensureTestPlannerExists();
|
||||||
]);
|
|
||||||
|
|
||||||
visit('/login')
|
$this->browse(function (Browser $browser) {
|
||||||
->type('input[id="email"]', $planner->email)
|
$browser->driver->manage()->deleteAllCookies();
|
||||||
->type('input[id="password"]', 'wrong-password')
|
$browser->visit('http://dishplanner_app:8000/login')
|
||||||
->press('Sign In')
|
->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')
|
->assertPathIs('/login')
|
||||||
->assertSee('These credentials do not match our records');
|
->assertSee('These credentials do not match our records');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('requires the email and password fields', function () {
|
public function test_login_form_required_fields(): void
|
||||||
visit('/login')
|
{
|
||||||
->assertScript("document.querySelector('input[id=\"email\"]').required")
|
$this->browse(function (Browser $browser) {
|
||||||
->assertScript("document.querySelector('input[id=\"password\"]').required")
|
$browser->driver->manage()->deleteAllCookies();
|
||||||
->assertAttribute('input[id="email"]', 'type', 'email')
|
$browser->visit('http://dishplanner_app:8000/login')
|
||||||
->assertAttribute('input[id="password"]', 'type', 'password')
|
->waitFor('input[id="email"]', self::TIMEOUT_SHORT);
|
||||||
->press('Sign In')
|
|
||||||
|
// 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');
|
->assertPathIs('/login');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
105
tests/Browser/Components/DishModal.php
Normal file
105
tests/Browser/Components/DishModal.php
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
89
tests/Browser/Components/LoginForm.php
Normal file
89
tests/Browser/Components/LoginForm.php
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<?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,8 +1,50 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('validates the dish name field', function () {
|
namespace Tests\Browser\Dishes;
|
||||||
loginAndGoToDishes()
|
|
||||||
->click('button[wire\:click="create"]')
|
use Laravel\Dusk\Browser;
|
||||||
->press('Create Dish')
|
use Tests\Browser\Components\DishModal;
|
||||||
->assertSee('required');
|
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');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,53 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('creates a dish successfully', function () {
|
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 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();
|
$dishName = 'Test Dish '.uniqid();
|
||||||
|
|
||||||
loginAndGoToDishes()
|
$this->loginAndGoToDishes($browser);
|
||||||
->click('button[wire\:click="create"]')
|
|
||||||
->type('input[wire\:model="name"]', $dishName)
|
$browser->on(new DishesPage)
|
||||||
->press('Create Dish')
|
->openCreateModal()
|
||||||
->assertSee($dishName)
|
->within(new DishModal('create'), function ($browser) use ($dishName) {
|
||||||
|
$browser->fillForm($dishName)
|
||||||
|
->submit();
|
||||||
|
})
|
||||||
|
->pause(3000)
|
||||||
|
->assertDishVisible($dishName)
|
||||||
->assertSee('Dish created successfully');
|
->assertSee('Dish created successfully');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,47 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('can access the dishes page', function () {
|
namespace Tests\Browser\Dishes;
|
||||||
loginAndGoToDishes()
|
|
||||||
->assertPathIs('/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('MANAGE DISHES')
|
||||||
->assertSee('Add Dish');
|
->assertSee('Add Dish');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Moved to separate single-method test files to avoid static planner issues
|
||||||
|
// See: OpenCreateDishModalTest, CreateDishFormValidationTest, CancelDishCreationTest, CreateDishSuccessTest
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,77 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Dish;
|
namespace Tests\Browser\Dishes;
|
||||||
|
|
||||||
it('exposes the delete feature', function () {
|
use App\Models\Planner;
|
||||||
$planner = createPlanner();
|
use Laravel\Dusk\Browser;
|
||||||
Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
|
use Tests\Browser\LoginHelpers;
|
||||||
|
use Tests\DuskTestCase;
|
||||||
|
|
||||||
loginAs($planner, '/dishes')
|
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')
|
->assertPathIs('/dishes')
|
||||||
->assertSee('MANAGE DISHES')
|
->assertSee('MANAGE DISHES');
|
||||||
->assertSee('Delete');
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,50 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Dish;
|
namespace Tests\Browser\Dishes;
|
||||||
|
|
||||||
it('shows the deletion safety features', function () {
|
use Laravel\Dusk\Browser;
|
||||||
$planner = createPlanner();
|
use Tests\Browser\LoginHelpers;
|
||||||
Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
|
use Tests\DuskTestCase;
|
||||||
|
|
||||||
loginAs($planner, '/dishes')
|
class DishDeletionSafetyTest extends DuskTestCase
|
||||||
->assertSee('MANAGE DISHES')
|
{
|
||||||
->assertSee('Add Dish')
|
use LoginHelpers;
|
||||||
->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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,74 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Dish;
|
namespace Tests\Browser\Dishes;
|
||||||
|
|
||||||
it('exposes the edit feature', function () {
|
use App\Models\Planner;
|
||||||
$planner = createPlanner();
|
use Laravel\Dusk\Browser;
|
||||||
Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
|
use Tests\Browser\LoginHelpers;
|
||||||
|
use Tests\DuskTestCase;
|
||||||
|
|
||||||
loginAs($planner, '/dishes')
|
class EditDishTest extends DuskTestCase
|
||||||
|
{
|
||||||
|
use LoginHelpers;
|
||||||
|
|
||||||
|
protected static $editDishTestPlanner = null;
|
||||||
|
|
||||||
|
protected static $editDishTestEmail = null;
|
||||||
|
|
||||||
|
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')
|
->assertPathIs('/dishes')
|
||||||
->assertSee('MANAGE DISHES')
|
->assertSee('MANAGE DISHES');
|
||||||
->assertSee('Edit');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the edit modal components', function () {
|
// Verify that edit functionality is available by looking for the text in the page source
|
||||||
loginAndGoToDishes()
|
$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('MANAGE DISHES')
|
||||||
->assertSee('Add Dish');
|
->assertSee('Add Dish');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('renders the dishes page structure', function () {
|
public function test_dishes_page_structure(): void
|
||||||
$planner = createPlanner();
|
{
|
||||||
Dish::factory()->create(['planner_id' => $planner->id, 'name' => 'Test Dish']);
|
$this->browse(function (Browser $browser) {
|
||||||
|
$this->loginAndGoToDishes($browser)
|
||||||
|
->assertSee('MANAGE DISHES')
|
||||||
|
->assertSee('Add Dish');
|
||||||
|
|
||||||
loginAs($planner, '/dishes')
|
// Check that the dishes CRUD structure is present
|
||||||
->assertSee('Edit')
|
$pageSource = $browser->driver->getPageSource();
|
||||||
->assertSee('Delete');
|
// 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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
66
tests/Browser/LoginHelpers.php
Normal file
66
tests/Browser/LoginHelpers.php
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
86
tests/Browser/Pages/DishesPage.php
Normal file
86
tests/Browser/Pages/DishesPage.php
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
47
tests/Browser/Pages/LoginPage.php
Normal file
47
tests/Browser/Pages/LoginPage.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
21
tests/Browser/Pages/Page.php
Normal file
21
tests/Browser/Pages/Page.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?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"]',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
110
tests/Browser/Pages/SchedulePage.php
Normal file
110
tests/Browser/Pages/SchedulePage.php
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
tests/Browser/Pages/UsersPage.php
Normal file
93
tests/Browser/Pages/UsersPage.php
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<?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,14 +1,38 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('redirects unauthenticated users to login', function () {
|
namespace Tests\Browser;
|
||||||
visit('/dashboard')
|
|
||||||
->assertPathIs('/login')
|
|
||||||
->assertSee('Sign In');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads the login page', function () {
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||||
visit('/login')
|
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')
|
->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('Email')
|
||||||
->assertSee('Password');
|
->assertSee('Password');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,128 @@
|
||||||
<?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
|
|
||||||
{
|
{
|
||||||
$planner = Planner::factory()->create([
|
protected static $planner = null;
|
||||||
'email' => fake()->unique()->safeEmail(),
|
|
||||||
'password' => Hash::make('password'),
|
protected static $email = null;
|
||||||
|
|
||||||
|
protected static $password = 'password';
|
||||||
|
|
||||||
|
protected static $user = null;
|
||||||
|
|
||||||
|
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),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create([
|
// Create a user for this planner
|
||||||
'planner_id' => $planner->id,
|
self::$user = User::factory()->create([
|
||||||
|
'planner_id' => self::$planner->id,
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$dish = Dish::factory()->create([
|
// Create a dish and assign to user
|
||||||
'planner_id' => $planner->id,
|
self::$dish = Dish::factory()->create([
|
||||||
|
'planner_id' => self::$planner->id,
|
||||||
'name' => 'Test Dish',
|
'name' => 'Test Dish',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$dish->users()->attach($user);
|
// Attach user to dish (creates UserDish)
|
||||||
|
self::$dish->users()->attach(self::$user);
|
||||||
return [$planner, $user];
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('can generate a schedule with a user and dish', function () {
|
protected function loginAsPlanner(Browser $browser): Browser
|
||||||
[$planner] = makeScheduleFixture();
|
{
|
||||||
|
$browser->driver->manage()->deleteAllCookies();
|
||||||
|
|
||||||
loginAs($planner, '/schedule')
|
return $browser->visit('http://dishplanner_app:8000/login')
|
||||||
->assertSee('Test User')
|
->waitFor('input[id="email"]', DuskTestCase::TIMEOUT_SHORT)
|
||||||
->click('button[wire\:click="generate"]')
|
->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');
|
->assertSee('Test Dish');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('shows the generated dish on the calendar', function () {
|
public function test_generated_schedule_shows_dish_on_calendar(): void
|
||||||
[$planner] = makeScheduleFixture();
|
{
|
||||||
|
$this->browse(function (Browser $browser) {
|
||||||
|
$this->loginAsPlanner($browser);
|
||||||
|
|
||||||
loginAs($planner, '/schedule')
|
$browser->on(new SchedulePage)
|
||||||
->click('button[wire\:click="generate"]')
|
->clickGenerate()
|
||||||
|
->pause(2000)
|
||||||
|
// The dish should appear somewhere on the calendar
|
||||||
->assertSee('Test Dish');
|
->assertSee('Test Dish');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('can clear the month schedule', function () {
|
public function test_can_clear_month_schedule(): void
|
||||||
[$planner] = makeScheduleFixture();
|
{
|
||||||
|
$this->browse(function (Browser $browser) {
|
||||||
|
$this->loginAsPlanner($browser);
|
||||||
|
|
||||||
loginAs($planner, '/schedule')
|
$browser->on(new SchedulePage)
|
||||||
->click('button[wire\:click="generate"]')
|
// First generate a schedule
|
||||||
->assertSee('Test Dish')
|
->clickGenerate()
|
||||||
->click('button[wire\:click="clearMonth"]')
|
->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');
|
->assertSee('No dishes scheduled');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('selects all users by default', function () {
|
public function test_user_selection_affects_generation(): void
|
||||||
[$planner, $user] = makeScheduleFixture();
|
{
|
||||||
|
$this->browse(function (Browser $browser) {
|
||||||
|
$this->loginAsPlanner($browser);
|
||||||
|
|
||||||
loginAs($planner, '/schedule')
|
$browser->on(new SchedulePage)
|
||||||
|
// Verify the user checkbox is present
|
||||||
->assertSee('Test User')
|
->assertSee('Test User')
|
||||||
->assertChecked('input[value="'.$user->id.'"]');
|
// User should be selected by default
|
||||||
|
->assertChecked("input[value='".self::$user->id."']");
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,101 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('can access the schedule page', function () {
|
namespace Tests\Browser\Schedule;
|
||||||
loginAndGoToSchedule()
|
|
||||||
|
use Laravel\Dusk\Browser;
|
||||||
|
use Tests\Browser\LoginHelpers;
|
||||||
|
use Tests\Browser\Pages\SchedulePage;
|
||||||
|
use Tests\DuskTestCase;
|
||||||
|
|
||||||
|
class SchedulePageTest extends DuskTestCase
|
||||||
|
{
|
||||||
|
use LoginHelpers;
|
||||||
|
|
||||||
|
protected static $schedulePageTestPlanner = null;
|
||||||
|
|
||||||
|
protected static $schedulePageTestEmail = null;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
self::$testPlanner = self::$schedulePageTestPlanner;
|
||||||
|
self::$testEmail = self::$schedulePageTestEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
self::$schedulePageTestPlanner = self::$testPlanner;
|
||||||
|
self::$schedulePageTestEmail = self::$testEmail;
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_access_schedule_page(): void
|
||||||
|
{
|
||||||
|
$this->browse(function (Browser $browser) {
|
||||||
|
$this->loginAndGoToSchedule($browser);
|
||||||
|
|
||||||
|
$browser->on(new SchedulePage)
|
||||||
->assertSee('SCHEDULE')
|
->assertSee('SCHEDULE')
|
||||||
->assertSee('Generate Schedule');
|
->assertSee('Generate Schedule');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('shows the month navigation', function () {
|
public function test_schedule_page_has_month_navigation(): void
|
||||||
loginAndGoToSchedule()
|
{
|
||||||
->assertPresent('button[wire\:click="previousMonth"]')
|
$this->browse(function (Browser $browser) {
|
||||||
->assertPresent('button[wire\:click="nextMonth"]')
|
$this->loginAndGoToSchedule($browser);
|
||||||
|
|
||||||
|
$browser->on(new SchedulePage)
|
||||||
|
->assertPresent('@previous-month')
|
||||||
|
->assertPresent('@next-month')
|
||||||
->assertSee(now()->format('F Y'));
|
->assertSee(now()->format('F Y'));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_can_navigate_to_next_month(): void
|
||||||
|
{
|
||||||
|
$this->browse(function (Browser $browser) {
|
||||||
|
$this->loginAndGoToSchedule($browser);
|
||||||
|
|
||||||
it('can navigate to the next month', function () {
|
|
||||||
$nextMonth = now()->addMonth();
|
$nextMonth = now()->addMonth();
|
||||||
|
|
||||||
loginAndGoToSchedule()
|
$browser->on(new SchedulePage)
|
||||||
->click('button[wire\:click="nextMonth"]')
|
->goToNextMonth()
|
||||||
->assertSee($nextMonth->format('F Y'));
|
->assertSee($nextMonth->format('F Y'));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('can navigate to the previous month', function () {
|
public function test_can_navigate_to_previous_month(): void
|
||||||
$previousMonth = now()->subMonth();
|
{
|
||||||
|
$this->browse(function (Browser $browser) {
|
||||||
|
$this->loginAndGoToSchedule($browser);
|
||||||
|
|
||||||
loginAndGoToSchedule()
|
$prevMonth = now()->subMonth();
|
||||||
->click('button[wire\:click="previousMonth"]')
|
|
||||||
->assertSee($previousMonth->format('F Y'));
|
$browser->on(new SchedulePage)
|
||||||
|
->goToPreviousMonth()
|
||||||
|
->assertSee($prevMonth->format('F Y'));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('shows the user selection', function () {
|
public function test_schedule_generator_shows_user_selection(): void
|
||||||
loginAndGoToSchedule()
|
{
|
||||||
|
$this->browse(function (Browser $browser) {
|
||||||
|
$this->loginAndGoToSchedule($browser);
|
||||||
|
|
||||||
|
$browser->on(new SchedulePage)
|
||||||
->assertSee('Select Users')
|
->assertSee('Select Users')
|
||||||
->assertPresent('button[wire\:click="generate"]')
|
->assertPresent('@generate-button')
|
||||||
->assertPresent('button[wire\:click="clearMonth"]');
|
->assertPresent('@clear-month-button');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('shows the days of the week', function () {
|
public function test_calendar_displays_days_of_week(): void
|
||||||
loginAndGoToSchedule()
|
{
|
||||||
|
$this->browse(function (Browser $browser) {
|
||||||
|
$this->loginAndGoToSchedule($browser);
|
||||||
|
|
||||||
|
$browser->on(new SchedulePage)
|
||||||
->assertSee('Mon')
|
->assertSee('Mon')
|
||||||
->assertSee('Tue')
|
->assertSee('Tue')
|
||||||
->assertSee('Wed')
|
->assertSee('Wed')
|
||||||
|
|
@ -46,3 +104,5 @@
|
||||||
->assertSee('Sat')
|
->assertSee('Sat')
|
||||||
->assertSee('Sun');
|
->assertSee('Sun');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,104 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('can access the users page', function () {
|
namespace Tests\Browser\Users;
|
||||||
loginAndGoToUsers()
|
|
||||||
|
use Laravel\Dusk\Browser;
|
||||||
|
use Tests\Browser\LoginHelpers;
|
||||||
|
use Tests\Browser\Pages\UsersPage;
|
||||||
|
use Tests\DuskTestCase;
|
||||||
|
|
||||||
|
class CreateUserTest extends DuskTestCase
|
||||||
|
{
|
||||||
|
use LoginHelpers;
|
||||||
|
|
||||||
|
protected static $createUserTestPlanner = null;
|
||||||
|
|
||||||
|
protected static $createUserTestEmail = null;
|
||||||
|
|
||||||
|
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('MANAGE USERS')
|
||||||
->assertSee('Add User');
|
->assertSee('Add User');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('can open the create user modal', function () {
|
public function test_can_open_create_user_modal(): void
|
||||||
loginAndGoToUsers()
|
{
|
||||||
->click('button[wire\:click="create"]')
|
$this->browse(function (Browser $browser) {
|
||||||
|
$this->loginAndGoToUsers($browser);
|
||||||
|
|
||||||
|
$browser->on(new UsersPage)
|
||||||
|
->openCreateModal()
|
||||||
->assertSee('Add New User')
|
->assertSee('Add New User')
|
||||||
->assertSee('Name')
|
->assertSee('Name')
|
||||||
->assertSee('Cancel')
|
->assertSee('Cancel')
|
||||||
->assertSee('Create User');
|
->assertSee('Create User');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('validates the user name field', function () {
|
public function test_create_user_form_validation(): void
|
||||||
loginAndGoToUsers()
|
{
|
||||||
->click('button[wire\:click="create"]')
|
$this->browse(function (Browser $browser) {
|
||||||
|
$this->loginAndGoToUsers($browser);
|
||||||
|
|
||||||
|
$browser->on(new UsersPage)
|
||||||
|
->openCreateModal()
|
||||||
->press('Create User')
|
->press('Create User')
|
||||||
|
->pause(self::PAUSE_MEDIUM)
|
||||||
->assertSee('The name field is required');
|
->assertSee('The name field is required');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('can create a user', function () {
|
public function test_can_create_user(): void
|
||||||
|
{
|
||||||
|
$this->browse(function (Browser $browser) {
|
||||||
$userName = 'TestCreate_'.uniqid();
|
$userName = 'TestCreate_'.uniqid();
|
||||||
|
|
||||||
loginAndGoToUsers()
|
$this->loginAndGoToUsers($browser);
|
||||||
->click('button[wire\:click="create"]')
|
|
||||||
->type('input[wire\:model="name"]', $userName)
|
$browser->on(new UsersPage)
|
||||||
|
->openCreateModal()
|
||||||
|
->type('input[wire\\:model="name"]', $userName)
|
||||||
->press('Create User')
|
->press('Create User')
|
||||||
|
->pause(self::PAUSE_MEDIUM)
|
||||||
->assertSee('User created successfully')
|
->assertSee('User created successfully')
|
||||||
->assertSee($userName);
|
->assertSee($userName);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it('can cancel user creation', function () {
|
public function test_can_cancel_user_creation(): void
|
||||||
loginAndGoToUsers()
|
{
|
||||||
->click('button[wire\:click="create"]')
|
$this->browse(function (Browser $browser) {
|
||||||
->type('input[wire\:model="name"]', 'Test Cancel User')
|
$this->loginAndGoToUsers($browser);
|
||||||
|
|
||||||
|
$browser->on(new UsersPage)
|
||||||
|
->openCreateModal()
|
||||||
|
->type('input[wire\\:model="name"]', 'Test Cancel User')
|
||||||
->press('Cancel')
|
->press('Cancel')
|
||||||
|
->pause(self::PAUSE_SHORT)
|
||||||
|
// Modal should be closed, we should be back on users page
|
||||||
->assertSee('MANAGE USERS')
|
->assertSee('MANAGE USERS')
|
||||||
->assertDontSee('Add New User');
|
->assertDontSee('Add New User');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
2
tests/Browser/console/.gitignore
vendored
Normal file
2
tests/Browser/console/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
2
tests/Browser/screenshots/.gitignore
vendored
Normal file
2
tests/Browser/screenshots/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
2
tests/Browser/source/.gitignore
vendored
Normal file
2
tests/Browser/source/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?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.
|
|
||||||
}
|
|
||||||
55
tests/DuskTestCase.php
Normal file
55
tests/DuskTestCase.php
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<?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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
<?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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<?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,7 +6,6 @@
|
||||||
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;
|
||||||
|
|
@ -60,40 +59,6 @@ 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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue