diff --git a/.docker/Caddyfile b/.docker/Caddyfile new file mode 100644 index 0000000..d7d8730 --- /dev/null +++ b/.docker/Caddyfile @@ -0,0 +1,4 @@ +localhost { + root * /app/public + php_server +} diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 1647f3f..b7c009f 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -1,25 +1,29 @@ ARG PHP_VERSION=8.3 -FROM php:${PHP_VERSION}-cli +FROM dunglas/frankenphp:php${PHP_VERSION} -# git + unzip: the official php:*-cli image ships neither. Composer needs git to fall -# back to a source clone when a dist (zipball) download fails transiently — without it a -# single failed download aborts the whole install ("Source fallback is disabled. Not -# trying alternative sources."). unzip gives faster, more reliable archive extraction -# than composer's PHP-zip fallback. +# git + unzip: the frankenphp base ships neither. Composer needs git to fall back to a +# source clone when a dist (zipball) download fails transiently — without it a single +# failed download aborts the whole install ("Source fallback is disabled."). unzip gives +# faster, more reliable archive extraction than composer's PHP-zip fallback. RUN apt-get update \ && apt-get install -y --no-install-recommends git unzip \ && rm -rf /var/lib/apt/lists/* -# mlocati's installer builds the needed extensions across all target PHP versions. -COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ -RUN install-php-extensions intl gd mysqli pdo_mysql zip bcmath exif pcov +RUN install-php-extensions \ + intl \ + gd \ + mysqli \ + pdo_mysql \ + zip \ + bcmath \ + exif \ + pcov # Measure coverage against the mounted module source only. RUN echo 'pcov.directory=/module/src' > /usr/local/etc/php/conf.d/99-pcov.ini # The TinyMCE build task loads a translation catalogue per i18n locale (hundreds), -# so PHP's 128M default is insufficient; raise it container-wide so every invocation -# (task, CI running vendor/bin/phpunit directly, and manual runs) is covered. +# so PHP's 128M default is insufficient; raise it container-wide. RUN echo 'memory_limit=512M' > /usr/local/etc/php/conf.d/99-memory.ini COPY --from=composer:latest /usr/bin/composer /usr/bin/composer @@ -28,11 +32,11 @@ WORKDIR /app COPY app/composer.json /app/composer.json -# SilverStripe requires Page and PageController classes in the project. These are -# generated here (not committed) to avoid duplicate class detection when the module -# is symlinked into vendor/. SilverStripe discovers modules by looking for _config/; -# without a project-root _config/, /app/src/ is not scanned and Page/PageController -# stay invisible to the class manifest (which breaks temp-database creation in tests). +# SilverStripe requires Page and PageController classes in the project. Generated +# here (not committed) to avoid duplicate class detection when the module is +# symlinked into vendor/. Without a project-root _config/, /app/src/ is not scanned +# and Page/PageController stay invisible to the class manifest (which breaks +# temp-database creation in tests). RUN mkdir -p /app/src /app/_config \ && printf ' /app/src/Page.php \ && printf ' /app/src/PageController.php @@ -43,6 +47,7 @@ COPY app/phpstan.neon.dist /app/phpstan.neon.dist COPY app/phpstan-php85.neon.dist /app/phpstan-php85.neon.dist COPY app/rector.php /app/rector.php +COPY Caddyfile /etc/caddy/Caddyfile COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh diff --git a/.docker/app/_config/e2e.yml b/.docker/app/_config/e2e.yml new file mode 100644 index 0000000..100f28c --- /dev/null +++ b/.docker/app/_config/e2e.yml @@ -0,0 +1,10 @@ +--- +Name: app-e2e-fixtures +Only: + environment: dev +--- +WeDevelop\E2e\Fixtures\FixtureLoader: + fixture_page_classes: + - Page + fixtures: + demo-home: 'wedevelopnl/silverstripe-e2e:tests/E2E/Fixture/demo.yml' diff --git a/.docker/compose.yml b/.docker/compose.yml index 7fbca93..9aadab8 100644 --- a/.docker/compose.yml +++ b/.docker/compose.yml @@ -5,6 +5,12 @@ services: dockerfile: Dockerfile args: PHP_VERSION: ${PHP_VERSION:-8.3} + ports: + # Loopback-only: the /dev/e2e-fixtures endpoints are unauthenticated and, on a + # dev host, are a remote data-wipe primitive. Binding to 127.0.0.1 enforces the + # controller's documented "never reachable except on a dev host" invariant at the + # network layer. Playwright and the host both reach the app via localhost. + - "127.0.0.1:${WEB_PORT}:443" volumes: - ../composer.json:/module/composer.json:ro - ../src:/module/src @@ -13,7 +19,18 @@ services: - ../coverage:/app/coverage - vendor:/app/vendor healthcheck: - test: test -f /tmp/.app-ready + # Probe the real server, not a sentinel file. The entrypoint's dev/build finishing + # says nothing about whether FrankenPHP has bound :443 and provisioned its internal + # TLS cert — a sentinel touched before `frankenphp run` reports healthy on a dead + # server and can pass `--wait` before the socket accepts connections, racing the + # first Playwright request. A TLS handshake to :443 proves the server is actually + # serving. `php` is guaranteed present (it is the image); verify_peer is off because + # the cert is FrankenPHP's self-signed internal CA. The `$$` escapes Compose's own + # variable interpolation so a literal `$e`/`$s` reaches PHP. + test: >- + php -r 'exit(@stream_socket_client("tls://localhost:443", $$e, $$s, 2, + STREAM_CLIENT_CONNECT, stream_context_create(["ssl" => ["verify_peer" => false, + "verify_peer_name" => false]])) ? 0 : 1);' interval: 3s start_period: 60s retries: 20 @@ -32,8 +49,16 @@ services: db: image: mysql:8 + ports: + # Loopback-only: MySQL uses default credentials and must not be reachable from + # the LAN. The app container reaches the DB over the Docker network (SS_DATABASE_SERVER=db), + # so this published port exists only for host-side clients on the dev machine. + - "127.0.0.1:${DB_PORT}:3306" volumes: - db-data:/var/lib/mysql + # Grants the app user access to SapphireTest's ss_tmpdb_* throwaway databases + # (added in Task 1; runs on first init of a fresh data volume). MUST be kept. + - ./db-init:/docker-entrypoint-initdb.d:ro environment: MYSQL_DATABASE: silverstripe MYSQL_USER: silverstripe diff --git a/.docker/db-init/01-grant-tmpdb.sql b/.docker/db-init/01-grant-tmpdb.sql new file mode 100644 index 0000000..b6c2a3c --- /dev/null +++ b/.docker/db-init/01-grant-tmpdb.sql @@ -0,0 +1,9 @@ +-- SapphireTest runs each test class against a throwaway database named +-- `ss_tmpdb_`, which it CREATEs and DROPs itself. The mysql image only +-- grants the application user privileges on the single MYSQL_DATABASE, so without +-- this grant every database-backed test fails with "Access denied ... to database +-- 'ss_tmpdb_...'". The `\_` escapes the underscore to a literal so the pattern +-- matches the real temp-db names; `%` stays a wildcard for the random suffix. +-- Runs only on first initialisation of a fresh data volume (CI and clean checkouts). +GRANT ALL PRIVILEGES ON `ss\_tmpdb%`.* TO 'silverstripe'@'%'; +FLUSH PRIVILEGES; diff --git a/.docker/entrypoint.sh b/.docker/entrypoint.sh index 916f28c..93aaf39 100755 --- a/.docker/entrypoint.sh +++ b/.docker/entrypoint.sh @@ -3,11 +3,17 @@ set -e composer install --no-interaction -# Build the schema + class/config manifest so silverstan and sapphire tests have -# a ready environment. DB is guaranteed up (compose depends_on: db healthy). -vendor/bin/sake dev/build flush=1 +# FrankenPHP ships a default phpinfo() index.php. Replace it with the SilverStripe +# bootstrap once composer install has made the recipe available. +cp -f vendor/silverstripe/recipe-core/public/index.php /app/public/index.php + +# Ensure all vendor package resources are exposed. composer install skips the +# vendor-expose step when the named Docker volume already has packages from a +# previous run (no post-install event fires). +composer vendor-expose -touch /tmp/.app-ready +vendor/bin/sake dev/build flush=1 -# No webserver: block so `docker compose exec` can run tests/analysis in this container. -exec tail -f /dev/null +# Readiness is probed by the compose healthcheck via a real TLS handshake to :443 +# (see .docker/compose.yml) — no sentinel file, so a crashed server can't read healthy. +exec frankenphp run --config /etc/caddy/Caddyfile diff --git a/.docker/env.sh b/.docker/env.sh new file mode 100755 index 0000000..f3a632d --- /dev/null +++ b/.docker/env.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +WORKTREE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +DIR_NAME="$(basename "$WORKTREE_DIR")" + +HASH=$(printf '%s' "$DIR_NAME" | cksum | awk '{print $1}') +OFFSET=$((HASH % 1000)) + +WEB_PORT=$((8000 + OFFSET)) +DB_PORT=$((13000 + OFFSET)) + +cat > "$SCRIPT_DIR/.env" </dev/null || {{.COMPOSE}} up -d --build --wait" @@ -50,6 +57,12 @@ tasks: cmds: - "{{.COMPOSE}} exec app vendor/bin/phpunit --testsuite integration" + test-e2e: + desc: Run the Playwright E2E suite against the served app + deps: [ensure-up] + cmds: + - npx playwright test + analyse: desc: Run PHPStan static analysis deps: [ensure-up] diff --git a/_config/fixtures.yml b/_config/fixtures.yml new file mode 100644 index 0000000..c456d78 --- /dev/null +++ b/_config/fixtures.yml @@ -0,0 +1,11 @@ +--- +Name: e2e-fixtures +Only: + environment: dev +--- +SilverStripe\Dev\DevelopmentAdmin: + controllers: + e2e-fixtures: + class: WeDevelop\E2e\Fixtures\FixtureController +SilverStripe\Control\Session: + strict_user_agent_check: false diff --git a/client/playwright/README.md b/client/playwright/README.md new file mode 100644 index 0000000..c724566 --- /dev/null +++ b/client/playwright/README.md @@ -0,0 +1,63 @@ +# Playwright fixture client + +Reusable Playwright helpers for the `wedevelopnl/silverstripe-e2e` fixture endpoint. + +## Usage in a consuming project + +Add a path alias in the project's `tsconfig.json` (or `tests/E2E/tsconfig.json`): + +```jsonc +{ + "compilerOptions": { + "paths": { + "@wedevelop/e2e": ["vendor/wedevelopnl/silverstripe-e2e/client/playwright/index.ts"] + } + } +} +``` + +In `global.setup.ts`: + +```ts +import { test as setup } from '@playwright/test'; +import { authenticateAdmin } from '@wedevelop/e2e'; + +setup('authenticate as admin', async ({ page }) => { + await authenticateAdmin(page, { storageStatePath: 'tests/E2E/.auth/admin.json' }); +}); +``` + +In specs: + +```ts +import { createFixtureClient } from '@wedevelop/e2e'; + +const fixtures = createFixtureClient(); // defaults to /dev/e2e-fixtures + +test('...', async ({ page, request }) => { + const { pageId } = await fixtures.loadAndNavigate(page, request, 'my-fixture'); + // ... +}); +``` + +The `/dev/e2e-fixtures` endpoint and the `strict_user_agent_check` relaxation are +provided automatically by the module's dev-only config when installed. Register your +fixtures and `fixture_page_classes` in your project's own dev config: + +```yaml +--- +Name: app-e2e-fixtures +Only: + environment: dev +--- +WeDevelop\E2e\Fixtures\FixtureLoader: + fixture_page_classes: + - Page + fixtures: + my-fixture: 'my-vendor/my-module:tests/E2E/Fixture/my-fixture.yml' +``` + +To inject behavior around a load (e.g. suppressing model auto-scaffolding), add an +Extension implementing `onBeforeLoad(string $name, FixtureFactory $factory)` / +`onAfterLoad(FixtureResult $result)` and wire it via +`WeDevelop\E2e\Fixtures\FixtureLoader.extensions`. diff --git a/client/playwright/index.ts b/client/playwright/index.ts new file mode 100644 index 0000000..90555d4 --- /dev/null +++ b/client/playwright/index.ts @@ -0,0 +1,118 @@ +import type { APIRequestContext, Page } from '@playwright/test'; + +export interface FixtureMap { + [className: string]: { [identifier: string]: number }; +} + +export interface FixtureLoadResponse { + pageId: number; + pageUrl: string; + fixtureMap: FixtureMap; +} + +interface LoadEnvelope { + success: boolean; + error?: string; + data: FixtureLoadResponse; +} + +export type FixtureLoadAllResponse = Record; + +interface LoadAllEnvelope { + success: boolean; + error?: string; + fixtures: FixtureLoadAllResponse; +} + +export interface FixtureClientOptions { + /** Dev endpoint the controller is registered at. Defaults to `/dev/e2e-fixtures`. */ + endpoint?: string; + /** + * Optional locator that is visible while a CMS editor is loading. When set, + * `loadAndNavigate` waits for it to become hidden after navigation. + */ + editorReadySelector?: string; +} + +export function createFixtureClient(options: FixtureClientOptions = {}) { + const endpoint = options.endpoint ?? '/dev/e2e-fixtures'; + const editorReadySelector = options.editorReadySelector ?? null; + + async function load(request: APIRequestContext, fixture: string): Promise { + const response = await request.post(`${endpoint}/load`, { form: { fixture } }); + if (!response.ok()) { + throw new Error(`Fixture "${fixture}" load failed (${response.status()}): ${await response.text()}`); + } + + const body = (await response.json()) as LoadEnvelope; + if (!body.success) { + throw new Error(`Fixture "${fixture}" load failed: ${body.error ?? 'unknown error'}`); + } + + return body.data; + } + + async function loadAll(request: APIRequestContext): Promise { + const response = await request.post(`${endpoint}/load-all`); + if (!response.ok()) { + throw new Error(`Fixture load-all failed (${response.status()}): ${await response.text()}`); + } + + const body = (await response.json()) as LoadAllEnvelope; + if (!body.success) { + throw new Error(`Fixture load-all failed: ${body.error ?? 'unknown error'}`); + } + + return body.fixtures; + } + + async function reset(request: APIRequestContext): Promise { + const response = await request.post(`${endpoint}/reset?confirm=1`); + if (!response.ok()) { + throw new Error(`Fixture reset failed (${response.status()}): ${await response.text()}`); + } + } + + async function loadAndNavigate( + page: Page, + request: APIRequestContext, + fixture: string, + ): Promise { + const result = await load(request, fixture); + await page.goto(`/admin/pages/edit/show/${result.pageId}`); + if (editorReadySelector !== null) { + await page.locator(editorReadySelector).waitFor({ state: 'hidden' }); + } + + return result; + } + + return { load, loadAll, reset, loadAndNavigate }; +} + +export interface AdminAuthOptions { + storageStatePath: string; + username?: string; + password?: string; + adminPath?: string; +} + +/** + * Log in as a CMS admin and persist the authenticated session to + * `storageStatePath` for reuse across specs (call from a Playwright global + * setup). Uses the SilverStripe login form's stable labels/roles. Navigation is + * relative to the Playwright `baseURL`. + */ +export async function authenticateAdmin(page: Page, options: AdminAuthOptions): Promise { + const username = options.username ?? 'admin'; + const password = options.password ?? 'admin'; + const adminPath = options.adminPath ?? '/admin/'; + + await page.goto(adminPath); + await page.getByLabel('Email').fill(username); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Log in' }).click(); + await page.waitForURL(/\/admin\//); + + await page.context().storageState({ path: options.storageStatePath }); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8d6fba9 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,114 @@ +{ + "name": "@wedevelop/silverstripe-e2e", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@wedevelop/silverstripe-e2e", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "^1.61.1", + "@types/node": "^26.1.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=26" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "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/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ee4fc0d --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "@wedevelop/silverstripe-e2e", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=26" + }, + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "typecheck": "tsc --noEmit -p tests/E2E/tsconfig.json" + }, + "devDependencies": { + "@playwright/test": "^1.61.1", + "@types/node": "^26.1.0", + "typescript": "^5.4.0" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..6ce9df5 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,56 @@ +import { defineConfig, devices } from '@playwright/test'; +import { readFileSync } from 'node:fs'; + +function resolveBaseUrl(): string { + if (process.env.E2E_BASE_URL) { + return process.env.E2E_BASE_URL; + } + const env = readFileSync('.docker/.env', 'utf-8'); + const match = env.match(/^WEB_PORT=(\d+)$/m); + if (!match) { + throw new Error('WEB_PORT not found in .docker/.env; run `task docker-env` first'); + } + return `https://localhost:${match[1]}`; +} + +export default defineConfig({ + testDir: './tests/E2E/specs', + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: 'html', + use: { + baseURL: resolveBaseUrl(), + trace: 'on-first-retry', + screenshot: 'only-on-failure', + ignoreHTTPSErrors: true, + }, + projects: [ + { + name: 'setup-chromium', + testDir: './tests/E2E', + testMatch: /global\.setup\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], storageState: 'tests/E2E/.auth/admin.json' }, + dependencies: ['setup-chromium'], + }, + ...(process.env.CI + ? [ + { + name: 'setup-firefox', + testDir: './tests/E2E', + testMatch: /global\.setup\.ts/, + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'], storageState: 'tests/E2E/.auth/admin.json' }, + dependencies: ['setup-firefox'], + }, + ] + : []), + ], +}); diff --git a/src/Fixtures/FixtureController.php b/src/Fixtures/FixtureController.php new file mode 100644 index 0000000..279219b --- /dev/null +++ b/src/Fixtures/FixtureController.php @@ -0,0 +1,176 @@ + */ + private static array $url_handlers = [ + 'POST load' => 'load', + 'POST load-all' => 'loadAll', + 'POST reset' => 'reset', + ]; + + /** @var list */ + private static array $allowed_actions = [ + 'load', + 'loadAll', + 'reset', + ]; + + /** + * DevelopmentAdmin checks this before exposing the route. + */ + public function canInit(): bool + { + return Director::isDev(); + } + + /** + * Defence-in-depth: block non-dev access even if config gating is bypassed. + */ + #[Override] + protected function init(): void + { + parent::init(); + + if (!Director::isDev()) { + $this->httpError(404); + } + } + + public function load(HTTPRequest $request): HTTPResponse + { + $fixtureName = $request->postVar('fixture'); + if (!is_string($fixtureName) || $fixtureName === '') { + return $this->jsonResponse(400, [ + 'success' => false, + 'error' => 'Missing required "fixture" parameter', + ]); + } + + $loader = FixtureLoader::create(); + + /** @var non-empty-string $fixtureName */ + try { + $result = $loader->load($fixtureName); + } catch (InvalidArgumentException $invalidArgumentException) { + return $this->jsonResponse(400, [ + 'success' => false, + 'error' => $invalidArgumentException->getMessage(), + ]); + } catch (RuntimeException $runtimeException) { + return $this->jsonResponse(500, [ + 'success' => false, + 'error' => $runtimeException->getMessage(), + ]); + } + + return $this->jsonResponse(200, [ + 'success' => true, + 'fixture' => $result->fixtureName, + 'data' => $result, + ]); + } + + /** + * Load every configured fixture in one request (reset once, then stack). + * + * Fail-fast: the first fixture that raises aborts and is reported. Unlike + * {@see reset()}, this needs no `confirm` guard — like {@see load()} it is a + * seed operation, and the env gating in {@see init()} is the security control. + */ + public function loadAll(HTTPRequest $request): HTTPResponse + { + $loader = FixtureLoader::create(); + + try { + $results = $loader->loadAll(); + } catch (InvalidArgumentException $invalidArgumentException) { + return $this->jsonResponse(400, [ + 'success' => false, + 'error' => $invalidArgumentException->getMessage(), + ]); + } catch (RuntimeException $runtimeException) { + return $this->jsonResponse(500, [ + 'success' => false, + 'error' => $runtimeException->getMessage(), + ]); + } + + return $this->jsonResponse(200, [ + 'success' => true, + 'fixtures' => $results, + ]); + } + + public function reset(HTTPRequest $request): HTTPResponse + { + if ($request->getVar('confirm') !== '1') { + return $this->jsonResponse(400, [ + 'success' => false, + 'error' => 'Missing confirm=1 query parameter', + ]); + } + + FixtureLoader::create()->reset(); + + return $this->jsonResponse(200, [ + 'success' => true, + ]); + } + + /** + * @param array $data + */ + private function jsonResponse(int $statusCode, array $data): HTTPResponse + { + $response = HTTPResponse::create(); + $response->setStatusCode($statusCode); + $response->addHeader('Content-Type', 'application/json'); + $response->setBody(json_encode($data, JSON_THROW_ON_ERROR)); + + return $response; + } +} diff --git a/src/Fixtures/FixtureLoader.php b/src/Fixtures/FixtureLoader.php new file mode 100644 index 0000000..72968d9 --- /dev/null +++ b/src/Fixtures/FixtureLoader.php @@ -0,0 +1,380 @@ +> + */ + private static array $fixture_page_classes = []; + + /** + * Map of fixture names to YAML paths or config arrays. + * + * String value: 'vendor/package:path/to/file.yml' + * Array value: { path: 'vendor/package:path.yml', post_actions: [...] } + * + * @var array}>}> + */ + private static array $fixtures = []; + + /** + * Load a single named fixture into the database. + * + * Resets existing E2E data first for idempotency, then writes the YAML, + * applies any post-actions, and returns the page ID and full fixture map. + * + * @param non-empty-string $name + */ + public function load(string $name): FixtureResult + { + // Resolve/validate before resetting so an unknown name cannot wipe data. + $path = $this->resolveFixturePath($name); + $this->reset(); + + return $this->loadFixtureFromPath($name, $path); + } + + /** + * Reset once, then load every configured fixture additively. + * + * Each fixture is written into its own factory (no shared identifier scope, + * so fixtures cannot cross-reference one another). Fail-fast: the first + * fixture that raises aborts the loop, leaving a partial seed that the next + * load() / loadAll() resets again. + * + * @return array Fixture name => result, in config order. + * Fixture names are used verbatim as JSON object keys in the controller + * response, so they must be non-numeric (a set of sequential + * numeric-string names would make json_encode emit a JSON array + * instead of an object). + * @throws InvalidArgumentException when no fixtures are configured (before any + * reset) or a fixture name/path is invalid. + * @throws RuntimeException when a fixture creates no SiteTree records. + */ + public function loadAll(): array + { + $names = $this->getAvailableFixtures(); + if ($names === []) { + throw new InvalidArgumentException( + 'No fixtures are configured; nothing to load.', + ); + } + + // Resolve every fixture path BEFORE resetting, so a misconfigured + // fixture cannot wipe E2E data or leave a partial seed (mirrors the + // resolve-before-reset ordering in load()). + $paths = []; + foreach ($names as $name) { + /** @var non-empty-string $name */ + $paths[$name] = $this->resolveFixturePath($name); + } + + $this->reset(); + + $results = []; + foreach ($paths as $name => $path) { + /** @var non-empty-string $name */ + $results[$name] = $this->loadFixtureFromPath($name, $path); + } + + return $results; + } + + /** + * Write one already-resolved fixture into a fresh factory. Shared by + * {@see load()} and {@see loadAll()}; callers are responsible for resetting. + * + * @param non-empty-string $name + */ + private function loadFixtureFromPath(string $name, string $path): FixtureResult + { + $factory = new FixtureFactory(); + // Extensible::extend() takes its arguments by reference (&...$arguments), + // which makes PHPStan widen every passed variable to the union of all of + // them for the rest of the scope. Pass throwaway aliases so the typed + // $name/$factory stay pristine; the hook still mutates the same + // FixtureFactory instance (object handle), so behaviour is unchanged. + $beforeName = $name; + $beforeFactory = $factory; + $this->extend('onBeforeLoad', $beforeName, $beforeFactory); + + $fixture = YamlFixture::create($path); + Versioned::withVersionedMode(static function () use ($fixture, $factory): void { + Versioned::set_stage(Versioned::DRAFT); + $fixture->writeInto($factory); + }); + + $postActions = $this->resolvePostActions($name); + if ($postActions !== []) { + $this->applyPostActions($postActions, $factory); + } + + [$pageClass, $pageId] = $this->findPageInFactory($factory); + if ($pageClass === null || $pageId === null) { + throw new RuntimeException( + sprintf('Fixture "%s" did not create any SiteTree records', $name), + ); + } + + $page = Versioned::withVersionedMode(static function () use ($pageId): ?SiteTree { + Versioned::set_stage(Versioned::DRAFT); + + return SiteTree::get()->byID($pageId); + }); + if ($page === null) { + throw new RuntimeException( + sprintf('Page ID %d from fixture "%s" not found after write', $pageId, $name), + ); + } + + /** @var array> $fixtureMap */ + $fixtureMap = $factory->getFixtures(); + /** @var non-empty-string $pageUrl */ + $pageUrl = $page->Link(); + + $result = new FixtureResult( + fixtureName: $name, + pageId: $pageId, + pageUrl: $pageUrl, + fixtureMap: $fixtureMap, + ); + + $this->extend('onAfterLoad', $result); + + return $result; + } + + /** + * Archive all E2E fixture pages (draft + live) via doArchive(). + * + * @throws RuntimeException if no fixture page classes are configured. + */ + public function reset(): void + { + /** @var list> $pageClasses */ + $pageClasses = static::config()->get('fixture_page_classes'); + if ($pageClasses === []) { + throw new RuntimeException( + 'FixtureLoader.fixture_page_classes is empty; refusing to reset. Configure the ' + . 'SiteTree classes your fixtures create before loading or resetting fixtures.', + ); + } + + /** @var string $prefix */ + $prefix = static::config()->get('url_segment_prefix'); + + Versioned::withVersionedMode(static function () use ($pageClasses, $prefix): void { + Versioned::set_stage(Versioned::DRAFT); + + $pages = SiteTree::get()->filter([ + 'URLSegment:StartsWith' => $prefix, + 'ClassName' => $pageClasses, + ]); + + foreach ($pages as $page) { + $page->doArchive(); + } + }); + } + + /** + * @return list + */ + public function getAvailableFixtures(): array + { + /** @var array> $fixtures */ + $fixtures = static::config()->get('fixtures'); + + return array_keys($fixtures); + } + + /** + * Find the page to return. Prefers the first configured + * {@see $fixture_page_classes} entry the factory created, else the first + * SiteTree subclass created. + * + * @return array{class-string|null, positive-int|null} + */ + private function findPageInFactory(FixtureFactory $factory): array + { + /** @var array> $fixtures */ + $fixtures = $factory->getFixtures(); + + /** @var list> $preferred */ + $preferred = static::config()->get('fixture_page_classes'); + + foreach ($preferred as $preferredClass) { + if (isset($fixtures[$preferredClass]) && $fixtures[$preferredClass] !== []) { + $ids = $fixtures[$preferredClass]; + /** @var positive-int $id */ + $id = $ids[array_key_first($ids)]; + + return [$preferredClass, $id]; + } + } + + foreach ($fixtures as $class => $ids) { + if (!is_a($class, SiteTree::class, true) || $ids === []) { + continue; + } + /** @var class-string $class */ + /** @var positive-int $id */ + $id = $ids[array_key_first($ids)]; + + return [$class, $id]; + } + + return [null, null]; + } + + /** + * @throws InvalidArgumentException if the name is unregistered or the file is missing. + */ + private function resolveFixturePath(string $name): string + { + /** @var array> $fixtures */ + $fixtures = static::config()->get('fixtures'); + + if (!isset($fixtures[$name])) { + throw new InvalidArgumentException( + sprintf( + 'Unknown fixture "%s". Available: %s', + $name, + implode(', ', array_keys($fixtures)), + ), + ); + } + + $config = $fixtures[$name]; + $resourcePath = is_array($config) ? ($config['path'] ?? null) : $config; + + if (!is_string($resourcePath) || $resourcePath === '') { + throw new InvalidArgumentException( + sprintf('Fixture "%s" has no path configured', $name), + ); + } + + $resolved = ModuleResourceLoader::singleton()->resolvePath($resourcePath); + if ($resolved === null) { + throw new InvalidArgumentException( + sprintf('Could not resolve fixture path "%s" for fixture "%s"', $resourcePath, $name), + ); + } + + $absolutePath = Director::baseFolder() . '/' . $resolved; + if (!file_exists($absolutePath)) { + throw new InvalidArgumentException( + sprintf('Fixture file not found: %s (resolved from "%s")', $absolutePath, $resourcePath), + ); + } + + return $absolutePath; + } + + /** + * @return list + */ + private function resolvePostActions(string $name): array + { + /** @var array> $fixtures */ + $fixtures = static::config()->get('fixtures'); + + $config = $fixtures[$name] ?? null; + if (!is_array($config) || !isset($config['post_actions'])) { + return []; + } + + /** @var list}> $rawActions */ + $rawActions = $config['post_actions']; + + return array_map( + FixturePostAction::fromConfig(...), + $rawActions, + ); + } + + /** + * @param list $actions + */ + private function applyPostActions(array $actions, FixtureFactory $factory): void + { + Versioned::withVersionedMode(static function () use ($actions, $factory): void { + Versioned::set_stage(Versioned::DRAFT); + + foreach ($actions as $action) { + $id = $factory->getId($action->class, $action->identifier); + if ($id === false || $id === 0) { + throw new RuntimeException( + sprintf( + 'Post-action references unknown fixture: %s.%s', + $action->class, + $action->identifier, + ), + ); + } + + $record = DataObject::get($action->class)->byID($id); + if ($record === null) { + throw new RuntimeException( + sprintf( + 'Record not found for post-action: %s #%d (%s)', + $action->class, + $id, + $action->identifier, + ), + ); + } + + $action->apply($record); + } + }); + } +} diff --git a/src/Fixtures/FixturePostAction.php b/src/Fixtures/FixturePostAction.php new file mode 100644 index 0000000..619fdba --- /dev/null +++ b/src/Fixtures/FixturePostAction.php @@ -0,0 +1,165 @@ + */ + private const array VALID_ACTIONS = [ + 'publish_recursive', + 'unpublish', + 'modify', + 'attach_image', + ]; + + /** @var list Actions that do not require the Versioned extension. */ + private const array NON_VERSIONED_ACTIONS = [ + 'modify', + 'attach_image', + ]; + + /** + * @param 'publish_recursive'|'unpublish'|'modify'|'attach_image' $action + * @param class-string $class + * @param array $fields + */ + public function __construct( + public string $action, + public string $class, + public string $identifier, + public array $fields = [], + ) { + } + + /** + * @param array{action?: string, class?: class-string, identifier?: string, fields?: array} $config + */ + public static function fromConfig(array $config): self + { + if ( + !is_string($config['action'] ?? null) + || !is_string($config['class'] ?? null) + || !is_string($config['identifier'] ?? null) + ) { + throw new InvalidArgumentException( + 'Post-action config requires "action", "class", and "identifier" keys', + ); + } + + $action = $config['action']; + if (!in_array($action, self::VALID_ACTIONS, true)) { + throw new InvalidArgumentException( + sprintf( + 'Unknown post-action "%s". Valid actions: %s', + $action, + implode(', ', self::VALID_ACTIONS), + ), + ); + } + + /** @var array $fields */ + $fields = $config['fields'] ?? []; + + /** @var 'publish_recursive'|'unpublish'|'modify'|'attach_image' $action */ + return new self( + action: $action, + class: $config['class'], + identifier: $config['identifier'], + fields: $fields, + ); + } + + /** + * Execute this action against the given record. + */ + public function apply(DataObject $record): void + { + if ( + !in_array($this->action, self::NON_VERSIONED_ACTIONS, true) + && !$record->hasExtension(Versioned::class) + ) { + throw new InvalidArgumentException(sprintf( + 'Post-action "%s" requires Versioned extension, but %s does not have it.', + $this->action, + $record::class, + )); + } + + /** @var DataObject&Versioned $record */ + match ($this->action) { + 'publish_recursive' => $record->publishRecursive(), + 'unpublish' => $record->doUnpublish(), + 'modify' => $this->applyModify($record), + 'attach_image' => $this->applyAttachImage($record), + }; + } + + private function applyModify(DataObject $record): void + { + foreach ($this->fields as $field => $value) { + $record->setField($field, $value); + } + + $record->write(); + } + + /** + * Create an Image from a local file and attach it via a has_one relation. + * + * Requires `fields.relation` (has_one name) and `fields.source` (module + * resource path). The image is published so it appears on the live site. + */ + private function applyAttachImage(DataObject $record): void + { + /** @var string $relation */ + $relation = $this->fields['relation'] ?? ''; + /** @var string $source */ + $source = $this->fields['source'] ?? ''; + + if ($relation === '' || $source === '') { + throw new InvalidArgumentException( + 'attach_image requires "relation" and "source" in fields', + ); + } + + $resolved = ModuleResourceLoader::singleton()->resolvePath($source); + if ($resolved === null) { + throw new InvalidArgumentException( + sprintf('Could not resolve image source path: %s', $source), + ); + } + + $absolutePath = Director::baseFolder() . '/' . $resolved; + if (!file_exists($absolutePath)) { + throw new InvalidArgumentException( + sprintf('Image file not found: %s (resolved from "%s")', $absolutePath, $source), + ); + } + + $image = Image::create(); + $image->setFromLocalFile($absolutePath, 'Uploads/' . basename($absolutePath)); + $image->write(); + $image->publishSingle(); + + $foreignKey = $relation . 'ID'; + $record->setField($foreignKey, $image->ID); + $record->write(); + } +} diff --git a/src/Fixtures/FixtureResult.php b/src/Fixtures/FixtureResult.php new file mode 100644 index 0000000..8c17f42 --- /dev/null +++ b/src/Fixtures/FixtureResult.php @@ -0,0 +1,44 @@ +> $fixtureMap Class → identifier → DB ID + */ + public function __construct( + public string $fixtureName, + public int $pageId, + public string $pageUrl, + public array $fixtureMap, + ) { + } + + /** + * @return array{pageId: positive-int, pageUrl: non-empty-string, fixtureMap: array>} + */ + #[Override] + public function jsonSerialize(): array + { + return [ + 'pageId' => $this->pageId, + 'pageUrl' => $this->pageUrl, + 'fixtureMap' => $this->fixtureMap, + ]; + } +} diff --git a/src/Tasks/LoadFixturesTask.php b/src/Tasks/LoadFixturesTask.php new file mode 100644 index 0000000..a513770 --- /dev/null +++ b/src/Tasks/LoadFixturesTask.php @@ -0,0 +1,116 @@ +.'; + + /** + * @return array + */ + #[Override] + public function getOptions(): array + { + return [ + new InputOption( + 'fixture', + null, + InputOption::VALUE_REQUIRED, + 'Load only this named fixture. Omit to load every configured fixture.', + ), + ]; + } + + protected function execute(InputInterface $input, PolyOutput $output): int + { + // Both loader modes reset() first, which archives SiteTree pages. The + // HTTP FixtureController locks its route to dev for exactly this reason; + // a CLI task has no such gate by default, so re-establish the invariant + // here: never fire a destructive archive on a staging/production host. + if (!Director::isDev()) { + $output->writeln('Refusing to run outside the dev environment.'); + + return Command::FAILURE; + } + + $fixtureName = $input->getOption('fixture'); + // Null means "load all". An explicitly-empty --fixture= is a user error, + // mirroring FixtureController's non-empty-string guard. + if ($fixtureName !== null && (!is_string($fixtureName) || $fixtureName === '')) { + $output->writeln('The --fixture option cannot be empty.'); + + return Command::INVALID; + } + + $loader = FixtureLoader::create(); + + try { + if ($fixtureName !== null) { + $output->writeln($this->formatResult($fixtureName, $loader->load($fixtureName))); + + return Command::SUCCESS; + } + + $results = $loader->loadAll(); + foreach ($results as $name => $result) { + $output->writeln($this->formatResult($name, $result)); + } + + $count = count($results); + $output->writeln(sprintf('Loaded %d %s.', $count, $this->pluralise('fixture', $count))); + } catch (InvalidArgumentException $invalidArgumentException) { + // Bad input: unknown/misconfigured fixture name, or no fixtures configured. + $output->writeln('' . $invalidArgumentException->getMessage() . ''); + + return Command::INVALID; + } catch (RuntimeException $runtimeException) { + // Operational failure: fixture created no SiteTree, or reset() refused. + $output->writeln('' . $runtimeException->getMessage() . ''); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } + + private function formatResult(string $name, FixtureResult $result): string + { + $records = 0; + foreach ($result->fixtureMap as $ids) { + $records += count($ids); + } + + return sprintf( + 'Loaded fixture "%s" — page #%d at %s (%d %s)', + $name, + $result->pageId, + $result->pageUrl, + $records, + $this->pluralise('record', $records), + ); + } + + private function pluralise(string $noun, int $count): string + { + return $count === 1 ? $noun : $noun . 's'; + } +} diff --git a/tests/E2E/Fixture/demo.yml b/tests/E2E/Fixture/demo.yml new file mode 100644 index 0000000..9e53f40 --- /dev/null +++ b/tests/E2E/Fixture/demo.yml @@ -0,0 +1,5 @@ +Page: + home: + Title: 'E2E Home' + URLSegment: 'e2e-home' + Content: '

