Files
Aniworld/tests/unit/test_database_models.py
Lukas c3aca9217d fix(episodes): prevent duplicate-row accumulation at schema + write sites
Followup to commit c84f968 (read-boundary dedup) and commit f75d591
(cleanup CLI). The read boundary filters duplicates out of the
in-memory episodeDict and the CLI cleans up historical duplicates
in the DB, but the underlying pathology — duplicate rows being
created in the first place — was still active on every rescan.

Two layered prevention fixes:

1. Schema-level guard: add UNIQUE(series_id, season, episode_number)
   to the episodes table. SQLite's CREATE UNIQUE INDEX requires
   no existing duplicates, but the cleanup CLI from f75d591 has
   already been run (or is a one-shot prerequisite for users on
   older DBs). Future duplicate rows are rejected at the DB layer.

2. Write-site guard: SerieScanner.scan_single_series used to
   `extend` the in-memory episodeDict on every rescan of a
   series already in keyDict — across N rescans, the same missing
   list was appended N times, growing the dict with duplicates that
   then flowed through _update_series_in_db into the episodes
   table. The fix replaces the cache with the latest scan result
   instead of extending, and dedupes within a single call as
   defense in depth against a buggy upstream loader.

Defensive dedup is layered three deep:
  - schema constraint (this commit, primary)
  - scan_single_series replace-not-extend (this commit, secondary)
  - episodeDict property read-boundary dedup (commit c84f968,
    tertiary — covers legacy DBs that predate the constraint)

Tests:
  - Updated test_serie_scanner.test_scan_single_series_existing_entry
    to assert the new replace-not-merge behavior (the old assertion
    encoded the buggy extend behavior).
  - New test_serie_scanner_scan_dedup.py covers the regression
    directly: two rescans of the same series with the same missing
    list must yield a canonical dict, not an accumulated one.
  - test_database_models and test_clean_duplicate_episodes_cli now
    use a legacy_engine fixture that drops the UNIQUE constraint,
    so the duplicate-row scenarios they exercise (the read-boundary
    dedup and the cleanup tool, both meant to defend against
    pre-migration state) can still be tested under the new schema.

Verified manually: clean_duplicate_episodes --apply on the user's
backup DB still removes all 633 duplicate rows under the new
schema (the CLI doesn't depend on the UNIQUE constraint — it
operates on whatever rows already exist).
2026-09-15 20:58:30 +02:00

898 lines
28 KiB
Python

