mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +02:00
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).
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
services:
|
||||
# ---------------------------------------------------------------------
|
||||
# Postgres holding the stirling_pdf schema. Flyway migrates V1-V13 on
|
||||
# backend startup against this DB. Exposed on host port 5433 so the
|
||||
# cucumber harness can connect via psycopg from features/steps/payg_*.
|
||||
# ---------------------------------------------------------------------
|
||||
postgres-saas:
|
||||
image: postgres:17-alpine
|
||||
container_name: payg-cucumber-postgres
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
# Bootstrap stirling_pdf schema + seed test team / api key.
|
||||
# Runs once when the container is first created.
|
||||
- ./payg/saas-init.sql:/docker-entrypoint-initdb.d/00-init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||
interval: 3s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
networks:
|
||||
- payg-cucumber-network
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Stirling-PDF backend with STIRLING_FLAVOR=saas. Authenticates via
|
||||
# API key (SECURITY_CUSTOMGLOBALAPIKEY) — Supabase JWT enforcement is
|
||||
# disabled for the test profile via SAAS_DB_PROJECT_REF=disabled so the
|
||||
# OAuth resource-server filter no-ops. The PaygChargeInterceptor's
|
||||
# resolveUser() ApiKey-token path is what the cucumber tests exercise.
|
||||
# ---------------------------------------------------------------------
|
||||
stirling-pdf-saas:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/embedded/Dockerfile
|
||||
args:
|
||||
STIRLING_FLAVOR: saas
|
||||
container_name: payg-cucumber-stirling
|
||||
depends_on:
|
||||
postgres-saas:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP'"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
retries: 24
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
# Activate the saas profile + bring the saas subproject onto the classpath.
|
||||
# `payg-cucumber` registers the test-only throw-500 controller in
|
||||
# `app/saas/.../payg/test/PaygCucumberThrowController.java` — that bean is
|
||||
# @Profile("payg-cucumber") so it never registers in production.
|
||||
SPRING_PROFILES_ACTIVE: "saas,payg-cucumber"
|
||||
STIRLING_FLAVOR: "saas"
|
||||
ENABLE_SAAS: "true"
|
||||
DISABLE_ADDITIONAL_FEATURES: "false"
|
||||
|
||||
# Datasource — point Flyway + JPA at the test postgres.
|
||||
SAAS_DB_URL: "jdbc:postgresql://postgres-saas:5432/postgres"
|
||||
SAAS_DB_USERNAME: "postgres"
|
||||
SAAS_DB_PASSWORD: "postgres"
|
||||
SAAS_DB_PROJECT_REF: "disabled"
|
||||
|
||||
# The saas Flyway migrations assume `users` and `teams` already exist
|
||||
# (V2 ALTERs `users`; V5 references `teams(id)`) because in production
|
||||
# those tables are provisioned by Supabase before Stirling starts. On
|
||||
# a clean test postgres they don't exist, so we disable Flyway and let
|
||||
# Hibernate's `ddl-auto=create-drop` build the full schema from the
|
||||
# entity graph. The default-pricing-policy row that V12 seeds in
|
||||
# production is re-seeded by testing/compose/payg/saas-seed.sql.
|
||||
SPRING_FLYWAY_ENABLED: "false"
|
||||
SPRING_JPA_HIBERNATE_DDL_AUTO: "create-drop"
|
||||
|
||||
# Disable Supabase JWT enforcement for the test profile. The
|
||||
# PaygChargeInterceptor resolves via ApiKey-token authority — that
|
||||
# path is what we cover here.
|
||||
SPRING_AUTOCONFIGURE_EXCLUDE: "org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration"
|
||||
|
||||
# Test API key matching the user seeded by saas-init.sql.
|
||||
SECURITY_CUSTOMGLOBALAPIKEY: "payg-cucumber-key"
|
||||
SECURITY_ENABLELOGIN: "false"
|
||||
|
||||
# Conservative defaults. The cucumber runs send small fixtures so
|
||||
# the in-memory threshold never bites; the kill-switch is needed by
|
||||
# one scenario but is toggled via a separate compose override.
|
||||
PAYG_FILTER_ENABLED: "true"
|
||||
PAYG_FILTER_RESPONSE_IN_MEMORY_THRESHOLD_BYTES: "10485760"
|
||||
|
||||
SYSTEM_DEFAULTLOCALE: "en-US"
|
||||
SYSTEM_MAXFILESIZE: "100"
|
||||
networks:
|
||||
- payg-cucumber-network
|
||||
|
||||
networks:
|
||||
payg-cucumber-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Bootstrap minimal stirling_pdf state for PAYG cucumber tests.
|
||||
--
|
||||
-- Runs once when the postgres-saas container is first created (loaded via
|
||||
-- /docker-entrypoint-initdb.d on the official postgres image). Flyway then
|
||||
-- migrates V1-V13 on backend startup, which fills in the schema; this file
|
||||
-- only seeds the data rows the cucumber scenarios reference by name.
|
||||
--
|
||||
-- Idempotent — if the container is re-created, this whole file runs again
|
||||
-- but every INSERT is guarded against duplicates.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS stirling_pdf;
|
||||
|
||||
-- Note: the actual seed of teams / users / payg_team_extensions / wallet_policy
|
||||
-- happens AFTER Flyway migrations have applied. We can't insert here because
|
||||
-- the tables don't exist yet at container-init time. Instead we register a
|
||||
-- helper function the backend invokes once at startup (or the cucumber
|
||||
-- harness invokes via psql before running scenarios).
|
||||
--
|
||||
-- For "make a start", the simplest approach is to keep this file as the
|
||||
-- schema bootstrap, and put the seed inserts in a separate sidecar SQL that
|
||||
-- the cucumber test.sh harness pipes through psql AFTER waiting for backend
|
||||
-- health. See testing/compose/payg/saas-seed.sql below.
|
||||
|
||||
-- Reserved for future container-init-time schema bootstrap if we ever need it.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,118 @@
|
||||
-- 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');
|
||||
Reference in New Issue
Block a user