# CC Soccer — Automated Testing Strategy

**Written:** September 7, 2026 · Andrew + Claude
**Status:** 🔲 Agreed, not built. Nothing in this document exists in the tree yet.

---

## What this file is

**The plan for how this codebase gets automated tests, and the rules for writing them.**
It is a design document, not a tracker. It says what we are building and why; it does **not** say how
far along we are. Progress lives in `OUTSTANDING_ISSUES.md` (the item is **§P10 B-7**) and in git.

Once Phase 0 lands, the operational half of this file — how to run the suite, how to read a failure —
moves to `web/modules/custom/ccsoccer/tests/README.md`, next to the tests. This file keeps the
reasoning.

---

## The decisions, taken September 7

| # | Decision | Chosen |
|---|---|---|
| 1 | How far up the pyramid | **Static analysis + Unit + Kernel + Functional.** JavaScript tests deferred |
| 2 | Where tests run | **Local DDEV only.** No CI for now |
| 3 | How results reach Claude | **A script writes results into the repo**; Claude reads the file |
| 4 | What drives coverage | **Foundations first, by feature area.** Map to register IDs later |

Each is explained where it appears below. Decisions 1 and 2 are the ones most likely to be revisited;
**§4 states the conditions under which we would.**

---

## 1. Why — the evidence, not the principle

Everyone agrees tests are good, which is why "we should write tests" has been true and ignored for
eight months. The argument that matters is narrower: **this codebase produces the same small set of
bug shapes over and over, and they are all mechanically detectable.**

`OUTSTANDING_ISSUES.md` §P10 **B-7** already made this case and named the blocker:

> No automated test coverage — nothing on payment, credits, registration, notifications. The strongest
> item in that doc: this codebase has produced the *same bug shape* six or more times (missing status
> filter, bare `reset()`, drifted twin paths) — precisely what tests catch. […] **PHPUnit is not
> installed** […] so step one is `composer require --dev drupal/core-dev` plus a `phpunit.xml`.

The recurring shapes, each with a confirmed incident behind it:

| Shape | Where it has bitten | Layer that catches it |
|---|---|---|
| **Drifted twin paths** — season and tournament code copied, then one side fixed | Sep 7: the identical four-line payment-state bug in *both* `CancelRegistrationForm` and `TournamentCancelRegistrationForm` | Kernel, run against both twins from one data provider |
| **Bare `reset()` over registrations** — takes the oldest row, which is the cancelled one | The whole reason `LiveRegistrationTrait` exists (CF4). Recurred Sep 7 in all three refund forms | Unit (on the trait) + Kernel (on each caller) |
| **Missing status filter / four definitions of "live"** | Decision 2: allowlist at 49 sites, `paid`-only at 7, exclude-list at 2, partial exclude-list at 6 | Unit pins the definition; Kernel asserts each checkpoint agrees |
| **Check-then-act with no lock** | P6 capacity race — orders 304 and 309, two players paid and got no registration. Same shape again Sep 7 in concurrent deposit refunds | Kernel (the count logic), Functional (the checkpoint fires) |
| **Guard enforced on 1 of N paths** | `groups_locked` on 1 of 4 accept paths; capacity on 1 of 4; five auto-decline sites notify nobody | Kernel, data-driven across all N paths |
| **Silent no-op after a deploy step is skipped** | Sep 7 hook 9075 — code without `updb` throws on Captain Deposits and *silently drops writes* to the new field | Functional smoke on the affected admin page |

**Six shapes. Every one of them is a test.** That is the case for doing this, and it is why the phasing
in §7 follows the shapes rather than the file tree.

### What this will not fix

Worth being straight about, so nobody expects too much:

- **Tests do not catch deploy-state drift.** The single thing this project most often gets wrong is
  *what is actually on PROD*. No local suite will tell you that. Keep checking git and the servers.
- **Tests do not catch config drift.** `config:status`, `cim` and the `config_ignore` list are their
  own discipline. A Functional test installs config from code, so it proves the code works — not that
  PROD's active config matches.
- **Tests do not replace the two-pass review on money code.** The Sep 7 review found six real defects
  *in the fix itself*. Tests would have caught two of them. Keep the review.
- **The Authorize.net sandbox stays out of reach.** As the Sep 7 entry records, authnet only refunds
  *settled* transactions and the sandbox settles in daily batches. Everything the Commerce **Manual**
  gateway can express is testable locally; the real-gateway branch stays a TEST-environment manual step.

---

## 2. A correction worth carrying: "unit test" is not what we are building

The request that started this asked for "unit tests" and then described clicking an email invitation
link and walking a checkout. **Those are two different things, and the distinction drives the whole
plan**, so it is written down here rather than discovered in session three.

A **unit test** instantiates one class with fake collaborators and asserts on its return value. It is
the fastest kind and the *least* useful here, because almost every service in this module takes
`@entity_type.manager` and immediately queries entities. Mocking an entity query well enough to be
meaningful takes more code than the method under test, and it asserts that the mock behaves like the
mock.

**Drupal ships two better layers that this codebase actually wants**, and everything interesting lives
in them:

- **Kernel tests** boot a real Drupal with a real database and real services from the container. No
  HTTP. Seconds each. Create a Season, register a player, dispatch the order-complete event, assert
  what happened.
- **Functional tests** install a complete site and make real page requests through an internal
  browser. No JavaScript. Under a minute each. Log in as a player, add a season to the cart, check
  out, and assert the registration exists — the flow, end to end.

