Keep the engine's state in SQLite, not Postgres
One process owns this database — the image has run a single uvicorn worker for that reason since the four-engines bug — so a file beside the flows is the honest shape for it, and it is what lets `fluksio serve` need no infrastructure at all. Live values, node execution and the work queue never came here anyway; what does is a rollup a minute at a time, a row per cascade and the run history, and WAL keeps the readers going while that one writer works. DATA_DIR is now the one setting that moves everything an installation keeps; the rest derive from it and the images still spell theirs out. The schema is prepared in-process at startup, so the prestart service is gone, and the ten Postgres-only revisions collapse into one portable baseline. Three things only worked because psycopg was casting for us: a token's subject arriving as a string where the column is a UUID, `greatest`, and `date_bin`. The timestamps needed a column type of their own — SQLite stores no offset, and a naive datetime read back either raises against an aware `now` or serialises as local time. Postgres stays in the stack only for Umami, behind the analytics profile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,32 @@
|
||||
"""Redirect the suite at its own database.
|
||||
|
||||
`app.core.db` builds the engine at import time and several modules bind that
|
||||
object, so the name has to be in the environment before anything imports the
|
||||
settings. Overriding it here — the first module pytest imports for the
|
||||
`fluksio.core.db` builds the engine at import time and several modules bind
|
||||
that object, so the URL has to be in the environment before anything imports
|
||||
the settings. Overriding it here — the first module pytest imports for the
|
||||
package — keeps a test run from touching the development data.
|
||||
|
||||
A file rather than `:memory:`: the suite starts the engine's own threads (the
|
||||
run service, the metrics collector, the portal connector) against this engine,
|
||||
and an in-memory database is per connection unless every one of them shares a
|
||||
single connection, which those threads would then contend for.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
os.environ["POSTGRES_DB"] = "app_test"
|
||||
_DB_DIR = tempfile.mkdtemp(prefix="fluksio-test-")
|
||||
os.environ.setdefault("DATABASE_URL", f"sqlite:///{_DB_DIR}/app_test.db")
|
||||
# Nothing outside this directory: the suite must not pick up a checkout's .env
|
||||
# and write into the development data.
|
||||
os.environ.setdefault("FLUKSIO_ENV_FILE", os.path.join(_DB_DIR, "env"))
|
||||
os.environ.setdefault("DATA_DIR", _DB_DIR)
|
||||
# Named here rather than taken from a developer's .env, so the suite is the
|
||||
# same run everywhere and a checkout's own superuser is never involved.
|
||||
os.environ.setdefault("FIRST_SUPERUSER", "admin@example.com")
|
||||
os.environ.setdefault("FIRST_SUPERUSER_PASSWORD", "testpassword")
|
||||
# What `emails_enabled` needs; the tests that send mail patch SMTP_HOST on top
|
||||
# of it. Nothing is delivered — the sender is faked where it matters.
|
||||
os.environ.setdefault("EMAILS_FROM_EMAIL", "noreply@example.com")
|
||||
# The MCP session manager can only be entered once per instance, and the suite
|
||||
# builds a TestClient — and so a lifespan — per test module. Tests that want the
|
||||
# endpoint mount it themselves.
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.models import OAuthRefreshToken
|
||||
|
||||
PREFIX = f"{settings.API_V1_STR}/oauth"
|
||||
REDIRECT = "http://127.0.0.1:41234/callback"
|
||||
@@ -235,6 +238,7 @@ def test_everything_is_refused_while_mcp_is_off(
|
||||
|
||||
def test_one_agent_can_be_revoked_without_touching_the_others(
|
||||
client: TestClient,
|
||||
db: Session,
|
||||
superuser_token_headers: dict[str, str],
|
||||
normal_user_token_headers: dict[str, str],
|
||||
) -> None:
|
||||
@@ -272,3 +276,13 @@ def test_one_agent_can_be_revoked_without_touching_the_others(
|
||||
data={"grant_type": "refresh_token", "refresh_token": tokens["refresh_token"]},
|
||||
)
|
||||
assert refreshed.status_code == 400
|
||||
|
||||
# Went, rather than merely stopped working: nothing here deletes those rows
|
||||
# itself, so an orphan is a foreign key the database is not enforcing —
|
||||
# which SQLite does not do unless it is asked (`PRAGMA foreign_keys`).
|
||||
left = db.exec(
|
||||
select(OAuthRefreshToken).where(
|
||||
OAuthRefreshToken.client_id == uuid.UUID(client_id)
|
||||
)
|
||||
).all()
|
||||
assert left == []
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session, select
|
||||
@@ -20,7 +22,8 @@ def test_create_user(client: TestClient, db: Session) -> None:
|
||||
|
||||
data = r.json()
|
||||
|
||||
user = db.exec(select(User).where(User.id == data["id"])).first()
|
||||
# The id comes back from JSON as a string, and the column is a UUID.
|
||||
user = db.exec(select(User).where(User.id == uuid.UUID(data["id"]))).first()
|
||||
|
||||
assert user
|
||||
assert user.email == "pollo@listo.com"
|
||||
|
||||
+16
-19
@@ -1,18 +1,18 @@
|
||||
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 create_engine, text
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlmodel import Session, SQLModel, select
|
||||
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, init_db
|
||||
from fluksio.core.db import engine, prepare
|
||||
from fluksio.main import app
|
||||
from fluksio.models import User
|
||||
from tests.utils.portal import INSTALLATION_ID, ISSUER, jwks
|
||||
@@ -33,28 +33,25 @@ def flow_data(tmp_path_factory: pytest.TempPathFactory) -> Generator[None, None,
|
||||
@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(str(settings.SQLALCHEMY_DATABASE_URI))
|
||||
# The teardown drops this database, so refuse to run against anything but
|
||||
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.database and url.database.endswith("_test"), url.database
|
||||
assert url.get_backend_name() == "sqlite", url.get_backend_name()
|
||||
assert url.database and url.database.endswith("_test.db"), url.database
|
||||
|
||||
maintenance = create_engine(
|
||||
url.set(database="postgres"), isolation_level="AUTOCOMMIT"
|
||||
)
|
||||
drop = text(f'DROP DATABASE IF EXISTS "{url.database}" WITH (FORCE)')
|
||||
with maintenance.connect() as connection:
|
||||
connection.execute(drop)
|
||||
connection.execute(text(f'CREATE DATABASE "{url.database}"'))
|
||||
|
||||
SQLModel.metadata.create_all(engine)
|
||||
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:
|
||||
init_db(session)
|
||||
yield session
|
||||
|
||||
engine.dispose()
|
||||
with maintenance.connect() as connection:
|
||||
connection.execute(drop)
|
||||
maintenance.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)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
|
||||
@@ -146,7 +146,9 @@ def test_a_connector_is_discovered_from_its_entry_point(monkeypatch):
|
||||
def load(self):
|
||||
return Sensor
|
||||
|
||||
monkeypatch.setattr("fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()])
|
||||
monkeypatch.setattr(
|
||||
"fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()]
|
||||
)
|
||||
try:
|
||||
assert load_plugins() == ["test_sensor"]
|
||||
assert NODE_TYPES["test_sensor"].plugin == "fluksio-connector-test 0.1.0"
|
||||
@@ -166,7 +168,9 @@ def test_a_connector_written_for_another_contract_is_refused(monkeypatch):
|
||||
def load(self):
|
||||
return Outdated
|
||||
|
||||
monkeypatch.setattr("fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()])
|
||||
monkeypatch.setattr(
|
||||
"fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()]
|
||||
)
|
||||
assert load_plugins() == []
|
||||
assert "outdated" not in NODE_TYPES
|
||||
|
||||
@@ -179,6 +183,8 @@ def test_a_connector_may_not_take_over_a_built_in_type(monkeypatch):
|
||||
def load(self): # pragma: no cover - never reached
|
||||
raise AssertionError("should not be loaded")
|
||||
|
||||
monkeypatch.setattr("fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()])
|
||||
monkeypatch.setattr(
|
||||
"fluksio.flow.plugins.entry_points", lambda group: [FakeEntry()]
|
||||
)
|
||||
assert load_plugins() == []
|
||||
assert NODE_TYPES["mqtt"].plugin is None
|
||||
|
||||
@@ -123,7 +123,7 @@ def test_a_node_imports_the_standard_library_not_the_engines_own_modules(pool):
|
||||
|
||||
def test_the_engines_secrets_are_not_in_a_workers_environment(pool, monkeypatch):
|
||||
monkeypatch.setenv("SECRET_KEY", "not-for-nodes")
|
||||
monkeypatch.setenv("POSTGRES_PASSWORD", "not-for-nodes")
|
||||
monkeypatch.setenv("DATABASE_URL", "not-for-nodes")
|
||||
monkeypatch.setenv("FLUKSIO_HARMLESS", "fine")
|
||||
# A fresh process, so it is built from the environment set just now.
|
||||
pool.respawn_all()
|
||||
@@ -132,7 +132,7 @@ def test_the_engines_secrets_are_not_in_a_workers_environment(pool, monkeypatch)
|
||||
pool,
|
||||
"import os\n\n\n"
|
||||
"def process():\n"
|
||||
" return {'out': [k for k in ('SECRET_KEY', 'POSTGRES_PASSWORD',\n"
|
||||
" return {'out': [k for k in ('SECRET_KEY', 'DATABASE_URL',\n"
|
||||
" 'FLUKSIO_HARMLESS') if k in os.environ]}\n",
|
||||
)
|
||||
assert result == {"out": ["FLUKSIO_HARMLESS"]}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from fluksio.backend_pre_start import init, logger
|
||||
|
||||
|
||||
def test_init_successful_connection() -> None:
|
||||
engine_mock = MagicMock()
|
||||
|
||||
session_mock = MagicMock()
|
||||
session_mock.__enter__.return_value = session_mock
|
||||
|
||||
select1 = select(1)
|
||||
|
||||
with (
|
||||
patch("fluksio.backend_pre_start.Session", return_value=session_mock),
|
||||
patch("fluksio.backend_pre_start.select", return_value=select1),
|
||||
patch.object(logger, "info"),
|
||||
patch.object(logger, "error"),
|
||||
patch.object(logger, "warn"),
|
||||
):
|
||||
try:
|
||||
init(engine_mock)
|
||||
connection_successful = True
|
||||
except Exception:
|
||||
connection_successful = False
|
||||
|
||||
assert connection_successful, (
|
||||
"The database connection should be successful and not raise an exception."
|
||||
)
|
||||
|
||||
session_mock.exec.assert_called_once_with(select1)
|
||||
@@ -1,33 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from fluksio.tests_pre_start import init, logger
|
||||
|
||||
|
||||
def test_init_successful_connection() -> None:
|
||||
engine_mock = MagicMock()
|
||||
|
||||
session_mock = MagicMock()
|
||||
session_mock.__enter__.return_value = session_mock
|
||||
|
||||
select1 = select(1)
|
||||
|
||||
with (
|
||||
patch("fluksio.tests_pre_start.Session", return_value=session_mock),
|
||||
patch("fluksio.tests_pre_start.select", return_value=select1),
|
||||
patch.object(logger, "info"),
|
||||
patch.object(logger, "error"),
|
||||
patch.object(logger, "warn"),
|
||||
):
|
||||
try:
|
||||
init(engine_mock)
|
||||
connection_successful = True
|
||||
except Exception:
|
||||
connection_successful = False
|
||||
|
||||
assert connection_successful, (
|
||||
"The database connection should be successful and not raise an exception."
|
||||
)
|
||||
|
||||
session_mock.exec.assert_called_once_with(select1)
|
||||
@@ -206,7 +206,9 @@ def test_adding_a_remote_user_maps_and_revokes(
|
||||
return_value={"user_id": "portal-user-9", "email": "remote@example.com"}
|
||||
),
|
||||
)
|
||||
with patch("fluksio.api.routes.cloud.httpx.post", return_value=portal_reply) as post:
|
||||
with patch(
|
||||
"fluksio.api.routes.cloud.httpx.post", return_value=portal_reply
|
||||
) as post:
|
||||
added = client.post(
|
||||
f"{settings.API_V1_STR}/cloud/users",
|
||||
headers=superuser_token_headers,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""What the database has to keep true whatever dialect is under it."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from fluksio.core.db import engine
|
||||
from fluksio.models import EngineEvent
|
||||
|
||||
# The `db` fixture is session-scoped and autouse, so the schema is already there.
|
||||
|
||||
|
||||
def test_a_stored_instant_comes_back_aware_and_in_utc() -> None:
|
||||
"""SQLite stores no offset, so the column type has to put one back.
|
||||
|
||||
Without it a timestamp read from the database is naive: comparing it to
|
||||
`datetime.now(timezone.utc)` raises, and serialising it hands the frontend
|
||||
a time with no zone, which it reads as local.
|
||||
"""
|
||||
stamp = datetime(2026, 8, 21, 15, 30, tzinfo=timezone(timedelta(hours=2)))
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
EngineEvent(ts=stamp, type="node_error", flow="tz", node="n", detail="")
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with Session(engine) as session:
|
||||
stored = session.exec(select(EngineEvent).where(EngineEvent.flow == "tz")).one()
|
||||
assert stored.ts.tzinfo is not None
|
||||
assert stored.ts.utcoffset() == timedelta(0)
|
||||
assert stored.ts == stamp
|
||||
# And it still compares against an aware "now" rather than raising.
|
||||
assert stored.ts < datetime.now(timezone.utc)
|
||||
Reference in New Issue
Block a user