Files
Stirling-PDF/testing/compose/payg/saas-seed.sql
T
ConnorYohandGitHub ff96a80947 PAYG B-3 / S-3: cucumber suite for shadow-mode flows + CI workflow (#6522)
## What this PR is

End-to-end cucumber coverage for the PAYG shadow charging engine (the
filter + interceptor stack from #6519), wired into CI via a new
`docker-compose-tests-saas.yml` workflow that runs only on PAYG-touching
PRs.

Stacked on #6519.

## Automated scenarios (run by `docker-compose-tests-saas.yml`)

See
[`testing/cucumber/features/payg/shadow_charges.feature`](../tree/payg-s3-cucumber/testing/cucumber/features/payg/shadow_charges.feature):

| Scenario | Validates |
|---|---|
| First tool call writes a CHARGED row | Filter + interceptor fire
end-to-end |
| Lineage join — second call on output | `JobService.joinOrOpen`
matching; no new shadow row |
| 4xx leaves the row CHARGED | "Customer paid for the attempt" semantics
|
| ZIP-returning tool records per-PDF OUTPUT | `PaygOutputExtractor`
unpacks + records signatures |
| Multi-file input writes a single shadow row | Multi-input group sizing
|
| `X-Stirling-Automation` sets PIPELINE source | Header → `JobSource`
detection |

All 6 run locally via `./testing/test-payg.sh` and will run on CI for
any PR that touches `app/saas/**`, the PAYG cucumber features, the saas
compose stack, or the workflow itself.

## Manual-only scenarios — documented in design doc, not in this suite

Two parts of the shadow engine are deliberately not automated; the
engine paths are unit-tested in
`PaygChargeInterceptorTest.afterCompletion_5xx_opened_*`, and the manual
procedures (which require a temporary throw endpoint or a container
restart with a flag flipped) live in [`notes/PAYG_DESIGN.md` §7.5.2
"PAYG cucumber: manual-only
scenarios"](../tree/payg-s3-cucumber/notes/PAYG_DESIGN.md).

- **5xx first-step failure → REFUNDED + CLOSED.** No reliably-5xx-ing
endpoint exists; manual procedure adds a throw endpoint, runs, asserts,
removes.
- **Kill-switch (`PAYG_FILTER_ENABLED=false`).** Needs a container
restart mid-suite; manual procedure tears down, flips env, brings up,
asserts zero shadow rows.

If either gets a hot-reload path (test-only throw endpoint shipped
behind a profile gate, or admin endpoint for the kill switch), automate
it in a follow-up and drop the manual procedure.

## CI workflow

`.github/workflows/docker-compose-tests-saas.yml` (new) —
self-contained, not wired into `build.yml`'s `files-changed` matrix so
the saas-cucumber job fails and succeeds independently. Triggers only on
PAYG-relevant paths. No JaCoCo coverage in v1 (saas compose doesn't have
the coverage override; can add later).

## Test infrastructure (recap)

- **`testing/compose/docker-compose-saas.yml`** — Stirling-PDF backend
with `STIRLING_FLAVOR=saas` + Postgres holding the `stirling_pdf`
schema. Supabase JWT auto-config disabled; API-key auth via
`SECURITY_CUSTOMGLOBALAPIKEY` is the live path the cucumber tests
exercise.
- **`testing/compose/payg/saas-init.sql`** + **`saas-seed.sql`** —
schema bootstrap + idempotent seed (team / user / wallet_policy).
- **`testing/cucumber/features/payg/shadow_charges.feature`** — the 6
scenarios above.
- **`testing/cucumber/features/steps/payg_step_definitions.py`** — step
defs using `requests` (HTTP) + `psycopg` (direct DB inspection). Direct
DB reads are deliberate — we want to see the filter's side effects, not
relay them through another API layer.
- **`testing/test-payg.sh`** — companion runner to `testing/test.sh`.
Brings up the saas compose, waits for health, seeds, runs behave, tears
down.
- **`behave.ini`** excludes `features/payg` from the default behave run
(the saas-cucumber CI job invokes it explicitly).

## Why a separate harness from `testing/test.sh`

The existing `test.sh` covers the proprietary-flavour stack (no PAYG
tables, no saas profile). Coupling two CI matrices that fail and succeed
independently into one script is asking for trouble. Keep the
saas-cucumber job focused on its own concerns; once the harness is
mature, the wider team can decide whether to merge them.

## Tracked in

`notes/PAYG_DESIGN.md` §7.5 (PR-S3) + §7.5.2 (manual scenarios).
2026-06-09 14:47:40 +00:00

119 lines
5.4 KiB
SQL

-- Seed test team / payg policy / wallet_policy in PAYG_SHADOW mode for the
-- cucumber harness. Piped through psql AFTER Hibernate has built the schema
-- on backend startup (compose disables Flyway — see docker-compose-saas.yml
-- for the rationale: saas Flyway migrations assume `users` + `teams` exist
-- because Supabase normally provisions them).
--
-- Idempotent — guarded against duplicate keys so re-running on the same
-- container is safe between scenarios.
-- ---------------------------------------------------------------------------
-- 0. Default pricing policy + per-source step limits (V12 in production).
-- Required because PricingPolicyService.getEffectivePolicy() throws if
-- no row has is_default = TRUE. Explicit timestamps because Hibernate's
-- @CreationTimestamp is application-side; direct INSERTs bypass it.
-- ---------------------------------------------------------------------------
INSERT INTO stirling_pdf.pricing_policy (
version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
min_charge_units, file_unit_cap, is_default, notes, created_by,
created_at
)
SELECT
'v1-cucumber', CURRENT_TIMESTAMP, 25, 5242880,
1, 1000, TRUE,
'Cucumber test default policy', 'system',
CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM stirling_pdf.pricing_policy WHERE is_default = TRUE
);
INSERT INTO stirling_pdf.pricing_policy_step_limit (policy_id, job_source, step_limit)
SELECT p.policy_id, src.job_source, src.step_limit
FROM stirling_pdf.pricing_policy p
CROSS JOIN (
VALUES
('WEB', 10),
('API', 10),
('PIPELINE', 20),
('DESKTOP_APP', 10)
) AS src(job_source, step_limit)
WHERE p.is_default = TRUE
AND NOT EXISTS (
SELECT 1 FROM stirling_pdf.pricing_policy_step_limit s
WHERE s.policy_id = p.policy_id AND s.job_source = src.job_source
);
-- ---------------------------------------------------------------------------
-- 1. Test team. The Stirling-PDF backend auto-creates `Default` and
-- `Internal` teams at boot; we add a third one specifically for the
-- PAYG scenarios so we can isolate state and assert per-team.
-- ---------------------------------------------------------------------------
INSERT INTO stirling_pdf.teams (name)
SELECT 'payg-cucumber-team'
WHERE NOT EXISTS (
SELECT 1 FROM stirling_pdf.teams WHERE name = 'payg-cucumber-team'
);
-- ---------------------------------------------------------------------------
-- 2. payg_team_extensions sidecar — uses the default pricing policy.
-- ---------------------------------------------------------------------------
INSERT INTO stirling_pdf.payg_team_extensions (team_id, pricing_policy_id, stripe_customer_id)
SELECT t.team_id, NULL, NULL
FROM stirling_pdf.teams t
WHERE t.name = 'payg-cucumber-team'
AND NOT EXISTS (
SELECT 1 FROM stirling_pdf.payg_team_extensions ext WHERE ext.team_id = t.team_id
);
-- ---------------------------------------------------------------------------
-- 3. Bind the auto-created CUSTOM_API_USER to our cucumber team. The user
-- is created by the backend's SECURITY_CUSTOMGLOBALAPIKEY handling — we
-- don't seed our own user (would collide on the unique api_key).
-- Update their primary `team_id` so JobChargeService picks up
-- payg-cucumber-team as the owner team on requests.
-- ---------------------------------------------------------------------------
UPDATE stirling_pdf.users u
SET team_id = (SELECT team_id FROM stirling_pdf.teams WHERE name = 'payg-cucumber-team')
WHERE u.username = 'CUSTOM_API_USER';
-- ---------------------------------------------------------------------------
-- 4. Team membership row so /teams/* admin paths recognise the user.
-- All timestamp columns set explicitly because Hibernate-generated DDL
-- doesn't carry the Flyway-migration DEFAULT CURRENT_TIMESTAMP.
-- ---------------------------------------------------------------------------
INSERT INTO stirling_pdf.team_memberships (
team_id, user_id, role, invited_at, created_at, updated_at
)
SELECT t.team_id, u.user_id, 'LEADER',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM stirling_pdf.teams t, stirling_pdf.users u
WHERE t.name = 'payg-cucumber-team'
AND u.username = 'CUSTOM_API_USER'
AND NOT EXISTS (
SELECT 1 FROM stirling_pdf.team_memberships m
WHERE m.team_id = t.team_id AND m.user_id = u.user_id
);
-- ---------------------------------------------------------------------------
-- 5. wallet_policy in PAYG_SHADOW mode.
-- auto_group_strategy is the dead column tracked for drop in PR-R11
-- (PAYG_DESIGN.md §7.7) — until that ships, the NOT NULL constraint
-- means we have to populate it. 'AUTO' is the prod default.
-- ---------------------------------------------------------------------------
INSERT INTO stirling_pdf.wallet_policy (
team_id, engine, cap_period, warn_at_pct, degrade_at_pct,
degraded_feature_set, auto_group_strategy, notification_emails,
updated_at
)
SELECT t.team_id, 'PAYG_SHADOW', 'CALENDAR_MONTH', 80, 100,
'MINIMAL', 'AUTO', '[]'::jsonb, CURRENT_TIMESTAMP
FROM stirling_pdf.teams t
WHERE t.name = 'payg-cucumber-team'
AND NOT EXISTS (
SELECT 1 FROM stirling_pdf.wallet_policy wp WHERE wp.team_id = t.team_id
);
UPDATE stirling_pdf.wallet_policy
SET engine = 'PAYG_SHADOW', updated_at = CURRENT_TIMESTAMP
WHERE team_id = (SELECT team_id FROM stirling_pdf.teams WHERE name = 'payg-cucumber-team');