**And the email link works better than expected.** Drupal's test mail collector captures outgoing mail
into an array, so a Functional test sends a real invitation, reads the sent message, extracts the
token URL from the body and requests it. No mail server, no inbox, no waiting, no flake. Every branch
of "click the link in the email" from the original request is reachable this way, including accepting
later, declining, and the token that has already been used.

**So the vocabulary from here on:** *unit* means the narrow thing, *kernel* and *functional* mean the
useful things, and **"the suite"** means all of them. When the ask is "run the unit tests", the answer
is one command that runs the lot.

---

## 3. The four layers

Cheapest first. Each entry says what belongs there, what does not, and what it costs.

### Layer 0 — Static analysis (PHPStan + PHPCS)

**No test-writing at all.** These read the code and report. Seconds for the whole module.

- **PHPStan** with `mglaman/phpstan-drupal` and `phpstan/phpstan-deprecation-rules`. Catches calls to
  methods that do not exist, wrong argument types, unreachable branches, undefined variables,
  deprecated core APIs before a Drupal minor removes them.
- **PHPCS** with `Drupal` and `DrupalPractice` standards. Formatting, docblocks, and a set of
  genuinely useful practice rules.

**Why this is first and why it is the best value in the plan:** the register is full of findings that
are exactly PHPStan output. The **second, unreachable `status` field definition at
`ccsoccer.module:1536-1836`**, gated on an entity id that never matches. The **duplicate
`return $fields;`** in `Registration.php` (**B-14**, still there at `:279` and `:281`). Dead methods
like `tokenMatchesTournament()`, noted in review and left. **These cost nothing to find once the tool
is installed, and nothing to keep finding.**

**Start at level 1 and ratchet.** A 67,000-line module that has never seen PHPStan will produce
hundreds of findings at level 5. Generate a **baseline file** on day one so the existing noise is
silenced, then every *new* line of code is held to the standard, and the baseline shrinks as files get
touched. This is the only sane way to introduce it to an existing codebase.

**Cost:** one session to install and baseline. Near-zero maintenance after.

### Layer 1 — Unit tests

`tests/src/Unit/`. Milliseconds each. No database, no Drupal bootstrap.

**Belongs here:** pure logic that can be handed its inputs directly.

- **`LiveRegistrationTrait`** — the highest-value unit test in the codebase. `pickLiveRegistration()`
  and `isDeadRegistrationStatus()` need nothing but stub objects with a status. This is where the
  answer to Decision 2 gets **pinned**, so a fifth definition of "live" cannot appear without a red
  test. Cases: empty set; all dead; one live; live plus a newer cancelled; `waitlist` is live (D-13);
  `expired` is dead; two live rows returns the higher id.
- **`RefundablePaymentTrait`** — the outstanding-balance cap arithmetic. Partial then partial, refund
  exceeding the cap, a forfeit after a partial refund, zero, the "No refund" path.
- **`TeamBalancerService`** scoring maths — the skill/age weighting, extractable from the entity loads.
- **Credit arithmetic** in `CreditManagerService` — splitting, expiry comparison, rounding.
- **Date-window logic** — registration open/closed/late, override expiry. Feed it timestamps.

**Does not belong here:** anything that loads or queries an entity. Push it to Kernel.

**Expected size: 30–40 tests, and that is the right number.** If this layer starts growing past that,
it is a sign we are mocking the entity manager, which we are not doing.

### Layer 2 — Kernel tests — the workhorse

`tests/src/Kernel/`. Real database, real container, no HTTP. **~2–5 seconds per test**, and each test
class gets its own throwaway database.

**This is where most of the suite lives — call it 60%.** Everything about entities, services and
event subscribers is reachable here without the cost of a page request.

**Belongs here:**

- **Entity behaviour** — all twelve types. `season`, `tournament`, `team`, `game`, `league`,
  `tournament_series`, `ccsoccer_registration`, `ccsoccer_waitlist`, `ccsoccer_override`,
  `ccsoccer_invitation`, `credits`, `season_credit_event`. Create, save, load, computed methods,
  `getSpotsRemaining()`, `isFull()`, `getRosterStats()`.
- **Services** — `CreditManagerService`, `WaitlistManagerService`, `OverrideManagerService`,
  `GroupDissolveService`, `TeamManagerService`, `TournamentTeamManager`, `TournamentInviteService`,
  `CommerceProductService`, `TeamBalancerService`, both schedule generators.
- **Event subscribers** — `OrderCompleteSubscriber` and `CartEventSubscriber` are dispatched directly
  with a constructed order. **No checkout UI needed to test what happens at order completion.**
- **Invariants across N call sites.** The pattern that fits this codebase best: one test, a data
  provider listing every path, the same assertion for all of them.
  - **The D-8 invariant** across all thirteen cancel/decline/remove/leave/dissolve sites: no solo
    `group_id` survives. *The first draft missed two of thirteen, all in admin surfaces, and the
    register says "assume there is a third."* This test is how you find the third.
  - **`groups_locked`** across all four accept paths.
  - **Capacity** across all four accept paths and all three season checkpoints.
  - **The three season capacity checkpoints.** ⚠ **The register is stale on this one** — it says
    `Season::getSpotsRemaining()` subtracts `reserved_spots` while the two subscribers do not.
    **Checked against the tree today: all three now subtract it**, almost certainly as a result of the
    Sep 6 waitlist-override work (`5369931`). What they *still* disagree about is narrower and more
    interesting:

    | Checkpoint | Counts | `reserved_spots` |
    |---|---|---|
    | `Season::getSpotsRemaining()` | `status IN (paid, active)` | always subtracted |
    | `CartEventSubscriber` (cart add) | `status = 'paid'` only | subtracted **unless** the player holds an override |
    | `OrderCompleteSubscriber` (completion) | `status = 'paid'` only | subtracted **unless** the player holds an override |

    So the page can say *full* to a player the checkout will happily accept — because
    `getSpotsRemaining()` counts `active` rows the subscribers ignore, and because it has no player
    context and therefore **cannot** honour an override the other two do. One data-driven test across
    all three is how this stops drifting a fourth time. **Update the register entry when this test is
    written.**
