Files
app/backend/tests/conftest.py
T
stroblmeandClaude Opus 5 989d008d37
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 15m39s
Playwright Tests / test-playwright (2, 2) (push) Successful in 10m17s
pre-commit / pre-commit (push) Failing after 2m28s
Test Backend / test-backend (push) Failing after 2m29s
Compose Smoke Test / test-compose (push) Successful in 30s
Playwright Tests / merge-reports (push) Successful in 5m36s
Merge branch 'main' of git.stroblme.de:Fluksio/app
The two sides both touched `submit_ready`'s readiness check, for unrelated
reasons, so the conflict is textual rather than semantic and both changes
stand:

- `831a537` completes a node that is not ready instead of passing over it,
  so a producer that can never run stops stranding its consumers.
- the audit branch has `_is_node_ready` return the values it read, so the
  node runs on them instead of asking state for the same keys again.

Merged as: read once, keep the values whether or not the answer is yes, and
take the not-ready branch from `831a537`. Its reasoning holds under the
merge — by the time readiness is consulted, `in_degree` is zero and every
in-wave producer has finished, so the answer cannot change later in the
wave.

Also fixes a fixture this branch added: the module-scoped row cleanup in
`tests/conftest.py` assumed a schema, and `tests/flow` overrides `db` with a
no-op because those tests need no database. It only showed when that
directory ran on its own.

746 tests green, and each directory green alone. Engine throughput is
unchanged by the merge (559 msg/s on the memory backend, against 639 before
it and 262 at the start of the audit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6hPWS6YEbT1P8LxhhFb2T
2026-08-29 21:22:12 +02:00

164 lines
5.4 KiB
Python

import uuid
from collections.abc import Generator
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi.testclient import TestClient
from sqlalchemy import delete, inspect
from sqlalchemy.engine import make_url
from sqlmodel import Session, select
from fluksio.cloud import config as cloud_config
from fluksio.core.config import settings
from fluksio.core.db import engine, prepare
from fluksio.main import app
from fluksio.models import (
EngineEvent,
FlowRun,
MetricBucket,
Run,
RunArtifact,
RunMetric,
RunNode,
User,
)
from tests.utils.portal import INSTALLATION_ID, ISSUER, jwks
from tests.utils.user import authentication_token_from_email
from tests.utils.utils import get_superuser_token_headers
@pytest.fixture(scope="session", autouse=True)
def flow_data(tmp_path_factory: pytest.TempPathFactory) -> Generator[None, None, None]:
"""Keep flows and secrets written by tests out of the real store."""
root = tmp_path_factory.mktemp("flow-data")
settings.FLOWS_DIR = root / "flows"
settings.SECRETS_FILE = root / "secrets.enc"
settings.PANELS_FILE = root / "panels.json"
yield
@pytest.fixture(scope="session", autouse=True)
def db() -> Generator[Session, None, None]:
"""Create the throwaway database `tests/__init__.py` points at, drop it after."""
url = make_url(settings.SQLALCHEMY_DATABASE_URI)
# The teardown deletes this database, so refuse to run against anything but
# the dedicated test one.
assert url.get_backend_name() == "sqlite", url.get_backend_name()
assert url.database and url.database.endswith("_test.db"), url.database
path = Path(url.database)
path.unlink(missing_ok=True)
# The real path, so the suite runs against a database built the way a
# deployment's is — including the alembic stamp the app's own startup
# would otherwise trip over.
prepare(engine)
with Session(engine) as session:
yield session
engine.dispose()
# The write-ahead log and its index are part of the database.
for suffix in ("", "-wal", "-shm"):
path.with_name(path.name + suffix).unlink(missing_ok=True)
#: Tables the engine writes to on its own, from the collector and the run
#: service the module-scoped app starts.
_ENGINE_TABLES = (
RunArtifact,
RunMetric,
RunNode,
Run,
FlowRun,
EngineEvent,
MetricBucket,
)
@pytest.fixture(scope="module", autouse=True)
def _clean_engine_rows() -> Generator[None, None, None]:
"""Clear what a module wrote before the next one queries it.
The `db` session is session-scoped and every commit in it is permanent, so
a `FlowRun` one module left marked `running` turned up in another module's
query. Per-*test* rollback is not available here: the module-scoped
`client` runs the real lifespan, and the collector and run service write
through sessions of their own that no test transaction wraps. Clearing
between modules bounds it to where the writes actually come from.
"""
yield
# `tests/flow` overrides `db` with a no-op — the engine holds no database
# state, so those tests build no schema — and there is nothing to clear
# there. Running that directory on its own is how that shows.
if not inspect(engine).has_table("run"):
return
with Session(engine) as session:
for model in _ENGINE_TABLES:
session.execute(delete(model))
session.commit()
@pytest.fixture(scope="module")
def client() -> Generator[TestClient, None, None]:
with TestClient(app) as c:
yield c
@pytest.fixture(scope="module")
def superuser_token_headers(client: TestClient) -> dict[str, str]:
return get_superuser_token_headers(client)
@pytest.fixture(scope="module")
def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]:
return authentication_token_from_email(
client=client, email=settings.EMAIL_TEST_USER, db=db
)
@pytest.fixture
def portal_key() -> rsa.RSAPrivateKey:
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
@pytest.fixture
def enrolled(
tmp_path_factory: pytest.TempPathFactory,
portal_key: rsa.RSAPrivateKey,
db: Session,
) -> Any:
"""Enrol this installation with a fake portal, then undo it."""
local_user = db.exec(
select(User).where(User.email == settings.FIRST_SUPERUSER)
).one()
original = settings.CLOUD_CONFIG_FILE
settings.CLOUD_CONFIG_FILE = (
tmp_path_factory.mktemp(f"cloud-{uuid.uuid4().hex[:6]}") / "cloud.json"
)
cloud_config.save(
cloud_config.CloudConfig(
portal_url=ISSUER,
ws_url=f"{ISSUER}/api/v1/tunnel/attach",
installation_id=INSTALLATION_ID,
token="installation-token",
issuer=ISSUER,
jwks=jwks(portal_key),
local_user_id=str(local_user.id),
enrolled_at=datetime.now(UTC).isoformat(),
portal_account=settings.FIRST_SUPERUSER,
)
)
# Enrolment also maps the enrolling account to the portal identity that
# owns the installation; without it a portal session resolves to nobody.
local_user.portal_sub = "portal-user-1"
db.add(local_user)
db.commit()
yield local_user
local_user.portal_sub = None
db.add(local_user)
db.commit()
cloud_config.delete()
settings.CLOUD_CONFIG_FILE = original