diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..94ab86d --- /dev/null +++ b/alembic.ini @@ -0,0 +1,150 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# Use environment variable DATABASE_URL from .env file +sqlalchemy.url = %(DATABASE_URL)s + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..1b8b87b --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,101 @@ +"""Alembic environment configuration. + +T11: Setup Alembic and initial schema migration +""" +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context + +# Add src to path to import models +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +# Import models to register them with Base +from openrouter_monitor.database import Base +from openrouter_monitor.models import User, ApiKey, UsageStats, ApiToken + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Override sqlalchemy.url with environment variable if available +# This allows DATABASE_URL from .env to be used +database_url = os.getenv('DATABASE_URL') +if database_url: + config.set_main_option('sqlalchemy.url', database_url) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Set target_metadata to the Base.metadata from our models +# This is required for 'autogenerate' support +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + # For SQLite, we need to handle check_same_thread=False + db_url = config.get_main_option("sqlalchemy.url") + + if db_url and 'sqlite' in db_url: + # SQLite specific configuration + from sqlalchemy import create_engine + connectable = create_engine( + db_url, + connect_args={"check_same_thread": False}, + poolclass=pool.NullPool, + ) + else: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/export/progress.md b/export/progress.md index 372b5a7..3a18439 100644 --- a/export/progress.md +++ b/export/progress.md @@ -44,13 +44,13 @@ - [x] T04: Setup file configurazione (.env, config.py) (2024-04-07) - [x] T05: Configurare pytest e struttura test (2024-04-07) -### 🗄️ Database & Models (T06-T11) - 5/6 completati +### 🗄️ Database & Models (T06-T11) - 6/6 completati - [x] T06: Creare database.py (connection & session) - ✅ Completato (2026-04-07 11:00) - [x] T07: Creare model User (SQLAlchemy) - ✅ Completato (2026-04-07 11:15) - [x] T08: Creare model ApiKey (SQLAlchemy) - ✅ Completato (2026-04-07 11:15) - [x] T09: Creare model UsageStats (SQLAlchemy) - ✅ Completato (2026-04-07 11:15) - [x] T10: Creare model ApiToken (SQLAlchemy) - ✅ Completato (2026-04-07 11:15) -- [ ] T11: Setup Alembic e creare migrazione iniziale - 🟡 In progress +- [x] T11: Setup Alembic e creare migrazione iniziale - ✅ Completato (2026-04-07 11:20) ### 🔐 Servizi di Sicurezza (T12-T16) - 0/5 completati - [ ] T12: Implementare EncryptionService (AES-256) diff --git a/tests/unit/models/test_migrations.py b/tests/unit/models/test_migrations.py new file mode 100644 index 0000000..0744d71 --- /dev/null +++ b/tests/unit/models/test_migrations.py @@ -0,0 +1,321 @@ +"""Tests for Alembic migrations (T11). + +T11: Setup Alembic e migrazione iniziale +""" +import pytest +import os +import tempfile +from pathlib import Path + + +@pytest.mark.unit +class TestAlembicInitialization: + """Test Alembic initialization and configuration.""" + + def test_alembic_ini_exists(self): + """Test that alembic.ini file exists.""" + # Arrange + project_root = Path(__file__).parent.parent.parent.parent + alembic_ini_path = project_root / "alembic.ini" + + # Assert + assert alembic_ini_path.exists(), "alembic.ini should exist" + + def test_alembic_directory_exists(self): + """Test that alembic directory exists.""" + # Arrange + project_root = Path(__file__).parent.parent.parent.parent + alembic_dir = project_root / "alembic" + + # Assert + assert alembic_dir.exists(), "alembic directory should exist" + assert alembic_dir.is_dir(), "alembic should be a directory" + + def test_alembic_env_py_exists(self): + """Test that alembic/env.py file exists.""" + # Arrange + project_root = Path(__file__).parent.parent.parent.parent + env_py_path = project_root / "alembic" / "env.py" + + # Assert + assert env_py_path.exists(), "alembic/env.py should exist" + + def test_alembic_versions_directory_exists(self): + """Test that alembic/versions directory exists.""" + # Arrange + project_root = Path(__file__).parent.parent.parent.parent + versions_dir = project_root / "alembic" / "versions" + + # Assert + assert versions_dir.exists(), "alembic/versions directory should exist" + + def test_alembic_ini_contains_database_url(self): + """Test that alembic.ini contains DATABASE_URL configuration.""" + # Arrange + project_root = Path(__file__).parent.parent.parent.parent + alembic_ini_path = project_root / "alembic.ini" + + # Act + with open(alembic_ini_path, 'r') as f: + content = f.read() + + # Assert + assert "sqlalchemy.url" in content, "alembic.ini should contain sqlalchemy.url" + + def test_alembic_env_py_imports_base(self): + """Test that alembic/env.py imports Base from models.""" + # Arrange + project_root = Path(__file__).parent.parent.parent.parent + env_py_path = project_root / "alembic" / "env.py" + + # Act + with open(env_py_path, 'r') as f: + content = f.read() + + # Assert + assert "Base" in content or "target_metadata" in content, \ + "alembic/env.py should reference Base or target_metadata" + + +@pytest.mark.integration +class TestAlembicMigrations: + """Test Alembic migration functionality.""" + + def test_migration_file_exists(self): + """Test that at least one migration file exists.""" + # Arrange + project_root = Path(__file__).parent.parent.parent.parent + versions_dir = project_root / "alembic" / "versions" + + # Act + migration_files = list(versions_dir.glob("*.py")) + + # Assert + assert len(migration_files) > 0, "At least one migration file should exist" + + def test_migration_contains_create_tables(self): + """Test that migration contains table creation commands.""" + # Arrange + project_root = Path(__file__).parent.parent.parent.parent + versions_dir = project_root / "alembic" / "versions" + + # Get the first migration file + migration_files = list(versions_dir.glob("*.py")) + if not migration_files: + pytest.skip("No migration files found") + + migration_file = migration_files[0] + + # Act + with open(migration_file, 'r') as f: + content = f.read() + + # Assert + assert "upgrade" in content, "Migration should contain upgrade function" + assert "downgrade" in content, "Migration should contain downgrade function" + + def test_alembic_upgrade_creates_tables(self, tmp_path): + """Test that alembic upgrade creates all required tables.""" + # Arrange + import subprocess + import sys + + # Create a temporary database + db_path = tmp_path / "test_alembic.db" + + # Set up environment with test database + env = os.environ.copy() + env['DATABASE_URL'] = f"sqlite:///{db_path}" + env['SECRET_KEY'] = "test-secret-key-min-32-characters-long" + env['ENCRYPTION_KEY'] = "test-32-byte-encryption-key!!" + + # Change to project root + project_root = Path(__file__).parent.parent.parent.parent + + # Act - Run alembic upgrade + result = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=project_root, + env=env, + capture_output=True, + text=True + ) + + # Assert + assert result.returncode == 0, f"Alembic upgrade failed: {result.stderr}" + + # Verify database file exists + assert db_path.exists(), "Database file should be created" + + def test_alembic_downgrade_removes_tables(self, tmp_path): + """Test that alembic downgrade removes tables.""" + # Arrange + import subprocess + import sys + + # Create a temporary database + db_path = tmp_path / "test_alembic_downgrade.db" + + # Set up environment with test database + env = os.environ.copy() + env['DATABASE_URL'] = f"sqlite:///{db_path}" + env['SECRET_KEY'] = "test-secret-key-min-32-characters-long" + env['ENCRYPTION_KEY'] = "test-32-byte-encryption-key!!" + + # Change to project root + project_root = Path(__file__).parent.parent.parent.parent + + # Act - First upgrade + subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=project_root, + env=env, + capture_output=True, + text=True + ) + + # Then downgrade + result = subprocess.run( + [sys.executable, "-m", "alembic", "downgrade", "-1"], + cwd=project_root, + env=env, + capture_output=True, + text=True + ) + + # Assert + assert result.returncode == 0, f"Alembic downgrade failed: {result.stderr}" + + def test_alembic_upgrade_downgrade_cycle(self, tmp_path): + """Test that upgrade followed by downgrade and upgrade again works.""" + # Arrange + import subprocess + import sys + + # Create a temporary database + db_path = tmp_path / "test_alembic_cycle.db" + + # Set up environment with test database + env = os.environ.copy() + env['DATABASE_URL'] = f"sqlite:///{db_path}" + env['SECRET_KEY'] = "test-secret-key-min-32-characters-long" + env['ENCRYPTION_KEY'] = "test-32-byte-encryption-key!!" + + # Change to project root + project_root = Path(__file__).parent.parent.parent.parent + + # Act - Upgrade + result1 = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=project_root, + env=env, + capture_output=True, + text=True + ) + + # Downgrade + result2 = subprocess.run( + [sys.executable, "-m", "alembic", "downgrade", "-1"], + cwd=project_root, + env=env, + capture_output=True, + text=True + ) + + # Upgrade again + result3 = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=project_root, + env=env, + capture_output=True, + text=True + ) + + # Assert + assert result1.returncode == 0, "First upgrade failed" + assert result2.returncode == 0, "Downgrade failed" + assert result3.returncode == 0, "Second upgrade failed" + + +@pytest.mark.integration +class TestDatabaseTables: + """Test that database tables are created correctly.""" + + def test_users_table_created(self, tmp_path): + """Test that users table is created by migration.""" + # Arrange + import subprocess + import sys + from sqlalchemy import create_engine, inspect + + # Create a temporary database + db_path = tmp_path / "test_tables.db" + + # Set up environment with test database + env = os.environ.copy() + env['DATABASE_URL'] = f"sqlite:///{db_path}" + env['SECRET_KEY'] = "test-secret-key-min-32-characters-long" + env['ENCRYPTION_KEY'] = "test-32-byte-encryption-key!!" + + # Change to project root + project_root = Path(__file__).parent.parent.parent.parent + + # Act - Run alembic upgrade + subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=project_root, + env=env, + capture_output=True, + text=True + ) + + # Verify tables + engine = create_engine(f"sqlite:///{db_path}") + inspector = inspect(engine) + tables = inspector.get_table_names() + + # Assert + assert "users" in tables, "users table should be created" + assert "api_keys" in tables, "api_keys table should be created" + assert "usage_stats" in tables, "usage_stats table should be created" + assert "api_tokens" in tables, "api_tokens table should be created" + + engine.dispose() + + def test_alembic_version_table_created(self, tmp_path): + """Test that alembic_version table is created.""" + # Arrange + import subprocess + import sys + from sqlalchemy import create_engine, inspect + + # Create a temporary database + db_path = tmp_path / "test_version.db" + + # Set up environment with test database + env = os.environ.copy() + env['DATABASE_URL'] = f"sqlite:///{db_path}" + env['SECRET_KEY'] = "test-secret-key-min-32-characters-long" + env['ENCRYPTION_KEY'] = "test-32-byte-encryption-key!!" + + # Change to project root + project_root = Path(__file__).parent.parent.parent.parent + + # Act - Run alembic upgrade + subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=project_root, + env=env, + capture_output=True, + text=True + ) + + # Verify tables + engine = create_engine(f"sqlite:///{db_path}") + inspector = inspect(engine) + tables = inspector.get_table_names() + + # Assert + assert "alembic_version" in tables, "alembic_version table should be created" + + engine.dispose()