- **The named first test, from B-7:** after a partial `refundPayment()`, the payment state is
  `partially_refunded` and `canRefundPayment()` is still TRUE. **That one assertion would have caught
  the Sep 7 bug that made $200 unreachable from every UI.** Commerce's **Manual** gateway has
  byte-identical partial/full state logic to AcceptJs and makes no network call, so this is fully
  testable locally — the Sep 7 entry already established that.

**Does not belong here:** anything that needs a rendered page, a form submission through HTTP, a
redirect, an access-denied response, or an email round-trip.

### Layer 3 — Functional tests — the flows

`tests/src/Functional/`. Full site install, real requests, **no JavaScript**. **~30–60 seconds per
test.**

**This is the layer that covers what the original request described.** Everything below is a real
click-path through the site.

**Belongs here:**

- **Registration and order flows.** Season registration; tournament registration; with a jersey and
  without; using credits and not; accepting a group and not; the deposit path for a tournament
  captain. Assert the order total, the registration row, the credit ledger and the receipt.
- **The invitation lifecycle, including the email link.** Send an invitation → read the captured mail
  → extract the token URL → request it. Then: accept at the first page; accept later from My
  Registrations; decline; an already-used token; a token for a player who is already registered; an
  invitation to a group that filled while they were deciding. **Both season groups and tournament
  teams, from one shared test body** — the twins get tested together by construction.
- **The administrative side.** Create a season; create a tournament; team builder; roster builder;
  schedule builder; publish a schedule; change season open and close dates. Then the date edge cases:
  a player registering on time, after close, and with an admin override.
- **Access control across all 176 routes.** Cheap, fast to write, and it covers the security items —
  the missing-permission route found Sep 7, the `invite()` route that never checks the caller is the
  group manager (the rule currently lives in Twig, so a POST bypasses it), and the Board Member /
  Tournament Director permission gaps. **A table of route → role → expected status code catches an
  entire class of bug in one file.**
- **Game-day ops:** the Game Status form, rainout and cancellation notices, and what gets sent.
- **The admin-page smoke set.** Load every admin page as the role that owns it and assert HTTP 200.
  Dull, and it is what catches the shape where a page throws because a column is missing.

**Does not belong here:** anything requiring a real browser — drag-and-drop, AJAX-driven grids,
the Authorize.net JS pane, or a JavaScript-triggered download. Those are Layer 4, deferred.

**Keep this layer honest about cost.** At ~45 seconds each, 80 functional tests is an hour. That is
fine for a nightly or pre-deploy run and too slow for every save, which is why §5 splits the run into
`fast` and `full`.

---

## 4. What we are deliberately NOT doing yet

Recorded so it is a decision rather than an oversight.

### Layer 4 — JavaScript tests (deferred)

`tests/src/FunctionalJavascript/`, real Chrome over WebDriver, **1–3 minutes each and genuinely
flaky.** Needs a Selenium or chromedriver add-on in DDEV.

**There is real coverage missing here, and we are choosing to miss it for now:**

- The **roster builder drag-and-drop**, including the **Shift+drag merge that fails on synthetic
  `captain_<id>` group ids (T11)**.
- The **schedule builder grid** and the **tournament schedule grid**.
- The **jersey report CSV download button** — the exact bug fixed in `28a40d1`, where the handler
  scoped to a Claro-only selector and silently did nothing for board members in the front-end theme.
  **A JS test is the only layer that would have caught it.**
- The Authorize.net checkout pane.

**Revisit when** Layers 0–3 are green and stable for a few weeks, *or* the moment a second
drag-and-drop bug ships to PROD — whichever comes first. **Budget under ten tests when we do**, and
only for behaviour with no non-JS equivalent. Every JS test added is a test that will eventually fail
for a reason unrelated to the code.

### Continuous integration (deferred)

No `.github/` exists. Tests run locally, by hand, on purpose:

- The suite has to be worth trusting before it becomes a merge gate. A red CI that everyone learns to
  ignore is worse than no CI.
- It is two people who both run DDEV locally.
- CI needs its own database service and config to maintain, and this project already carries more
  environment-specific setup than it wants.

**Revisit when** the full suite is green, takes under about ten minutes, and has gone a month without
a false failure. At that point the same `scripts/test.sh` is most of a GitHub Actions job, so nothing
here is wasted. **Structure the scripts for it now, adopt it later.**

### Mapping tests to register IDs (deferred, on purpose)

The alternative considered was naming every test after its `OUTSTANDING_ISSUES.md` id
(`testB7PartialRefundStaysRefundable`). **We chose feature-area organisation instead**, because
foundations come first and most of the early tests protect happy paths that have no register id at
all. Register ids appear in a **docblock `@see`**, not in the method name — ids get merged, renumbered
and closed, and a test method renamed to follow them is a test nobody can find.

