# CC Soccer — Automated Testing Strategy

**Written:** September 7, 2026 · Andrew + Claude
**Status:** 🔲 Agreed, not built. **As of Oct 22, 2026:** Phase 0a (Caleb) has not started — its
runbook is `PHASE_0A_CALEB.md`. Phase 0b is **authored but never run**, on Andrew's local branch
`chore/phase0b_harness` (`701cf07`, Sep 20). No test has executed yet. The original Sep 7 text is
archived at `archive/TESTING_STRATEGY_2026_09_07.md`.

---

## 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 ships only if a
  server runs `composer install` **without** `--no-dev`. Making sure the deploy aliases pass it, and
  proving `vendor/bin/phpunit` is absent on both servers afterwards, is Phase 0a — see
  `PHASE_0A_CALEB.md` Steps 1 and 6.
- **`drupal/core-dev` must move with core.** It pins its own tool versions, so it has to stay at the
  same version as `drupal/core`. The documented core-update command,
  `composer update "drupal/core-*" --with-all-dependencies`, already matches it; never update
  `drupal/core-recommended` alone.
- **Check the PHPUnit version on the first 0b run.** The harness was written against the PHPUnit
  10.5 schema with `@group` docblock annotations. If `core-dev` for core 11.4 brings a newer
  PHPUnit, expect to run `vendor/bin/phpunit --migrate-configuration` and to switch the smoke tests
  to `#[Group]` attributes.
- **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.** ✅ **Met.** The Sep 7 refund work
(`fix/partial_refunds`, hook `9075`) is merged to `main` as `f90f72d`.

---

#### 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. **The step-by-step runbook, with every
command, is `PHASE_0A_CALEB.md`.** It replaced the version of this section written on Sep 7 (in
`archive/TESTING_STRATEGY_2026_09_07.md`). In outline:

| Step | What | Where |
|---|---|---|
| 1 | Read the `ccsDeploy` / `ccsProdDeploy` aliases; make their composer step `--no-dev`; confirm Devel is not enabled | TEST and PROD |
| 2 | `ddev composer require --dev "drupal/core-dev:^11.2" --with-all-dependencies`; `core-dev` at the same version as core | Caleb's DDEV |
| 3 | Prove no production package moved — compare the lockfile's non-dev `packages` with `main` | Caleb's DDEV |
| 4 | `updb` and `cim` are both no-ops | Caleb's DDEV |
| 5 | PR with exactly two files | GitHub |
| 6 | Deploy; `vendor/bin/phpunit` and `vendor/drupal/devel` **absent** on both servers | TEST and PROD |

**Step 1 is the reason 0a is a separate PR, and it must not be skipped.** The alias runs before
anyone could remember to type `--no-dev` by hand, so the alias itself is what has to be right.
`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 internet; `--no-dev` keeps it off the servers.

**After 0a, the producer/consumer rules gain a dev half.** `composer.lock` holds both `packages` and
`packages-dev`, and `--no-dev` is chosen at install time: the servers skip the test tools, every
`ddev composer install` gets them. Andrew therefore never runs `composer require --dev` himself.
Both flows are in `PHASE_0A_CALEB.md` and `UPDATE_WORKFLOW.md`.

---

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

**Already authored, never run.** Every file listed below exists on Andrew's local branch
`chore/phase0b_harness` (`701cf07`, Sep 20; not pushed). It was checked on Oct 22 and merges onto
`main` with no conflicts. The session after 0a lands is: rebase that branch onto `main`, run the
consumer flow below, generate the real PHPStan baseline (`phpstan-baseline.neon` is an empty
placeholder), and fix whatever the first run turns up.

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`?**~~ **Owned by
  `PHASE_0A_CALEB.md` Step 1**, which must answer it before the 0a PR merges and record the real
  alias text in `UPDATE_WORKFLOW.md` → *Production Deploy Reference*.
- **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 |
| `PHASE_0A_CALEB.md` | The Phase 0a runbook for Caleb, plus the updater and consumer flows once `core-dev` is in the lockfile |
| `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: Phase 0a (Caleb, `PHASE_0A_CALEB.md`), then Phase 0b (Andrew + Claude, from `chore/phase0b_harness`).**
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.