"""Unit tests for database models and connection management.
Tests SQLAlchemy models, relationships, session management, and database
operations. Uses an in-memory SQLite database for isolated testing.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from src.server.database.base import Base, SoftDeleteMixin, TimestampMixin
from src.server.database.models import (
AnimeSeries,
DownloadQueueItem,
Episode,
UserSession,
)
@pytest.fixture
def db_engine():
"""Create in-memory SQLite database engine for testing."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
return engine
@pytest.fixture
def db_session(db_engine):
"""Create database session for testing."""
SessionLocal = sessionmaker(bind=db_engine)
session = SessionLocal()
yield session
session.close()
class TestAnimeSeries:
"""Test cases for AnimeSeries model."""
def test_create_anime_series(self, db_session: Session):
"""Test creating an anime series."""
series = AnimeSeries(
key="attack-on-titan",
name="Attack on Titan",
site="https://aniworld.to",
folder="/anime/attack-on-titan",
)
db_session.add(series)
db_session.commit()
# Verify saved
assert series.id is not None
assert series.key == "attack-on-titan"
assert series.name == "Attack on Titan"
assert series.created_at is not None
assert series.updated_at is not None
def test_anime_series_unique_key(self, db_session: Session):
"""Test that series key must be unique."""
series1 = AnimeSeries(
key="unique-key",
name="Series 1",
site="https://example.com",
folder="/anime/series1",
)
series2 = AnimeSeries(
key="unique-key",
name="Series 2",
site="https://example.com",
folder="/anime/series2",
)
db_session.add(series1)
db_session.commit()
db_session.add(series2)
with pytest.raises(Exception): # IntegrityError
db_session.commit()
def test_anime_series_relationships(self, db_session: Session):
"""Test relationships with episodes and download items."""
series = AnimeSeries(
key="test-series",
name="Test Series",
site="https://example.com",
folder="/anime/test",
)
db_session.add(series)
db_session.commit()
# Add episodes
episode1 = Episode(
series_id=series.id,
season=1,
episode_number=1,
title="Episode 1",
)
episode2 = Episode(
series_id=series.id,
season=1,
episode_number=2,
title="Episode 2",
)
db_session.add_all([episode1, episode2])
db_session.commit()
# Verify relationship
assert len(series.episodes) == 2
assert series.episodes[0].title == "Episode 1"
def test_anime_series_cascade_delete(self, db_session: Session):
"""Test that deleting series cascades to episodes."""
series = AnimeSeries(
key="cascade-test",
name="Cascade Test",
site="https://example.com",
folder="/anime/cascade",
)
db_session.add(series)
db_session.commit()
# Add episodes
episode = Episode(
series_id=series.id,
season=1,
episode_number=1,
)
db_session.add(episode)
db_session.commit()
series_id = series.id
# Delete series
db_session.delete(series)
db_session.commit()
# Verify episodes are deleted
result = db_session.execute(
select(Episode).where(Episode.series_id == series_id)
)
assert result.scalar_one_or_none() is None
def test_anime_series_nfo_fields_default_values(self, db_session: Session):
"""Test NFO fields have correct default values."""
series = AnimeSeries(
key="nfo-test",
name="NFO Test Series",
site="https://example.com",
folder="/anime/nfo-test",
)
db_session.add(series)
db_session.commit()
# Verify NFO fields default values
assert series.has_nfo is False
assert series.nfo_created_at is None
assert series.nfo_updated_at is None
assert series.tmdb_id is None
assert series.tvdb_id is None
def test_anime_series_nfo_fields_set_values(self, db_session: Session):
"""Test setting NFO field values."""
now = datetime.now(timezone.utc)
series = AnimeSeries(
key="nfo-values-test",
name="NFO Values Test",
site="https://example.com",
folder="/anime/nfo-values",
has_nfo=True,
nfo_created_at=now,
nfo_updated_at=now,
tmdb_id=12345,
tvdb_id=67890,
)
db_session.add(series)
db_session.commit()
# Verify NFO fields are saved
assert series.has_nfo is True
assert series.nfo_created_at is not None
assert series.nfo_updated_at is not None
# Check time is close (within 1 second)
# Timezone may be lost in SQLite
created_delta = abs(
(series.nfo_created_at.replace(tzinfo=timezone.utc) - now)
.total_seconds()
)
updated_delta = abs(
(series.nfo_updated_at.replace(tzinfo=timezone.utc) - now)
.total_seconds()
)
assert created_delta < 1
assert updated_delta < 1
assert series.tmdb_id == 12345
assert series.tvdb_id == 67890
def test_anime_series_update_nfo_status(self, db_session: Session):
"""Test updating NFO status fields."""
series = AnimeSeries(
key="nfo-update-test",
name="NFO Update Test",
site="https://example.com",
folder="/anime/nfo-update",
)
db_session.add(series)
db_session.commit()
# Initially no NFO
assert series.has_nfo is False
# Update NFO status
now = datetime.now(timezone.utc)
series.has_nfo = True
series.nfo_created_at = now
series.nfo_updated_at = now
series.tmdb_id = 99999
db_session.commit()
# Verify update
db_session.refresh(series)
assert series.has_nfo is True
assert series.nfo_created_at is not None
assert series.nfo_updated_at is not None
assert series.tmdb_id == 99999
def test_anime_series_query_by_nfo_status(self, db_session: Session):
"""Test querying series by NFO status."""
# Create series with and without NFO
series_with_nfo = AnimeSeries(
key="with-nfo",
name="Series With NFO",
site="https://example.com",
folder="/anime/with-nfo",
has_nfo=True,
tmdb_id=111,
)
series_without_nfo = AnimeSeries(
key="without-nfo",
name="Series Without NFO",
site="https://example.com",
folder="/anime/without-nfo",
has_nfo=False,
)
db_session.add_all([series_with_nfo, series_without_nfo])
db_session.commit()
# Query series with NFO
with_nfo = db_session.execute(
select(AnimeSeries).where(
AnimeSeries.has_nfo == True # noqa: E712
)
).scalars().all()
assert len(with_nfo) == 1
assert with_nfo[0].key == "with-nfo"
# Query series without NFO
without_nfo = db_session.execute(
select(AnimeSeries).where(
AnimeSeries.has_nfo == False # noqa: E712
)
).scalars().all()
assert len(without_nfo) == 1
assert without_nfo[0].key == "without-nfo"
def test_anime_series_query_by_tmdb_id(self, db_session: Session):
"""Test querying series by TMDB ID."""
series1 = AnimeSeries(
key="tmdb-test-1",
name="TMDB Test 1",
site="https://example.com",
folder="/anime/tmdb-1",
tmdb_id=12345,
)
series2 = AnimeSeries(
key="tmdb-test-2",
name="TMDB Test 2",
site="https://example.com",
folder="/anime/tmdb-2",
tmdb_id=67890,
)
series3 = AnimeSeries(
key="tmdb-test-3",
name="TMDB Test 3",
site="https://example.com",
folder="/anime/tmdb-3",
)
db_session.add_all([series1, series2, series3])
db_session.commit()
# Query by specific TMDB ID
result = db_session.execute(
select(AnimeSeries).where(AnimeSeries.tmdb_id == 12345)
).scalar_one_or_none()
assert result is not None
assert result.key == "tmdb-test-1"
# Query series with any TMDB ID
with_tmdb = db_session.execute(
select(AnimeSeries).where(AnimeSeries.tmdb_id.isnot(None))
).scalars().all()
assert len(with_tmdb) == 2
class TestEpisodeDictDedup:
"""Regression tests for the ``AnimeSeries.episodeDict`` dedup.
The ``episodes`` table has a UNIQUE constraint on
``(series_id, season, episode_number)`` (added as the schema-level
prevention in commit f75d591..), so duplicate rows cannot be
created via normal write paths. The property's defensive dedup
still matters for two reasons:
1. DBs that predate the UNIQUE constraint may have stale duplicate
rows from historical scans (visible in the user's backup DB
before clean_duplicate_episodes was run).
2. ``_episode_dict_cache`` is populated directly by scanners and
loaders, which can carry duplicates from their internal logic
(e.g. ``scan_single_series`` previously extended the dict on
every rescan).
These tests cover both paths. The class uses its own engine
fixture that drops the UNIQUE constraint after schema creation,
so the duplicate-row tests can set up the pre-migration state
the property's dedup is meant to defend against.
"""
@pytest.fixture
def legacy_engine(self):
"""In-memory SQLite engine without the UNIQUE constraint
on episodes — simulates a pre-migration DB.
SQLite ties UNIQUE constraints to an internal auto-named
index that can't be dropped directly, so we rebuild the
episodes table with raw DDL that omits the
``uq_episode_per_series_season`` constraint. The rest of
the schema comes from ``Base.metadata.create_all``.
"""
from sqlalchemy import text
engine = create_engine("sqlite:///:memory:", echo=False)
# Create everything except the episodes table.
for table in Base.metadata.sorted_tables:
if table.name != "episodes":
table.create(engine)
# Recreate episodes without the UNIQUE constraint.
with engine.begin() as conn:
conn.execute(
text(
"""
CREATE TABLE episodes (
id INTEGER NOT NULL PRIMARY KEY,
series_id INTEGER NOT NULL
REFERENCES anime_series(id) ON DELETE CASCADE,
season INTEGER NOT NULL,
episode_number INTEGER NOT NULL,
title VARCHAR(500),
file_path VARCHAR(1000),
is_downloaded BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
)
"""
)
)
conn.execute(
text("CREATE INDEX ix_episodes_series_id ON episodes (series_id)")
)
SessionLocal = sessionmaker(bind=engine)
session = SessionLocal()
yield session
session.close()
engine.dispose()
def _make_series(self, session: Session, key: str) -> AnimeSeries:
series = AnimeSeries(
key=key,
name=key.replace("-", " ").title(),
site="https://aniworld.to",
folder=f"/anime/{key}",
)
session.add(series)
session.commit()
return series
def test_episodeDict_dedupes_duplicate_relationship_rows(
self, legacy_engine: Session
):
"""Duplicate Episode rows for the same series — including
ones that pre-date the UNIQUE constraint — must not
duplicate the entries in ``episodeDict``."""
session = legacy_engine
series = self._make_series(session, "dedup-rel")
# Three duplicate rows for (S1, E3) — exactly the pattern
# the scanner accumulated across repeated rescans in the
# pre-migration era. With the constraint dropped in the
# legacy_engine fixture, these inserts succeed.
for _ in range(3):
session.add(
Episode(series_id=series.id, season=1, episode_number=3)
)
# One row each for the surrounding unique episodes.
for ep in (1, 2, 4):
session.add(
Episode(series_id=series.id, season=1, episode_number=ep)
)
session.commit()
result = series.episodeDict
# Order depends on SQLAlchemy row order, which is not strictly
# insertion order — only the *set* of episodes matters here.
assert set(result.keys()) == {1}
assert set(result[1]) == {1, 2, 3, 4}
# Defensive: no season has duplicate episode numbers.
for season, eps in result.items():
assert len(eps) == len(set(eps)), (
f"season {season} has duplicate episode numbers: {eps}"
)
def test_episodeDict_excludes_downloaded_episodes(
self, db_session: Session
):
"""is_downloaded rows must still be filtered out."""
series = self._make_series(db_session, "dedup-downloaded")
db_session.add(
Episode(series_id=series.id, season=1, episode_number=1)
)
# Downloaded row at (S1, E2): must be filtered out.
db_session.add(
Episode(
series_id=series.id,
season=1,
episode_number=2,
is_downloaded=True,
)
)
db_session.commit()
assert series.episodeDict == {1: [1]}
def test_episodeDict_dedupes_cached_value(self, db_session: Session):
"""The legacy ``_episode_dict_cache`` path (set directly by
scanners and loaders) must also dedupe, since loaders can
populate the cache with duplicated data — historically
``scan_single_series`` extended the dict on every rescan."""
series = self._make_series(db_session, "dedup-cache")
# No episodes in the DB at all — the property will fall back
# to the cache.
series._episode_dict_cache = {1: [3, 3, 3, 4, 4]}
assert series.episodeDict == {1: [3, 4]}
def test_episodeDict_preserves_unique_entries_across_seasons(
self, db_session: Session
):
"""Dedup must be per-(season, episode_number), not
per-episode_number alone — same ep number in different
seasons is legitimate and must be preserved."""
series = self._make_series(db_session, "multi-season")
for season, ep in [(1, 1), (1, 2), (2, 1), (2, 2)]:
db_session.add(
Episode(
series_id=series.id,
season=season,
episode_number=ep,
)
)
db_session.commit()
result = series.episodeDict
# Order is not guaranteed across SQLAlchemy relationships;
# compare as sets.
assert set(result.keys()) == {1, 2}
assert set(result[1]) == {1, 2}
assert set(result[2]) == {1, 2}
class TestEpisode:
"""Test cases for Episode model."""
def test_create_episode(self, db_session: Session):
"""Test creating an episode."""
series = AnimeSeries(
key="test-series",
name="Test Series",
site="https://example.com",
folder="/anime/test",
)
db_session.add(series)
db_session.commit()
episode = Episode(
series_id=series.id,
season=1,
episode_number=5,
title="The Fifth Episode",
file_path="/anime/test/S01E05.mp4",
is_downloaded=True,
)
db_session.add(episode)
db_session.commit()
# Verify saved
assert episode.id is not None
assert episode.season == 1
assert episode.episode_number == 5
assert episode.is_downloaded is True
assert episode.created_at is not None
def test_episode_relationship_to_series(self, db_session: Session):
"""Test episode relationship to series."""
series = AnimeSeries(
key="relationship-test",
name="Relationship Test",
site="https://example.com",
folder="/anime/relationship",
)
db_session.add(series)
db_session.commit()
episode = Episode(
series_id=series.id,
season=1,
episode_number=1,
)
db_session.add(episode)
db_session.commit()
# Verify relationship
assert episode.series.name == "Relationship Test"
assert episode.series.key == "relationship-test"
class TestDownloadQueueItem:
"""Test cases for DownloadQueueItem model."""
def test_create_download_item(self, db_session: Session):
"""Test creating a download queue item."""
series = AnimeSeries(
key="download-test",
name="Download Test",
site="https://example.com",
folder="/anime/download",
)
db_session.add(series)
db_session.commit()
episode = Episode(
series_id=series.id,
season=1,
episode_number=3,
)
db_session.add(episode)
db_session.commit()
item = DownloadQueueItem(
series_id=series.id,
episode_id=episode.id,
download_url="https://example.com/download/ep3",
file_destination="/anime/download/S01E03.mp4",
)
db_session.add(item)
db_session.commit()
# Verify saved
assert item.id is not None
assert item.episode_id == episode.id
assert item.series_id == series.id
def test_download_item_episode_relationship(self, db_session: Session):
"""Test download item episode relationship."""
series = AnimeSeries(
key="relationship-test",
name="Relationship Test",
site="https://example.com",
folder="/anime/relationship",
)
db_session.add(series)
db_session.commit()
episode = Episode(
series_id=series.id,
season=1,
episode_number=1,
)
db_session.add(episode)
db_session.commit()
item = DownloadQueueItem(
series_id=series.id,
episode_id=episode.id,
)
db_session.add(item)
db_session.commit()
# Verify relationship
assert item.episode.id == episode.id
assert item.series.id == series.id
def test_download_item_error_handling(self, db_session: Session):
"""Test download item with error information."""
series = AnimeSeries(
key="error-test",
name="Error Test",
site="https://example.com",
folder="/anime/error",
)
db_session.add(series)
db_session.commit()
episode = Episode(
series_id=series.id,
season=1,
episode_number=1,
)
db_session.add(episode)
db_session.commit()
item = DownloadQueueItem(
series_id=series.id,
episode_id=episode.id,
error_message="Network timeout after 30 seconds",
)
db_session.add(item)
db_session.commit()
# Verify error info
assert item.error_message == "Network timeout after 30 seconds"
class TestUserSession:
"""Test cases for UserSession model."""
def test_create_user_session(self, db_session: Session):
"""Test creating a user session."""
expires = datetime.now(timezone.utc) + timedelta(hours=24)
session = UserSession(
session_id="test-session-123",
token_hash="hashed-token-value",
user_id="user-1",
ip_address="192.168.1.100",
user_agent="Mozilla/5.0",
expires_at=expires,
is_active=True,
)
db_session.add(session)
db_session.commit()
# Verify saved
assert session.id is not None
assert session.session_id == "test-session-123"
assert session.is_active is True
assert session.created_at is not None
def test_session_unique_session_id(self, db_session: Session):
"""Test that session_id must be unique."""
expires = datetime.now(timezone.utc) + timedelta(hours=24)
session1 = UserSession(
session_id="duplicate-id",
token_hash="hash1",
expires_at=expires,
)
session2 = UserSession(
session_id="duplicate-id",
token_hash="hash2",
expires_at=expires,
)
db_session.add(session1)
db_session.commit()
db_session.add(session2)
with pytest.raises(Exception): # IntegrityError
db_session.commit()
def test_session_is_expired(self, db_session: Session):
"""Test session expiration check."""
# Create expired session
expired = datetime.now(timezone.utc) - timedelta(hours=1)
session = UserSession(
session_id="expired-session",
token_hash="hash",
expires_at=expired,
)
db_session.add(session)
db_session.commit()
# Verify is_expired
assert session.is_expired is True
def test_session_revoke(self, db_session: Session):
"""Test session revocation."""
expires = datetime.now(timezone.utc) + timedelta(hours=24)
session = UserSession(
session_id="revoke-test",
token_hash="hash",
expires_at=expires,
is_active=True,
)
db_session.add(session)
db_session.commit()
# Revoke session
session.revoke()
db_session.commit()
# Verify revoked
assert session.is_active is False
class TestTimestampMixin:
"""Test cases for TimestampMixin."""
def test_timestamp_auto_creation(self, db_session: Session):
"""Test that timestamps are automatically created."""
series = AnimeSeries(
key="timestamp-test",
name="Timestamp Test",
site="https://example.com",
folder="/anime/timestamp",
)
db_session.add(series)
db_session.commit()
# Verify timestamps exist
assert series.created_at is not None
assert series.updated_at is not None
assert series.created_at == series.updated_at
def test_timestamp_auto_update(self, db_session: Session):
"""Test that updated_at is automatically updated."""
series = AnimeSeries(
key="update-test",
name="Update Test",
site="https://example.com",
folder="/anime/update",
)
db_session.add(series)
db_session.commit()
original_updated = series.updated_at
# Update and save
series.name = "Updated Name"
db_session.commit()
# Verify updated_at changed
# Note: This test may be flaky due to timing
assert series.created_at is not None
class TestSoftDeleteMixin:
"""Test cases for SoftDeleteMixin."""
def test_soft_delete_not_applied_to_models(self):
"""Test that SoftDeleteMixin is not applied to current models.
This is a documentation test - models don't currently use
SoftDeleteMixin, but it's available for future use.
"""
# Verify models don't have deleted_at attribute
series = AnimeSeries(
key="soft-delete-test",
name="Soft Delete Test",
site="https://example.com",
folder="/anime/soft-delete",
)
# Models shouldn't have soft delete attributes
assert not hasattr(series, "deleted_at")
assert not hasattr(series, "is_deleted")
assert not hasattr(series, "soft_delete")
class TestDatabaseQueries:
"""Test complex database queries and operations."""
def test_query_series_with_episodes(self, db_session: Session):
"""Test querying series with their episodes."""
# Create series with episodes
series = AnimeSeries(
key="query-test",
name="Query Test",
site="https://example.com",
folder="/anime/query",
)
db_session.add(series)
db_session.commit()
# Add multiple episodes
for i in range(1, 6):
episode = Episode(
series_id=series.id,
season=1,
episode_number=i,
title=f"Episode {i}",
)
db_session.add(episode)
db_session.commit()
# Query series with episodes
result = db_session.execute(
select(AnimeSeries).where(AnimeSeries.key == "query-test")
)
queried_series = result.scalar_one()
# Verify episodes loaded
assert len(queried_series.episodes) == 5
def test_query_download_queue_by_status(self, db_session: Session):
"""Test querying download queue by status."""
series = AnimeSeries(
key="queue-query-test",
name="Queue Query Test",
site="https://example.com",
folder="/anime/queue-query",
)
db_session.add(series)
db_session.commit()
# Create episodes and items
for i in range(3):
episode = Episode(
series_id=series.id,
season=1,
episode_number=i + 1,
)
db_session.add(episode)
db_session.commit()
item = DownloadQueueItem(
series_id=series.id,
episode_id=episode.id,
)
db_session.add(item)
db_session.commit()
# Query all items
result = db_session.execute(
select(DownloadQueueItem)
)
items = result.scalars().all()
# Verify query
assert len(items) == 3
def test_query_active_sessions(self, db_session: Session):
"""Test querying active user sessions."""
expires = datetime.now(timezone.utc) + timedelta(hours=24)
# Create active and inactive sessions
active = UserSession(
session_id="active-1",
token_hash="hash1",
expires_at=expires,
is_active=True,
)
inactive = UserSession(
session_id="inactive-1",
token_hash="hash2",
expires_at=expires,
is_active=False,
)
db_session.add_all([active, inactive])
db_session.commit()
# Query active sessions
result = db_session.execute(
select(UserSession).where(UserSession.is_active == True)
)
active_sessions = result.scalars().all()
# Verify query
assert len(active_sessions) == 1
assert active_sessions[0].session_id == "active-1"