Revisit once the register-driven regression work starts in Phase 6.

---

## 5. The harness — exactly what gets built

### Files added in Phase 0

```
composer.json                  # + require-dev: drupal/core-dev, phpstan, phpcs
phpunit.xml                    # project root; DDEV database + base URL
phpstan.neon                   # level 1, with a baseline
phpstan-baseline.neon          # existing findings, silenced
phpcs.xml                      # Drupal + DrupalPractice, scoped to custom code
scripts/test.sh                # the one command
tests/results/                 # git-ignored; where results land
web/modules/custom/ccsoccer/tests/
├── README.md                  # how to run and read the suite
└── src/
    ├── Traits/
    │   └── CcsoccerTestTrait.php
    ├── Unit/
    ├── Kernel/
    └── Functional/
```

**One `.gitignore` addition: `/tests/results/`.** Results are per-run and local.
**The PHPStan baseline is committed, not ignored** — if it is not shared, PHPStan reports different
findings for each of us and the whole mechanism stops meaning anything.

### The one command

```bash
ddev exec ./scripts/test.sh          # everything
ddev exec ./scripts/test.sh fast     # static + unit + kernel — under a minute
ddev exec ./scripts/test.sh unit
ddev exec ./scripts/test.sh kernel
ddev exec ./scripts/test.sh functional
ddev exec ./scripts/test.sh static
ddev exec ./scripts/test.sh --filter=Refund    # one test or one pattern
```

**`fast` is the one to run after each set of changes.** Static analysis plus unit plus kernel, in
under a minute, and it covers the majority of the bug shapes in §1. `full` before a commit that
touches a flow, and always before a deploy.

**Add a shell alias** to match the existing `ccsDeploy` / `ccsProdCr` family:

```bash
alias ccsTest='ddev exec ./scripts/test.sh'
alias ccsTestFast='ddev exec ./scripts/test.sh fast'
```

### What the script does

1. Runs each requested layer in order, **cheapest first**, so a PHPStan error stops you before you
   wait 40 minutes for functional tests.
2. Prints human-readable progress to the terminal as it goes.
3. Writes three files into `tests/results/`:
   - **`latest.txt`** — the full `--testdox` run, every test named in plain English, plus the failure
     output and stack traces in full.
   - **`junit.xml`** — machine-readable, for later CI and for precise failure counts.
   - **`summary.txt`** — five lines: date, layers run, counts, duration, exit code.
4. **Exits non-zero if anything failed**, so it composes into other commands.

**Never `set -e` on the layer runs** — the script must run *every* requested layer and report all of
them, not stop at the first red one. Collect exit codes, report at the end.

### Why results go into a file — the Claude loop

**Claude cannot run DDEV.** Docker runs on the Mac host; the shell Claude gets is a sandboxed Linux VM
with only the repo folder mounted, no Docker socket and no network. This is a hard constraint, not a
configuration problem, so the plan is built around it rather than pretending otherwise.

The loop:

1. **Claude writes tests** directly into `tests/src/…` in the repo.
2. **Andrew runs `ccsTestFast`** (or the full suite).
3. **Results land in `tests/results/latest.txt`.**
4. **Claude reads that file** and sees every failure with its full stack trace — no copy-paste, no
   truncation.
5. Claude fixes the test or the code, and it goes round again.

**Two things Claude can still do unaided**, worth knowing:

- **`php -l` on any changed file**, by copying it to the cloud container. Catches a syntax error in
  seconds without a round-trip.
- **Read the test source and the results together**, which is most of diagnosing a failure.

**One rule this loop needs:** `tests/results/latest.txt` is git-ignored, so **say when you last ran
it.** A stale results file that Claude reads as current is the one way this loop lies. The summary
file carries a timestamp for exactly this reason — check it before trusting it.

### Configuration details that will bite

Recorded now so Phase 0 does not rediscover them:

- **`phpunit.xml` at the project root**, not `web/core/phpunit.xml.dist`. Core's file gets overwritten
  by `composer install` scaffolding.
- **`SIMPLETEST_DB`** for DDEV is `mysql://db:db@db/db`. The credentials are in
  `web/sites/default/settings.ddev.php` and they are all literally `db`.
- **`SIMPLETEST_BASE_URL`** is `http://web` from inside the DDEV web container — the container's
  internal hostname, **not** `https://ccsoccer-d11.ddev.site`. Getting this wrong is the classic
  first-day failure and produces a misleading connection error.
- **`BROWSERTEST_OUTPUT_DIRECTORY`** should point at `/tmp`, not into the repo.
- **Functional tests need `web/sites` writable by the web user** — each test installs a site under
  `web/sites/simpletest/`. Verify in Phase 0; DDEV usually gets this right, and the failure message
  when it does not is unhelpful.
- **`drupal/core-dev` pulls a large dependency tree.** It is `require-dev`, so it never ships — but
  confirm `composer install --no-dev` still works on TEST and PROD before anyone deploys again. The
  `ccsDeploy` aliases run `composer install`; **check whether they pass `--no-dev`**, and if they do
  not, that is a Phase 0 finding worth acting on.
- **The module declares nine contrib modules from six projects** — `commerce`,
  `commerce_checkout`, `commerce_payment`, `commerce_product`, `commerce_authnet`, `masquerade`,
  `admin_toolbar`, `date_ical`, `entity_print_views` — plus core's `taxonomy`, `telephone` and
  `text`. Kernel tests must list every one *and its own dependencies* in `$modules`, then call
  `installEntitySchema()` for each of the twelve entity types they touch. Commerce's tree is the
  awkward part. **The fixture trait exists to hide all of this**; get it right once.

