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>
168 lines
4.7 KiB
Python
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'"))
|