mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 18:44:05 +02:00
## 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).
474 lines
18 KiB
Python
474 lines
18 KiB
Python
"""
|
|
Step definitions for PAYG shadow-mode end-to-end tests.
|
|
|
|
Runs against the saas-profile stack defined in
|
|
testing/compose/docker-compose-saas.yml — the backend with STIRLING_FLAVOR=saas
|
|
plus a Postgres container holding the stirling_pdf schema.
|
|
|
|
The test harness talks to the backend over HTTP and inspects the resulting
|
|
rows in `payg_shadow_charge`, `processing_job`, `processing_job_step`, and
|
|
`job_artifact_hash` via a direct psycopg connection. Direct DB inspection is
|
|
deliberate — we want to verify the *side effects* of the filter, not relay
|
|
them through another API layer that itself might be wrong.
|
|
|
|
Auth model: the saas profile expects Supabase JWTs. For cucumber we
|
|
configure the stack with a test user whose API key is recognised via the
|
|
X-API-KEY header, which the PaygChargeInterceptor.resolveUser() path
|
|
handles natively. The companion docker-compose-saas.yml seeds the user +
|
|
team rows via saas-init.sql so each scenario starts from a known state.
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
|
|
import psycopg
|
|
import requests
|
|
from behave import given, then, when
|
|
|
|
BASE_URL = os.environ.get("PAYG_BASE_URL", "http://localhost:8080")
|
|
API_KEY = os.environ.get("PAYG_API_KEY", "payg-cucumber-key")
|
|
|
|
DB_HOST = os.environ.get("PAYG_DB_HOST", "localhost")
|
|
DB_PORT = int(os.environ.get("PAYG_DB_PORT", "5433"))
|
|
DB_NAME = os.environ.get("PAYG_DB_NAME", "postgres")
|
|
DB_USER = os.environ.get("PAYG_DB_USER", "postgres")
|
|
DB_PASSWORD = os.environ.get("PAYG_DB_PASSWORD", "postgres")
|
|
DB_SCHEMA = os.environ.get("PAYG_DB_SCHEMA", "stirling_pdf")
|
|
|
|
# Fixture paths — small PDFs that ship with the cucumber harness.
|
|
FIXTURE_DIR = os.path.join(
|
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
|
"exampleFiles",
|
|
)
|
|
# Existing cucumber fixtures (verified page counts via pypdf):
|
|
# tables.pdf = 1 page → SINGLE_PAGE_PDF
|
|
# ghost1.pdf = 3 pages → THREE_PAGE_PDF
|
|
# images.pdf = 5 pages (available if a scenario needs more)
|
|
# If you add more PAYG scenarios that need a different shape, drop the
|
|
# new fixture in exampleFiles/ and reference it here.
|
|
SINGLE_PAGE_PDF = os.path.join(FIXTURE_DIR, "tables.pdf")
|
|
THREE_PAGE_PDF = os.path.join(FIXTURE_DIR, "ghost1.pdf")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DB helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _db():
|
|
"""Open a fresh connection for each step — keeps things simple."""
|
|
return psycopg.connect(
|
|
host=DB_HOST,
|
|
port=DB_PORT,
|
|
dbname=DB_NAME,
|
|
user=DB_USER,
|
|
password=DB_PASSWORD,
|
|
autocommit=True,
|
|
)
|
|
|
|
|
|
def _team_id_for(team_name, conn):
|
|
"""Look up our test team's id (seeded by saas-init.sql)."""
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"SELECT team_id FROM {DB_SCHEMA}.teams WHERE name = %s LIMIT 1",
|
|
(team_name,),
|
|
)
|
|
row = cur.fetchone()
|
|
assert row is not None, f"No team named '{team_name}' in {DB_SCHEMA}.teams"
|
|
return row[0]
|
|
|
|
|
|
def _shadow_rows_for(team_name):
|
|
with _db() as conn:
|
|
team_id = _team_id_for(team_name, conn)
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"""SELECT shadow_id, payg_units, status, refunded_at, refund_reason, job_id
|
|
FROM {DB_SCHEMA}.payg_shadow_charge
|
|
WHERE team_id = %s
|
|
ORDER BY occurred_at DESC""",
|
|
(team_id,),
|
|
)
|
|
return cur.fetchall()
|
|
|
|
|
|
def _latest_job_for(team_name):
|
|
"""Return the most recently opened job for the team as a dict."""
|
|
with _db() as conn:
|
|
team_id = _team_id_for(team_name, conn)
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"""SELECT job_id, status, source, step_count, started_at, closed_at
|
|
FROM {DB_SCHEMA}.processing_job
|
|
WHERE owner_team_id = %s
|
|
ORDER BY started_at DESC
|
|
LIMIT 1""",
|
|
(team_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
assert row is not None, f"No processing_job rows for team '{team_name}'"
|
|
return {
|
|
"job_id": row[0],
|
|
"status": row[1],
|
|
"source": row[2],
|
|
"step_count": row[3],
|
|
"started_at": row[4],
|
|
"closed_at": row[5],
|
|
}
|
|
|
|
|
|
def _steps_for_job(job_id):
|
|
with _db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"""SELECT step_id, tool_id, status, error_code
|
|
FROM {DB_SCHEMA}.processing_job_step
|
|
WHERE job_id = %s
|
|
ORDER BY started_at ASC""",
|
|
(job_id,),
|
|
)
|
|
return cur.fetchall()
|
|
|
|
|
|
def _output_artifact_count(job_id):
|
|
with _db() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"""SELECT COUNT(*) FROM {DB_SCHEMA}.job_artifact_hash
|
|
WHERE job_id = %s AND kind = 'OUTPUT'""",
|
|
(job_id,),
|
|
)
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTTP helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _api_headers(extra=None):
|
|
headers = {"X-API-KEY": API_KEY}
|
|
if extra:
|
|
headers.update(extra)
|
|
return headers
|
|
|
|
|
|
def _wait_for_health(timeout_seconds=60):
|
|
"""Block until /api/v1/info/status returns 2xx, or timeout."""
|
|
deadline = time.time() + timeout_seconds
|
|
while time.time() < deadline:
|
|
try:
|
|
r = requests.get(f"{BASE_URL}/api/v1/info/status", timeout=5)
|
|
if 200 <= r.status_code < 300:
|
|
return
|
|
except requests.RequestException:
|
|
pass
|
|
time.sleep(2)
|
|
raise AssertionError(
|
|
f"SaaS stack did not become healthy within {timeout_seconds}s at {BASE_URL}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GIVEN — environment + test fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the SaaS stack is running with PAYG enabled")
|
|
def step_saas_stack_running(context):
|
|
_wait_for_health()
|
|
|
|
|
|
@given('team "{team_name}" exists with wallet_policy.engine = "{engine}"')
|
|
def step_team_exists_with_engine(context, team_name, engine):
|
|
"""Verify the seed migration created the team + flipped its engine."""
|
|
with _db() as conn:
|
|
team_id = _team_id_for(team_name, conn)
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"SELECT engine FROM {DB_SCHEMA}.wallet_policy WHERE team_id = %s",
|
|
(team_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
assert row is not None, f"No wallet_policy row for team '{team_name}'"
|
|
assert row[0] == engine, (
|
|
f"Team '{team_name}' engine is '{row[0]}', expected '{engine}'. "
|
|
"Check saas-init.sql seeded the row correctly."
|
|
)
|
|
context.team_name = team_name
|
|
|
|
|
|
@given('I am authenticated as a member of team "{team_name}"')
|
|
def step_authenticated_as_team_member(context, team_name):
|
|
# The seeded API key is bound to a member of this team via saas-init.sql.
|
|
context.team_name = team_name
|
|
|
|
|
|
@given('there are no existing shadow charges for team "{team_name}"')
|
|
def step_clear_shadow_charges(context, team_name):
|
|
with _db() as conn:
|
|
team_id = _team_id_for(team_name, conn)
|
|
with conn.cursor() as cur:
|
|
cur.execute(
|
|
f"DELETE FROM {DB_SCHEMA}.payg_shadow_charge WHERE team_id = %s",
|
|
(team_id,),
|
|
)
|
|
cur.execute(
|
|
f"""DELETE FROM {DB_SCHEMA}.job_artifact_hash
|
|
WHERE job_id IN (
|
|
SELECT job_id FROM {DB_SCHEMA}.processing_job
|
|
WHERE owner_team_id = %s
|
|
)""",
|
|
(team_id,),
|
|
)
|
|
cur.execute(
|
|
f"""DELETE FROM {DB_SCHEMA}.processing_job_step
|
|
WHERE job_id IN (
|
|
SELECT job_id FROM {DB_SCHEMA}.processing_job
|
|
WHERE owner_team_id = %s
|
|
)""",
|
|
(team_id,),
|
|
)
|
|
cur.execute(
|
|
f"DELETE FROM {DB_SCHEMA}.processing_job WHERE owner_team_id = %s",
|
|
(team_id,),
|
|
)
|
|
|
|
|
|
# NOTE: The kill-switch scenario (PAYG_FILTER_ENABLED=false) is verified
|
|
# manually — see notes/PAYG_DESIGN.md §7.5 "PAYG cucumber: manual-only
|
|
# scenarios" for the procedure. No automated step def is provided because
|
|
# toggling the env var requires restarting the docker container, and
|
|
# orchestrating that mid-suite would couple test setup to compose state
|
|
# in ways that have historically been fragile.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WHEN — invoke tool endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I POST a single-page PDF to "{endpoint}"')
|
|
def step_post_single_pdf(context, endpoint):
|
|
_post_pdf(context, endpoint, SINGLE_PAGE_PDF)
|
|
|
|
|
|
@when('I POST a 3-page PDF to "{endpoint}"')
|
|
def step_post_three_page_pdf(context, endpoint):
|
|
_post_pdf(context, endpoint, THREE_PAGE_PDF)
|
|
|
|
|
|
@when('I POST a single-page PDF to "{endpoint}" with form fields:')
|
|
def step_post_with_form_fields(context, endpoint):
|
|
"""The Gherkin step takes a `table` of key/value pairs that become
|
|
multipart form fields. Use this when the default `password=...` field
|
|
from _post_pdf() isn't what we want for the scenario."""
|
|
data = {row[0]: row[1] for row in context.table.rows} if context.table else {}
|
|
_post_pdf(context, endpoint, SINGLE_PAGE_PDF, form_data=data)
|
|
|
|
|
|
@when('I POST a 3-page PDF to "{endpoint}" with form fields:')
|
|
def step_post_three_page_with_form_fields(context, endpoint):
|
|
data = {row[0]: row[1] for row in context.table.rows} if context.table else {}
|
|
_post_pdf(context, endpoint, THREE_PAGE_PDF, form_data=data)
|
|
|
|
|
|
@when('I POST a single-page PDF with header "{header_name}: {header_value}" to "{endpoint}"')
|
|
def step_post_with_header(context, header_name, header_value, endpoint):
|
|
_post_pdf(context, endpoint, SINGLE_PAGE_PDF, extra_headers={header_name: header_value})
|
|
|
|
|
|
@when('I POST two single-page PDFs as a multi-file payload to "{endpoint}"')
|
|
def step_post_two_pdfs(context, endpoint):
|
|
with open(SINGLE_PAGE_PDF, "rb") as a, open(SINGLE_PAGE_PDF, "rb") as b:
|
|
files = [
|
|
("fileInput", ("a.pdf", a.read(), "application/pdf")),
|
|
("fileInput", ("b.pdf", b.read(), "application/pdf")),
|
|
]
|
|
context.response = requests.post(
|
|
f"{BASE_URL}{endpoint}",
|
|
files=files,
|
|
headers=_api_headers(),
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
@when('I take the response body as "{name}"')
|
|
def step_capture_response_body(context, name):
|
|
assert context.response is not None, "No response on context"
|
|
if not hasattr(context, "captured_bodies"):
|
|
context.captured_bodies = {}
|
|
context.captured_bodies[name] = context.response.content
|
|
|
|
|
|
@when('I POST "{captured_name}" to "{endpoint}"')
|
|
def step_post_captured(context, captured_name, endpoint):
|
|
body = context.captured_bodies[captured_name]
|
|
files = {"fileInput": (f"{captured_name}.pdf", body, "application/pdf")}
|
|
# Most tools need at least one extra form field; for the sanitize endpoint
|
|
# the defaults are sufficient.
|
|
context.response = requests.post(
|
|
f"{BASE_URL}{endpoint}",
|
|
files=files,
|
|
headers=_api_headers(),
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
def _post_pdf(context, endpoint, fixture_path, extra_headers=None, form_data=None):
|
|
"""Default form data carries `password=cucumber-test-password` because
|
|
/add-password (our most-used scenario endpoint) requires it. Pass
|
|
{@code form_data} to override (e.g. to send the wrong password to
|
|
/remove-password for the 4xx scenario)."""
|
|
with open(fixture_path, "rb") as f:
|
|
files = {"fileInput": (os.path.basename(fixture_path), f, "application/pdf")}
|
|
data = form_data if form_data is not None else {"password": "cucumber-test-password"}
|
|
context.response = requests.post(
|
|
f"{BASE_URL}{endpoint}",
|
|
files=files,
|
|
data=data,
|
|
headers=_api_headers(extra_headers),
|
|
timeout=60,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# THEN — assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the response status is {status:d}")
|
|
def step_assert_status(context, status):
|
|
assert context.response.status_code == status, _status_mismatch_message(
|
|
context.response, status
|
|
)
|
|
|
|
|
|
def _status_mismatch_message(response, expected_status):
|
|
"""Verbose diagnostic for unexpected statuses — dumps URL, headers, body
|
|
so we don't have to round-trip via container logs to figure out what came
|
|
back. Kept as a helper so future status-range / status-min steps can call
|
|
it too."""
|
|
body_preview = response.text[:1000] if response.text else "<empty body>"
|
|
headers_brief = {
|
|
k: v
|
|
for k, v in response.headers.items()
|
|
if k.lower()
|
|
in ("content-type", "content-length", "x-content-type-options", "location")
|
|
}
|
|
return (
|
|
f"Expected {expected_status}, got {response.status_code}\n"
|
|
f" URL: {response.request.method} {response.request.url}\n"
|
|
f" Headers: {headers_brief}\n"
|
|
f" Body: {body_preview}"
|
|
)
|
|
|
|
|
|
@then("the response status is >= {minimum:d}")
|
|
def step_assert_status_min(context, minimum):
|
|
assert context.response.status_code >= minimum, (
|
|
f"Expected >= {minimum}, got {context.response.status_code}"
|
|
)
|
|
|
|
|
|
@then("the response status is >= {minimum:d} and < {maximum:d}")
|
|
def step_assert_status_range(context, minimum, maximum):
|
|
code = context.response.status_code
|
|
assert minimum <= code < maximum, f"Expected [{minimum}, {maximum}), got {code}"
|
|
|
|
|
|
@then('the response Content-Type is "{expected}"')
|
|
def step_assert_content_type(context, expected):
|
|
actual = (context.response.headers.get("Content-Type") or "").split(";")[0].strip()
|
|
assert actual == expected, f"Expected {expected}, got {actual}"
|
|
|
|
|
|
@then('exactly {n:d} shadow charge row exists for team "{team_name}"')
|
|
@then('exactly {n:d} shadow charge rows exist for team "{team_name}"')
|
|
def step_assert_shadow_count(context, n, team_name):
|
|
rows = _shadow_rows_for(team_name)
|
|
assert len(rows) == n, f"Expected {n} shadow rows for '{team_name}', found {len(rows)}: {rows}"
|
|
|
|
|
|
@then('the latest shadow charge row has status "{status}"')
|
|
def step_assert_latest_shadow_status(context, status):
|
|
rows = _shadow_rows_for(context.team_name)
|
|
assert rows, "No shadow rows for team"
|
|
assert rows[0][2] == status, f"Expected status '{status}', got '{rows[0][2]}'"
|
|
|
|
|
|
@then("the latest shadow charge row has payg_units >= {minimum:d}")
|
|
def step_assert_payg_units_min(context, minimum):
|
|
rows = _shadow_rows_for(context.team_name)
|
|
assert rows[0][1] >= minimum, f"Expected payg_units >= {minimum}, got {rows[0][1]}"
|
|
|
|
|
|
@then("the latest shadow charge row's refunded_at is not null")
|
|
def step_assert_refunded_at_set(context):
|
|
rows = _shadow_rows_for(context.team_name)
|
|
assert rows[0][3] is not None, "refunded_at is null on the latest shadow row"
|
|
|
|
|
|
@then('the latest shadow charge row\'s refund_reason starts with "{prefix}"')
|
|
def step_assert_refund_reason(context, prefix):
|
|
rows = _shadow_rows_for(context.team_name)
|
|
reason = rows[0][4] or ""
|
|
assert reason.startswith(prefix), f"refund_reason '{reason}' does not start with '{prefix}'"
|
|
|
|
|
|
@then("the latest shadow charge row's job is {status}")
|
|
def step_assert_latest_jobstatus_via_shadow(context, status):
|
|
job = _latest_job_for(context.team_name)
|
|
assert job["status"] == status, f"Latest job status is '{job['status']}', expected '{status}'"
|
|
|
|
|
|
@then("the latest job is {status}")
|
|
def step_assert_latest_job_status(context, status):
|
|
job = _latest_job_for(context.team_name)
|
|
assert job["status"] == status, f"Latest job status is '{job['status']}', expected '{status}'"
|
|
|
|
|
|
@then("the latest job has step_count = {expected:d}")
|
|
def step_assert_step_count(context, expected):
|
|
job = _latest_job_for(context.team_name)
|
|
assert job["step_count"] == expected, (
|
|
f"Latest job step_count is {job['step_count']}, expected {expected}"
|
|
)
|
|
|
|
|
|
@then('the latest job\'s source is "{source}"')
|
|
def step_assert_job_source(context, source):
|
|
job = _latest_job_for(context.team_name)
|
|
assert job["source"] == source, f"Latest job source is '{job['source']}', expected '{source}'"
|
|
|
|
|
|
@then('the latest job has {n:d} step recorded with status "{status}"')
|
|
@then('the latest job has {n:d} steps recorded with status "{status}"')
|
|
def step_assert_step_count_with_status(context, n, status):
|
|
job = _latest_job_for(context.team_name)
|
|
steps = _steps_for_job(job["job_id"])
|
|
matching = [s for s in steps if s[2] == status]
|
|
assert len(matching) == n, (
|
|
f"Expected {n} steps with status '{status}', got {len(matching)}: {steps}"
|
|
)
|
|
|
|
|
|
@then("the latest step's error_code matches the response status")
|
|
def step_assert_step_error_code(context):
|
|
job = _latest_job_for(context.team_name)
|
|
steps = _steps_for_job(job["job_id"])
|
|
assert steps, "No steps recorded"
|
|
latest_step = steps[-1]
|
|
expected = str(context.response.status_code)
|
|
assert latest_step[3] == expected, (
|
|
f"Latest step error_code is '{latest_step[3]}', expected '{expected}'"
|
|
)
|
|
|
|
|
|
@then("the latest job has at least {n:d} OUTPUT artifact hashes recorded")
|
|
def step_assert_output_artifact_count(context, n):
|
|
job = _latest_job_for(context.team_name)
|
|
count = _output_artifact_count(job["job_id"])
|
|
assert count >= n, f"Latest job has {count} OUTPUT artifacts, expected >= {n}"
|