---

## 6. The fixture trait — the real deliverable of the early sessions

Everything after Phase 1 is fast to write *only* if this exists and is good. It is worth its own
session.

### On the timestamp instinct

The original plan was to create seasons, tournaments and players stamped with a timestamp so each run
gets fresh data. **That is exactly right for the smoke layer** and unnecessary for Layers 2 and 3,
because **each test class installs its own database and destroys it afterwards.** There is nothing to
collide with. So:

- **Kernel and Functional fixtures are deterministic and plainly named** — `Mens 2026 Fall`,
  `player1@example.com`. Readable failures, reproducible runs, no timestamp noise in assertions.
- **Timestamps belong only to anything run against the real local DDEV database**, where collisions
  are real. See §8.

### `CcsoccerTestTrait`

One trait, used by Kernel and Functional alike:

```php
createLeague(array $values = []): League
createSeason(array $values = []): Season            // sensible defaults; override any field
createTournament(array $values = []): Tournament
createPlayer(array $values = []): User              // profile complete by default — photo, dob, gender
createIncompletePlayer(array $values = []): User    // for the photo and age gates
createTeam(Season|Tournament $parent, array $values = []): Team
registerPlayer(User $p, Season|Tournament $s, array $values = []): Registration
createGroup(User $manager, array $members = []): string     // returns the group_id UUID
inviteToGroup(User $inviter, string $group_id, string $email): Invitation
createCaptain(User $p, Tournament $t): Registration
grantCredits(User $p, string $amount): Credits
createOverride(User $p, Season $s, int $expires): Override
addToWaitlist(User $p, Season $s): Waitlist
assertNoSoloGroups(): void                          // the D-8 invariant, one call
lastMail(): array                                   // the most recent captured message
extractInvitationLink(array $mail): string          // token URL out of a mail body
```

**Three rules for the trait:**

1. **Defaults must produce a valid, open, registerable season and a complete player**, so a test that
   cares about one field overrides one field. If every test has to set eleven fields, the trait has
   failed.
2. **Season and Tournament helpers take the same shape wherever possible**, so twin tests can be
   written once and run against both from a data provider. **This is the single most valuable property
   of the trait** — it is how drifted twins get caught by construction rather than by remembering.
3. **No test touches `drush ccsoccer:seed`.** That command builds data in a real site for humans to
   click. Test fixtures are code. Keeping the two separate stops a seeder change from breaking the
   suite.

---

## 7. The phased plan

Roughly one to two sessions per phase, and each phase leaves the suite **green** — a phase that ends
red is not finished. Order follows §1's bug shapes, not the directory tree.

### Phase 0 — Harness — split into 0a and 0b

**Phase 0 is split across two people, because it contains exactly one producer action and a lot of
authoring.** In the language of `UPDATE_WORKFLOW.md`: `composer require --dev` is a **producer**
command that rewrites `composer.lock`, and everything else in Phase 0 is new files that exist on no
branch and can conflict with nothing.

Splitting it costs one extra PR and buys three things: the production `--no-dev` question gets
answered by the person with server access, the lockfile change gets reviewed on its own instead of
buried under thirty new files, and **Andrew gets to rehearse the consumer flow against a lockfile he
did not produce** — which is the only way to find out whether it is clean.

**Prerequisite for both halves: `main` must be current.** As of Sep 7 the refund work sits on a local
`fix/partial_refunds` branch, five commits ahead of `main` and **never pushed to origin**, carrying
hook `9075`. **Land that first.** Starting 0a on top of it would put a lockfile change and a
column-adding schema hook in the same lineage, and would force a rebase across `composer.lock` — the
one file `UPDATE_WORKFLOW.md` says never to hand-merge.

---

#### Phase 0a — Caleb: dependencies and the production check

**One small PR. `composer.json` and `composer.lock` only — no test files, no `phpunit.xml`, no
`scripts/`.** Those are 0b and they are not blocked on this.

##### Step 1 — Answer the `--no-dev` question BEFORE changing anything

**This is the whole reason 0a is a separate PR, and it is the one step that must not be skipped.**

Two project documents disagree about what the deploy aliases do:

| Source | Claim |
|---|---|
| `UPDATE_WORKFLOW.md` → *Production Deploy Reference* | `ccsDeploy` is "cd to site dir + git pull". Run `composer install --no-dev --optimize-autoloader` separately afterwards |
| `SESSION_HANDOFF.md` → Aug 23, first-hand during the PR #136 deploy | *"`ccsDeploy` / `ccsProdDeploy` **already run `composer install`**… The aliases are more than a `git pull`."* |

**Neither states whether the alias's `composer install` passes `--no-dev`.** That has never mattered,
because `require-dev` has only ever been `drupal/devel` — one small module. After this PR it is
PHPUnit, Behat, Mink, a WebDriver client, PHPStan, PHPCS and their dependency trees.

On **TEST** and **PROD**, read the actual definition:

```bash
type ccsProdDeploy 2>/dev/null || grep -n "ccsProdDeploy\|ccsDeploy\|ccsUpdb\|ccsCim\|ccsCr" ~/.bashrc
```

*(`SESSION_HANDOFF.md` establishes the aliases live in `~/.bashrc` — that is why cron cannot see them.)*