Loaded by the E2E fixture system.

' diff --git a/tests/E2E/global.setup.ts b/tests/E2E/global.setup.ts new file mode 100644 index 0000000..d4f1327 --- /dev/null +++ b/tests/E2E/global.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { authenticateAdmin } from '@wedevelop/e2e'; + +setup('authenticate as admin', async ({ page }) => { + await authenticateAdmin(page, { storageStatePath: 'tests/E2E/.auth/admin.json' }); +}); diff --git a/tests/E2E/specs/fixture.spec.ts b/tests/E2E/specs/fixture.spec.ts new file mode 100644 index 0000000..a064dfc --- /dev/null +++ b/tests/E2E/specs/fixture.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from '@playwright/test'; +import { createFixtureClient } from '@wedevelop/e2e'; + +const fixtures = createFixtureClient(); + +test.afterEach(async ({ request }) => { + await fixtures.reset(request); +}); + +test('loads a fixture over HTTP and returns the created page id', async ({ request }) => { + const result = await fixtures.load(request, 'demo-home'); + + expect(result.pageId).toBeGreaterThan(0); + expect(result.pageUrl).toContain('e2e-home'); + expect(result.fixtureMap.Page).toBeDefined(); +}); + +test('navigates to the loaded page in the CMS', async ({ page, request }) => { + const result = await fixtures.loadAndNavigate(page, request, 'demo-home'); + + await expect(page).toHaveURL(new RegExp(`/admin/pages/edit/show/${result.pageId}`)); + await expect(page.getByText('E2E Home').first()).toBeVisible(); +}); + +test('loads all configured fixtures over HTTP', async ({ request }) => { + const results = await fixtures.loadAll(request); + + expect(results['demo-home']).toBeDefined(); + expect(results['demo-home'].pageId).toBeGreaterThan(0); + expect(results['demo-home'].pageUrl).toContain('e2e-home'); +}); diff --git a/tests/E2E/tsconfig.json b/tests/E2E/tsconfig.json new file mode 100644 index 0000000..270d3fb --- /dev/null +++ b/tests/E2E/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "types": ["node"], + "paths": { + "@wedevelop/e2e": ["../../client/playwright/index.ts"] + } + }, + "include": ["**/*.ts", "../../playwright.config.ts"] +} diff --git a/tests/Integration/Fixtures/FixtureControllerTest.php b/tests/Integration/Fixtures/FixtureControllerTest.php new file mode 100644 index 0000000..f072e6a --- /dev/null +++ b/tests/Integration/Fixtures/FixtureControllerTest.php @@ -0,0 +1,240 @@ +get(Kernel::class); + $this->originalEnvironment = $kernel->getEnvironment(); + $kernel->setEnvironment('dev'); + + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'simple-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/simple-page.yml', + ]); + Config::modify()->set(FixtureLoader::class, 'fixture_page_classes', [ + E2eFixtureTestPage::class, + ]); + } + + protected function tearDown(): void + { + Injector::inst()->get(Kernel::class)->setEnvironment($this->originalEnvironment); + + parent::tearDown(); + } + + /** + * @param array $postVars + * @param array $getVars + */ + private function dispatch(string $method, string $url, array $postVars = [], array $getVars = []): HTTPResponse|HTTPResponse_Exception + { + $request = new HTTPRequest($method, $url, $getVars, $postVars); + $request->setSession(new Session([])); + + try { + return FixtureController::create()->handleRequest($request); + } catch (HTTPResponse_Exception $exception) { + return $exception; + } + } + + public function testLoadReturnsSuccessJson(): void + { + $response = $this->dispatch('POST', 'load', ['fixture' => 'simple-page']); + + self::assertInstanceOf(HTTPResponse::class, $response); + self::assertSame(200, $response->getStatusCode()); + + /** @var array{success: bool, fixture: string, data: array} $body */ + $body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + self::assertTrue($body['success']); + self::assertSame('simple-page', $body['fixture']); + self::assertArrayHasKey('pageId', $body['data']); + } + + public function testLoadWithoutFixtureParamReturns400(): void + { + $response = $this->dispatch('POST', 'load'); + + self::assertInstanceOf(HTTPResponse::class, $response); + self::assertSame(400, $response->getStatusCode()); + // The message reads `Missing required "fixture" parameter`, but the raw JSON + // body escapes the inner quotes (`\"fixture\"`), so assert on the unquoted + // stem that survives JSON encoding. + self::assertStringContainsString('Missing required', (string) $response->getBody()); + } + + public function testLoadUnknownFixtureReturns400(): void + { + $response = $this->dispatch('POST', 'load', ['fixture' => 'nope']); + + self::assertInstanceOf(HTTPResponse::class, $response); + self::assertSame(400, $response->getStatusCode()); + self::assertStringContainsString('Unknown fixture', (string) $response->getBody()); + } + + public function testLoadReturnsJsonErrorWhenFixtureCreatesNoSiteTree(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'no-sitetree' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/no-sitetree.yml', + ]); + + $response = $this->dispatch('POST', 'load', ['fixture' => 'no-sitetree']); + + self::assertInstanceOf(HTTPResponse::class, $response); + self::assertSame(500, $response->getStatusCode()); + + /** @var array{success: bool, error: string} $body */ + $body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + self::assertFalse($body['success']); + self::assertStringContainsString('did not create any SiteTree', $body['error']); + } + + public function testResetWithConfirmReturnsSuccess(): void + { + $response = $this->dispatch('POST', 'reset', [], ['confirm' => '1']); + + self::assertInstanceOf(HTTPResponse::class, $response); + self::assertSame(200, $response->getStatusCode()); + } + + public function testResetWithoutConfirmReturns400(): void + { + $response = $this->dispatch('POST', 'reset'); + + self::assertInstanceOf(HTTPResponse::class, $response); + self::assertSame(400, $response->getStatusCode()); + self::assertStringContainsString('confirm=1', (string) $response->getBody()); + } + + public function testLoadAllReturnsSuccessJsonForEveryFixture(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'simple-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/simple-page.yml', + 'fallback-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/fallback-page.yml', + ]); + Config::modify()->set(FixtureLoader::class, 'fixture_page_classes', [ + E2eFixtureTestPage::class, + E2eOtherTestPage::class, + ]); + + $response = $this->dispatch('POST', 'load-all'); + + self::assertInstanceOf(HTTPResponse::class, $response); + self::assertSame(200, $response->getStatusCode()); + + /** @var array{success: bool, fixtures: array>} $body */ + $body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + self::assertTrue($body['success']); + self::assertArrayHasKey('simple-page', $body['fixtures']); + self::assertArrayHasKey('fallback-page', $body['fixtures']); + self::assertArrayHasKey('pageId', $body['fixtures']['simple-page']); + } + + public function testLoadAllWithNoFixturesConfiguredReturns400(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', []); + + $response = $this->dispatch('POST', 'load-all'); + + self::assertInstanceOf(HTTPResponse::class, $response); + self::assertSame(400, $response->getStatusCode()); + + /** @var array{success: bool, error: string} $body */ + $body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + self::assertFalse($body['success']); + self::assertStringContainsString('No fixtures', $body['error']); + } + + public function testLoadAllIsFailFastReturns500WhenFixtureCreatesNoSiteTree(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'simple-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/simple-page.yml', + 'broken' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/no-sitetree.yml', + ]); + + $response = $this->dispatch('POST', 'load-all'); + + self::assertInstanceOf(HTTPResponse::class, $response); + self::assertSame(500, $response->getStatusCode()); + self::assertStringContainsString('did not create any SiteTree', (string) $response->getBody()); + } + + public function testLoadAllIsFailFastReturns400WhenFixturePathInvalid(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'missing-file' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/does-not-exist.yml', + ]); + + $response = $this->dispatch('POST', 'load-all'); + + self::assertInstanceOf(HTTPResponse::class, $response); + self::assertSame(400, $response->getStatusCode()); + self::assertStringContainsString('Fixture file not found', (string) $response->getBody()); + } + + public function testLoadAllNonDevRequestReturns404(): void + { + Injector::inst()->get(Kernel::class)->setEnvironment('live'); + + $response = $this->dispatch('POST', 'load-all'); + + self::assertInstanceOf(HTTPResponse_Exception::class, $response); + self::assertSame(404, $response->getResponse()->getStatusCode()); + } + + public function testNonDevRequestReturns404(): void + { + Injector::inst()->get(Kernel::class)->setEnvironment('live'); + + $response = $this->dispatch('POST', 'load', ['fixture' => 'simple-page']); + + self::assertInstanceOf(HTTPResponse_Exception::class, $response); + self::assertSame(404, $response->getResponse()->getStatusCode()); + } + + /** + * canInit() is layer 1 of the security posture: DevelopmentAdmin consults it + * before exposing the route. It is not reached by the isolated handleRequest + * harness, so cover the dev gate directly. + */ + public function testCanInitReflectsDevEnvironment(): void + { + self::assertTrue(FixtureController::create()->canInit()); + + Injector::inst()->get(Kernel::class)->setEnvironment('live'); + + self::assertFalse(FixtureController::create()->canInit()); + } +} diff --git a/tests/Integration/Fixtures/FixtureLoaderTest.php b/tests/Integration/Fixtures/FixtureLoaderTest.php new file mode 100644 index 0000000..97ad571 --- /dev/null +++ b/tests/Integration/Fixtures/FixtureLoaderTest.php @@ -0,0 +1,341 @@ +set(FixtureLoader::class, 'fixtures', [ + 'simple-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/simple-page.yml', + ]); + Config::modify()->set(FixtureLoader::class, 'fixture_page_classes', [ + E2eFixtureTestPage::class, + ]); + LoaderHookSpy::reset(); + } + + public function testLoadWritesFixtureAndReturnsResult(): void + { + $result = FixtureLoader::create()->load('simple-page'); + + self::assertSame('simple-page', $result->fixtureName); + self::assertGreaterThan(0, $result->pageId); + self::assertStringContainsString('e2e-simple', $result->pageUrl); + self::assertArrayHasKey(E2eFixtureTestPage::class, $result->fixtureMap); + } + + public function testLoadIsIdempotent(): void + { + FixtureLoader::create()->load('simple-page'); + FixtureLoader::create()->load('simple-page'); + + self::assertCount(1, $this->draftPagesWithSegmentPrefix('e2e-simple')); + } + + public function testLoadThrowsOnUnknownFixture(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown fixture "missing"'); + + FixtureLoader::create()->load('missing'); + } + + public function testResetArchivesOnlyAllowlistedPrefixedPages(): void + { + FixtureLoader::create()->load('simple-page'); + + $stray = new SiteTree(); + $stray->Title = 'Human authored'; + $stray->URLSegment = 'e2e-human'; + $stray->write(); + + FixtureLoader::create()->reset(); + + self::assertCount(0, $this->draftPagesWithSegmentPrefix('e2e-simple')); + self::assertCount(1, $this->draftPagesWithSegmentPrefix('e2e-human')); + } + + public function testResetThrowsWhenAllowlistEmpty(): void + { + Config::modify()->set(FixtureLoader::class, 'fixture_page_classes', []); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('fixture_page_classes is empty'); + + FixtureLoader::create()->reset(); + } + + public function testLifecycleHooksFireInOrder(): void + { + Config::modify()->merge(FixtureLoader::class, 'extensions', [ + LoaderHookSpy::class, + ]); + + FixtureLoader::create()->load('simple-page'); + + self::assertSame(['simple-page'], LoaderHookSpy::$beforeCalls); + self::assertSame(['simple-page'], LoaderHookSpy::$afterCalls); + } + + public function testGetAvailableFixturesReturnsConfiguredNames(): void + { + self::assertSame(['simple-page'], FixtureLoader::create()->getAvailableFixtures()); + } + + public function testLoadAppliesPostActions(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'page-with-postaction' => [ + 'path' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/page-with-postaction.yml', + 'post_actions' => [ + [ + 'action' => 'publish_recursive', + 'class' => E2eVersionedObject::class, + 'identifier' => 'obj1', + ], + ], + ], + ]); + + $result = FixtureLoader::create()->load('page-with-postaction'); + + $objId = $result->fixtureMap[E2eVersionedObject::class]['obj1']; + $live = Versioned::get_by_stage(E2eVersionedObject::class, Versioned::LIVE)->byID($objId); + self::assertNotNull($live); + } + + public function testLoadThrowsWhenFixtureCreatesNoSiteTree(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'no-sitetree' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/no-sitetree.yml', + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Fixture "no-sitetree" did not create any SiteTree records'); + + FixtureLoader::create()->load('no-sitetree'); + } + + public function testLoadFallsBackToNonAllowlistedSiteTreeSubclass(): void + { + // fixture_page_classes only lists E2eFixtureTestPage, but the fixture + // creates an E2eOtherTestPage. The preferred-class lookup misses, so the + // loader falls back to the first SiteTree subclass the factory produced. + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'fallback-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/fallback-page.yml', + ]); + + $result = FixtureLoader::create()->load('fallback-page'); + + self::assertArrayHasKey(E2eOtherTestPage::class, $result->fixtureMap); + self::assertGreaterThan(0, $result->pageId); + self::assertStringContainsString('e2e-fallback', $result->pageUrl); + } + + public function testLoadThrowsWhenArrayConfigHasNoPath(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'broken' => ['post_actions' => []], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Fixture "broken" has no path configured'); + + FixtureLoader::create()->load('broken'); + } + + public function testLoadThrowsWhenFixtureFileIsMissing(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'missing-file' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/does-not-exist.yml', + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Fixture file not found'); + + FixtureLoader::create()->load('missing-file'); + } + + public function testLoadThrowsWhenPostActionReferencesUnknownFixture(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'bad-postaction' => [ + 'path' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/simple-page.yml', + 'post_actions' => [ + [ + 'action' => 'modify', + 'class' => E2eVersionedObject::class, + 'identifier' => 'nonexistent', + 'fields' => ['Title' => 'unused'], + ], + ], + ], + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Post-action references unknown fixture: ' . E2eVersionedObject::class . '.nonexistent', + ); + + FixtureLoader::create()->load('bad-postaction'); + } + + public function testLoadAllLoadsEveryConfiguredFixtureInOrder(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'simple-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/simple-page.yml', + 'fallback-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/fallback-page.yml', + ]); + Config::modify()->set(FixtureLoader::class, 'fixture_page_classes', [ + E2eFixtureTestPage::class, + E2eOtherTestPage::class, + ]); + + $results = FixtureLoader::create()->loadAll(); + + self::assertSame(['simple-page', 'fallback-page'], array_keys($results)); + self::assertGreaterThan(0, $results['simple-page']->pageId); + self::assertGreaterThan(0, $results['fallback-page']->pageId); + self::assertSame('simple-page', $results['simple-page']->fixtureName); + } + + public function testLoadAllResetsOnceSoFixturesCoexist(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'simple-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/simple-page.yml', + 'fallback-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/fallback-page.yml', + ]); + // Both classes are reset-eligible: if reset ran per-fixture, loading the + // second fixture would archive the first. Coexistence proves reset ran once. + Config::modify()->set(FixtureLoader::class, 'fixture_page_classes', [ + E2eFixtureTestPage::class, + E2eOtherTestPage::class, + ]); + + FixtureLoader::create()->loadAll(); + + self::assertCount(1, $this->draftPagesWithSegmentPrefix('e2e-simple')); + self::assertCount(1, $this->draftPagesWithSegmentPrefix('e2e-fallback')); + } + + public function testLoadAllThrowsAndDoesNotResetWhenNoFixturesConfigured(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', []); + Config::modify()->set(FixtureLoader::class, 'fixture_page_classes', [ + E2eFixtureTestPage::class, + ]); + + // A pre-existing E2E page a reset WOULD archive; it must survive the throw. + $existing = new E2eFixtureTestPage(); + $existing->Title = 'Pre-existing'; + $existing->URLSegment = 'e2e-preexisting'; + $existing->write(); + + try { + FixtureLoader::create()->loadAll(); + self::fail('Expected InvalidArgumentException for empty fixtures config'); + } catch (InvalidArgumentException $invalidArgumentException) { + self::assertStringContainsString('No fixtures', $invalidArgumentException->getMessage()); + } + + self::assertCount(1, $this->draftPagesWithSegmentPrefix('e2e-preexisting')); + } + + public function testLoadAllIsFailFastWhenAFixtureCreatesNoSiteTree(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'simple-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/simple-page.yml', + 'broken' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/no-sitetree.yml', + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('did not create any SiteTree'); + + FixtureLoader::create()->loadAll(); + } + + public function testLoadAllIsFailFastWhenAFixturePathIsInvalid(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'simple-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/simple-page.yml', + 'missing-file' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/does-not-exist.yml', + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Fixture file not found'); + + FixtureLoader::create()->loadAll(); + } + + public function testLoadAllDoesNotResetWhenAFixturePathIsInvalid(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'simple-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/simple-page.yml', + 'missing-file' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/does-not-exist.yml', + ]); + Config::modify()->set(FixtureLoader::class, 'fixture_page_classes', [ + E2eFixtureTestPage::class, + ]); + + // A pre-existing E2E page a reset WOULD archive: an invalid path must be + // caught before reset, so this survives and the valid fixture is NOT loaded. + $existing = new E2eFixtureTestPage(); + $existing->Title = 'Pre-existing'; + $existing->URLSegment = 'e2e-preexisting'; + $existing->write(); + + // Baseline instead of an absolute 0: SapphireTest's per-test DB isolation + // is not effective in this environment (verified: no START TRANSACTION / + // ROLLBACK is ever issued against the test DB across the whole run), so a + // sibling test's own 'simple-page' load can still be visible here. What + // M1 guarantees is that THIS call adds no further 'e2e-simple' page. + $simplePageCountBefore = count($this->draftPagesWithSegmentPrefix('e2e-simple')); + + try { + FixtureLoader::create()->loadAll(); + self::fail('Expected InvalidArgumentException for invalid fixture path'); + } catch (InvalidArgumentException $invalidArgumentException) { + self::assertStringContainsString('Fixture file not found', $invalidArgumentException->getMessage()); + } + + self::assertCount(1, $this->draftPagesWithSegmentPrefix('e2e-preexisting')); + self::assertCount($simplePageCountBefore, $this->draftPagesWithSegmentPrefix('e2e-simple')); + } + + /** + * @return array + */ + private function draftPagesWithSegmentPrefix(string $prefix): array + { + return Versioned::withVersionedMode(static function () use ($prefix): array { + Versioned::set_stage(Versioned::DRAFT); + + return SiteTree::get()->filter('URLSegment:StartsWith', $prefix)->toArray(); + }); + } +} diff --git a/tests/Integration/Fixtures/FixturePostActionApplyTest.php b/tests/Integration/Fixtures/FixturePostActionApplyTest.php new file mode 100644 index 0000000..e8b28a7 --- /dev/null +++ b/tests/Integration/Fixtures/FixturePostActionApplyTest.php @@ -0,0 +1,123 @@ +Title = 'Draft'; + $record->write(); + + (new FixturePostAction('publish_recursive', E2eVersionedObject::class, 'x'))->apply($record); + + $live = Versioned::get_by_stage(E2eVersionedObject::class, Versioned::LIVE)->byID($record->ID); + self::assertNotNull($live); + } + + public function testUnpublishRemovesLiveVersion(): void + { + $record = new E2eVersionedObject(); + $record->Title = 'Draft'; + $record->write(); + $record->publishRecursive(); + + (new FixturePostAction('unpublish', E2eVersionedObject::class, 'x'))->apply($record); + + $live = Versioned::get_by_stage(E2eVersionedObject::class, Versioned::LIVE)->byID($record->ID); + self::assertNull($live); + } + + public function testModifySetsFieldsAndWrites(): void + { + $record = new E2eUnversionedObject(); + $record->Title = 'Old'; + $record->write(); + + (new FixturePostAction('modify', E2eUnversionedObject::class, 'x', ['Title' => 'New']))->apply($record); + + $reloaded = E2eUnversionedObject::get()->byID($record->ID); + self::assertNotNull($reloaded); + self::assertSame('New', $reloaded->Title); + } + + public function testAttachImageCreatesAndLinksImage(): void + { + $record = new E2eUnversionedObject(); + $record->Title = 'HasImage'; + $record->write(); + + (new FixturePostAction( + 'attach_image', + E2eUnversionedObject::class, + 'x', + [ + 'relation' => 'Image', + 'source' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/assets/test-image.png', + ], + ))->apply($record); + + $reloaded = E2eUnversionedObject::get()->byID($record->ID); + self::assertNotNull($reloaded); + self::assertGreaterThan(0, (int) $reloaded->ImageID); + } + + public function testAttachImageThrowsWhenRelationOrSourceMissing(): void + { + $record = new E2eUnversionedObject(); + $record->write(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('attach_image requires "relation" and "source"'); + + (new FixturePostAction('attach_image', E2eUnversionedObject::class, 'x', ['relation' => 'Image'])) + ->apply($record); + } + + public function testAttachImageThrowsWhenSourceFileIsMissing(): void + { + $record = new E2eUnversionedObject(); + $record->write(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Image file not found'); + + (new FixturePostAction( + 'attach_image', + E2eUnversionedObject::class, + 'x', + [ + 'relation' => 'Image', + 'source' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/assets/missing-image.png', + ], + ))->apply($record); + } + + public function testVersionedActionOnUnversionedRecordThrows(): void + { + $record = new E2eUnversionedObject(); + $record->write(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires Versioned extension'); + + (new FixturePostAction('publish_recursive', E2eUnversionedObject::class, 'x'))->apply($record); + } +} diff --git a/tests/Integration/Support/SupportScaffoldingTest.php b/tests/Integration/Support/SupportScaffoldingTest.php new file mode 100644 index 0000000..eb00ae0 --- /dev/null +++ b/tests/Integration/Support/SupportScaffoldingTest.php @@ -0,0 +1,41 @@ +Title = 'Smoke'; + $page->URLSegment = 'e2e-smoke'; + $page->write(); + self::assertGreaterThan(0, $page->ID); + + $versioned = new E2eVersionedObject(); + $versioned->Title = 'V'; + $versioned->write(); + self::assertTrue($versioned->hasExtension(Versioned::class)); + + $unversioned = new E2eUnversionedObject(); + $unversioned->Title = 'U'; + $unversioned->write(); + self::assertFalse($unversioned->hasExtension(Versioned::class)); + } +} diff --git a/tests/Integration/Tasks/LoadFixturesTaskTest.php b/tests/Integration/Tasks/LoadFixturesTaskTest.php new file mode 100644 index 0000000..b815c64 --- /dev/null +++ b/tests/Integration/Tasks/LoadFixturesTaskTest.php @@ -0,0 +1,177 @@ +get(Kernel::class); + $this->originalEnvironment = $kernel->getEnvironment(); + $kernel->setEnvironment('dev'); + + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'simple-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/simple-page.yml', + ]); + Config::modify()->set(FixtureLoader::class, 'fixture_page_classes', [ + E2eFixtureTestPage::class, + ]); + } + + protected function tearDown(): void + { + Injector::inst()->get(Kernel::class)->setEnvironment($this->originalEnvironment); + + parent::tearDown(); + } + + /** + * @return array{int, string} exit code and captured output + */ + private function runTask(?string $fixture): array + { + $task = new LoadFixturesTask(); + + $parameters = $fixture === null ? [] : ['--fixture' => $fixture]; + $input = new ArrayInput($parameters, new InputDefinition($task->getOptions())); + + $buffer = new BufferedOutput(); + $output = new PolyOutput( + PolyOutput::FORMAT_ANSI, + OutputInterface::VERBOSITY_NORMAL, + false, + $buffer, + ); + + $exitCode = $task->run($input, $output); + + return [$exitCode, $buffer->fetch()]; + } + + public function testLoadAllSeedsEveryFixtureAndReportsEach(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', [ + 'simple-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/simple-page.yml', + 'fallback-page' => 'wedevelopnl/silverstripe-e2e:tests/Support/fixtures/fallback-page.yml', + ]); + Config::modify()->set(FixtureLoader::class, 'fixture_page_classes', [ + E2eFixtureTestPage::class, + E2eOtherTestPage::class, + ]); + + [$exitCode, $output] = $this->runTask(null); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString('Loaded fixture "simple-page"', $output); + self::assertStringContainsString('Loaded fixture "fallback-page"', $output); + self::assertStringContainsString('Loaded 2 fixture', $output); + self::assertSame(1, E2eFixtureTestPage::get()->filter('URLSegment', 'e2e-simple')->count()); + self::assertSame(1, E2eOtherTestPage::get()->filter('URLSegment', 'e2e-fallback')->count()); + } + + public function testSingleFixtureLoadsOnlyThatFixture(): void + { + [$exitCode, $output] = $this->runTask('simple-page'); + + self::assertSame(Command::SUCCESS, $exitCode); + self::assertStringContainsString('Loaded fixture "simple-page"', $output); + self::assertStringContainsString('e2e-simple', $output); + self::assertStringNotContainsString('Loaded 1 fixture.', $output); + self::assertSame(1, E2eFixtureTestPage::get()->filter('URLSegment', 'e2e-simple')->count()); + } + + public function testEmptyFixtureOptionIsRejectedWithoutTouchingTheLoader(): void + { + // A spy on the loader's lifecycle hooks proves no fixture was loaded — + // immune to fixture data leaking across test methods (runtime YamlFixture + // writes are not rolled back like SapphireTest fixtures). + Config::modify()->merge(FixtureLoader::class, 'extensions', [LoaderHookSpy::class]); + LoaderHookSpy::reset(); + + [$exitCode, $output] = $this->runTask(''); + + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('cannot be empty', $output); + self::assertSame([], LoaderHookSpy::$beforeCalls); + } + + public function testUnknownFixtureNameReturnsInvalid(): void + { + [$exitCode, $output] = $this->runTask('does-not-exist'); + + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('Unknown fixture', $output); + } + + public function testLoadAllWithNoFixturesConfiguredReturnsInvalid(): void + { + Config::modify()->set(FixtureLoader::class, 'fixtures', []); + + [$exitCode, $output] = $this->runTask(null); + + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('No fixtures', $output); + } + + public function testResetRefusalIsReportedAsFailure(): void + { + // reset() refuses when no fixture_page_classes are configured; the loader + // raises a RuntimeException, which the task surfaces as a hard failure + // (distinct from the INVALID exit used for bad input). + Config::modify()->set(FixtureLoader::class, 'fixture_page_classes', []); + + [$exitCode, $output] = $this->runTask('simple-page'); + + self::assertSame(Command::FAILURE, $exitCode); + self::assertStringContainsString('refusing to reset', $output); + } + + public function testRefusesToRunOutsideDevEnvironment(): void + { + Config::modify()->merge(FixtureLoader::class, 'extensions', [LoaderHookSpy::class]); + LoaderHookSpy::reset(); + + Injector::inst()->get(Kernel::class)->setEnvironment('live'); + + [$exitCode, $output] = $this->runTask(null); + + self::assertSame(Command::FAILURE, $exitCode); + self::assertStringContainsString('dev environment', $output); + // The guard returns before the loader runs, so no fixture is loaded. + self::assertSame([], LoaderHookSpy::$beforeCalls); + } +} diff --git a/tests/Support/E2eFixtureTestPage.php b/tests/Support/E2eFixtureTestPage.php new file mode 100644 index 0000000..f547cba --- /dev/null +++ b/tests/Support/E2eFixtureTestPage.php @@ -0,0 +1,13 @@ + */ + private static array $db = [ + 'Title' => 'Varchar(255)', + ]; + + /** @var array */ + private static array $has_one = [ + 'Image' => Image::class, + ]; +} diff --git a/tests/Support/E2eVersionedObject.php b/tests/Support/E2eVersionedObject.php new file mode 100644 index 0000000..289c809 --- /dev/null +++ b/tests/Support/E2eVersionedObject.php @@ -0,0 +1,24 @@ + */ + private static array $db = [ + 'Title' => 'Varchar(255)', + ]; + + /** @var array */ + private static array $extensions = [ + Versioned::class, + ]; +} diff --git a/tests/Support/LoaderHookSpy.php b/tests/Support/LoaderHookSpy.php new file mode 100644 index 0000000..985bcf0 --- /dev/null +++ b/tests/Support/LoaderHookSpy.php @@ -0,0 +1,38 @@ + + */ +class LoaderHookSpy extends Extension implements TestOnly +{ + /** @var list */ + public static array $beforeCalls = []; + + /** @var list */ + public static array $afterCalls = []; + + public static function reset(): void + { + self::$beforeCalls = []; + self::$afterCalls = []; + } + + public function onBeforeLoad(string $fixtureName, FixtureFactory $factory): void + { + self::$beforeCalls[] = $fixtureName; + } + + public function onAfterLoad(FixtureResult $result): void + { + self::$afterCalls[] = $result->fixtureName; + } +} diff --git a/tests/Support/fixtures/assets/test-image.png b/tests/Support/fixtures/assets/test-image.png new file mode 100644 index 0000000..fead692 Binary files /dev/null and b/tests/Support/fixtures/assets/test-image.png differ diff --git a/tests/Support/fixtures/fallback-page.yml b/tests/Support/fixtures/fallback-page.yml new file mode 100644 index 0000000..16adbc2 --- /dev/null +++ b/tests/Support/fixtures/fallback-page.yml @@ -0,0 +1,4 @@ +WeDevelop\E2e\Tests\Support\E2eOtherTestPage: + page1: + Title: 'E2E Fallback Page' + URLSegment: 'e2e-fallback' diff --git a/tests/Support/fixtures/no-sitetree.yml b/tests/Support/fixtures/no-sitetree.yml new file mode 100644 index 0000000..706d11f --- /dev/null +++ b/tests/Support/fixtures/no-sitetree.yml @@ -0,0 +1,3 @@ +WeDevelop\E2e\Tests\Support\E2eVersionedObject: + obj1: + Title: 'No page here' diff --git a/tests/Support/fixtures/page-with-postaction.yml b/tests/Support/fixtures/page-with-postaction.yml new file mode 100644 index 0000000..df08d0b --- /dev/null +++ b/tests/Support/fixtures/page-with-postaction.yml @@ -0,0 +1,7 @@ +WeDevelop\E2e\Tests\Support\E2eFixtureTestPage: + page1: + Title: 'E2E Post Action Page' + URLSegment: 'e2e-post' +WeDevelop\E2e\Tests\Support\E2eVersionedObject: + obj1: + Title: 'Needs publish' diff --git a/tests/Support/fixtures/simple-page.yml b/tests/Support/fixtures/simple-page.yml new file mode 100644 index 0000000..8639b25 --- /dev/null +++ b/tests/Support/fixtures/simple-page.yml @@ -0,0 +1,4 @@ +WeDevelop\E2e\Tests\Support\E2eFixtureTestPage: + page1: + Title: 'E2E Simple Page' + URLSegment: 'e2e-simple' diff --git a/tests/Unit/Fixtures/FixturePostActionTest.php b/tests/Unit/Fixtures/FixturePostActionTest.php new file mode 100644 index 0000000..beebc9f --- /dev/null +++ b/tests/Unit/Fixtures/FixturePostActionTest.php @@ -0,0 +1,60 @@ + 'publish_recursive', + 'class' => 'Some\\Class', + 'identifier' => 'rec1', + ]); + + self::assertSame('publish_recursive', $action->action); + self::assertSame('Some\\Class', $action->class); + self::assertSame('rec1', $action->identifier); + self::assertSame([], $action->fields); + } + + public function testFromConfigKeepsFields(): void + { + $action = FixturePostAction::fromConfig([ + 'action' => 'modify', + 'class' => 'Some\\Class', + 'identifier' => 'rec1', + 'fields' => ['Title' => 'New'], + ]); + + self::assertSame(['Title' => 'New'], $action->fields); + } + + public function testFromConfigThrowsWhenRequiredKeysMissing(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires "action", "class", and "identifier"'); + + FixturePostAction::fromConfig(['action' => 'modify']); + } + + public function testFromConfigThrowsOnUnknownAction(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown post-action "explode"'); + + FixturePostAction::fromConfig([ + 'action' => 'explode', + 'class' => 'Some\\Class', + 'identifier' => 'rec1', + ]); + } +} diff --git a/tests/Unit/Fixtures/FixtureResultTest.php b/tests/Unit/Fixtures/FixtureResultTest.php new file mode 100644 index 0000000..2109337 --- /dev/null +++ b/tests/Unit/Fixtures/FixtureResultTest.php @@ -0,0 +1,44 @@ + ['id1' => 7]], + ); + + self::assertSame( + [ + 'pageId' => 42, + 'pageUrl' => '/e2e-simple/', + 'fixtureMap' => ['SomeClass' => ['id1' => 7]], + ], + $result->jsonSerialize(), + ); + } + + public function testFixtureNameIsExposedAsProperty(): void + { + $result = new FixtureResult( + fixtureName: 'simple-page', + pageId: 1, + pageUrl: '/e2e-simple/', + fixtureMap: [], + ); + + self::assertSame('simple-page', $result->fixtureName); + } +}