From e178ae406d83208d5635efc888d1787a975cbcc3 Mon Sep 17 00:00:00 2001 From: Jonas Miederer Date: Sun, 6 Sep 2026 22:28:53 +0000 Subject: [PATCH] feat: flow engine data layer with tests - SQLModel tables: flow_definitions/versions/runs, approval_tasks - Alembic setup with autogenerate (sqlmodel types fixed via script.py.mako) - Settings extended (shared root .env), db engine/session helpers - pytest suite: conftest with isolated test DB, 5 model tests green --- backend/alembic.ini | 149 ++++++++++++++++++ backend/app/db.py | 36 +++++ backend/app/models/__init__.py | 3 + backend/app/models/flow.py | 90 +++++++++++ backend/app/settings.py | 20 ++- backend/migrations/README | 1 + backend/migrations/env.py | 84 ++++++++++ backend/migrations/script.py.mako | 29 ++++ .../62ea9a5d8f9d_flow_engine_tables.py | 108 +++++++++++++ backend/pyproject.toml | 18 ++- backend/tests/conftest.py | 69 ++++++++ backend/tests/test_models.py | 82 ++++++++++ 12 files changed, 686 insertions(+), 3 deletions(-) create mode 100644 backend/alembic.ini create mode 100644 backend/app/db.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/flow.py create mode 100644 backend/migrations/README create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/62ea9a5d8f9d_flow_engine_tables.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_models.py diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..08f1d9f --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..668fede --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,36 @@ +"""Database engine, sessions, and FastAPI dependency.""" + +from collections.abc import Iterator + +from sqlalchemy import Engine +from sqlmodel import Session, SQLModel, create_engine + +from .settings import settings + +_engine: Engine | None = None + + +def get_engine(url: str | None = None) -> Engine: + """Process-wide engine singleton (override URL in tests via settings).""" + global _engine + if _engine is None: + _engine = create_engine(url or settings.database_url) + return _engine + + +def reset_engine() -> None: + """Drop the cached engine (used by the test suite between DB targets).""" + global _engine + if _engine is not None: + _engine.dispose() + _engine = None + + +def get_session() -> Iterator[Session]: + with Session(get_engine()) as session: + yield session + + +def create_all(url: str | None = None) -> None: + """Create tables directly (dev convenience; migrations own prod schema).""" + SQLModel.metadata.create_all(get_engine(url)) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..6ff89f5 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,3 @@ +from .flow import ApprovalTask, FlowDefinition, FlowRun, FlowVersion + +__all__ = ["ApprovalTask", "FlowDefinition", "FlowRun", "FlowVersion"] diff --git a/backend/app/models/flow.py b/backend/app/models/flow.py new file mode 100644 index 0000000..4d45ca3 --- /dev/null +++ b/backend/app/models/flow.py @@ -0,0 +1,90 @@ +"""Flow engine domain models (SQLModel tables).""" + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from sqlalchemy import JSON, Column, DateTime, UniqueConstraint +from sqlmodel import Field, SQLModel + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _ts_column() -> Column: + return Column(DateTime(timezone=True)) + + +def _json_column() -> Column: + # Generic JSON: JSONB on Postgres, JSON/TEXT elsewhere. + return Column(JSON) + + +class FlowDefinition(SQLModel, table=True): + """A named decision flow (e.g. 'Bauvorhaben pruefen'). Versions live separately.""" + + __tablename__ = "flow_definitions" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(index=True, unique=True, max_length=200) + description: str = Field(default="", max_length=2000) + status: str = Field(default="active", max_length=20) # active | archived + # Flow to run after a human approval on this flow's runs (nullable). + continuation_flow_id: UUID | None = Field( + default=None, foreign_key="flow_definitions.id" + ) + created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + + +class FlowVersion(SQLModel, table=True): + """An immutable version of a flow: the exact Langflow JSON that runs.""" + + __tablename__ = "flow_versions" + __table_args__ = (UniqueConstraint("flow_id", "version_no"),) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + flow_id: UUID = Field(foreign_key="flow_definitions.id", index=True) + version_no: int = Field(index=True) + langflow_json: dict = Field(default_factory=dict, sa_column=_json_column()) + # Flow id under which LFX serves this version (set on deploy). + lfx_flow_id: str | None = Field(default=None, max_length=200) + deployed_at: datetime | None = Field(default=None, sa_column=_ts_column()) + author: str = Field(default="", max_length=200) + created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + + +class FlowRun(SQLModel, table=True): + """One execution of a flow version.""" + + __tablename__ = "flow_runs" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + version_id: UUID = Field(foreign_key="flow_versions.id", index=True) + tenant_id: str = Field(default="default", index=True, max_length=200) + # running | completed | pending_approval | failed + status: str = Field(default="running", index=True, max_length=30) + input: dict | None = Field(default=None, sa_column=_json_column()) + output: dict | None = Field(default=None, sa_column=_json_column()) + session_id: str | None = Field(default=None, max_length=200) + created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + finished_at: datetime | None = Field(default=None, sa_column=_ts_column()) + + +class ApprovalTask(SQLModel, table=True): + """Human task created by a flow that reached a terminal approval state.""" + + __tablename__ = "approval_tasks" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + run_id: UUID = Field(foreign_key="flow_runs.id", index=True) + version_id: UUID = Field(foreign_key="flow_versions.id", index=True) + tenant_id: str = Field(default="default", index=True, max_length=200) + title: str = Field(default="", max_length=500) + payload: dict = Field(default_factory=dict, sa_column=_json_column()) + continuation_flow_id: UUID | None = Field(default=None) + approver: str | None = Field(default=None, max_length=200) + # open | approved | rejected + status: str = Field(default="open", index=True, max_length=20) + decision_note: str = Field(default="", max_length=2000) + created_at: datetime = Field(default_factory=utcnow, sa_column=_ts_column()) + decided_at: datetime | None = Field(default=None, sa_column=_ts_column()) diff --git a/backend/app/settings.py b/backend/app/settings.py index b8c93fb..d3a51f5 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -1,11 +1,27 @@ -from pydantic_settings import BaseSettings +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Repo-root .env (shared with the compose stack). Real environment variables +# always take precedence over .env values. +REPO_ROOT = Path(__file__).resolve().parent.parent.parent class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=REPO_ROOT / ".env", extra="ignore") + version: str = "0.1.0" debug: bool = False - model_config = {"env_prefix": "KIRCHE_"} + database_url: str = "postgresql+psycopg://kirche:kirche@localhost:5432/kirche_einfach" + test_database_url: str = ( + "postgresql+psycopg://kirche:kirche@localhost:5432/kirche_einfach_test" + ) + + langflow_url: str = "http://localhost:7860" + langflow_api_key: str = "" + lfx_url: str = "http://localhost:6004" + llm_api_key: str = "" settings = Settings() diff --git a/backend/migrations/README b/backend/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..76c102d --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,84 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +from app.models import * # noqa: F401,F403 - register all tables +from app.settings import settings +from sqlmodel import SQLModel + +target_metadata = SQLModel.metadata + +# The URL always comes from settings (repo-root .env or environment), +# alembic.ini's sqlalchemy.url is only a fallback placeholder. +config.set_main_option("sqlalchemy.url", settings.database_url) + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..62ae56b --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,29 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel # noqa: F401 - autogenerate renders sqlmodel.* column types +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/62ea9a5d8f9d_flow_engine_tables.py b/backend/migrations/versions/62ea9a5d8f9d_flow_engine_tables.py new file mode 100644 index 0000000..850f2f9 --- /dev/null +++ b/backend/migrations/versions/62ea9a5d8f9d_flow_engine_tables.py @@ -0,0 +1,108 @@ +"""flow engine tables + +Revision ID: 62ea9a5d8f9d +Revises: +Create Date: 2026-09-06 22:27:07.129788 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '62ea9a5d8f9d' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('flow_definitions', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False), + sa.Column('continuation_flow_id', sa.Uuid(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['continuation_flow_id'], ['flow_definitions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_flow_definitions_name'), 'flow_definitions', ['name'], unique=True) + op.create_table('flow_versions', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('flow_id', sa.Uuid(), nullable=False), + sa.Column('version_no', sa.Integer(), nullable=False), + sa.Column('langflow_json', sa.JSON(), nullable=True), + sa.Column('lfx_flow_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True), + sa.Column('deployed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('author', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['flow_id'], ['flow_definitions.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('flow_id', 'version_no') + ) + op.create_index(op.f('ix_flow_versions_flow_id'), 'flow_versions', ['flow_id'], unique=False) + op.create_index(op.f('ix_flow_versions_version_no'), 'flow_versions', ['version_no'], unique=False) + op.create_table('flow_runs', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('version_id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=30), nullable=False), + sa.Column('input', sa.JSON(), nullable=True), + sa.Column('output', sa.JSON(), nullable=True), + sa.Column('session_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_flow_runs_status'), 'flow_runs', ['status'], unique=False) + op.create_index(op.f('ix_flow_runs_tenant_id'), 'flow_runs', ['tenant_id'], unique=False) + op.create_index(op.f('ix_flow_runs_version_id'), 'flow_runs', ['version_id'], unique=False) + op.create_table('approval_tasks', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('run_id', sa.Uuid(), nullable=False), + sa.Column('version_id', sa.Uuid(), nullable=False), + sa.Column('tenant_id', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False), + sa.Column('payload', sa.JSON(), nullable=True), + sa.Column('continuation_flow_id', sa.Uuid(), nullable=True), + sa.Column('approver', sqlmodel.sql.sqltypes.AutoString(length=200), nullable=True), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=20), nullable=False), + sa.Column('decision_note', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('decided_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['run_id'], ['flow_runs.id'], ), + sa.ForeignKeyConstraint(['version_id'], ['flow_versions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_approval_tasks_run_id'), 'approval_tasks', ['run_id'], unique=False) + op.create_index(op.f('ix_approval_tasks_status'), 'approval_tasks', ['status'], unique=False) + op.create_index(op.f('ix_approval_tasks_tenant_id'), 'approval_tasks', ['tenant_id'], unique=False) + op.create_index(op.f('ix_approval_tasks_version_id'), 'approval_tasks', ['version_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_approval_tasks_version_id'), table_name='approval_tasks') + op.drop_index(op.f('ix_approval_tasks_tenant_id'), table_name='approval_tasks') + op.drop_index(op.f('ix_approval_tasks_status'), table_name='approval_tasks') + op.drop_index(op.f('ix_approval_tasks_run_id'), table_name='approval_tasks') + op.drop_table('approval_tasks') + op.drop_index(op.f('ix_flow_runs_version_id'), table_name='flow_runs') + op.drop_index(op.f('ix_flow_runs_tenant_id'), table_name='flow_runs') + op.drop_index(op.f('ix_flow_runs_status'), table_name='flow_runs') + op.drop_table('flow_runs') + op.drop_index(op.f('ix_flow_versions_version_no'), table_name='flow_versions') + op.drop_index(op.f('ix_flow_versions_flow_id'), table_name='flow_versions') + op.drop_table('flow_versions') + op.drop_index(op.f('ix_flow_definitions_name'), table_name='flow_definitions') + op.drop_table('flow_definitions') + # ### end Alembic commands ### diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7c2c84d..d8ffaae 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,9 +4,12 @@ version = "0.1.0" description = "Kirche Einfach - KI-gestuetzte Verwaltungsplattform fuer Kirche" requires-python = ">=3.12" dependencies = [ + "alembic>=1.14", "fastapi[standard]>=0.115.0", - "django>=5.1", + "httpx>=0.28", + "psycopg[binary]>=3.2", "pydantic-settings>=2.0", + "sqlmodel>=0.0.24", "uvicorn[standard]>=0.30.0", ] @@ -17,3 +20,16 @@ build-backend = "uv_build" [tool.uv.build-backend] module-name = "app" module-root = "" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[dependency-groups] +dev = [ + "lfx==1.12.0", + "pytest>=8", + "pytest-asyncio>=0.24", + "pytest-cov>=6", + "respx>=0.22", +] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..0083486 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,69 @@ +"""Shared pytest fixtures. + +Requires TEST_DATABASE_URL (a dedicated Postgres database, e.g. the +`kirche_einfach_test` database from the compose stack). The URL is mapped +onto DATABASE_URL *before* any app module is imported, so settings and the +alembic env both target the test database. Never point this at production. +""" + +import os +from pathlib import Path + +import pytest + +TEST_DB_URL = os.environ.get("TEST_DATABASE_URL") +if not TEST_DB_URL: + raise RuntimeError( + "TEST_DATABASE_URL is not set. Refusing to run tests without " + "an explicit test database (see .env.example)." + ) + +# Must precede all `app.*` imports: Settings is instantiated at import time. +os.environ["DATABASE_URL"] = TEST_DB_URL + +BACKEND_DIR = Path(__file__).resolve().parent.parent + + +@pytest.fixture(scope="session") +def db_engine(): + """Engine on the test DB, migrated to head once per session.""" + from alembic import command as alembic_command + from alembic.config import Config + + from app import db as app_db + + app_db.reset_engine() + engine = app_db.get_engine() + + cfg = Config() + cfg.set_main_option("script_location", str(BACKEND_DIR / "migrations")) + alembic_command.upgrade(cfg, "head") + + yield engine + + engine.dispose() + app_db.reset_engine() + + +@pytest.fixture() +def session(db_engine): + """Function-scoped session; tables are truncated after each test.""" + from sqlmodel import Session, SQLModel + + with Session(db_engine) as s: + yield s + with db_engine.begin() as conn: + for table in reversed(SQLModel.metadata.sorted_tables): + conn.execute(table.delete()) + + +@pytest.fixture() +async def client(): + """ASGI test client for the FastAPI app.""" + from httpx import ASGITransport, AsyncClient + + from app.main import app + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py new file mode 100644 index 0000000..40a82de --- /dev/null +++ b/backend/tests/test_models.py @@ -0,0 +1,82 @@ +"""Model-level tests: defaults, constraints, JSON roundtrip, linkage.""" + +import pytest +from sqlalchemy.exc import IntegrityError +from sqlmodel import select + +from app.models import ApprovalTask, FlowDefinition, FlowRun, FlowVersion + + +def test_flow_definition_defaults(session): + d = FlowDefinition(name="bauvorhaben") + session.add(d) + session.commit() + session.refresh(d) + assert d.id is not None + assert d.status == "active" + assert d.continuation_flow_id is None + assert d.created_at is not None + assert d.created_at.tzinfo is not None + + +def test_flow_definition_name_unique(session): + session.add(FlowDefinition(name="doppelt")) + session.commit() + session.add(FlowDefinition(name="doppelt")) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + + +def test_flow_version_unique_per_flow(session): + d = FlowDefinition(name="v-flow") + session.add(d) + session.commit() + session.add(FlowVersion(flow_id=d.id, version_no=1, author="a")) + session.commit() + session.add(FlowVersion(flow_id=d.id, version_no=1, author="b")) + with pytest.raises(IntegrityError): + session.commit() + session.rollback() + session.add(FlowVersion(flow_id=d.id, version_no=2, author="b")) + session.commit() + + +def test_flow_version_json_roundtrip(session): + payload = {"data": {"nodes": [{"id": "n1"}], "edges": []}, "name": "x"} + d = FlowDefinition(name="json-flow") + session.add(d) + session.commit() + v = FlowVersion(flow_id=d.id, version_no=1, langflow_json=payload) + session.add(v) + session.commit() + session.refresh(v) + assert v.langflow_json == payload + assert v.deployed_at is None + assert v.lfx_flow_id is None + + +def test_run_and_task_linkage_with_tenant_default(session): + d = FlowDefinition(name="linked") + session.add(d) + session.commit() + v = FlowVersion(flow_id=d.id, version_no=1) + session.add(v) + session.commit() + run = FlowRun(version_id=v.id, input={"a": 1}) + session.add(run) + session.commit() + task = ApprovalTask(run_id=run.id, version_id=v.id, title="bitte pruefen") + session.add(task) + session.commit() + + assert run.tenant_id == "default" + assert run.status == "running" + assert task.tenant_id == "default" + assert task.status == "open" + + runs = session.exec(select(FlowRun).where(FlowRun.version_id == v.id)).all() + assert len(runs) == 1 + tasks = session.exec(select(ApprovalTask).where(ApprovalTask.run_id == run.id)).all() + assert len(tasks) == 1 + assert tasks[0].payload == {}