- **If the alias already passes `--no-dev`:** record the exact alias text in `UPDATE_WORKFLOW.md`, so
  the contradiction above stops existing. Proceed.
- **If it does not:** **fix the alias before merging this PR.** Do not rely on remembering to run
  `composer install --no-dev` by hand afterwards — the alias runs first and will already have
  installed the dev tree.

**Why this matters more than "extra megabytes on the server":**
`vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php` (CVE-2017-9841, remote code execution) is one of
the most heavily scanned paths on the public internet and is probed by bots continuously. Drupal's
vendor `.htaccess` mitigates it. **The file still has no business being on a production web server**,
and `--no-dev` is the thing that keeps it off.

##### Step 2 — Branch and require

Coordinate first per `UPDATE_WORKFLOW.md` §1 — tell Andrew you are taking it, so nobody else runs a
producer command in the same window.

```bash
git checkout main && git pull
git checkout -b chore/phase0a_test_dependencies

ddev composer require --dev "drupal/core-dev:^11.2" --with-all-dependencies
```

Notes on that command:

- **`^11.2` matches the existing `drupal/core-recommended` constraint** in `composer.json`. Do not
  leave it unconstrained — an unpinned `core-dev` can try to drag core somewhere else.
- **`--with-all-dependencies` is expected to be necessary**, not optional. `core-recommended` pins
  exact versions of Symfony and friends; `core-dev` frequently wants different ones, and without the
  flag the resolve just fails.
- **`phpstan/extension-installer` and `dealerdirect/phpcodesniffer-composer-installer` are already in
  the `allow-plugins` list** in `composer.json`, so neither should prompt. If either does, something
  unexpected is happening — stop and look.
- **After it resolves, check what actually landed** rather than assuming:
  `ddev composer show | grep -iE "phpunit|phpstan|coder|codesniffer|mink|behat"`.
  If PHPStan or PHPCS did **not** come along with `core-dev`, add them explicitly:
  `ddev composer require --dev phpstan/phpstan mglaman/phpstan-drupal phpstan/phpstan-deprecation-rules drupal/coder`.

##### Step 3 — Review the lockfile diff for production packages ⚠

**The step that gets skipped, and the one with real risk.**

`composer require --dev` does **not** resolve dev and production separately. It resolves everything
into one lockfile, so **a dev-only requirement can bump the locked version of a package that ships to
production.**

```bash
git diff composer.lock | grep -E '^[-+]\s+"(name|version)"' | head -100
```

Better, if you want it precise — compare the non-dev install before and after:

```bash
git stash && ddev composer install --no-dev --dry-run > /tmp/before.txt
git stash pop && ddev composer install --no-dev --dry-run > /tmp/after.txt
diff /tmp/before.txt /tmp/after.txt
```

**Expected result: no difference.** If a production package moved, that is a real production change
riding along inside a PR whose description says "add test tooling". Either back it out, or split it
into its own commit and say so in the PR — `UPDATE_WORKFLOW.md`'s last rule, applied.

##### Step 4 — Confirm nothing else changed

```bash
ddev drush updb -y      # expect: "No pending updates."
ddev drush cim -y       # expect: "There are no changes to import."
ddev drush cr
ddev drush status       # site still bootstraps
```

`core-dev` ships no update hooks and no config. **If either of the first two reports work to do,
stop** — something came in that this PR did not intend.

##### Step 5 — Commit and PR

```bash
git add composer.json composer.lock
git commit -m "Add test dependencies (drupal/core-dev) for the Phase 0 harness"
git push -u origin chore/phase0a_test_dependencies
```

**Only those two files.** If `git status` shows anything else staged, unstage it.

PR description should state: the `--no-dev` finding from Step 1 (and the alias fix, if one was
needed), and the Step 3 result — explicitly, "no production package versions changed" or the list of
what did.

##### Step 6 — Deploy, then prove `--no-dev` actually held

Deploy TEST → PROD with the normal alias chain. Then, **on each server**:

```bash
ls vendor/bin/phpunit        # MUST be: "No such file or directory"
ls -d vendor/phpunit         # MUST be: "No such file or directory"
ls -d vendor/drupal/devel    # also absent — it was always require-dev too
```

**Those three lines are the entire point of Phase 0a.** If `vendor/bin/phpunit` exists on PROD, the
alias is not passing `--no-dev`: remove the directory, fix the alias, redeploy, and re-check.

Then the standing checks from the Aug 23 entry, since a deploy is a deploy:

```bash
drush updatedb:status
drush config:status --format=json     # --format=json avoids the "Invalid option: bold" error on TEST and PROD
```

##### 0a definition of done

- [ ] Alias definitions read on TEST and PROD; `--no-dev` confirmed or fixed
- [ ] `UPDATE_WORKFLOW.md` updated so it and `SESSION_HANDOFF.md` agree on what `ccsDeploy` does
- [ ] No production package versions moved (or the ones that did are named in the PR)
- [ ] `updb` and `cim` both no-ops locally
- [ ] PR contains exactly two files
- [ ] Merged to `main` and deployed to TEST and PROD
- [ ] `vendor/bin/phpunit` verified **absent** on both servers
- [ ] Andrew told it has landed

---

#### Phase 0b — Andrew + Claude: the harness (1 session, after 0a lands)

Starts with the consumer flow from `UPDATE_WORKFLOW.md`, which is also the rehearsal:

