Files
Florian WildeandClaude Opus 5.5 a60daf0039 feat: Projektgerüst, Datenmodell und initiale Migration
Schritt 1: src-Layout mit FastAPI-Stack, SQLAlchemy-2.x-Modell inkl.
Buchführung für Kopiermodus und Quarantäne, Alembic-Migration 0001,
strukturierte Logs, CLI (migrate/version), Changelog und Devlog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:25:42 +02:00

168 lines
4.7 KiB
Python

"""Schritt 1: Schema, Migrationen und Integritätsregeln."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta, timezone
import pytest
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError, StatementError
from sqlalchemy.orm import Session
from netmover.db import make_engine
from netmover.models import (
Base,
CopiedFile,
Credential,
ItemStatus,
Mount,
MountState,
MountType,
Rule,
Run,
RunItem,
)
def _mount(name: str = "scans", **kw: object) -> Mount:
defaults: dict[str, object] = {
"name": name,
"slug": name,
"type": MountType.CIFS,
"server": "192.168.1.10",
"share": "scans",
"mountpoint": f"/mnt/netmover/{name}",
}
defaults.update(kw)
return Mount(**defaults)
def test_migration_matches_models(db_url: str) -> None:
engine = make_engine(db_url)
with engine.connect() as conn:
ctx = MigrationContext.configure(conn, opts={"compare_type": True})
diff = compare_metadata(ctx, Base.metadata)
engine.dispose()
assert diff == []
def test_downgrade_and_upgrade_roundtrip(db_url: str) -> None:
from alembic import command
from netmover.db import alembic_config, upgrade
command.downgrade(alembic_config(db_url), "base")
upgrade(db_url)
def test_defaults(session: Session) -> None:
m = _mount()
session.add(m)
session.flush()
r = Rule(name="r", src_mount_id=m.id, dst_path="/opt/consume")
session.add(r)
session.commit()
assert m.state is MountState.UNMOUNTED
assert m.auto_mount is True
assert r.interval_seconds == 30
assert r.stability_seconds == 5
assert r.op_timeout_seconds == 120
assert r.dst_mount_id is None
def test_datetime_roundtrip_is_utc_aware(session: Session) -> None:
berlin = timezone(timedelta(hours=2))
t = datetime(2026, 10, 10, 17, 0, tzinfo=berlin)
m = _mount(last_mounted_at=t)
session.add(m)
session.commit()
session.expire_all()
loaded = session.scalars(select(Mount)).one()
assert loaded.last_mounted_at == t
assert loaded.last_mounted_at is not None
assert loaded.last_mounted_at.tzinfo is UTC
def test_naive_datetime_rejected(session: Session) -> None:
session.add(_mount(last_mounted_at=datetime(2026, 1, 1)))
with pytest.raises(StatementError):
session.commit()
def test_mount_in_use_cannot_be_deleted(session: Session) -> None:
m = _mount()
session.add(m)
session.flush()
session.add(Rule(name="r", src_mount_id=m.id, dst_path="/x"))
session.commit()
session.delete(m)
with pytest.raises(IntegrityError):
session.commit()
def test_credential_in_use_cannot_be_deleted(session: Session) -> None:
c = Credential(name="nas", username="scan", secret_enc="x")
session.add(c)
session.flush()
session.add(_mount(cred_id=c.id))
session.commit()
session.delete(c)
with pytest.raises(IntegrityError):
session.commit()
def test_rule_delete_cascades_runs_items_and_bookkeeping(session: Session) -> None:
m = _mount()
session.add(m)
session.flush()
r = Rule(name="r", src_mount_id=m.id, dst_path="/x")
session.add(r)
session.flush()
run = Run(rule_id=r.id)
session.add(run)
session.flush()
session.add(RunItem(run_id=run.id, src="a.pdf", status=ItemStatus.MOVED))
session.add(
CopiedFile(rule_id=r.id, src_relpath="a.pdf", size=1, mtime_ns=1, dst_path="/x/a.pdf")
)
session.commit()
session.delete(r)
session.commit()
assert session.scalars(select(Run)).all() == []
assert session.scalars(select(RunItem)).all() == []
assert session.scalars(select(CopiedFile)).all() == []
def test_copied_files_unique_per_version(session: Session) -> None:
m = _mount()
session.add(m)
session.flush()
r = Rule(name="r", src_mount_id=m.id, dst_path="/x")
session.add(r)
session.flush()
session.add(
CopiedFile(rule_id=r.id, src_relpath="a.pdf", size=1, mtime_ns=1, dst_path="/x/a.pdf")
)
# neue Version derselben Datei ist erlaubt
session.add(
CopiedFile(rule_id=r.id, src_relpath="a.pdf", size=2, mtime_ns=2, dst_path="/x/a-1.pdf")
)
session.commit()
session.add(
CopiedFile(rule_id=r.id, src_relpath="a.pdf", size=1, mtime_ns=1, dst_path="/x/a-2.pdf")
)
with pytest.raises(IntegrityError):
session.commit()
def test_enum_check_constraint_enforced_in_db(session: Session) -> None:
from sqlalchemy import text
session.add(_mount())
session.commit()
with pytest.raises(IntegrityError):
session.execute(text("UPDATE mounts SET type = 'ftp'"))