Code import - branch release/SIENTIAPDE-1645
This commit is contained in:
3
e2e/__init__.py
Normal file
3
e2e/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
End-to-end tests for the Model Manager Temporal workflows.
|
||||
"""
|
||||
697
e2e/conftest.py
Normal file
697
e2e/conftest.py
Normal 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
195
e2e/helpers.py
Normal 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)
|
||||
108
e2e/test_cleanup_files_workflow.py
Normal file
108
e2e/test_cleanup_files_workflow.py
Normal 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
|
||||
298
e2e/test_train_model_validation.py
Normal file
298
e2e/test_train_model_validation.py
Normal 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 10–100 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',
|
||||
)
|
||||
493
e2e/test_train_model_workflow.py
Normal file
493
e2e/test_train_model_workflow.py
Normal 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)
|
||||
Reference in New Issue
Block a user