```bash
git checkout main && git pull
ddev composer install        # the only step doing real work for 0a
ddev drush updb -y           # no-op
ddev drush cim -y            # no-op
ddev drush cr
ls vendor/bin/phpunit        # MUST exist here — the mirror image of the server check
```

**That asymmetry is the correct end state:** `phpunit` present locally, absent on the servers. Check
both halves; one without the other proves nothing.

Then the authoring, none of which touches `composer.lock`:

`phpunit.xml`, `phpstan.neon` + `phpstan-baseline.neon`, `phpcs.xml`, `scripts/test.sh`,
`tests/README.md`, the directory structure, the `/tests/results/` gitignore line, and **three trivial
tests — one unit, one kernel, one functional — that assert something uninteresting and pass.**

**The goal of 0b is a working loop, not coverage.** Do not write a real test until `ccsTestFast` runs
clean and Claude can read `tests/results/latest.txt`. Expect the friction to be in §5's configuration
list — `SIMPLETEST_BASE_URL` is `http://web`, not the `.ddev.site` URL, and the Kernel `$modules`
array has to name all nine contrib modules plus their own dependencies.

### Phase 1 — Fixtures and entity foundations (1–2 sessions)

`CcsoccerTestTrait`, then a Kernel test per entity: create, save, load, required fields, computed
methods, `getSpotsRemaining()`, `isFull()`, `getRosterStats()`, cascade behaviour on delete
(**B-9/CF7 — expect this one to fail; that is the point**).

Plus the Layer 1 unit tests: `LiveRegistrationTrait` first — it is the smallest test in the plan and
pins the definition four different call sites currently disagree about.

### Phase 2 — Registration and order flow (2 sessions)

Kernel on `OrderCompleteSubscriber` and `CartEventSubscriber`, dispatched directly. Then Functional
through real checkout. Season and tournament, from one shared test body. Jersey or not, credits or
not, group accepted or not. The captain deposit path.

**Includes B-7's named first test** — the partial-refund assertion — and a data-driven test that both
cancel forms behave identically, which is the Sep 7 shape caught by construction.

### Phase 3 — Invitations and groups (2 sessions)

The email-link lifecycle in full: send, extract, click, accept at first page, accept later, decline,
already-used token, already-registered invitee, group full mid-decision.

Then the invariants, each across every call site:
**the D-8 solo-group rule (thirteen sites — expect to find the third missed one)**, `groups_locked`
(four accept paths), capacity at accept (four paths), and whether the five auto-decline sites notify
anybody (**Q-B2 — they do not; the test documents it before it is fixed**).

### Phase 4 — Admin builders (2 sessions)

Create season, create tournament, team builder, roster builder, schedule builder, publish schedules.
Season open and close dates, and the registration-timing edge cases: on time, after close, admin
override. Everything reachable without JavaScript; the drag-and-drop half waits for Layer 4.

### Phase 5 — Capacity, waitlist, overrides (2 sessions)

The P6 cluster. All three season checkpoints must agree, including on `reserved_spots`. Draft-cart
holds. The waitlist offer, expiry and redemption. Override creation, expiry, the daily reminder, and
revocation on waitlist cancel.

**Several of these will be red on arrival, and should be** — they are describing behaviour the
register already says is wrong. **Write the test to assert the agreed correct behaviour, mark it
skipped with the register id, and unskip it in the session that fixes the code.** A skipped test with
an id is a to-do that cannot be lost.

### Phase 6 — Notifications, credits, cancellation and refunds (2 sessions)

`NotificationService` preference handling (**S1** — transactional sends must bypass the preference),
the false-positive receipt (**it gates on cart contents, not on whether a registration was created**),
rainout and cancellation notices, the credit ledger end to end, cancellation with refund versus
credit, and the two refund ledgers that can diverge.

**This is the phase where the register starts driving**, and where per-item `@see` docblocks become
the norm.

### Phase 7 — Permission sweep (1 session)

One table-driven Functional test: every route, every role, expected status. **176 routes, one file**,
and it closes an entire class of security finding — including `invite()`, whose manager-only rule
currently lives in Twig and is therefore not enforced at all against a direct POST.

**Total: 12–14 sessions**, plus Caleb's 0a, which is under an hour and mostly reading an alias.
Phases 0 through 3 are the ones that pay for themselves; if the effort has to stop somewhere, stop
after Phase 3, not before it.

---

## 8. The smoke script — a separate thing, on purpose

Everything above runs in an isolated, freshly-installed Drupal. That isolation is what makes the suite
fast and reliable, and it is also its blind spot: **it proves the code is right, not that your local
site is.** Config, permissions, views, mail settings and un-run update hooks are all invisible to it.

So one more artefact, and it is **not PHPUnit**:

```bash
ddev drush ccsoccer:smoke        # ~60 seconds, against the real local database
```

A drush command that, against your actual seeded site:

- Creates a timestamped season, tournament and a handful of players — **this is where the timestamp
  instinct belongs**, because here collisions are real.
- Walks one registration, one invitation and one order through to completion.
- Loads every admin page and asserts 200. **This is the check that catches the Sep 7 shape** — code
  deployed without `updb`, where the Captain Deposits page throws and writes to the new field are
  silently dropped.
- Runs `updatedb:status` and `config:status` and reports anything pending.
- **Cleans up after itself**, with `--keep` to leave the data for clicking through by hand.

**Build it in Phase 1**, small, and grow it. It is the thing to run after a `drush updb` or a config
import, where the PHPUnit suite has nothing to say.

---

## 9. Conventions

Short list, meant to be followed rather than admired.

