mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
RunBulkStatusHandler exists to take a mark-read or archive over a whole view out of the request — its docblock quotes the thirty-second timeout that produced it, and BulkStatusController answers with an indicator so nobody waits. Neither was true: the message was never listed in messenger.yaml's routing map, and Messenger handles an unrouted message inline in whichever process dispatched it. Every whole-view action ran inside the web request, under the limit it was written to escape, at request concurrency — which is where the production deadlock came from. It gets a queue of its own rather than joining maintenance, because somebody is watching an indicator for it and queueing it behind an embedding backfill would be slower than the inline version it replaces. That means one new container, worker-bulk, in every compose file and in the handbook. The deadlock itself is retried now instead of failing the job, three times per chunk, and a collision that outlasts those hands the job back to the queue rather than ending it. The failure path no longer writes through an EntityManager Doctrine has closed, which is what it was doing for every failure that came out of a flush. And the list catches up: nothing ever refreshed it on the strength of the job, only on the toolbar's own "the write landed" event, which is now the instant the work was QUEUED rather than finished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
12 KiB
YAML
241 lines
12 KiB
YAML
# The test stack — PHPUnit and the Playwright E2E suite.
|
|
#
|
|
# npm run test:unit:docker
|
|
# npm run test:e2e:docker
|
|
#
|
|
# Not only local: .github/workflows/e2e.yml boots this same file and runs those
|
|
# same two suites against it, which is the point. CI used to serve the app with
|
|
# `php -S` on the runner and install PHP beside it, so the two environments
|
|
# agreed about nothing except the source tree, and each had failures the other
|
|
# could not reproduce.
|
|
#
|
|
# Deliberately its OWN compose project (see `name:` below) with its OWN Postgres,
|
|
# so it can never reconcile, recreate or restart the dev stack, and can never
|
|
# reach the database holding real mail. `docker compose ...` without -f is
|
|
# untouched by this file.
|
|
#
|
|
# Served by the app's own FrankenPHP image rather than PHP's built-in server:
|
|
# variables_order is GPCS, so `php -S` never exposes APP_ENV to Symfony and the
|
|
# app silently falls back to .env.local and the dev database. A server SAPI puts
|
|
# the environment into $_SERVER, so the values below actually take effect.
|
|
name: pl_mail_test
|
|
|
|
x-test-app: &test-app
|
|
# The dev target, not the ghcr image: prod is built with `composer install
|
|
# --no-dev` (no PHPUnit) and ships a dump-env'd .env.local.php pinning prod.
|
|
build:
|
|
context: .
|
|
target: frankenphp_dev
|
|
# Named explicitly rather than left to compose's `<project>-<service>`
|
|
# default, for two reasons.
|
|
#
|
|
# `app` and `app-init` are the same image built from the same target, so the
|
|
# default naming had compose tagging it twice under two names. And the name
|
|
# stopped being predictable the moment anyone passed `-p` — a worktree on its
|
|
# own project got its own tags, which is why the one-off-container recipes in
|
|
# the handbook could not just name an image.
|
|
#
|
|
# Fixed, it is also a build CI can do ahead of time: .github/workflows/e2e.yml
|
|
# builds this tag with a layer cache and then brings the stack up without
|
|
# `--build`, which it could not do if the tag depended on the project name.
|
|
image: pl_mail_test-app
|
|
volumes:
|
|
- ./:/app
|
|
# A dedicated var volume. Never share the dev stack's `app_var`: it is a
|
|
# named volume overlaying the bind mount, so sharing it would let the two
|
|
# environments write each other's caches and built assets.
|
|
- app_var_test:/app/var
|
|
# Same trick, same reason, for the compiled assets `asset-map:compile`
|
|
# writes in app-init. `public/assets/` does not exist in the repo and the
|
|
# dev stack bind-mounts this very directory, so without an overlay the
|
|
# compiled output would land on the host and be served — stale — by the
|
|
# dev stack on every source edit, since Caddy prefers a real file over
|
|
# index.php. Keeping it in a volume means the test stack gets static assets
|
|
# and development is untouched by it.
|
|
#
|
|
# Docker does create an *empty* `public/assets/` on the host as the mount
|
|
# point. That is harmless and expected: the compiled files live in the
|
|
# volume, so the dev stack finds nothing there and falls through to PHP
|
|
# exactly as it did before. `.gitignore` already covers the directory.
|
|
- app_public_assets_test:/app/public/assets
|
|
environment: &test-env
|
|
APP_ENV: test
|
|
APP_DEBUG: "1"
|
|
# Pinned, and passed as real environment variables rather than left to
|
|
# .env.test: the entrypoint generates these when the environment does not
|
|
# carry them, and a generated value would win over the dotenv one. The
|
|
# suite wants them fixed — stable CSRF tokens, and a seed encrypted with
|
|
# the same key every run. Same values as .env.test.
|
|
APP_SECRET: 282065e0f71bf96c0dbcf0496c1f5d3e
|
|
APP_ENCRYPTION_KEY: emL6R8zs0pSXCVV2HvVvbFubriWJIla7kgnqSGqCJ8E=
|
|
# Host is `database`, unlike .env.test which targets 127.0.0.1 for CI.
|
|
# Doctrine's when@test dbname_suffix (config/packages/doctrine.yaml) turns
|
|
# this into `app_test` automatically.
|
|
DATABASE_URL: postgresql://app:app@database:5432/app?serverVersion=18&charset=utf8
|
|
# `in-memory://` for the three shared queues, which means a message routed
|
|
# to one is kept and never handled — every worker process is a container
|
|
# this stack does not run, and an in-memory transport is per-process
|
|
# anyway. That is deliberate and is what keeps the suite deterministic: a
|
|
# spec is never racing an AI classification, a mailbox resort or a rule
|
|
# pass that fired in the background.
|
|
MESSENGER_TRANSPORT_DSN: in-memory://
|
|
# The one exception, and the reason `bulk` has a DSN of its own in
|
|
# config/packages/messenger.yaml. A whole-view action HAS to actually run
|
|
# for bulk-select-all.spec.ts to mean anything — it selects the view,
|
|
# archives it and waits for the rows to go — so this queue is a real
|
|
# Postgres one with the worker below draining it. Pointing the shared
|
|
# variable here instead would switch the other three on at the same time.
|
|
#
|
|
# auto_setup=0: `messenger_messages` is created by a migration
|
|
# (Version20260625123836), and app-init has run it before anything else
|
|
# starts.
|
|
MESSENGER_BULK_DSN: doctrine://default?auto_setup=0
|
|
MAILER_DSN: null://null
|
|
APP_DEV_USER_EMAIL: e2e@plmail.test
|
|
APP_DEV_USER_PASSWORD: e2e-password-change-me
|
|
# Same-origin: the browser reaches the hub through the app's own Caddy
|
|
# (`handle /.well-known/mercure*` in frankenphp/Caddyfile), so the
|
|
# subscriber cookie is a first-party cookie and needs no CORS.
|
|
#
|
|
# http, not https, on purpose — Symfony derives the cookie's `secure` flag
|
|
# from this URL's scheme, so an https value here would mint a cookie the
|
|
# browser refuses to send back over the test stack's plain HTTP.
|
|
MERCURE_PUBLIC_URL: http://127.0.0.1:${TEST_HTTP_PORT:-8001}/.well-known/mercure
|
|
MERCURE_URL: http://mercure/.well-known/mercure
|
|
# Fixed, like APP_SECRET above: the suite signs subscriber JWTs with it and
|
|
# the hub verifies them, so both sides have to agree without depending on
|
|
# generated secrets that differ per run.
|
|
MERCURE_JWT_SECRET: e2e-mercure-secret-not-for-production
|
|
depends_on:
|
|
database:
|
|
condition: service_healthy
|
|
|
|
services:
|
|
database:
|
|
image: postgres:18-alpine
|
|
environment:
|
|
POSTGRES_DB: app
|
|
POSTGRES_USER: app
|
|
POSTGRES_PASSWORD: app
|
|
healthcheck:
|
|
test: ["CMD", "pg_isready", "-d", "app", "-U", "app"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
volumes:
|
|
- database_data_test:/var/lib/postgresql
|
|
|
|
# Owns schema, assets and the seed user, then exits. `app` waits for it.
|
|
app-init:
|
|
<<: *test-app
|
|
# The image entrypoint waits on the database and runs migrations *before*
|
|
# the database exists, so it cannot bootstrap an empty volume. Drop to the
|
|
# upstream PHP entrypoint and do the ordering here.
|
|
entrypoint: ["docker-php-entrypoint"]
|
|
restart: "no"
|
|
command:
|
|
- sh
|
|
- -c
|
|
- |
|
|
set -e
|
|
# The entrypoint this service deliberately skips is also the thing that
|
|
# installs PHP dependencies into the bind mount (see the long note in
|
|
# frankenphp/docker-entrypoint.sh). Dropping to docker-php-entrypoint
|
|
# bought correct ordering around the database and lost that, which was
|
|
# invisible for as long as everyone running this stack already had a
|
|
# vendor/ from the dev stack. On a tree that never had one — a fresh
|
|
# clone, a new worktree, a CI checkout — every bin/console below died on
|
|
# "Dependencies are missing. Try running composer install." and the
|
|
# stack never came up at all.
|
|
#
|
|
# No lock around it, unlike the entrypoint's version: this service runs
|
|
# on its own and `app` waits for it to exit, so there is no second
|
|
# container racing for the same vendor/.
|
|
[ -f vendor/autoload_runtime.php ] || composer install --prefer-dist --no-progress --no-interaction
|
|
php bin/console doctrine:database:create --if-not-exists
|
|
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
|
php bin/console importmap:install
|
|
# Not optional: specs assert toBeVisible()/toBeHidden(), which depend on
|
|
# Tailwind's `.hidden { display: none }`. Building here also keeps this
|
|
# stack independent of the dev `tailwind` watcher.
|
|
php bin/console tailwind:build
|
|
# Compiled, not served through PHP. Without this, `try_files {path}
|
|
# index.php` in frankenphp/Caddyfile sends every asset request into a
|
|
# full Symfony kernel boot: /login alone pulls 54 modulepreload entries
|
|
# plus 2 stylesheets, measured at ~35ms each against ~1.6ms for a real
|
|
# static file. Playwright gives every test a cold HTTP cache, so that
|
|
# was ~1.9s of PHP spent serving JavaScript once per test — about 40%
|
|
# of the whole e2e suite.
|
|
php bin/console asset-map:compile
|
|
php bin/console app:test:seed-user
|
|
# The admin the PHPUnit controller tests look for. Seeded here rather
|
|
# than left to the Playwright suite, which is where it used to come
|
|
# from: PageRendersTest and friends skip themselves when the user is
|
|
# missing, so `npm run test:unit:docker` on a fresh volume quietly ran
|
|
# ~50 fewer tests than it appeared to until the browser suite had been
|
|
# run at least once.
|
|
php bin/console app:test:seed-user --admin --email=e2e-admin@plmail.test --password=e2e-admin-password
|
|
|
|
# The real hub, so the suite can assert on live updates and on recovery after
|
|
# the stream drops. Runs the image's DEFAULT Caddyfile, not dev.Caddyfile:
|
|
# the default one requires a subscriber JWT, which is what production serves,
|
|
# so the tests exercise the authorization path rather than an anonymous hub
|
|
# that would accept anything and hide a broken cookie.
|
|
mercure:
|
|
image: dunglas/mercure
|
|
environment:
|
|
SERVER_NAME: ":80"
|
|
MERCURE_PUBLISHER_JWT_KEY: e2e-mercure-secret-not-for-production
|
|
MERCURE_SUBSCRIBER_JWT_KEY: e2e-mercure-secret-not-for-production
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-f", "http://localhost/healthz"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
|
|
app:
|
|
<<: *test-app
|
|
environment:
|
|
<<: *test-env
|
|
# Plain HTTP on 80 inside the container — no TLS, no cert prompts.
|
|
SERVER_NAME: ":80"
|
|
ports:
|
|
- "${TEST_HTTP_PORT:-8001}:80"
|
|
depends_on:
|
|
database:
|
|
condition: service_healthy
|
|
app-init:
|
|
condition: service_completed_successfully
|
|
mercure:
|
|
condition: service_healthy
|
|
|
|
# Drains the one queue this stack makes real — see MESSENGER_BULK_DSN above.
|
|
#
|
|
# `--time-limit` is short because the stack is short-lived and a worker that
|
|
# has exited is easier to read in `compose ps` than one that is idle; the
|
|
# restart policy brings it straight back either way. `-vv` so a handler that
|
|
# throws says why in `compose logs worker-bulk`, which is the only window
|
|
# onto work that no longer happens inside a request the browser can see.
|
|
worker-bulk:
|
|
<<: *test-app
|
|
command: php bin/console messenger:consume bulk --time-limit=600 --memory-limit=256M -vv
|
|
restart: unless-stopped
|
|
stop_grace_period: 5s
|
|
# The image carries a HEALTHCHECK that curls the web server, which a worker
|
|
# is not. Left on, `compose up --wait` reports this container unhealthy and
|
|
# exits non-zero with the stack up and working — which in CI is a red run
|
|
# for a container doing exactly what it should. Every worker in
|
|
# compose.yaml disables it for the same reason.
|
|
healthcheck:
|
|
disable: true
|
|
depends_on:
|
|
database:
|
|
condition: service_healthy
|
|
app-init:
|
|
condition: service_completed_successfully
|
|
|
|
volumes:
|
|
app_var_test:
|
|
app_public_assets_test:
|
|
database_data_test:
|