Code import - branch release/SIENTIAPDE-1645

This commit is contained in:
2026-08-05 13:53:37 +00:00
commit d481e0acff
116 changed files with 92848 additions and 0 deletions

3
e2e/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
End-to-end tests for the Model Manager Temporal workflows.
"""

697
e2e/conftest.py Normal file
View File

@@ -0,0 +1,697 @@
"""
Pytest configuration and fixtures for E2E tests.
External dependencies use testcontainers or real SDK integrations (no mocks of
model_manager or other first-party code):
- PostgreSQL, MinIO, MongoDB, Gitea: testcontainers.
- MLflow: real client with ``file://`` tracking URI (no MLflow server process).
- Temporal: ``WorkflowEnvironment.start_time_skipping()`` — official in-process
test runtime from temporalio; exercises real workflows and activity code, not
stubs of business logic.
- Observability: ``Logger`` (``get_logger`` from ``model_manager.utils.logger_helper``)
and ``MetricsController`` from sientia_do, same stack as production.
"""
from concurrent.futures import ThreadPoolExecutor
import base64
import csv
import io
import os
import shutil
import tempfile
import time
import mlflow
import pytest
import pytest_asyncio
import requests
from minio import Minio
from sqlalchemy import create_engine, text
from testcontainers.core.container import DockerContainer # type: ignore[import,import-untyped]
from testcontainers.minio import MinioContainer # type: ignore[import-untyped]
from testcontainers.mongodb import MongoDbContainer # type: ignore[import-untyped]
from testcontainers.postgres import PostgresContainer # type: ignore[import,import-untyped]
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker
from model_manager.activities.activities import Activities
from model_manager.utils.logger_helper import get_logger
from model_manager.workflows.cleanup_files import CleanupFiles
from model_manager.workflows.train_model import TrainModel
from sientia_do.notifications.handlers import CoreNotificationHandler
from sientia_do.observability.metrics_controller import MetricsController
from sientia_model.model_repository.plugin_store import PluginStore
# ---------------------------------------------------------------------------
# CSV training data: columns must match the variable_columns and target_variable
# used across all test scenarios.
_TRAIN_CSV_COLUMNS = [
'timestamp',
'303-WIT-200(Value)',
'03CV020/CORRENTE_N_M1_PV(Value)',
'303-WIT-230(Value)',
'03CV022/CORRENTE_N_M1_PV(Value)',
]
_MINIO_BUCKET = 'model-training'
_MINIO_OBJECT = 'training_data.csv'
# ---------------------------------------------------------------------------
# Helpers CSV generation
# ---------------------------------------------------------------------------
def _build_training_csv() -> bytes:
"""
Generate a 150-row CSV with all columns needed by test scenarios.
The numeric values cycle deterministically so lags and static-window
removal always find enough rows in both train and validation splits.
"""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(_TRAIN_CSV_COLUMNS)
for i in range(150):
ts = f'2025-06-{(i // 24) + 2:02d} {i % 24:02d}:00:00'
wit200 = round(30.0 + (i % 20) * 0.5, 2)
cv020 = round(100.0 + (i % 15) * 0.3, 2)
wit230 = round(25.0 + (i % 18) * 0.4, 2)
cv022 = round(90.0 + (i % 12) * 0.25, 2)
writer.writerow([ts, wit200, cv020, wit230, cv022])
return output.getvalue().encode('utf-8')
def _build_training_csv_dd_mm_yyyy() -> bytes:
"""
Generate a 150-row CSV with dd/MM/yyyy HH:mm:ss timestamps and
a DATA column header, for scenarios 12/13 that use a different date format.
"""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
'DATA',
'303-WIT-230(Value)',
'03CV022/CORRENTE_N_M1_PV(Value)',
])
for i in range(150):
day = (i % 30) + 1
ts = f'{day:02d}/05/2022 {i % 24:02d}:00:00'
wit230 = round(25.0 + (i % 18) * 0.4, 2)
cv022 = round(90.0 + (i % 12) * 0.25, 2)
writer.writerow([ts, wit230, cv022])
return output.getvalue().encode('utf-8')
def _build_training_csv_custom_target_column() -> bytes:
"""
Same layout as the standard CSV but the target column has a non-default name
(not ``target``) to exercise report and metrics paths.
"""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
'timestamp',
'303-WIT-200(Value)',
'MY_CUSTOM_TARGET_COLUMN',
])
for i in range(150):
ts = f'2025-06-{(i // 24) + 2:02d} {i % 24:02d}:00:00'
wit200 = round(30.0 + (i % 20) * 0.5, 2)
target_val = round(100.0 + (i % 15) * 0.3, 2)
writer.writerow([ts, wit200, target_val])
return output.getvalue().encode('utf-8')
def _build_training_csv_timestamp_header_naive() -> bytes:
"""
Naive datetimes under column ``Timestamp`` (common UI export) for scenario 16.
"""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
'Timestamp',
'303-WIT-200(Value)',
'03CV020/CORRENTE_N_M1_PV(Value)',
])
for i in range(150):
ts = f'2025-06-{(i // 24) + 2:02d} {i % 24:02d}:00:00'
wit200 = round(30.0 + (i % 20) * 0.5, 2)
cv020 = round(100.0 + (i % 15) * 0.3, 2)
writer.writerow([ts, wit200, cv020])
return output.getvalue().encode('utf-8')
def _build_training_csv_blank_timestamp_row() -> bytes:
"""Standard columns with one row where ``timestamp`` is empty (NaN after parse)."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(_TRAIN_CSV_COLUMNS)
for i in range(150):
wit200 = round(30.0 + (i % 20) * 0.5, 2)
cv020 = round(100.0 + (i % 15) * 0.3, 2)
wit230 = round(25.0 + (i % 18) * 0.4, 2)
cv022 = round(90.0 + (i % 12) * 0.25, 2)
if i == 17:
writer.writerow(['', wit200, cv020, wit230, cv022])
else:
ts = f'2025-06-{(i // 24) + 2:02d} {i % 24:02d}:00:00'
writer.writerow([ts, wit200, cv020, wit230, cv022])
return output.getvalue().encode('utf-8')
# ---------------------------------------------------------------------------
# Helpers Gitea seed
# ---------------------------------------------------------------------------
def _wait_for_gitea(base_url: str, timeout: int = 120) -> None:
"""Poll Gitea until it responds to HTTP requests."""
deadline = time.time() + timeout
last_err = None
while time.time() < deadline:
try:
resp = requests.get(f'{base_url}/', timeout=3)
if resp.status_code in (200, 404, 302):
return
except Exception as e:
last_err = e
time.sleep(2)
raise TimeoutError(f'Gitea did not start within {timeout}s at {base_url}. Last error: {last_err}')
def _gitea_api(method: str, url: str, auth: tuple, **kwargs) -> requests.Response:
resp = requests.request(method, url, auth=auth, timeout=30, **kwargs)
try:
resp.raise_for_status()
except requests.exceptions.HTTPError as e:
raise RuntimeError(f"Gitea API error {resp.status_code}: {resp.text}") from e
return resp
def _seed_gitea(base_url: str, admin_user: str, admin_pass: str) -> None:
"""
Create a fictitious model-store repository with dummy models.
"""
auth = (admin_user, admin_pass)
api = f'{base_url}/api/v1'
# Create repository
_gitea_api(
'POST', f'{api}/user/repos', auth,
json={'name': 'model-store', 'private': False, 'auto_init': False},
)
# Root index.yaml
root_index = """
store_name: "E2E Test Store"
version: 1
models:
- name: "linear_regression"
version: 1
runtime: "basic"
- name: "polynomial_regression"
version: 1
runtime: "basic"
runtimes:
basic:
version: "1.0.0"
libraries:
- name: "pandas"
- name: "numpy"
"""
# Model index.yaml (shared for all dummies)
model_index = """
name: "{model_name}"
version: 1
runtime: "basic"
path: "wrapper.py"
class: "DummyWrapper"
model:
class: "DummyModel"
path: "model_logic.py"
external: false
data_model:
class: "DummyTransformer"
path: "model_logic.py"
external: false
"""
# schemas.yaml
schemas_yaml = """
model:
type: object
properties: {}
data_model:
type: object
properties: {}
opt_params:
type: object
properties: {}
"""
# wrapper.py
wrapper_py = """
from sientia_model.wrappers.sientia_model import SientiaModel
import pandas as pd
import numpy as np
from typing import Any
class DummyWrapper(SientiaModel):
def _predict(self, data: pd.DataFrame) -> tuple[pd.DataFrame, dict[str, Any]]:
self._log("info", f"Predicting dummy model for {self.model_type}")
# Return a simple prediction (mean or 0.5) to allow metrics computation
preds = pd.DataFrame({self.target: [0.5] * len(data)}, index=data.index)
return preds, {}
def _transform(self, data: pd.DataFrame) -> tuple[pd.DataFrame, dict[str, Any]]:
return data, {}
def _train_transformer(self, train_data: pd.DataFrame, val_data: pd.DataFrame) -> None:
pass
def _train_model(self, x: pd.DataFrame, y: pd.DataFrame, x_val: pd.DataFrame | None = None, y_val: pd.DataFrame | None = None) -> None:
self.target = y.columns[0]
def _retrain_transformer(self, data: pd.DataFrame) -> None:
pass
def _retrain_model(self, x: pd.DataFrame, y: pd.DataFrame | None) -> None:
pass
"""
# model_logic.py
model_logic_py = """
class DummyModel:
def __init__(self, **kwargs):
pass
class DummyTransformer:
def __init__(self, **kwargs):
pass
"""
# requirements.txt required by current model packaging path in training activity.
requirements_txt = """
pandas
numpy
"""
def push_file(path: str, content: str):
encoded = base64.b64encode(content.encode()).decode()
_gitea_api(
'POST',
f'{api}/repos/{admin_user}/model-store/contents/{path}',
auth,
json={'message': f'seed: {path}', 'content': encoded},
)
# Push root index
push_file('index.yaml', root_index)
# Push files for both models used in tests
for model_name in ['linear_regression', 'polynomial_regression']:
prefix = f'models/{model_name}'
push_file(f'{prefix}/index.yaml', model_index.format(model_name=model_name))
push_file(f'{prefix}/schemas.yaml', schemas_yaml)
push_file(f'{prefix}/wrapper.py', wrapper_py)
push_file(f'{prefix}/model_logic.py', model_logic_py)
push_file(f'{prefix}/requirements.txt', requirements_txt.strip() + '\n')
push_file(f'{prefix}/__init__.py', "")
# Push runtime
push_file('runtime/basic.yaml', 'name: basic\nversion: "1.0.0"\nlibraries: []')
# ---------------------------------------------------------------------------
# Session-scoped containers
# ---------------------------------------------------------------------------
@pytest.fixture(scope='session')
def postgres_container():
"""PostgreSQL 15 container for experiment_run table."""
container = PostgresContainer('postgres:15')
container.start()
yield container
container.stop()
@pytest.fixture(scope='session')
def minio_container():
"""MinIO container for training CSV storage."""
container = MinioContainer()
container.start()
yield container
container.stop()
@pytest.fixture(scope='session')
def mongodb_container():
"""MongoDB container for CoreNotificationHandler."""
container = MongoDbContainer('mongo:7')
container.start()
yield container
container.stop()
@pytest.fixture(scope='session')
def gitea_container():
"""
Gitea container with a ``model-store`` repo seeded via REST API
(``index.yaml``, dummy model files, ``_seed_gitea``).
The container starts with INSTALL_LOCK so no setup wizard is needed.
An admin user is created via Gitea's CLI before the HTTP API is used.
"""
admin_user = 'gitea_admin'
admin_pass = 'gitea_admin_pass' # noqa: S105
container = (
DockerContainer('gitea/gitea:latest')
.with_env('GITEA__security__INSTALL_LOCK', 'true')
.with_env('GITEA__server__HTTP_PORT', '3000')
.with_env('GITEA__log__LEVEL', 'Warn')
.with_exposed_ports(3000)
)
container.start()
port = container.get_exposed_port(3000)
base_url = f'http://localhost:{port}'
_wait_for_gitea(base_url)
time.sleep(5) # Wait a bit for DB to fully initialize after HTTP is up
# Create admin user via Gitea CLI inside the container
# Must run after Gitea is fully initialized
gitea_cmd = (
f'gitea admin user create '
f'--username {admin_user} '
f'--password {admin_pass} '
f'--email admin@test.local '
f'--admin '
f'--must-change-password=false'
)
exec_result = container.exec(f"su git -c '{gitea_cmd}'")
if exec_result.exit_code != 0:
raise RuntimeError(f"Failed to create Gitea admin user: {exec_result.output.decode('utf-8')}")
_seed_gitea(base_url, admin_user, admin_pass)
yield {
'container': container,
'base_url': base_url,
'admin_user': admin_user,
'admin_pass': admin_pass,
}
container.stop()
@pytest.fixture(scope='session')
def mlflow_tracking_dir():
"""Local MLflow filesystem tracking directory (no network needed)."""
tmpdir = tempfile.mkdtemp(prefix='mlflow-e2e-')
mlflow.set_tracking_uri(f'file://{tmpdir}')
yield tmpdir
shutil.rmtree(tmpdir, ignore_errors=True)
@pytest.fixture(scope='session', autouse=True)
def e2e_runtime_reports_dir():
"""
Route runtime report artifacts to a writable temp directory during E2E.
Production defaults point to /var/lib/model-manager; in local CI/dev runs this
path may be unavailable. This fixture keeps the same code paths while avoiding
host permission issues.
"""
import model_manager.runtime_paths as runtime_paths
import model_manager.utils.repository.data_manager_repository as data_repo_module
base_dir = tempfile.mkdtemp(prefix='model-manager-e2e-runtime-')
reports_root = f'{base_dir}/reports'
reports_temp_dir = f'{reports_root}/temp'
project_base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'model_manager'))
old_runtime_reports_root = runtime_paths.REPORTS_ROOT
old_runtime_reports_temp = runtime_paths.REPORTS_TEMP_DIR
old_runtime_project_base = runtime_paths.PROJECT_BASE_PATH
old_repo_reports_root = data_repo_module.REPORTS_ROOT
old_repo_project_base = data_repo_module.PROJECT_BASE_PATH
runtime_paths.REPORTS_ROOT = reports_root
runtime_paths.REPORTS_TEMP_DIR = reports_temp_dir
runtime_paths.PROJECT_BASE_PATH = project_base_path
data_repo_module.REPORTS_ROOT = reports_root
data_repo_module.PROJECT_BASE_PATH = project_base_path
try:
yield reports_root
finally:
runtime_paths.REPORTS_ROOT = old_runtime_reports_root
runtime_paths.REPORTS_TEMP_DIR = old_runtime_reports_temp
runtime_paths.PROJECT_BASE_PATH = old_runtime_project_base
data_repo_module.REPORTS_ROOT = old_repo_reports_root
data_repo_module.PROJECT_BASE_PATH = old_repo_project_base
shutil.rmtree(base_dir, ignore_errors=True)
# ---------------------------------------------------------------------------
# Session-scoped: seed MinIO with training CSV
# ---------------------------------------------------------------------------
@pytest.fixture(scope='session', autouse=True)
def upload_training_csv(minio_container, mlflow_tracking_dir): # noqa: ARG001
"""
Upload training CSV files to the MinIO container before any test runs.
Depends on mlflow_tracking_dir to ensure the MLflow URI is set at session start.
"""
port = minio_container.get_exposed_port(9000)
client = Minio(
f'localhost:{port}',
access_key='minioadmin',
secret_key='minioadmin',
secure=False,
)
if not client.bucket_exists(_MINIO_BUCKET):
client.make_bucket(_MINIO_BUCKET)
# Standard training CSV
csv_bytes = _build_training_csv()
client.put_object(
_MINIO_BUCKET,
_MINIO_OBJECT,
io.BytesIO(csv_bytes),
length=len(csv_bytes),
content_type='text/csv',
)
# dd/MM/yyyy format CSV for scenarios 12/13
alt_csv_bytes = _build_training_csv_dd_mm_yyyy()
client.put_object(
_MINIO_BUCKET,
'training_data_dd_mm_yyyy.csv',
io.BytesIO(alt_csv_bytes),
length=len(alt_csv_bytes),
content_type='text/csv',
)
custom_target = _build_training_csv_custom_target_column()
client.put_object(
_MINIO_BUCKET,
'training_data_custom_target.csv',
io.BytesIO(custom_target),
length=len(custom_target),
content_type='text/csv',
)
ts_header = _build_training_csv_timestamp_header_naive()
client.put_object(
_MINIO_BUCKET,
'training_data_timestamp_naive.csv',
io.BytesIO(ts_header),
length=len(ts_header),
content_type='text/csv',
)
blank_ts = _build_training_csv_blank_timestamp_row()
client.put_object(
_MINIO_BUCKET,
'training_data_blank_timestamp_row.csv',
io.BytesIO(blank_ts),
length=len(blank_ts),
content_type='text/csv',
)
# ---------------------------------------------------------------------------
# Function-scoped: database engine + schema setup
# ---------------------------------------------------------------------------
@pytest.fixture
def postgres_engine(postgres_container):
"""SQLAlchemy engine connected to the test PostgreSQL container."""
engine = create_engine(postgres_container.get_connection_url())
yield engine
engine.dispose()
@pytest.fixture(autouse=True)
def setup_experiment_run_table(postgres_engine):
"""
Create the experiment_run table before each test and drop it afterwards
to guarantee full isolation between tests.
"""
with postgres_engine.begin() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS public.experiment_run (
id INT PRIMARY KEY,
experiment_name TEXT NOT NULL,
run_name TEXT,
username TEXT,
status TEXT NOT NULL DEFAULT 'ORCHESTRATOR_WAITING_PROC',
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
bucket_name TEXT,
file_name TEXT
)
"""))
yield
with postgres_engine.begin() as conn:
conn.execute(text('DROP TABLE IF EXISTS public.experiment_run'))
# ---------------------------------------------------------------------------
# Observability (real sientia_do implementations)
# ---------------------------------------------------------------------------
@pytest.fixture(scope='session')
def e2e_logger():
"""Shared production-style Logger for the whole E2E session."""
return get_logger('model-manager-e2e')
@pytest.fixture
def metrics_controller(e2e_logger):
"""MetricsController bound to the E2E logger (fresh instance per test)."""
return MetricsController(logger=e2e_logger)
# ---------------------------------------------------------------------------
# Application fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def notification_handler(mongodb_container, e2e_logger):
"""
Real CoreNotificationHandler connected to the MongoDB testcontainer.
"""
connection_url = mongodb_container.get_connection_url()
handler = CoreNotificationHandler(
connection_string=connection_url,
database='test_notifications',
logger=e2e_logger,
project_name='model-manager-e2e',
)
yield handler
handler.shutdown()
@pytest.fixture
def plugin_store(gitea_container, e2e_logger, metrics_controller, notification_handler):
"""
Real PluginStore pointed at the Gitea testcontainer.
cache_ttl_seconds=0 forces a fresh download every test.
"""
store = PluginStore(
base_url=gitea_container['base_url'],
owner=gitea_container['admin_user'],
repo='model-store',
username=gitea_container['admin_user'],
password=gitea_container['admin_pass'],
cache_ttl_seconds=0,
logger=e2e_logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
yield store
@pytest.fixture
def test_activities(
postgres_container,
minio_container,
mlflow_tracking_dir, # noqa: ARG001 ensures MLflow URI is set
plugin_store,
e2e_logger,
notification_handler,
metrics_controller,
):
"""
Real Activities instance wired to all testcontainers.
"""
pg_port = postgres_container.get_exposed_port(5432)
minio_port = minio_container.get_exposed_port(9000)
activities = Activities(
postgres_config={
'host': 'localhost',
'port': int(pg_port),
'user': 'test',
'password': 'test',
'dbname': 'test',
'min_connections': 1,
'max_connections': 5,
},
mlflow_config={
'url': mlflow.get_tracking_uri(),
'username': None,
'password': None,
},
minio_config={
'endpoint_url': f'http://localhost:{minio_port}',
'access_key': 'minioadmin',
'secret_key': 'minioadmin',
'use_ssl': False,
'default_bucket': _MINIO_BUCKET,
},
plugin_store=plugin_store,
logger=e2e_logger,
notification_handler=notification_handler,
metrics_controller=metrics_controller,
)
yield activities
activities.shutdown()
def _activity_list(activities: Activities) -> list:
return [
activities.update_experiment_run,
activities.load_model_metadata,
activities.validate_train_params,
activities.train_model,
activities.cleanup_resources,
activities.cleanup_temp_directories,
]
@pytest_asyncio.fixture(scope='function')
async def temporal_test_env():
"""Temporal SDK test environment (time-skipping); runs real workflow/activity code."""
env = await WorkflowEnvironment.start_time_skipping()
async with env:
yield env
@pytest_asyncio.fixture(scope='function')
async def temporal_worker(temporal_test_env, test_activities):
"""Temporal worker registered with all workflows and activities."""
with ThreadPoolExecutor() as executor:
async with Worker(
temporal_test_env.client,
task_queue='test-queue',
workflows=[TrainModel, CleanupFiles],
activities=_activity_list(test_activities),
activity_executor=executor,
) as worker:
yield worker

195
e2e/helpers.py Normal file
View File

@@ -0,0 +1,195 @@
"""
Shared helpers for E2E tests (Temporal workflows + PostgreSQL).
"""
import asyncio
import json
from datetime import datetime
from pathlib import Path
from typing import Any
import pytest
from sqlalchemy import text
from sqlalchemy.engine import Engine
async def start_and_await_workflow(
client,
workflow_run,
input_data: dict,
workflow_id: str,
timeout: float = 600.0,
):
"""
Start a Temporal workflow and wait for its result.
Args:
client: Temporal client from WorkflowEnvironment.
workflow_run: Workflow run method (e.g. TrainModel.run).
input_data: Workflow input payload.
workflow_id: Unique workflow id.
timeout: Max seconds to wait for completion (default allows cold testcontainer startup).
Returns:
Workflow result value.
"""
handle = await client.start_workflow(
workflow_run,
input_data,
id=workflow_id,
task_queue='test-queue',
)
return await asyncio.wait_for(handle.result(), timeout=timeout)
def make_workflow_id(prefix: str) -> str:
"""Build a unique workflow id using a prefix and current timestamp."""
return f'{prefix}-{datetime.now().timestamp()}'
def insert_experiment_run(
engine: Engine,
experiment_run_id: int,
experiment_name: str = 'test_experiment',
status: str = 'ORCHESTRATOR_WAITING_PROC',
bucket_name: str = 'model-training',
file_name: str = 'training_data.csv',
) -> None:
"""
Insert a minimal experiment_run row to satisfy foreign-key-style lookups.
Args:
engine: SQLAlchemy engine connected to the test database.
experiment_run_id: Primary key for the row.
experiment_name: Human-readable experiment name.
status: Initial status string.
bucket_name: MinIO bucket name.
file_name: Training file name inside the bucket.
"""
with engine.begin() as conn:
conn.execute(
text("""
INSERT INTO public.experiment_run
(id, experiment_name, status, bucket_name, file_name)
VALUES
(:id, :experiment_name, :status, :bucket_name, :file_name)
ON CONFLICT (id) DO NOTHING
"""),
{
'id': experiment_run_id,
'experiment_name': experiment_name,
'status': status,
'bucket_name': bucket_name,
'file_name': file_name,
},
)
def assert_experiment_status(
engine: Engine,
experiment_run_id: int,
expected_status: str,
) -> None:
"""
Assert the final status of an experiment_run row.
Args:
engine: SQLAlchemy engine.
experiment_run_id: Row primary key.
expected_status: Expected status string.
"""
with engine.connect() as conn:
row = conn.execute(
text('SELECT status FROM public.experiment_run WHERE id = :id'),
{'id': experiment_run_id},
).fetchone()
assert row is not None, (
f'No experiment_run row found for id={experiment_run_id}'
)
assert row[0] == expected_status, (
f'Expected status={expected_status!r}, got {row[0]!r} '
f'for experiment_run id={experiment_run_id}'
)
def assert_experiment_run_name_set(
engine: Engine,
experiment_run_id: int,
) -> None:
"""Assert that run_name is not null/empty after a successful training."""
with engine.connect() as conn:
row = conn.execute(
text('SELECT run_name FROM public.experiment_run WHERE id = :id'),
{'id': experiment_run_id},
).fetchone()
assert row is not None, (
f'No experiment_run row found for id={experiment_run_id}'
)
assert row[0] is not None and row[0].strip() != '', (
f'Expected run_name to be set for experiment_run id={experiment_run_id}, got {row[0]!r}'
)
def assert_experiment_error(
engine: Engine,
experiment_run_id: int,
expected_status: str,
error_substr: str,
) -> None:
"""
Assert status and that error_message contains a given substring.
Args:
engine: SQLAlchemy engine.
experiment_run_id: Row primary key.
expected_status: Expected status string.
error_substr: Substring that must appear in error_message.
"""
with engine.connect() as conn:
row = conn.execute(
text(
'SELECT status, error_message FROM public.experiment_run WHERE id = :id'
),
{'id': experiment_run_id},
).fetchone()
assert row is not None, (
f'No experiment_run row found for id={experiment_run_id}'
)
assert row[0] == expected_status, (
f'Expected status={expected_status!r}, got {row[0]!r}'
)
assert row[1] is not None and error_substr.lower() in row[1].lower(), (
f'Expected error_message to contain {error_substr!r}, got {row[1]!r}'
)
def assert_no_experiment_row(engine: Engine, experiment_run_id: int) -> None:
"""Assert that no experiment_run row exists for the given id."""
with engine.connect() as conn:
count = conn.execute(
text('SELECT COUNT(*) FROM public.experiment_run WHERE id = :id'),
{'id': experiment_run_id},
).scalar()
assert count == 0, (
f'Expected no experiment_run row for id={experiment_run_id}, found {count}'
)
def load_scenario(scenario_filename: str) -> dict[str, Any]:
"""
Load a test scenario JSON file from docs/test-scenarios/.
Args:
scenario_filename: Filename without path (e.g. '01-linear-regression-basic.json').
Returns:
dict: Parsed scenario payload.
"""
scenario_path = (
Path(__file__).parent.parent / 'docs' / 'test-scenarios' / scenario_filename
)
with open(scenario_path) as f:
return json.load(f)

View File

@@ -0,0 +1,108 @@
"""
End-to-end tests for CleanupFiles workflow.
Covers scenarios 3.x: cleanup of temporary local directories.
"""
import pytest
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker
from e2e.helpers import make_workflow_id, start_and_await_workflow
from model_manager.workflows.cleanup_files import CleanupFiles
# Matches Cleanup.dir_timestamp_pattern: name_YYYYMMDD_HHMMSS_microseconds
_STALE_DIR_OLD = 'stale_run_20200102_030405_000001'
_STALE_DIR_OLDER = 'stale_run_20191231_235959_999999'
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_3_1_1_cleanup_with_no_temp_dirs(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
tmp_path,
):
"""Scenario 3.1.1 Cleanup when the temp directory is empty.
The cleanup_temp_directories activity should complete without error
and the workflow should finish successfully.
"""
# Use an empty temp directory as the reports path
empty_dir = tmp_path / 'reports_temp'
empty_dir.mkdir()
result = await start_and_await_workflow(
temporal_test_env.client,
CleanupFiles.run,
{'temp_path': str(empty_dir)},
make_workflow_id('test-s3-1-1'),
)
# Workflow returns None on success
assert result is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_3_1_2_cleanup_removes_old_temp_dirs(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
tmp_path,
):
"""Scenario 3.1.2 Cleanup removes stale subdirectories from the temp dir.
Creates two subdirectories with timestamp suffixes inside the reports
temp directory and verifies the activity removes them.
"""
reports_dir = tmp_path / 'reports_temp'
reports_dir.mkdir()
# Create two stale run directories (names must match cleanup activity regex)
stale1 = reports_dir / _STALE_DIR_OLD
stale2 = reports_dir / _STALE_DIR_OLDER
stale1.mkdir()
stale2.mkdir()
(stale1 / 'model.pkl').write_bytes(b'fake-model-data')
(stale2 / 'report.json').write_bytes(b'{"status": "old"}')
result = await start_and_await_workflow(
temporal_test_env.client,
CleanupFiles.run,
{'temp_path': str(reports_dir)},
make_workflow_id('test-s3-1-2'),
)
assert result is None
# The activity should have cleaned up the stale directories
remaining = list(reports_dir.iterdir())
assert len(remaining) == 0, (
f'Expected all stale dirs to be removed, but found: {remaining}'
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_3_1_3_cleanup_nonexistent_temp_path(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
tmp_path,
):
"""Scenario 3.1.3 Cleanup with a temp_path that does not exist.
The activity must handle a missing directory gracefully without
raising an unhandled exception, since the directory may have already
been cleaned by a previous run.
"""
nonexistent = str(tmp_path / 'does_not_exist' / 'reports')
# Should not raise — the activity is expected to handle a missing path
result = await start_and_await_workflow(
temporal_test_env.client,
CleanupFiles.run,
{'temp_path': nonexistent},
make_workflow_id('test-s3-1-3'),
)
assert result is None

View File

@@ -0,0 +1,298 @@
"""
End-to-end tests for TrainModel parameter validation paths.
Covers scenarios 2.1.x: workflows that must terminate with
ORCHESTRATOR_VALIDATION_ERROR due to invalid parameter values.
"""
import pytest
from temporalio.client import WorkflowFailureError
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker
from e2e.helpers import (
assert_experiment_error,
insert_experiment_run,
load_scenario,
make_workflow_id,
start_and_await_workflow,
)
from model_manager.workflows.train_model import TrainModel
# Base experiment_run ids for validation test scenarios (offset to avoid collision)
_VALIDATION_ID_BASE = 3000
def _exception_chain_text(exc: BaseException) -> str:
"""Concatenate messages from an exception __cause__/__context__ chain."""
parts: list[str] = []
cur: BaseException | None = exc
seen: set[int] = set()
while cur is not None and id(cur) not in seen:
seen.add(id(cur))
text = str(cur).strip()
if text:
parts.append(text)
cur = cur.__cause__ or getattr(cur, '__context__', None)
return ' | '.join(parts).lower()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_2_1_1_train_size_out_of_range(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 2.1.1 train_size=5 violates the 10100 business rule.
Expected: workflow updates status → ORCHESTRATOR_VALIDATION_ERROR
and error_message references 'train_size'.
"""
experiment_run_id = _VALIDATION_ID_BASE + 1
scenario = load_scenario('01-linear-regression-basic.json')
scenario = {**scenario, 'experiment_run_id': experiment_run_id, 'train_size': 5}
insert_experiment_run(postgres_engine, experiment_run_id)
with pytest.raises(Exception):
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s2-1-1'),
)
assert_experiment_error(
postgres_engine,
experiment_run_id,
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
error_substr='train_size',
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_2_1_2_empty_variable_columns(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 2.1.2 variable_columns=[] → ORCHESTRATOR_VALIDATION_ERROR."""
experiment_run_id = _VALIDATION_ID_BASE + 2
scenario = load_scenario('01-linear-regression-basic.json')
scenario = {**scenario, 'experiment_run_id': experiment_run_id, 'variable_columns': []}
insert_experiment_run(postgres_engine, experiment_run_id)
with pytest.raises(Exception):
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s2-1-2'),
)
assert_experiment_error(
postgres_engine,
experiment_run_id,
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
error_substr='variable_columns',
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_2_1_3_invalid_date_format(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 2.1.3 date_format='INVALID' is not in the allowed list."""
experiment_run_id = _VALIDATION_ID_BASE + 3
scenario = load_scenario('01-linear-regression-basic.json')
scenario = {**scenario, 'experiment_run_id': experiment_run_id, 'date_format': 'INVALID'}
insert_experiment_run(postgres_engine, experiment_run_id)
with pytest.raises(Exception):
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s2-1-3'),
)
assert_experiment_error(
postgres_engine,
experiment_run_id,
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
error_substr='date_format',
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_2_1_4_whitespace_only_model_name(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 2.1.4 model_name=' ' (whitespace) → ORCHESTRATOR_VALIDATION_ERROR."""
experiment_run_id = _VALIDATION_ID_BASE + 4
scenario = load_scenario('01-linear-regression-basic.json')
scenario = {**scenario, 'experiment_run_id': experiment_run_id, 'model_name': ' '}
insert_experiment_run(postgres_engine, experiment_run_id)
with pytest.raises(Exception):
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s2-1-4'),
)
assert_experiment_error(
postgres_engine,
experiment_run_id,
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
error_substr='model_name',
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_2_1_5_unknown_model_type(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 2.1.5 model_type='totally_unknown' → ORCHESTRATOR_VALIDATION_ERROR.
The PluginStore will not find this model in the Gitea repo, causing
load_model_metadata to fail before validate_train_params is even called.
"""
experiment_run_id = _VALIDATION_ID_BASE + 5
scenario = load_scenario('01-linear-regression-basic.json')
scenario = {
**scenario,
'experiment_run_id': experiment_run_id,
'model_type': 'totally_unknown_model',
}
insert_experiment_run(postgres_engine, experiment_run_id)
with pytest.raises(Exception):
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s2-1-5'),
)
assert_experiment_error(
postgres_engine,
experiment_run_id,
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
error_substr='totally_unknown_model',
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_2_1_6_missing_target_variable(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 2.1.6 target_variable='' (empty string) → ORCHESTRATOR_VALIDATION_ERROR."""
experiment_run_id = _VALIDATION_ID_BASE + 6
scenario = load_scenario('01-linear-regression-basic.json')
scenario = {**scenario, 'experiment_run_id': experiment_run_id, 'target_variable': ''}
insert_experiment_run(postgres_engine, experiment_run_id)
with pytest.raises(Exception):
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s2-1-6'),
)
assert_experiment_error(
postgres_engine,
experiment_run_id,
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
error_substr='target_variable',
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_2_1_7_missing_experiment_run_id(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
):
"""Scenario 2.1.7 experiment_run_id missing → workflow raises ValueError immediately.
No DB row is inserted because experiment_run_id is mandatory to even
know which row to update. The workflow should raise before any DB call.
"""
scenario = load_scenario('01-linear-regression-basic.json')
scenario = {k: v for k, v in scenario.items() if k != 'experiment_run_id'}
with pytest.raises(WorkflowFailureError) as excinfo:
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s2-1-7'),
)
combined = _exception_chain_text(excinfo.value)
assert 'experiment_run_id' in combined
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_2_1_8_missing_date_column(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
):
"""Scenario 2.1.8 date_column missing in payload raises before workflow business validation."""
scenario = load_scenario('01-linear-regression-basic.json')
scenario = {k: v for k, v in scenario.items() if k != 'date_column'}
with pytest.raises(WorkflowFailureError) as excinfo:
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s2-1-8'),
)
combined = _exception_chain_text(excinfo.value)
assert 'date_column' in combined
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_2_1_9_whitespace_date_column(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 2.1.9 date_column=' ' must produce ORCHESTRATOR_VALIDATION_ERROR."""
experiment_run_id = _VALIDATION_ID_BASE + 9
scenario = load_scenario('01-linear-regression-basic.json')
scenario = {**scenario, 'experiment_run_id': experiment_run_id, 'date_column': ' '}
insert_experiment_run(postgres_engine, experiment_run_id)
with pytest.raises(Exception):
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s2-1-9'),
)
assert_experiment_error(
postgres_engine,
experiment_run_id,
expected_status='ORCHESTRATOR_VALIDATION_ERROR',
error_substr='date_column',
)

View File

@@ -0,0 +1,493 @@
"""
End-to-end tests for TrainModel workflow main workflow scenarios.
Covers:
1.1.x Happy-path training (various scenarios from docs/test-scenarios/)
1.2.x Error paths (MinIO failure, missing DB row)
"""
import pytest
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker
from e2e.helpers import (
assert_experiment_error,
assert_experiment_run_name_set,
assert_experiment_status,
assert_no_experiment_row,
insert_experiment_run,
load_scenario,
make_workflow_id,
start_and_await_workflow,
)
from model_manager.workflows.train_model import TrainModel
# ---------------------------------------------------------------------------
# 1.1 Happy paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_1_linear_regression_basic(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.1 Linear Regression Basic (cenário 01).
Validates the complete training pipeline end-to-end:
load_model_metadata → validate_train_params → train_model →
update_experiment_run (TRAINING_SUCCESS).
"""
scenario = load_scenario('01-linear-regression-basic.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(postgres_engine, experiment_run_id)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-1'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_2_linear_regression_with_scaler(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.2 Linear regression with Standard Scaler (cenário 02)."""
scenario = load_scenario('02-linear-regression-with-scaler.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(postgres_engine, experiment_run_id)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-2'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_3_polynomial_regression_degree2_with_scaler(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.3 Polynomial Regression Degree 2 with Standard Scaler (cenário 03)."""
scenario = load_scenario('03-polynomial-regression-degree2.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(postgres_engine, experiment_run_id)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-3'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_4_polynomial_regression_degree3_with_scaler(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.4 Polynomial regression degree 3 with Standard Scaler (cenário 04)."""
scenario = load_scenario('04-polynomial-regression-degree3.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(postgres_engine, experiment_run_id)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-4'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_5_linear_regression_with_lags(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.5 Linear Regression with lag_train/lag_val per variable (cenário 05)."""
scenario = load_scenario('05-linear-regression-with-lags.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(postgres_engine, experiment_run_id)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-5'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_6_linear_regression_nan_interpolation(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.6 nan_treatment='linear interpolation' (cenário 06)."""
scenario = load_scenario('06-linear-regression-nan-interpolation.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(postgres_engine, experiment_run_id)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-6'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_7_linear_regression_static_window_removal(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.7 rem_static_win=true with default static_threshold (cenário 07)."""
scenario = load_scenario('07-linear-regression-static-window-removal.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(postgres_engine, experiment_run_id)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-7'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_8_linear_regression_with_limits(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.8 support_filters with min/max limits per variable (cenário 08)."""
scenario = load_scenario('08-linear-regression-with-limits.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(postgres_engine, experiment_run_id)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-8'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_9_polynomial_degree2_scaler_and_lags(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.9 Polynomial degree 2, Standard Scaler and lags (cenário 09)."""
scenario = load_scenario('09-polynomial-degree2-with-scaler-and-lags.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(postgres_engine, experiment_run_id)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-9'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_10_linear_regression_with_ar_opt_params(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.10 Linear regression with include_ar in opt_params (cenário 10)."""
scenario = load_scenario('10-linear-regression-with-ar.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(postgres_engine, experiment_run_id)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-10'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_11_linear_regression_static_threshold_custom(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.11 rem_static_win=true with custom static_threshold (cenário 11)."""
scenario = load_scenario('11-linear-regression-static-threshold-custom.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(postgres_engine, experiment_run_id)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-11'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_12_alternate_date_format_dd_mm_yyyy(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.12 DATA column and dd/MM/yyyy format CSV (cenário 12)."""
scenario = load_scenario('12-angular-test-date-format.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(
postgres_engine,
experiment_run_id,
file_name='training_data_dd_mm_yyyy.csv',
)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-12'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_13_alternate_csv_narrow_date_window(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.13 Same alternate CSV as 12 with date window (cenário 13)."""
scenario = load_scenario('13-angular-test-double-date-column.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(
postgres_engine,
experiment_run_id,
file_name='training_data_dd_mm_yyyy.csv',
)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-13'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_14_polynomial_with_support_filters(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.14 Polynomial degree 4, Standard Scaler, line support filters (cenário 14)."""
scenario = load_scenario('14-angular-test-polynomial-support-filters.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(postgres_engine, experiment_run_id)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-14'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_15_linear_regression_custom_target_column_name(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.15 Target column is not named ``target``; report path uses target_variable."""
scenario = load_scenario('15-linear-regression-custom-target-column.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(
postgres_engine,
experiment_run_id,
file_name='training_data_custom_target.csv',
)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-15'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_16_naive_timestamp_header_column(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.16 CSV uses ``Timestamp`` header; snake_case date_column/date_format."""
scenario = load_scenario('16-linear-regression-naive-timestamp-header.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(
postgres_engine,
experiment_run_id,
file_name='training_data_timestamp_naive.csv',
)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-16'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_1_17_linear_regression_blank_timestamp_row_dropped(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.1.17 CSV has one empty timestamp cell; row is dropped and training succeeds."""
scenario = load_scenario('17-linear-regression-blank-timestamp-row.json')
experiment_run_id = scenario['experiment_run_id']
insert_experiment_run(
postgres_engine,
experiment_run_id,
file_name='training_data_blank_timestamp_row.csv',
)
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-1-17'),
)
assert_experiment_status(postgres_engine, experiment_run_id, 'TRAINING_SUCCESS')
assert_experiment_run_name_set(postgres_engine, experiment_run_id)
# ---------------------------------------------------------------------------
# 1.2 Error paths
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_2_1_minio_file_not_found(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.2.1 Training file does not exist in MinIO → TRAINING_ERROR."""
scenario = load_scenario('01-linear-regression-basic.json')
scenario = {**scenario, 'experiment_run_id': 2001, 'file_name': 'does_not_exist.csv'}
experiment_run_id = 2001
insert_experiment_run(postgres_engine, experiment_run_id)
with pytest.raises(Exception):
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-2-1'),
)
assert_experiment_error(
postgres_engine,
experiment_run_id,
expected_status='TRAINING_ERROR',
error_substr='does_not_exist',
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_scenario_1_2_2_experiment_run_id_not_in_db(
temporal_test_env: WorkflowEnvironment,
temporal_worker: Worker,
postgres_engine,
):
"""Scenario 1.2.2 experiment_run_id row absent → update_experiment_run raises."""
scenario = load_scenario('01-linear-regression-basic.json')
scenario = {**scenario, 'experiment_run_id': 9999}
# Intentionally NOT inserting the row
with pytest.raises(Exception):
await start_and_await_workflow(
temporal_test_env.client,
TrainModel.run,
scenario,
make_workflow_id('test-s1-2-2'),
)
assert_no_experiment_row(postgres_engine, 9999)