1. **Test names are sentences.** `--testdox` prints them, and that output is what gets read on a
   failure. `testPartialRefundLeavesPaymentRefundable`, not `testRefund2`.
2. **One behaviour per test.** A test asserting six things reports the first failure and hides five.
3. **Season and tournament share a body wherever the code does.** Data provider, two rows. **This is
   the convention that directly attacks the most expensive bug shape in the project** — if a twin pair
   is being tested twice by copy-paste, that is the bug being reproduced in the test suite.
4. **Register ids go in a `@see` docblock, never in the method name.** Ids move; test names should not.
5. **A test for known-broken behaviour is written to assert the *correct* behaviour and marked
   skipped**, with the register id in the skip message. Never write a test that asserts a bug is
   present — that test passes on the day someone fixes it, and then argues against the fix.
6. **No `sleep()`.** If timing matters, inject `datetime.time`.
7. **Kernel over Functional whenever both would work.** Ten times faster, and the failure points at a
   line instead of a page.
8. **New bug → failing test first, then the fix.** The one discipline that stops the same shape
   recurring a seventh time.
9. **Fixtures live in the trait, not in the test.** A test that spends thirty lines building a season
   is a trait gap.
10. **Green before commit.** `ccsTestFast` on every change; the full suite before anything that
    touches a flow, and always before a deploy.

---

## 10. How to read the results

### Passing

```
Live Registration (Drupal\Tests\ccsoccer\Unit\LiveRegistrationTest)
 ✔ Empty candidate set returns null
 ✔ All cancelled candidates return null
 ✔ Newest live registration wins over older live one
 ✔ Waitlist status counts as live
 ✔ Expired status counts as dead

OK (5 tests, 11 assertions)
```

Exit code **0**. `summary.txt` reads `PASS`.

### Failing

```
 ✘ Partial refund leaves payment refundable
   Failed asserting that false is true.
   /var/www/html/web/modules/custom/ccsoccer/tests/src/Kernel/RefundTest.php:84

FAILURES! Tests: 5, Assertions: 9, Failures: 1.
```

Exit code **1**. `summary.txt` reads `FAIL 1`. The file names the test, the assertion, the file and
the line, and `latest.txt` holds the full stack trace.

### The three things a failure can mean

Worth naming, because the reflex to "fix the test" is strong and usually wrong:

1. **The code is broken.** The common case, and the whole point. Fix the code.
2. **The test is wrong** — bad fixture, wrong assumption. Fix the test, and note what the wrong
   assumption was; it is often a documentation bug too.
3. **The behaviour changed on purpose.** Update the test *and* say so in the commit message. **This is
   the one that matters** — a test quietly changed to match new behaviour is how a suite stops being
   evidence of anything.

### Asking Claude to run them

> *"Run the unit tests and review the results."*

What actually happens: Claude asks you to run `ccsTest` (or `ccsTestFast`), because Docker is out of
reach from its shell. You run it, then say so. Claude reads `tests/results/latest.txt`, reports what
failed, diagnoses each failure into one of the three categories above, and proposes fixes. **Check
`summary.txt`'s timestamp** if there is any doubt about whether the file is from this run.

---

## 11. Open questions

- ~~**Does `ccsDeploy` / `ccsProdDeploy` run `composer install --no-dev`?**~~ **Now owned by
  Phase 0a Step 1**, which must answer it before its own PR merges. Also note the two documents
  disagree about whether the alias runs `composer install` at all — 0a fixes that too.
- **How long is the full suite allowed to take before we split it?** Guess: ten minutes is fine,
  thirty is not. Measure at the end of Phase 3 and decide then.
- **Does Decision 2 get settled before or after the capacity tests?** Writing them first would
  *document* the four conventions precisely, which might be the most useful input to the decision.
  Andrew and Caleb's call.
- **Should the smoke script run from cron on LOCAL?** Probably not — it writes data. Noted and parked.

---

## Related

| Document | What it gives this plan |
|---|---|
| `OUTSTANDING_ISSUES.md` §P10 **B-7** | The register item this plan implements; names the first test |
| `UPDATE_WORKFLOW.md` | Producer vs consumer flow, and the alias contradiction Phase 0a Step 1 resolves |
| `SESSION_HANDOFF.md` Sep 7 (second session) | The same Phase 0a brief, written for Caleb |
| `OUTSTANDING_ISSUES.md` §P5, §P6 | The invitation, order and capacity edge cases Phases 2, 3 and 5 assert |
| `DECISION_2_ANALYSIS.md` | The four definitions of "live"; input to the Layer 1 trait test |
| `ARCHITECTURE_DECISIONS.md` D-4, D-8, D-13 | The invariants Phase 3 tests |
| `archive/ORDER_FLOW_EDGE_CASES.md` | Enumerated order-flow scenarios for Phase 2 |
| `INVITATION_FLOW_ANALYSIS.md` | Enumerated invitation scenarios for Phase 3 |
| `archive/GROUP_ID_AT_ACCEPT_BRIEF.md` §5.7 | The four accept paths and which guards each enforces |
| `SESSION_HANDOFF.md` Sep 7 entry | Why the Manual gateway makes the refund tests possible locally |

---

**Next: land the refund branch, then Phase 0a (Caleb), then Phase 0b (Andrew + Claude).**
Nothing in Phase 0 writes a real test — it builds the loop and proves it works. Do not skip ahead to
Phase 1 in the same session; a half-built harness that nobody trusts is how this stalls a second
time.
