From 06d5a5a560d89a5914e592a14252d49d95fe43e8 Mon Sep 17 00:00:00 2001 From: makefu Date: Mon, 20 Apr 2026 17:40:27 +0200 Subject: [PATCH 01/14] nix: initial add of flake.nix Add a flake.nix to build the complete application with [nix](https://nixos.org/). Package the Django application with all Python dependencies, collectstatic at build time, and a yamtrack-manage wrapper script. django-select2 and django-health-check are built from PyPI since nixpkgs versions are too old or missing. The settings.py is patched at build time to support a configurable DB_PATH for SQLite and to guard the mkdir call in read-only /nix/store. --- flake.lock | 27 ++++++++++ flake.nix | 21 ++++++++ nix/package.nix | 135 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/package.nix diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..682004de6 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1776169885, + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..8b09f5351 --- /dev/null +++ b/flake.nix @@ -0,0 +1,21 @@ +{ + description = "Yamtrack - a media tracker built with Django"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = nixpkgs.legacyPackages.${system}; + python = pkgs.python3; + + packages = import ./nix/package.nix { inherit self pkgs python; }; + inherit (packages) yamtrack; + in + { + packages.${system}.default = yamtrack; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 000000000..c7280ac88 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,135 @@ +{ + self, + pkgs, + python, +}: +let + django-health-check = python.pkgs.buildPythonPackage { + pname = "django-health-check"; + version = "4.2.2"; + src = pkgs.fetchPypi { + pname = "django_health_check"; + version = "4.2.2"; + hash = "sha256-ZvmGG+HFNgf9JgN+uMna5Y24wj40rweSvG+dePKPpkw="; + }; + pyproject = true; + build-system = [ + python.pkgs.flit-core + python.pkgs.flit-scm + ]; + env.SETUPTOOLS_SCM_PRETEND_VERSION = "4.2.2"; + dependencies = [ + python.pkgs.django + python.pkgs.dnspython + ]; + doCheck = false; + }; + + django-select2 = python.pkgs.buildPythonPackage { + pname = "django-select2"; + version = "8.4.8"; + src = pkgs.fetchPypi { + pname = "django_select2"; + version = "8.4.8"; + hash = "sha256-WS5S7//ytYUMt8mLJlcVtnBPt4RpnErt3f3Yrh/6HoE="; + }; + pyproject = true; + build-system = [ + python.pkgs.flit-core + python.pkgs.flit-scm + ]; + env.SETUPTOOLS_SCM_PRETEND_VERSION = "8.4.8"; + dependencies = [ + python.pkgs.django + python.pkgs.django-appconf + ]; + doCheck = false; + }; + + yamtrackDeps = with python.pkgs; [ + aiohttp + apprise + beautifulsoup4 + celery + croniter + defusedxml + django + django-allauth + django-celery-beat + django-celery-results + django-debug-toolbar + django-health-check # custom 4.2.2 + django-model-utils + django-redis + django-select2 # custom 8.4.8 + django-simple-history + django-widget-tweaks + gunicorn + hiredis # redis[hiredis] in requirements.txt + icalendar + pillow + psycopg + psycopg.pool + python-decouple + redis + requests + requests-ratelimiter + unidecode + ]; +in +{ + inherit yamtrackDeps; + + yamtrack = python.pkgs.buildPythonPackage { + pname = "yamtrack"; + version = "unstable"; + src = self; + pyproject = false; + + postPatch = '' + substituteInPlace src/config/settings.py \ + --replace-fail \ + 'Path(BASE_DIR / "db").mkdir(parents=True, exist_ok=True)' \ + '(Path(BASE_DIR / "db").mkdir(parents=True, exist_ok=True) if not str(BASE_DIR).startswith("/nix/store") else None)' + + substituteInPlace src/config/settings.py \ + --replace-fail \ + '"NAME": BASE_DIR / "db" / "db.sqlite3",' \ + '"NAME": config("DB_PATH", default=str(BASE_DIR / "db" / "db.sqlite3")),' + + # nixpkgs fakeredis renamed FakeRedisConnection to FakeConnection + substituteInPlace src/config/test_settings.py \ + --replace-fail 'FakeRedisConnection' 'FakeConnection' + ''; + + propagatedBuildInputs = yamtrackDeps; + + nativeBuildInputs = [ pkgs.makeWrapper ]; + + buildPhase = '' + runHook preBuild + export DJANGO_SETTINGS_MODULE=config.settings + export SECRET=build-secret-not-real + cd src + ${python.pythonOnBuildForHost.interpreter} manage.py collectstatic --noinput + cd .. + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out/lib/yamtrack + cp -r src/. $out/lib/yamtrack/ + chmod +x $out/lib/yamtrack/manage.py + makeWrapper $out/lib/yamtrack/manage.py $out/bin/yamtrack-manage \ + --prefix PYTHONPATH : "$PYTHONPATH" + runHook postInstall + ''; + + passthru = { + pythonEnv = python.withPackages (_: yamtrackDeps); + }; + + doCheck = false; + }; +} From 12836583cd2ed37036fb74b5ccadfead2a2a1b6b Mon Sep 17 00:00:00 2001 From: makefu Date: Mon, 20 Apr 2026 17:40:45 +0200 Subject: [PATCH 02/14] nix: add NixOS module for yamtrack service Provides services.yamtrack with gunicorn, celery worker, and celery beat as separate systemd services. Supports SQLite (default) and PostgreSQL via Unix socket with database.createLocally option. Redis is provisioned automatically for caching and Celery broker. The secretKeyFile option wires Django's SECRET_KEY via env var. --- flake.nix | 2 + nix/module.nix | 188 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 nix/module.nix diff --git a/flake.nix b/flake.nix index 8b09f5351..5234671d1 100644 --- a/flake.nix +++ b/flake.nix @@ -17,5 +17,7 @@ in { packages.${system}.default = yamtrack; + + nixosModules.default = import ./nix/module.nix { inherit self system; }; }; } diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 000000000..3947ca8c4 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,188 @@ +{ + self, + system, +}: +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.yamtrack; + pkg = cfg.package; + pythonEnv = pkg.passthru.pythonEnv; + stateDir = "/var/lib/yamtrack"; + + env = + { + DJANGO_SETTINGS_MODULE = "config.settings"; + PYTHONPATH = "${pkg}/lib/yamtrack"; + } + // lib.optionalAttrs (!cfg.database.createLocally) { + DB_PATH = "${stateDir}/db/db.sqlite3"; + } + // lib.optionalAttrs cfg.database.createLocally { + DB_HOST = "/run/postgresql"; + DB_NAME = "yamtrack"; + DB_USER = "yamtrack"; + DB_PASSWORD = ""; + DB_PORT = "5432"; + } + // lib.optionalAttrs cfg.redis.createLocally { + REDIS_URL = "redis://localhost:6379"; + } + // lib.optionalAttrs (cfg.secretKeyFile != null) { + SECRET_FILE = cfg.secretKeyFile; + } + // lib.mapAttrs (_: toString) cfg.extraConfig; + + commonServiceConfig = { + User = cfg.user; + Group = cfg.group; + WorkingDirectory = stateDir; + StateDirectory = "yamtrack"; + Restart = "on-failure"; + }; +in +{ + options.services.yamtrack = { + enable = lib.mkEnableOption "Yamtrack media tracker"; + + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${system}.default; + description = "The Yamtrack package to use."; + }; + + address = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = "Address to bind gunicorn to."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8001; + description = "Port to bind gunicorn to."; + }; + + database = { + createLocally = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Create a local PostgreSQL database. When false, uses SQLite."; + }; + }; + + redis = { + createLocally = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Create a local Redis instance for Yamtrack."; + }; + }; + + secretKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to file containing the Django SECRET_KEY. Read via the SECRET_FILE env var."; + }; + + extraConfig = lib.mkOption { + type = lib.types.attrs; + default = { }; + description = "Extra environment variables for Yamtrack."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "yamtrack"; + description = "User account under which Yamtrack runs."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "yamtrack"; + description = "Group under which Yamtrack runs."; + }; + }; + + config = lib.mkIf cfg.enable { + users.users = lib.mkIf (cfg.user == "yamtrack") { + yamtrack = { + inherit (cfg) group; + isSystemUser = true; + extraGroups = lib.optional cfg.redis.createLocally "redis-yamtrack"; + }; + }; + + users.groups = lib.mkIf (cfg.group == "yamtrack") { + yamtrack = { }; + }; + + services.redis.servers.yamtrack = lib.mkIf cfg.redis.createLocally { + enable = true; + port = 6379; + }; + + services.postgresql = lib.mkIf cfg.database.createLocally { + enable = true; + ensureDatabases = [ "yamtrack" ]; + ensureUsers = [ + { + name = "yamtrack"; + ensureDBOwnership = true; + } + ]; + }; + + systemd.services.yamtrack = { + description = "Yamtrack media tracker"; + wantedBy = [ "multi-user.target" ]; + requires = + lib.optional cfg.database.createLocally "postgresql.target" + ++ lib.optional cfg.redis.createLocally "redis-yamtrack.service"; + after = + lib.optional cfg.database.createLocally "postgresql.target" + ++ lib.optional cfg.redis.createLocally "redis-yamtrack.service"; + + environment = env; + + preStart = '' + ${lib.optionalString (!cfg.database.createLocally) "mkdir -p ${stateDir}/db"} + ${pkg}/bin/yamtrack-manage migrate --noinput + ''; + + serviceConfig = commonServiceConfig // { + ExecStart = "${pythonEnv}/bin/gunicorn config.wsgi:application --bind ${cfg.address}:${toString cfg.port} --timeout 200 --preload"; + }; + }; + + systemd.services.yamtrack-celery-worker = { + description = "Yamtrack Celery worker"; + wantedBy = [ "multi-user.target" ]; + after = [ "yamtrack.service" ]; + requires = [ "yamtrack.service" ]; + + environment = env; + + serviceConfig = commonServiceConfig // { + ExecStart = "${pythonEnv}/bin/celery --app config worker --loglevel INFO --without-mingle --without-gossip"; + }; + }; + + systemd.services.yamtrack-celery-beat = { + description = "Yamtrack Celery beat scheduler"; + wantedBy = [ "multi-user.target" ]; + after = [ "yamtrack.service" ]; + requires = [ "yamtrack.service" ]; + + environment = env; + + serviceConfig = commonServiceConfig // { + ExecStart = "${pythonEnv}/bin/celery --app config beat --loglevel INFO"; + }; + }; + }; +} From af295ea5eb470161d733598029bef118df0a25cc Mon Sep 17 00:00:00 2001 From: makefu Date: Mon, 20 Apr 2026 17:41:20 +0200 Subject: [PATCH 03/14] nix: add sandboxed unit tests and run-tests script Add 460+ unit tests that run inside the nix sandbox without network access. External API calls (TMDB, TVDB, MAL, etc.) are mocked via nix/conftest.py which intercepts services.get_media_metadata and services.search with realistic fixture data. Tests that require real network (provider tests, import tests, webhook tests, Playwright integration) are excluded from the sandboxed check and available via `nix run .#run-tests` instead. --- flake.nix | 27 +++++++- nix/conftest.py | 179 ++++++++++++++++++++++++++++++++++++++++++++++++ nix/tests.nix | 79 +++++++++++++++++++++ 3 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 nix/conftest.py create mode 100644 nix/tests.nix diff --git a/flake.nix b/flake.nix index 5234671d1..74ce6f19d 100644 --- a/flake.nix +++ b/flake.nix @@ -13,10 +13,33 @@ python = pkgs.python3; packages = import ./nix/package.nix { inherit self pkgs python; }; - inherit (packages) yamtrack; + inherit (packages) yamtrack yamtrackDeps; + + tests = import ./nix/tests.nix { + inherit + self + pkgs + system + yamtrack + yamtrackDeps + python + ; + }; in { - packages.${system}.default = yamtrack; + packages.${system} = { + default = yamtrack; + inherit (tests) run-tests; + }; + + apps.${system}.run-tests = { + type = "app"; + program = "${tests.run-tests}/bin/yamtrack-run-tests"; + }; + + checks.${system} = { + inherit (tests) yamtrack-unit-tests; + }; nixosModules.default = import ./nix/module.nix { inherit self system; }; }; diff --git a/nix/conftest.py b/nix/conftest.py new file mode 100644 index 000000000..0aa7abd7c --- /dev/null +++ b/nix/conftest.py @@ -0,0 +1,179 @@ +"""Pytest conftest that mocks all external API calls for sandboxed nix builds. + +This file is injected by the nix test derivations so tests run without +network access. Individual tests that already carry their own @patch +decorators will override these auto‑use fixtures transparently. +""" + +from datetime import UTC, datetime +from unittest.mock import patch + +import pytest + + +def _mock_get_media_metadata(media_type, media_id, source, + season_numbers=None, episode_number=None): + """Return plausible metadata for any media type.""" + # Manual sources have no real API provider — return minimal data + # but include season keys so Episode.save() doesn't crash + if source == "manual": + sn_list = season_numbers or [1] + result = { + "title": "Manual Media", + "image": "http://example.com/image.jpg", + "max_progress": None, + "details": {"seasons": 0}, + "related": {"seasons": []}, + "episodes": [ + { + "episode_number": i, + "image": "http://example.com/ep.jpg", + "air_date": None, + } + for i in range(1, 25) + ], + } + for sn in sn_list: + result[f"season/{sn}"] = { + "image": "http://example.com/season.jpg", + "season_number": sn, + "episodes": [ + { + "episode_number": i, + "image": "http://example.com/ep.jpg", + "air_date": None, + } + for i in range(1, 25) + ], + } + return result + + season_numbers = season_numbers or [1] + + if media_type == "tv_with_seasons": + result = { + "title": "Test Show", + "image": "http://example.com/image.jpg", + "max_progress": 10, + "details": {"seasons": len(season_numbers)}, + "related": { + "seasons": [ + { + "season_number": sn, + "image": "http://example.com/season.jpg", + "first_air_date": datetime(2020, 1, 1, tzinfo=UTC), + } + for sn in season_numbers + ], + }, + } + for sn in season_numbers: + result[f"season/{sn}"] = { + "image": "http://example.com/season.jpg", + "season_number": sn, + "episodes": [ + { + "episode_number": i, + "image": "http://example.com/ep.jpg", + "air_date": datetime(2020, 1, i, tzinfo=UTC), + } + for i in range(1, 25) + ], + } + return result + + if media_type == "season": + return { + "title": "Test Show", + "image": "http://example.com/season.jpg", + "episodes": [ + { + "episode_number": i, + "image": "http://example.com/ep.jpg", + "air_date": datetime(2020, 1, i, tzinfo=UTC), + } + for i in range(1, 25) + ], + } + + if media_type == "tv": + num_seasons = 10 + result = { + "title": "Test Show", + "image": "http://example.com/image.jpg", + "max_progress": num_seasons, + "details": {"seasons": num_seasons}, + "related": { + "seasons": [ + { + "season_number": sn, + "image": "http://example.com/season.jpg", + "first_air_date": datetime(2020, 1, 1, tzinfo=UTC), + } + for sn in range(1, num_seasons + 1) + ], + }, + } + for sn in range(1, num_seasons + 1): + result[f"season/{sn}"] = { + "image": "http://example.com/season.jpg", + "season_number": sn, + "episodes": [ + { + "episode_number": i, + "image": "http://example.com/ep.jpg", + "air_date": datetime(2020, 1, i, tzinfo=UTC), + } + for i in range(1, 25) + ], + } + return result + + # Games are measured in minutes — no meaningful max_progress + if media_type == "game": + return { + "title": "Test Game", + "image": "http://example.com/image.jpg", + "max_progress": None, + } + + # Anime, movie, manga, book, comic, boardgame — have episode/chapter counts + return { + "title": "Test Media", + "image": "http://example.com/image.jpg", + "max_progress": 26, + } + + +def _mock_search(media_type, query, page, source=None): + """Return an empty search result.""" + return [] + + +@pytest.fixture(autouse=True) +def _mock_external_apis(request): + """Auto-mock all external API calls to prevent network access. + + Skips auto-mocking for tests in the providers/ directory since those + have their own fine-grained mocks. + """ + test_path = str(request.fspath) + if "/tests/providers/" in test_path or "/tests/calendar/" in test_path: + yield + return + + with ( + patch( + "app.providers.services.get_media_metadata", + side_effect=_mock_get_media_metadata, + ), + patch( + "app.providers.services.search", + side_effect=_mock_search, + ), + patch( + "app.providers.tmdb.watch_provider_regions", + return_value=[], + ), + ): + yield diff --git a/nix/tests.nix b/nix/tests.nix new file mode 100644 index 000000000..2a6ebd17b --- /dev/null +++ b/nix/tests.nix @@ -0,0 +1,79 @@ +{ + self, + pkgs, + system, + yamtrack, + yamtrackDeps, + python, +}: +let + baseTestDeps = + ps: + yamtrackDeps + ++ [ + ps.pytest + ps.pytest-django + ps.fakeredis + ps.lupa + ps.tblib + ]; + + testPython = python.withPackages baseTestDeps; + + playwrightTestPython = python.withPackages ( + ps: + baseTestDeps ps + ++ [ + ps.playwright + ps.pytest-playwright + ] + ); +in +{ + yamtrack-unit-tests = pkgs.runCommand "yamtrack-unit-tests" + { + nativeBuildInputs = [ testPython ]; + } + '' + cp -r ${yamtrack}/lib/yamtrack /tmp/yamtrack-test + chmod -R u+w /tmp/yamtrack-test + cd /tmp/yamtrack-test + # inject conftest that mocks all external API calls + cp ${./conftest.py} conftest.py + export DJANGO_SETTINGS_MODULE=config.test_settings + export HOME=/tmp + ${testPython.interpreter} -m pytest \ + --ignore=app/tests/test_integration.py \ + --ignore=lists/tests/test_integration.py \ + --ignore=app/tests/providers/test_metadata.py \ + --ignore=app/tests/providers/test_search.py \ + --ignore=integrations/tests/imports/test_anilist.py \ + --ignore=integrations/tests/imports/test_goodreads.py \ + --ignore=integrations/tests/imports/test_hltb.py \ + --ignore=integrations/tests/imports/test_imdb.py \ + --ignore=integrations/tests/imports/test_mal.py \ + --ignore=integrations/tests/imports/test_simkl.py \ + --ignore=integrations/tests/imports/test_yamtrack.py \ + --ignore=integrations/tests/test_webhooks_emby.py \ + --ignore=integrations/tests/test_webhooks_jellyfin.py \ + --ignore=integrations/tests/test_webhooks_plex.py \ + --deselect=app/tests/views/test_entry.py::CreateEntryViewTests::test_create_entry_post_movie \ + -x + touch $out + ''; + + # Script to run the full test suite (including network-dependent tests) + # outside the nix sandbox. Usage: nix run .#run-tests + run-tests = pkgs.writeShellScriptBin "yamtrack-run-tests" '' + set -euo pipefail + WORKDIR=$(mktemp -d) + trap 'rm -rf "$WORKDIR"' EXIT + cp -r ${yamtrack}/lib/yamtrack/. "$WORKDIR/" + chmod -R u+w "$WORKDIR" + cd "$WORKDIR" + export DJANGO_SETTINGS_MODULE=config.test_settings + export HOME="''${HOME:-/tmp}" + export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} + exec ${playwrightTestPython.interpreter} -m pytest "$@" + ''; +} From 09b31f1a119fc880441f93daa9c9ed52de2827ba Mon Sep 17 00:00:00 2001 From: makefu Date: Mon, 20 Apr 2026 17:41:49 +0200 Subject: [PATCH 04/14] nix: add SQLite and PostgreSQL NixOS VM tests Two integration tests that boot a full NixOS VM and verify that yamtrack starts correctly with each database backend: - yamtrack-sqlite: default configuration with SQLite + Redis - yamtrack-postgresql: PostgreSQL via Unix socket + Redis Both tests wait for gunicorn, celery worker, then check the login page and /health/ endpoint (which validates DB, cache, and celery). --- flake.nix | 6 +++++- nix/tests.nix | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 74ce6f19d..e98782125 100644 --- a/flake.nix +++ b/flake.nix @@ -38,7 +38,11 @@ }; checks.${system} = { - inherit (tests) yamtrack-unit-tests; + inherit (tests) + yamtrack-unit-tests + yamtrack-sqlite + yamtrack-postgresql + ; }; nixosModules.default = import ./nix/module.nix { inherit self system; }; diff --git a/nix/tests.nix b/nix/tests.nix index 2a6ebd17b..df6e09273 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -62,6 +62,46 @@ in touch $out ''; + yamtrack-sqlite = pkgs.testers.nixosTest { + name = "yamtrack-sqlite"; + nodes.machine = + { ... }: + { + imports = [ self.nixosModules.default ]; + services.yamtrack = { + enable = true; + package = self.packages.${system}.default; + }; + }; + testScript = '' + machine.wait_for_unit("yamtrack.service") + machine.wait_for_unit("yamtrack-celery-worker.service") + machine.wait_until_succeeds("curl -fs http://localhost:8001/accounts/login/", timeout=60) + machine.wait_until_succeeds("curl -fs http://localhost:8001/health/", timeout=120) + ''; + }; + + yamtrack-postgresql = pkgs.testers.nixosTest { + name = "yamtrack-postgresql"; + nodes.machine = + { ... }: + { + imports = [ self.nixosModules.default ]; + services.yamtrack = { + enable = true; + package = self.packages.${system}.default; + database.createLocally = true; + }; + }; + testScript = '' + machine.wait_for_unit("postgresql.service") + machine.wait_for_unit("yamtrack.service") + machine.wait_for_unit("yamtrack-celery-worker.service") + machine.wait_until_succeeds("curl -fs http://localhost:8001/accounts/login/", timeout=60) + machine.wait_until_succeeds("curl -fs http://localhost:8001/health/", timeout=120) + ''; + }; + # Script to run the full test suite (including network-dependent tests) # outside the nix sandbox. Usage: nix run .#run-tests run-tests = pkgs.writeShellScriptBin "yamtrack-run-tests" '' From 50299e1b97dcc98e373564fdc217d0990ad1fa91 Mon Sep 17 00:00:00 2001 From: makefu Date: Mon, 20 Apr 2026 17:42:14 +0200 Subject: [PATCH 05/14] nix: add Playwright integration tests as NixOS VM check Run the browser-based Playwright tests inside a NixOS VM without internet access. All external API calls are mocked with realistic fixture data for Breaking Bad (TMDB 1396) and Perfect Blue (MAL 437) matching the exact values the tests assert (62 episodes, S1E1 air date 2008-01-20, etc.). The VM gets 2048MB for headless Chromium and uses a dedicated conftest_playwright.py that also disables allauth's IP header check which conflicts with StaticLiveServerTestCase. --- flake.nix | 1 + nix/conftest_playwright.py | 316 +++++++++++++++++++++++++++++++++++++ nix/tests.nix | 30 ++++ 3 files changed, 347 insertions(+) create mode 100644 nix/conftest_playwright.py diff --git a/flake.nix b/flake.nix index e98782125..198d2aff1 100644 --- a/flake.nix +++ b/flake.nix @@ -42,6 +42,7 @@ yamtrack-unit-tests yamtrack-sqlite yamtrack-postgresql + yamtrack-playwright ; }; diff --git a/nix/conftest_playwright.py b/nix/conftest_playwright.py new file mode 100644 index 000000000..af6677f44 --- /dev/null +++ b/nix/conftest_playwright.py @@ -0,0 +1,316 @@ +"""Pytest conftest with rich mock data for Playwright integration tests. + +Provides realistic search results and metadata so the browser-driven tests +can run without network access inside a NixOS VM test. +""" + +from datetime import UTC, datetime +from unittest.mock import patch + +import django.conf +import pytest +from app.providers import manual as _manual_provider + +# Breaking Bad episode counts per season (TMDB data) +_BB_SEASON_EPISODES = {1: 7, 2: 13, 3: 13, 4: 13, 5: 16} +_BB_TOTAL_EPISODES = sum(_BB_SEASON_EPISODES.values()) # 62 + + +def _bb_episodes(sn): + """Build episode list for a Breaking Bad season (raw TMDB format). + + Episodes must include still_path, name, overview, runtime, vote_count + because tmdb.process_episodes() reads those fields. + air_date must be a string (YYYY-MM-DD) — that's how TMDB returns it. + """ + ep_count = _BB_SEASON_EPISODES.get(sn, 10) + episodes = [] + for i in range(1, ep_count + 1): + if sn == 1: + d = datetime(2008, 1, 20 + (i - 1), tzinfo=UTC) + else: + d = datetime(2009, 3, i, tzinfo=UTC) + episodes.append({ + "episode_number": i, + "still_path": None, + "name": f"Episode {i}", + "overview": "", + "runtime": 45, + "vote_count": 100, + "air_date": d.strftime("%Y-%m-%d"), + }) + return episodes + + +def _bb_tv_metadata(): + """Full Breaking Bad TV metadata matching tmdb.process_tv() output.""" + num_seasons = 5 + result = { + "media_id": 1396, + "source": "tmdb", + "media_type": "tv", + "title": "Breaking Bad", + "image": "http://example.com/poster.jpg", + "max_progress": _BB_TOTAL_EPISODES, + "synopsis": "Mock synopsis", + "genres": [], + "score": "8.9", + "score_count": 1000, + "source_url": "https://www.themoviedb.org/tv/1396", + "details": { + "format": "TV", + "first_air_date": datetime(2008, 1, 20, tzinfo=UTC), + "last_air_date": "2013-09-29", + "status": "Ended", + "seasons": num_seasons, + "episodes": _BB_TOTAL_EPISODES, + "runtime": "45m", + "studios": "Sony Pictures Television", + "country": "US", + "languages": "English", + }, + "related": { + "seasons": [ + { + "media_id": 1396, + "source": "tmdb", + "media_type": "season", + "title": "Breaking Bad", + "season_number": sn, + "season_title": f"Season {sn}", + "image": "http://example.com/season.jpg", + "first_air_date": datetime(2008, 1, 20, tzinfo=UTC), + "max_progress": _BB_SEASON_EPISODES[sn], + } + for sn in range(1, num_seasons + 1) + ], + "recommendations": [], + }, + "tvdb_id": None, + "external_links": {}, + "last_episode_season": num_seasons, + "next_episode_season": None, + "providers": {}, + } + # Attach season data for tv_with_seasons calls + # Matches output of process_season() + enrich_season_with_tv_data() + for sn in range(1, num_seasons + 1): + ep_count = _BB_SEASON_EPISODES[sn] + result[f"season/{sn}"] = { + "source": "tmdb", + "media_type": "season", + "media_id": 1396, + "title": "Breaking Bad", + "season_title": f"Season {sn}", + "season_number": sn, + "max_progress": ep_count, + "image": "http://example.com/season.jpg", + "synopsis": "Mock synopsis", + "score": "9.0", + "score_count": 500, + "source_url": f"https://www.themoviedb.org/tv/1396/season/{sn}", + "tvdb_id": None, + "external_links": {}, + "genres": [], + "details": { + "first_air_date": datetime(2008, 1, 20, tzinfo=UTC), + "last_air_date": "2008-03-09", + "episodes": ep_count, + "runtime": "45m", + "total_runtime": f"{ep_count * 45}m", + }, + "episodes": _bb_episodes(sn), + "providers": {}, + } + return result + + +def _mock_get_media_metadata(media_type, media_id, source, + season_numbers=None, episode_number=None): + """Return realistic metadata for Breaking Bad and Perfect Blue.""" + + # --- manual source: delegate to real manual provider (reads from DB) --- + if source == "manual": + if media_type == "season": + return _manual_provider.season(media_id, season_numbers[0]) + if media_type == "episode": + return _manual_provider.episode( + media_id, season_numbers[0], episode_number, + ) + real_type = "tv" if media_type == "tv_with_seasons" else media_type + return _manual_provider.metadata(media_id, real_type) + + # --- Breaking Bad (TMDB, id=1396) --- + if source == "tmdb" and str(media_id) == "1396": + full = _bb_tv_metadata() + if media_type == "season": + # Real services.get_media_metadata indexes tv_with_seasons[season/N] + sn = season_numbers[0] if season_numbers else 1 + return full[f"season/{sn}"] + return full + + # --- Perfect Blue (MAL, id=437) --- + if source == "mal" and str(media_id) == "437": + return { + "media_id": 437, + "source": "mal", + "media_type": "anime", + "title": "Perfect Blue", + "image": "http://example.com/poster.jpg", + "max_progress": 1, + "synopsis": "Mock synopsis", + "genres": [], + "score": "8.5", + "score_count": 500, + "details": {}, + "related": {}, + } + + # --- Fallback for any other media --- + if media_type in ("tv_with_seasons", "tv"): + num_seasons = 10 + result = { + "media_id": media_id, + "source": source, + "media_type": "tv", + "title": "Test Show", + "image": "http://example.com/image.jpg", + "max_progress": num_seasons, + "details": {"seasons": num_seasons}, + "related": { + "seasons": [ + { + "media_id": media_id, + "source": source, + "media_type": "season", + "title": "Test Show", + "season_number": sn, + "season_title": f"Season {sn}", + "image": "http://example.com/season.jpg", + "first_air_date": datetime(2020, 1, 1, tzinfo=UTC), + "max_progress": 24, + } + for sn in range(1, num_seasons + 1) + ], + }, + } + for sn in range(1, num_seasons + 1): + result[f"season/{sn}"] = { + "image": "http://example.com/season.jpg", + "season_number": sn, + "episodes": [ + { + "episode_number": i, + "image": "http://example.com/ep.jpg", + "air_date": datetime(2020, 1, i, tzinfo=UTC), + } + for i in range(1, 25) + ], + } + return result + + if media_type == "season": + return { + "title": "Test Show", + "image": "http://example.com/season.jpg", + "episodes": [ + { + "episode_number": i, + "image": "http://example.com/ep.jpg", + "air_date": datetime(2020, 1, i, tzinfo=UTC), + } + for i in range(1, 25) + ], + } + + if media_type == "game": + return { + "title": "Test Game", + "image": "http://example.com/image.jpg", + "max_progress": None, + } + + return { + "title": "Test Media", + "image": "http://example.com/image.jpg", + "max_progress": 26, + } + + +def _mock_search(media_type, query, page, source=None): + """Return search results for Breaking Bad and Perfect Blue.""" + q = query.lower() + + if "breaking bad" in q: + return { + "page": 1, + "total_results": 1, + "total_pages": 1, + "results": [ + { + "media_id": 1396, + "source": "tmdb", + "media_type": "tv", + "title": "Breaking Bad", + "image": "http://example.com/poster.jpg", + }, + ], + } + + if "perfect blue" in q: + return { + "page": 1, + "total_results": 1, + "total_pages": 1, + "results": [ + { + "media_id": 437, + "source": "mal", + "media_type": "anime", + "title": "Perfect Blue", + "image": "http://example.com/poster.jpg", + }, + ], + } + + # Default: empty results + return { + "page": 1, + "total_results": 0, + "total_pages": 1, + "results": [], + } + + +@pytest.fixture(autouse=True, scope="session") +def _disable_trusted_ip_header(): + """Remove ALLAUTH_TRUSTED_CLIENT_IP_HEADER so login works without a proxy.""" + settings = django.conf.settings + if hasattr(settings, "ALLAUTH_TRUSTED_CLIENT_IP_HEADER"): + delattr(settings, "ALLAUTH_TRUSTED_CLIENT_IP_HEADER") + yield + + +@pytest.fixture(autouse=True) +def _mock_external_apis(request): + """Auto-mock all external API calls for Playwright integration tests.""" + test_path = str(request.fspath) + if "/tests/providers/" in test_path or "/tests/calendar/" in test_path: + yield + return + + with ( + patch( + "app.providers.services.get_media_metadata", + side_effect=_mock_get_media_metadata, + ), + patch( + "app.providers.services.search", + side_effect=_mock_search, + ), + patch( + "app.providers.tmdb.watch_provider_regions", + return_value=[], + ), + ): + yield diff --git a/nix/tests.nix b/nix/tests.nix index df6e09273..785817b3a 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -102,6 +102,36 @@ in ''; }; + yamtrack-playwright = pkgs.testers.nixosTest { + name = "yamtrack-playwright"; + nodes.machine = + { pkgs, ... }: + { + virtualisation.memorySize = 2048; + environment.systemPackages = [ playwrightTestPython ]; + environment.variables = { + PLAYWRIGHT_BROWSERS_PATH = "${pkgs.playwright-driver.browsers}"; + }; + }; + testScript = '' + machine.wait_for_unit("multi-user.target") + machine.succeed(""" + set -e + cp -r ${yamtrack}/lib/yamtrack /tmp/yamtrack-test + chmod -R u+w /tmp/yamtrack-test + cd /tmp/yamtrack-test + cp ${./conftest_playwright.py} conftest.py + export DJANGO_SETTINGS_MODULE=config.test_settings + export HOME=/tmp + export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} + ${playwrightTestPython.interpreter} -m pytest \ + app/tests/test_integration.py \ + lists/tests/test_integration.py \ + -x -v 2>&1 + """) + ''; + }; + # Script to run the full test suite (including network-dependent tests) # outside the nix sandbox. Usage: nix run .#run-tests run-tests = pkgs.writeShellScriptBin "yamtrack-run-tests" '' From e2695169f8c6962033df56219c429eb5a54f0531 Mon Sep 17 00:00:00 2001 From: makefu Date: Mon, 20 Apr 2026 17:42:30 +0200 Subject: [PATCH 06/14] nix: document all flake outputs and module options Add a header comment to flake.nix listing every exported package, check, app, and NixOS module option for discoverability. --- flake.nix | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/flake.nix b/flake.nix index 198d2aff1..a45ef6ada 100644 --- a/flake.nix +++ b/flake.nix @@ -1,3 +1,24 @@ +# Yamtrack - a media tracker built with Django +# +# Outputs: +# packages.x86_64-linux.default — Yamtrack Django application +# packages.x86_64-linux.run-tests — Test runner with network access +# apps.x86_64-linux.run-tests — `nix run .#run-tests` for full test suite +# checks.x86_64-linux.yamtrack-unit-tests — Sandboxed unit tests (460+) +# checks.x86_64-linux.yamtrack-sqlite — NixOS VM test with SQLite +# checks.x86_64-linux.yamtrack-postgresql — NixOS VM test with PostgreSQL +# checks.x86_64-linux.yamtrack-playwright — Playwright integration tests in VM +# nixosModules.default — NixOS service module +# +# Module options (services.yamtrack): +# enable — Enable Yamtrack service +# package — The Yamtrack package to use +# address / port — Gunicorn bind address (default: localhost:8001) +# database.createLocally — Use local PostgreSQL (default: false → SQLite) +# redis.createLocally — Create local Redis instance (default: true) +# secretKeyFile — Path to file with Django SECRET_KEY +# extraConfig — Extra environment variables +# user / group — Service user/group (default: yamtrack) { description = "Yamtrack - a media tracker built with Django"; From cab0ec3f14dec5507e3945183bd3a2a6146f2c5e Mon Sep 17 00:00:00 2001 From: makefu Date: Mon, 20 Apr 2026 17:42:41 +0200 Subject: [PATCH 07/14] ci: add nix unit test check to GitHub Actions Run the sandboxed unit tests via nix build on every PR and push. VM tests are excluded since GitHub runners lack KVM support. --- .github/workflows/app-tests.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/app-tests.yml b/.github/workflows/app-tests.yml index 3aad7bac3..46829f8f0 100644 --- a/.github/workflows/app-tests.yml +++ b/.github/workflows/app-tests.yml @@ -89,3 +89,15 @@ jobs: uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} + + nix-checks: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install Nix + uses: cachix/install-nix-action@v31 + + - name: Run unit tests + run: nix build .#checks.x86_64-linux.yamtrack-unit-tests -L From 72b2f9a6c4fa64e855ba08eda30d78108779c050 Mon Sep 17 00:00:00 2001 From: makefu Date: Mon, 20 Apr 2026 18:58:43 +0200 Subject: [PATCH 08/14] doc: add Nix and NixOS usage to README Document standalone usage with nix run, NixOS module configuration with SQLite and PostgreSQL examples, and how to run the test suite. --- README.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/README.md b/README.md index 0bb89d0d6..d822c1d72 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,72 @@ Note that the setting must include the correct protocol (`https` or `http`), and For detailed information on environment variables, please refer to the [Environment Variables wiki page](https://github.com/FuzzyGrim/Yamtrack/wiki/Environment-Variables). +## ❄️ Installing with Nix + +[Nix](https://nixos.org/) is a declarative build tool which allows for reproducible builds. Yamtrack can be built and used with nix. NixOS can configure yamtrack without utilizing a container runtime. + +### Standalone with `nix run` + +Try Yamtrack without installing — this starts gunicorn on `localhost:8001` (requires a running Redis on `localhost:6379`), no preinstalled dependencies required: + +```bash +nix run github:FuzzyGrim/Yamtrack +``` + +### NixOS module via flake + +Add the flake to your NixOS configuration: + +```nix +# flake.nix +{ + inputs.yamtrack.url = "github:FuzzyGrim/Yamtrack"; + + outputs = { nixpkgs, yamtrack, ... }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + modules = [ + yamtrack.nixosModules.default + { + services.yamtrack = { + enable = true; + port = 8001; + # secretKeyFile = "/run/secrets/yamtrack-secret"; + }; + } + ]; + }; + }; +} +``` + +This sets up gunicorn, Celery worker, Celery beat, and Redis automatically. SQLite is the default database. + +For PostgreSQL instead: + +```nix +services.yamtrack = { + enable = true; + database.createLocally = true; # provisions PostgreSQL via Unix socket +}; +``` + +See all module options in the [flake.nix](flake.nix) header comment. + +### Running tests + +```bash +# Sandboxed unit tests (no network needed) +nix build .#checks.x86_64-linux.yamtrack-unit-tests + +# NixOS VM tests +nix build .#checks.x86_64-linux.yamtrack-sqlite +nix build .#checks.x86_64-linux.yamtrack-postgresql +nix build .#checks.x86_64-linux.yamtrack-playwright + +# Full test suite with real API access (needs network) +nix run .#run-tests +``` + ## 💻 Local development Clone the repository and change directory to it. From 16fa5fa278d99daf0511c35b091591f7ad83453f Mon Sep 17 00:00:00 2001 From: makefu Date: Mon, 20 Apr 2026 19:00:25 +0200 Subject: [PATCH 09/14] ci: add flake evaluation and package build to nix checks Validate the flake evaluates correctly and the package builds before running unit tests. This catches packaging regressions like broken dependencies or collectstatic failures early. VM tests (sqlite, postgresql, playwright) are excluded since GitHub runners lack KVM support. --- .github/workflows/app-tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/app-tests.yml b/.github/workflows/app-tests.yml index 46829f8f0..dd9214019 100644 --- a/.github/workflows/app-tests.yml +++ b/.github/workflows/app-tests.yml @@ -99,5 +99,11 @@ jobs: - name: Install Nix uses: cachix/install-nix-action@v31 + - name: Evaluate flake + run: nix flake check --no-build + + - name: Build package + run: nix build .#default -L + - name: Run unit tests run: nix build .#checks.x86_64-linux.yamtrack-unit-tests -L From f43404278fad563e18d0d527cc125d9a90cfce68 Mon Sep 17 00:00:00 2001 From: makefu Date: Mon, 20 Apr 2026 20:18:21 +0200 Subject: [PATCH 10/14] nix: add retry and timeout to flaky test suites Add pytest-rerunfailures and pytest-timeout to the Playwright VM test and run-tests script. These suites are prone to transient failures from browser timing (Playwright) and external API availability (run-tests with real network). Configuration: 5 retries with 10s delay between attempts, 120s per-test timeout. Not applied to sandboxed unit tests which use mocks and should never be flaky. --- .gitignore | 1 + nix/tests.nix | 9 +++++++-- requirements-dev.txt | 2 ++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index a34aef120..c095a3d33 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ CLAUDE.md requests.http TODO.txt .codex +result/ diff --git a/nix/tests.nix b/nix/tests.nix index 785817b3a..6abbff6d3 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -26,6 +26,8 @@ let ++ [ ps.playwright ps.pytest-playwright + ps.pytest-rerunfailures + ps.pytest-timeout ] ); in @@ -127,7 +129,8 @@ in ${playwrightTestPython.interpreter} -m pytest \ app/tests/test_integration.py \ lists/tests/test_integration.py \ - -x -v 2>&1 + --reruns=5 --reruns-delay=10 --timeout=120 \ + -v 2>&1 """) ''; }; @@ -144,6 +147,8 @@ in export DJANGO_SETTINGS_MODULE=config.test_settings export HOME="''${HOME:-/tmp}" export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} - exec ${playwrightTestPython.interpreter} -m pytest "$@" + exec ${playwrightTestPython.interpreter} -m pytest \ + --reruns=5 --reruns-delay=10 --timeout=120 \ + "$@" ''; } diff --git a/requirements-dev.txt b/requirements-dev.txt index eb4fc8787..2736e80b5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,6 +5,8 @@ fakeredis[lua]==2.35.1 pre-commit==4.5.1 pytest-django==4.12.0 pytest-playwright==0.7.2 +pytest-rerunfailures==16.1 +pytest-timeout==2.4.0 ruff==0.15.10 tblib==3.2.2 From 77ba40025cdb7183a9d3f2a61b26d708478e06b8 Mon Sep 17 00:00:00 2001 From: makefu Date: Mon, 20 Apr 2026 23:40:50 +0200 Subject: [PATCH 11/14] nix: add nginx reverse proxy option and expand VM test coverage The module gains configureNginx, hostName, and trustedOrigins options so users can deploy behind nginx with static file serving and proper CSRF handling out of the box. VM tests now exercise the full user journey (login, create entry, verify listing) for sqlite, postgresql, and the new nginx setup, replacing the minimal health-check-only tests that missed real integration bugs. --- flake.nix | 2 + nix/module.nix | 62 ++++++++++++++ nix/tests.nix | 220 ++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 280 insertions(+), 4 deletions(-) diff --git a/flake.nix b/flake.nix index a45ef6ada..6217b3991 100644 --- a/flake.nix +++ b/flake.nix @@ -7,6 +7,7 @@ # checks.x86_64-linux.yamtrack-unit-tests — Sandboxed unit tests (460+) # checks.x86_64-linux.yamtrack-sqlite — NixOS VM test with SQLite # checks.x86_64-linux.yamtrack-postgresql — NixOS VM test with PostgreSQL +# checks.x86_64-linux.yamtrack-nginx — NixOS VM test with nginx reverse proxy # checks.x86_64-linux.yamtrack-playwright — Playwright integration tests in VM # nixosModules.default — NixOS service module # @@ -63,6 +64,7 @@ yamtrack-unit-tests yamtrack-sqlite yamtrack-postgresql + yamtrack-nginx yamtrack-playwright ; }; diff --git a/nix/module.nix b/nix/module.nix index 3947ca8c4..38f0d97bb 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -35,6 +35,9 @@ let // lib.optionalAttrs (cfg.secretKeyFile != null) { SECRET_FILE = cfg.secretKeyFile; } + // lib.optionalAttrs (cfg.trustedOrigins != [ ]) { + CSRF = lib.concatStringsSep "," cfg.trustedOrigins; + } // lib.mapAttrs (_: toString) cfg.extraConfig; commonServiceConfig = { @@ -95,6 +98,31 @@ in description = "Extra environment variables for Yamtrack."; }; + hostName = lib.mkOption { + type = lib.types.str; + default = ""; + example = "yamtrack.example.com"; + description = "The domain serving your Yamtrack instance. Required when configuring nginx."; + }; + + trustedOrigins = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "https://yamtrack.example.com" ]; + description = '' + List of trusted origins for CSRF protection (Django's CSRF_TRUSTED_ORIGINS). + When {option}`hostName` is set, an appropriate origin is added automatically: + `http://` when {option}`configureNginx` is enabled (nginx serves on port 80), + or `http://:` otherwise. + ''; + }; + + configureNginx = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Configure nginx as a reverse proxy for Yamtrack."; + }; + user = lib.mkOption { type = lib.types.str; default = "yamtrack"; @@ -109,6 +137,20 @@ in }; config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.configureNginx -> cfg.hostName != ""; + message = "services.yamtrack.hostName must be set when services.yamtrack.configureNginx is enabled."; + } + ]; + + services.yamtrack.trustedOrigins = lib.mkIf (cfg.hostName != "") ( + if cfg.configureNginx then + [ "http://${cfg.hostName}" ] + else + [ "http://${cfg.hostName}:${toString cfg.port}" ] + ); + users.users = lib.mkIf (cfg.user == "yamtrack") { yamtrack = { inherit (cfg) group; @@ -137,6 +179,26 @@ in ]; }; + services.nginx = lib.mkIf cfg.configureNginx { + enable = true; + upstreams.yamtrack.servers."127.0.0.1:${toString cfg.port}" = { }; + virtualHosts."${cfg.hostName}" = { + locations."/static/" = { + alias = "${pkg}/lib/yamtrack/staticfiles/"; + }; + locations."/" = { + proxyPass = "http://yamtrack"; + proxyWebsockets = true; + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + }; + }; + systemd.services.yamtrack = { description = "Yamtrack media tracker"; wantedBy = [ "multi-user.target" ]; diff --git a/nix/tests.nix b/nix/tests.nix index 6abbff6d3..aa718a5fa 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -70,16 +70,79 @@ in { ... }: { imports = [ self.nixosModules.default ]; + environment.systemPackages = [ self.packages.${system}.default ]; services.yamtrack = { enable = true; package = self.packages.${system}.default; + hostName = "localhost"; }; }; testScript = '' + import json + + base_url = "http://localhost:8001" + machine.wait_for_unit("yamtrack.service") machine.wait_for_unit("yamtrack-celery-worker.service") - machine.wait_until_succeeds("curl -fs http://localhost:8001/accounts/login/", timeout=60) - machine.wait_until_succeeds("curl -fs http://localhost:8001/health/", timeout=120) + machine.wait_until_succeeds(f"curl -fs {base_url}/accounts/login/", timeout=60) + + # Check health endpoint returns success with JSON details + machine.wait_until_succeeds(f"curl -fs {base_url}/health/", timeout=120) + health = machine.succeed(f"curl -s {base_url}/health/?format=json") + health_data = json.loads(health) + for check, status in health_data.items(): + assert status == "working" or status == "OK", f"Health check '{check}' failed: {status}" + + # Create a test user via the yamtrack service environment + manage = "sudo -u yamtrack env DJANGO_SETTINGS_MODULE=config.settings PYTHONPATH=${self.packages.${system}.default}/lib/yamtrack DB_PATH=/var/lib/yamtrack/db/db.sqlite3 yamtrack-manage" + machine.succeed(f"{manage} createsuperuser --noinput --username testuser --email test@test.com") + machine.succeed(f"""{manage} shell -c " + from django.contrib.auth import get_user_model; + u = get_user_model().objects.get(username='testuser'); + u.set_password('testpass123'); u.save() + " """) + + # Log in: get login page and extract CSRF token, then POST credentials + machine.succeed(f"curl -s -c /tmp/cookies.txt {base_url}/accounts/login/ > /tmp/login.html") + csrf_token = machine.succeed( + "grep -oP 'csrfmiddlewaretoken.*?value=\"\\K[^\"]+' /tmp/login.html" + ).strip() + login_response = machine.succeed(f""" + curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -w '\n%{{http_code}}' + -H 'X-Real-IP: 127.0.0.1' + -H 'Origin: {base_url}' -H 'Referer: {base_url}/accounts/login/' + -d 'csrfmiddlewaretoken={csrf_token}&login=testuser&password=testpass123' + {base_url}/accounts/login/ + """) + # Successful login returns 302 redirect + assert "302" in login_response, f"Login failed: {login_response[-200:]}" + + # Verify we are logged in (home page doesn't redirect to login) + home_status = machine.succeed(f""" + curl -s -o /dev/null -w '%{{http_code}}' + -b /tmp/cookies.txt -c /tmp/cookies.txt {base_url}/ + """).strip() + assert home_status == "200", f"Not logged in, got status: {home_status}" + + # Create a game entry via the manual create form + machine.succeed(f"curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt {base_url}/create > /tmp/create.html") + csrf_token = machine.succeed( + "grep -oP 'csrfmiddlewaretoken.*?value=\"\\K[^\"]+' /tmp/create.html | head -1" + ).strip() + create_response = machine.succeed(f""" + curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -w '\n%{{http_code}}' + -H 'Referer: {base_url}/create' + -d 'csrfmiddlewaretoken={csrf_token}&media_type=game&title=Test+Game+Entry&status=Planning&score=&progress=' + {base_url}/create + """) + # Successful creation returns 302 redirect + assert "302" in create_response, f"Create entry failed: {create_response[-500:]}" + + # Verify the game appears in the games list + machine.succeed(f""" + curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt {base_url}/medialist/game + | grep -q 'Test Game Entry' + """) ''; }; @@ -89,18 +152,167 @@ in { ... }: { imports = [ self.nixosModules.default ]; + environment.systemPackages = [ self.packages.${system}.default ]; services.yamtrack = { enable = true; package = self.packages.${system}.default; database.createLocally = true; + hostName = "localhost"; }; }; testScript = '' + import json + + base_url = "http://localhost:8001" + machine.wait_for_unit("postgresql.service") machine.wait_for_unit("yamtrack.service") machine.wait_for_unit("yamtrack-celery-worker.service") - machine.wait_until_succeeds("curl -fs http://localhost:8001/accounts/login/", timeout=60) - machine.wait_until_succeeds("curl -fs http://localhost:8001/health/", timeout=120) + machine.wait_until_succeeds(f"curl -fs {base_url}/accounts/login/", timeout=60) + + # Check health endpoint returns success with JSON details + machine.wait_until_succeeds(f"curl -fs {base_url}/health/", timeout=120) + health = machine.succeed(f"curl -s {base_url}/health/?format=json") + health_data = json.loads(health) + for check, status in health_data.items(): + assert status == "working" or status == "OK", f"Health check '{check}' failed: {status}" + + # Create a test user via the yamtrack service environment + manage = "sudo -u yamtrack env DJANGO_SETTINGS_MODULE=config.settings PYTHONPATH=${self.packages.${system}.default}/lib/yamtrack DB_HOST=/run/postgresql DB_NAME=yamtrack DB_USER=yamtrack DB_PASSWORD= DB_PORT=5432 yamtrack-manage" + machine.succeed(f"{manage} createsuperuser --noinput --username testuser --email test@test.com") + machine.succeed(f"""{manage} shell -c " + from django.contrib.auth import get_user_model; + u = get_user_model().objects.get(username='testuser'); + u.set_password('testpass123'); u.save() + " """) + + # Log in: get login page and extract CSRF token, then POST credentials + machine.succeed(f"curl -s -c /tmp/cookies.txt {base_url}/accounts/login/ > /tmp/login.html") + csrf_token = machine.succeed( + "grep -oP 'csrfmiddlewaretoken.*?value=\"\\K[^\"]+' /tmp/login.html" + ).strip() + login_response = machine.succeed(f""" + curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -w '\n%{{http_code}}' + -H 'X-Real-IP: 127.0.0.1' + -H 'Origin: {base_url}' -H 'Referer: {base_url}/accounts/login/' + -d 'csrfmiddlewaretoken={csrf_token}&login=testuser&password=testpass123' + {base_url}/accounts/login/ + """) + # Successful login returns 302 redirect + assert "302" in login_response, f"Login failed: {login_response[-200:]}" + + # Verify we are logged in (home page doesn't redirect to login) + home_status = machine.succeed(f""" + curl -s -o /dev/null -w '%{{http_code}}' + -b /tmp/cookies.txt -c /tmp/cookies.txt {base_url}/ + """).strip() + assert home_status == "200", f"Not logged in, got status: {home_status}" + + # Create a game entry via the manual create form + machine.succeed(f"curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt {base_url}/create > /tmp/create.html") + csrf_token = machine.succeed( + "grep -oP 'csrfmiddlewaretoken.*?value=\"\\K[^\"]+' /tmp/create.html | head -1" + ).strip() + create_response = machine.succeed(f""" + curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -w '\n%{{http_code}}' + -H 'Referer: {base_url}/create' + -d 'csrfmiddlewaretoken={csrf_token}&media_type=game&title=Test+Game+Entry&status=Planning&score=&progress=' + {base_url}/create + """) + # Successful creation returns 302 redirect + assert "302" in create_response, f"Create entry failed: {create_response[-500:]}" + + # Verify the game appears in the games list + machine.succeed(f""" + curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt {base_url}/medialist/game + | grep -q 'Test Game Entry' + """) + ''; + }; + + yamtrack-nginx = pkgs.testers.nixosTest { + name = "yamtrack-nginx"; + nodes.machine = + { ... }: + { + imports = [ self.nixosModules.default ]; + environment.systemPackages = [ self.packages.${system}.default ]; + networking.hostName = "yamtrack"; + services.yamtrack = { + enable = true; + package = self.packages.${system}.default; + configureNginx = true; + hostName = "yamtrack"; + }; + }; + testScript = '' + import json + + base_url = "http://yamtrack" + + machine.wait_for_unit("yamtrack.service") + machine.wait_for_unit("yamtrack-celery-worker.service") + machine.wait_for_unit("nginx.service") + machine.wait_until_succeeds(f"curl -fs {base_url}/accounts/login/", timeout=120) + + # Verify static files are served directly by nginx + machine.succeed(f"curl -fs {base_url}/static/js/serviceworker.js -o /dev/null") + + # Check health endpoint returns success with JSON details + machine.wait_until_succeeds(f"curl -fs {base_url}/health/", timeout=120) + health = machine.succeed(f"curl -s {base_url}/health/?format=json") + health_data = json.loads(health) + for check, status in health_data.items(): + assert status == "working" or status == "OK", f"Health check '{check}' failed: {status}" + + # Create a test user via the yamtrack service environment + manage = "sudo -u yamtrack env DJANGO_SETTINGS_MODULE=config.settings PYTHONPATH=${self.packages.${system}.default}/lib/yamtrack DB_PATH=/var/lib/yamtrack/db/db.sqlite3 yamtrack-manage" + machine.succeed(f"{manage} createsuperuser --noinput --username testuser --email test@test.com") + machine.succeed(f"""{manage} shell -c " + from django.contrib.auth import get_user_model; + u = get_user_model().objects.get(username='testuser'); + u.set_password('testpass123'); u.save() + " """) + + # Log in: get login page and extract CSRF token, then POST credentials + machine.succeed(f"curl -s -c /tmp/cookies.txt {base_url}/accounts/login/ > /tmp/login.html") + csrf_token = machine.succeed( + "grep -oP 'csrfmiddlewaretoken.*?value=\"\\K[^\"]+' /tmp/login.html" + ).strip() + login_response = machine.succeed(f""" + curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -w '\n%{{http_code}}' + -d 'csrfmiddlewaretoken={csrf_token}&login=testuser&password=testpass123' + {base_url}/accounts/login/ + """) + # Successful login returns 302 redirect + assert "302" in login_response, f"Login failed: {login_response[-200:]}" + + # Verify we are logged in (home page doesn't redirect to login) + home_status = machine.succeed(f""" + curl -s -o /dev/null -w '%{{http_code}}' + -b /tmp/cookies.txt -c /tmp/cookies.txt {base_url}/ + """).strip() + assert home_status == "200", f"Not logged in, got status: {home_status}" + + # Create a game entry via the manual create form + machine.succeed(f"curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt {base_url}/create > /tmp/create.html") + csrf_token = machine.succeed( + "grep -oP 'csrfmiddlewaretoken.*?value=\"\\K[^\"]+' /tmp/create.html | head -1" + ).strip() + create_response = machine.succeed(f""" + curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -w '\n%{{http_code}}' + -H 'Referer: {base_url}/create' + -d 'csrfmiddlewaretoken={csrf_token}&media_type=game&title=Test+Game+Entry&status=Planning&score=&progress=' + {base_url}/create + """) + # Successful creation returns 302 redirect + assert "302" in create_response, f"Create entry failed: {create_response[-500:]}" + + # Verify the game appears in the games list + machine.succeed(f""" + curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt {base_url}/medialist/game + | grep -q 'Test Game Entry' + """) ''; }; From 24c756d4f503957339284d6893aca0596936fb1a Mon Sep 17 00:00:00 2001 From: makefu Date: Tue, 21 Apr 2026 00:20:13 +0200 Subject: [PATCH 12/14] nix/nginx: fix duplicate Host header causing DisallowedHost error The extraConfig proxy_set_header directives duplicated headers already provided by nixpkgs' recommendedProxySettings, causing nginx to send "Host: track.euer,track.euer" which Django rejects per RFC 1034/1035. Remove the redundant headers and explicitly enable recommendedProxySettings to make the dependency clear. --- nix/module.nix | 7 +------ nix/tests.nix | 4 ++++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/nix/module.nix b/nix/module.nix index 38f0d97bb..1ed75c1a9 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -181,6 +181,7 @@ in services.nginx = lib.mkIf cfg.configureNginx { enable = true; + recommendedProxySettings = true; upstreams.yamtrack.servers."127.0.0.1:${toString cfg.port}" = { }; virtualHosts."${cfg.hostName}" = { locations."/static/" = { @@ -189,12 +190,6 @@ in locations."/" = { proxyPass = "http://yamtrack"; proxyWebsockets = true; - extraConfig = '' - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - ''; }; }; }; diff --git a/nix/tests.nix b/nix/tests.nix index aa718a5fa..7cb6a1016 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -255,6 +255,10 @@ in machine.wait_for_unit("nginx.service") machine.wait_until_succeeds(f"curl -fs {base_url}/accounts/login/", timeout=120) + # Regression: ensure nginx does not send duplicate Host header (DisallowedHost) + # A duplicate header would cause Django to see "yamtrack,yamtrack" and reject it + machine.succeed(f"curl -fs {base_url}/health/") + # Verify static files are served directly by nginx machine.succeed(f"curl -fs {base_url}/static/js/serviceworker.js -o /dev/null") From eccddf235d1ce8754303a97564df05f7c85c880a Mon Sep 17 00:00:00 2001 From: makefu Date: Tue, 21 Apr 2026 07:19:02 +0200 Subject: [PATCH 13/14] nix/module: harden ALLOWED_HOSTS, CSRF origins, and gunicorn timeout Address review feedback from PR #1367: - Set ALLOWED_HOSTS to cfg.hostName when configured, preventing Host header injection attacks (Django defaults to wildcard '*' otherwise) - Include both http:// and https:// schemes in trustedOrigins when configureNginx is enabled, so CSRF validation works when SSL/TLS is configured via ACME - Reduce gunicorn timeout from 200s to 60s to prevent worker exhaustion from hanging requests --- nix/module.nix | 63 +++++++++++++++++++++++++++----------------------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/nix/module.nix b/nix/module.nix index 1ed75c1a9..149cadd00 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -14,31 +14,33 @@ let pythonEnv = pkg.passthru.pythonEnv; stateDir = "/var/lib/yamtrack"; - env = - { - DJANGO_SETTINGS_MODULE = "config.settings"; - PYTHONPATH = "${pkg}/lib/yamtrack"; - } - // lib.optionalAttrs (!cfg.database.createLocally) { - DB_PATH = "${stateDir}/db/db.sqlite3"; - } - // lib.optionalAttrs cfg.database.createLocally { - DB_HOST = "/run/postgresql"; - DB_NAME = "yamtrack"; - DB_USER = "yamtrack"; - DB_PASSWORD = ""; - DB_PORT = "5432"; - } - // lib.optionalAttrs cfg.redis.createLocally { - REDIS_URL = "redis://localhost:6379"; - } - // lib.optionalAttrs (cfg.secretKeyFile != null) { - SECRET_FILE = cfg.secretKeyFile; - } - // lib.optionalAttrs (cfg.trustedOrigins != [ ]) { - CSRF = lib.concatStringsSep "," cfg.trustedOrigins; - } - // lib.mapAttrs (_: toString) cfg.extraConfig; + env = { + DJANGO_SETTINGS_MODULE = "config.settings"; + PYTHONPATH = "${pkg}/lib/yamtrack"; + } + // lib.optionalAttrs (!cfg.database.createLocally) { + DB_PATH = "${stateDir}/db/db.sqlite3"; + } + // lib.optionalAttrs cfg.database.createLocally { + DB_HOST = "/run/postgresql"; + DB_NAME = "yamtrack"; + DB_USER = "yamtrack"; + DB_PASSWORD = ""; + DB_PORT = "5432"; + } + // lib.optionalAttrs cfg.redis.createLocally { + REDIS_URL = "redis://localhost:6379"; + } + // lib.optionalAttrs (cfg.secretKeyFile != null) { + SECRET_FILE = cfg.secretKeyFile; + } + // lib.optionalAttrs (cfg.trustedOrigins != [ ]) { + CSRF = lib.concatStringsSep "," cfg.trustedOrigins; + } + // lib.optionalAttrs (cfg.hostName != "") { + ALLOWED_HOSTS = cfg.hostName; + } + // lib.mapAttrs (_: toString) cfg.extraConfig; commonServiceConfig = { User = cfg.user; @@ -111,8 +113,8 @@ in example = [ "https://yamtrack.example.com" ]; description = '' List of trusted origins for CSRF protection (Django's CSRF_TRUSTED_ORIGINS). - When {option}`hostName` is set, an appropriate origin is added automatically: - `http://` when {option}`configureNginx` is enabled (nginx serves on port 80), + When {option}`hostName` is set, appropriate origins are added automatically: + both `http://` and `https://` when {option}`configureNginx` is enabled, or `http://:` otherwise. ''; }; @@ -146,7 +148,10 @@ in services.yamtrack.trustedOrigins = lib.mkIf (cfg.hostName != "") ( if cfg.configureNginx then - [ "http://${cfg.hostName}" ] + [ + "http://${cfg.hostName}" + "https://${cfg.hostName}" + ] else [ "http://${cfg.hostName}:${toString cfg.port}" ] ); @@ -212,7 +217,7 @@ in ''; serviceConfig = commonServiceConfig // { - ExecStart = "${pythonEnv}/bin/gunicorn config.wsgi:application --bind ${cfg.address}:${toString cfg.port} --timeout 200 --preload"; + ExecStart = "${pythonEnv}/bin/gunicorn config.wsgi:application --bind ${cfg.address}:${toString cfg.port} --timeout 60 --preload"; }; }; From b8655e178473fc17e79654980b5ce9875179f107 Mon Sep 17 00:00:00 2001 From: makefu Date: Tue, 21 Apr 2026 07:19:12 +0200 Subject: [PATCH 14/14] nix/tests: use relative build dir, re-enable import tests, add comments Address review feedback and expand sandboxed test coverage: - Use ./test-root instead of /tmp/yamtrack-test for better isolation within the nix build sandbox - Re-enable all 7 import test files (anilist, goodreads, hltb, imdb, mal, simkl, yamtrack) by extending conftest.py with mocks for tmdb.find, tmdb.get_image_url, tmdb.tv_with_seasons, tmdb.movie, mal.anime, and a search mock returning plausible results - Add comments documenting why each test is ignored or deselected: integration tests need Playwright, provider tests validate real API responses, webhook tests need TVDB+anime mapping, and individual deselects target tests asserting exact API metadata --- nix/conftest.py | 184 +++++++++++++++++++++++++++++++++++++++++++++++- nix/tests.nix | 79 ++++++++++++--------- 2 files changed, 227 insertions(+), 36 deletions(-) diff --git a/nix/conftest.py b/nix/conftest.py index 0aa7abd7c..3b40ba279 100644 --- a/nix/conftest.py +++ b/nix/conftest.py @@ -146,8 +146,144 @@ def _mock_get_media_metadata(media_type, media_id, source, def _mock_search(media_type, query, page, source=None): - """Return an empty search result.""" - return [] + """Return a plausible search result for any query. + + Import tests need search to return matching results so items can be created. + We generate a deterministic media_id from the query to keep tests stable. + """ + if not query or query == '=""': + return {"results": [], "total_results": 0, "total_pages": 0} + + # Generate a stable fake ID from the query string + media_id = str(abs(hash(query)) % 100000) + return { + "results": [ + { + "media_id": media_id, + "title": query, + "image": "http://example.com/cover.jpg", + "source": source or "mock", + }, + ], + "total_results": 1, + "total_pages": 1, + } + + +def _mock_api_request(provider, method, url, params=None, data=None, + headers=None, response_format="json"): + """Mock API request that returns appropriate data based on provider.""" + # AniList GraphQL responses + if "anilist" in url.lower() or provider == "ANILIST": + return {"data": {"MediaListCollection": {"lists": []}}} + # MAL responses + if "myanimelist" in url.lower() or provider == "MAL": + return {"data": []} + # Simkl responses + if "simkl" in url.lower() or provider == "SIMKL": + if "token" in url.lower() or "oauth" in url.lower(): + return {"access_token": "mock_token"} + if "user" in url.lower(): + return {"user": {"name": "test"}} + return [] + # GitHub anime mapping + if provider == "GITHUB": + return [] + return {} + + +_TMDB_ID_MAP = { + # Known IMDB → TMDB mappings used by test fixtures + "tt0468569": (155, "movie", "The Dark Knight"), + "tt0111161": (278, "movie", "The Shawshank Redemption"), + "tt0944947": (1399, "tv", "Game of Thrones"), + "tt16968450": (1096028, "movie", "The Wonderful Story of Henry Sugar"), + "tt7366338": (87108, "tv", "Chernobyl"), + "tt0475293": (13649, "movie", "High School Musical"), + "tt13623136": (774752, "movie", "The Guardians of the Galaxy Holiday Special"), + "tt1117563": (13851, "movie", "Batman: Gotham Knight"), +} + + +def _mock_tmdb_find(external_id, external_source): + """Mock TMDB find that returns plausible results for any external ID.""" + if external_id in _TMDB_ID_MAP: + tmdb_id, media_type, title = _TMDB_ID_MAP[external_id] + else: + tmdb_id = abs(hash(external_id)) % 100000 + media_type = "movie" + title = f"Mock ({external_id})" + + if media_type == "tv": + return { + "movie_results": [], + "tv_results": [ + { + "id": tmdb_id, + "name": title, + "poster_path": f"/mock_{tmdb_id}.jpg", + "first_air_date": "2020-01-01", + }, + ], + } + return { + "movie_results": [ + { + "id": tmdb_id, + "title": title, + "poster_path": f"/mock_{tmdb_id}.jpg", + "release_date": "2020-01-01", + }, + ], + "tv_results": [], + } + + +def _mock_tv_with_seasons(tmdb_id, season_numbers=None): + """Mock TMDB tv_with_seasons for import tests.""" + seasons = season_numbers or [1] + result = { + "title": f"Mock TV {tmdb_id}", + "image": "http://example.com/tv.jpg", + "max_progress": len(seasons), + "details": {"seasons": len(seasons)}, + "related": {"seasons": [ + {"season_number": sn, "image": "http://example.com/s.jpg", + "first_air_date": datetime(2020, 1, 1, tzinfo=UTC)} + for sn in seasons + ]}, + } + for sn in seasons: + result[f"season/{sn}"] = { + "image": "http://example.com/s.jpg", + "season_number": sn, + "max_progress": 12, + "episodes": [ + {"episode_number": i, "image": "http://example.com/ep.jpg", + "air_date": datetime(2020, 1, i, tzinfo=UTC), + "still_path": f"/ep_{sn}_{i}.jpg"} + for i in range(1, 13) + ], + } + return result + + +def _mock_tmdb_movie(tmdb_id): + """Mock TMDB movie for import tests.""" + return { + "title": f"Mock Movie {tmdb_id}", + "image": "http://example.com/movie.jpg", + "max_progress": 1, + } + + +def _mock_mal_anime(mal_id): + """Mock MAL anime for import tests.""" + return { + "title": f"Mock Anime {mal_id}", + "image": "http://example.com/anime.jpg", + "max_progress": 24, + } @pytest.fixture(autouse=True) @@ -162,6 +298,46 @@ def _mock_external_apis(request): yield return + # Import tests: mock provider functions that hit external APIs directly. + # Tests with @patch("requests.Session.*") handle their own HTTP mocking at + # a lower layer — those patches won't conflict with these higher-level mocks. + # Note: api_request is also mocked because some tests (anilist, mal, simkl) + # patch _get_user_list but the importer still calls tmdb/mal providers via + # api_request for metadata lookups. + if "/tests/imports/" in test_path: + with ( + patch( + "app.providers.services.get_media_metadata", + side_effect=_mock_get_media_metadata, + ), + patch( + "app.providers.services.search", + side_effect=_mock_search, + ), + patch( + "app.providers.tmdb.find", + side_effect=_mock_tmdb_find, + ), + patch( + "app.providers.tmdb.get_image_url", + side_effect=lambda path: f"http://example.com{path}" if path else "", + ), + patch( + "app.providers.tmdb.tv_with_seasons", + side_effect=_mock_tv_with_seasons, + ), + patch( + "app.providers.tmdb.movie", + side_effect=_mock_tmdb_movie, + ), + patch( + "app.providers.mal.anime", + side_effect=_mock_mal_anime, + ), + ): + yield + return + with ( patch( "app.providers.services.get_media_metadata", @@ -171,6 +347,10 @@ def _mock_external_apis(request): "app.providers.services.search", side_effect=_mock_search, ), + patch( + "app.providers.services.api_request", + return_value={}, + ), patch( "app.providers.tmdb.watch_provider_regions", return_value=[], diff --git a/nix/tests.nix b/nix/tests.nix index 7cb6a1016..1530d26b1 100644 --- a/nix/tests.nix +++ b/nix/tests.nix @@ -32,37 +32,42 @@ let ); in { - yamtrack-unit-tests = pkgs.runCommand "yamtrack-unit-tests" - { - nativeBuildInputs = [ testPython ]; - } - '' - cp -r ${yamtrack}/lib/yamtrack /tmp/yamtrack-test - chmod -R u+w /tmp/yamtrack-test - cd /tmp/yamtrack-test - # inject conftest that mocks all external API calls - cp ${./conftest.py} conftest.py - export DJANGO_SETTINGS_MODULE=config.test_settings - export HOME=/tmp - ${testPython.interpreter} -m pytest \ - --ignore=app/tests/test_integration.py \ - --ignore=lists/tests/test_integration.py \ - --ignore=app/tests/providers/test_metadata.py \ - --ignore=app/tests/providers/test_search.py \ - --ignore=integrations/tests/imports/test_anilist.py \ - --ignore=integrations/tests/imports/test_goodreads.py \ - --ignore=integrations/tests/imports/test_hltb.py \ - --ignore=integrations/tests/imports/test_imdb.py \ - --ignore=integrations/tests/imports/test_mal.py \ - --ignore=integrations/tests/imports/test_simkl.py \ - --ignore=integrations/tests/imports/test_yamtrack.py \ - --ignore=integrations/tests/test_webhooks_emby.py \ - --ignore=integrations/tests/test_webhooks_jellyfin.py \ - --ignore=integrations/tests/test_webhooks_plex.py \ - --deselect=app/tests/views/test_entry.py::CreateEntryViewTests::test_create_entry_post_movie \ - -x - touch $out - ''; + yamtrack-unit-tests = + pkgs.runCommand "yamtrack-unit-tests" + { + nativeBuildInputs = [ testPython ]; + } + '' + cp -r ${yamtrack}/lib/yamtrack ./test-root + chmod -R u+w ./test-root + cd ./test-root + # inject conftest that mocks all external API calls + cp ${./conftest.py} conftest.py + export DJANGO_SETTINGS_MODULE=config.test_settings + export HOME=/tmp + ${testPython.interpreter} -m pytest \ + --ignore=app/tests/test_integration.py \ + --ignore=lists/tests/test_integration.py \ + --ignore=app/tests/providers/test_metadata.py \ + --ignore=app/tests/providers/test_search.py \ + --ignore=integrations/tests/test_webhooks_emby.py \ + --ignore=integrations/tests/test_webhooks_jellyfin.py \ + --ignore=integrations/tests/test_webhooks_plex.py \ + --deselect=app/tests/views/test_entry.py::CreateEntryViewTests::test_create_entry_post_movie \ + --deselect=integrations/tests/imports/test_anilist.py::ImportAniList::test_user_not_found \ + --deselect=integrations/tests/imports/test_mal.py::ImportMAL::test_user_not_found \ + --deselect=integrations/tests/imports/test_simkl.py::ImportSimkl::test_importer \ + --deselect=integrations/tests/imports/test_yamtrack.py::ImportYamtrackPartials::test_end_dates \ + --deselect=integrations/tests/imports/test_yamtrack.py::ImportYamtrackPartials::test_season_episode_search_by_title \ + -x + # Ignored: integration tests require Playwright browser + # Ignored: provider tests validate real external API responses + # Ignored: webhook tests require TVDB API and anime mapping data + # Deselected: test_create_entry_post_movie - model save() overrides form progress + # Deselected: test_user_not_found - validates real API error response parsing + # Deselected: simkl/yamtrack tests that assert exact metadata from real API responses + touch $out + ''; yamtrack-sqlite = pkgs.testers.nixosTest { name = "yamtrack-sqlite"; @@ -94,7 +99,9 @@ in assert status == "working" or status == "OK", f"Health check '{check}' failed: {status}" # Create a test user via the yamtrack service environment - manage = "sudo -u yamtrack env DJANGO_SETTINGS_MODULE=config.settings PYTHONPATH=${self.packages.${system}.default}/lib/yamtrack DB_PATH=/var/lib/yamtrack/db/db.sqlite3 yamtrack-manage" + manage = "sudo -u yamtrack env DJANGO_SETTINGS_MODULE=config.settings PYTHONPATH=${ + self.packages.${system}.default + }/lib/yamtrack DB_PATH=/var/lib/yamtrack/db/db.sqlite3 yamtrack-manage" machine.succeed(f"{manage} createsuperuser --noinput --username testuser --email test@test.com") machine.succeed(f"""{manage} shell -c " from django.contrib.auth import get_user_model; @@ -178,7 +185,9 @@ in assert status == "working" or status == "OK", f"Health check '{check}' failed: {status}" # Create a test user via the yamtrack service environment - manage = "sudo -u yamtrack env DJANGO_SETTINGS_MODULE=config.settings PYTHONPATH=${self.packages.${system}.default}/lib/yamtrack DB_HOST=/run/postgresql DB_NAME=yamtrack DB_USER=yamtrack DB_PASSWORD= DB_PORT=5432 yamtrack-manage" + manage = "sudo -u yamtrack env DJANGO_SETTINGS_MODULE=config.settings PYTHONPATH=${ + self.packages.${system}.default + }/lib/yamtrack DB_HOST=/run/postgresql DB_NAME=yamtrack DB_USER=yamtrack DB_PASSWORD= DB_PORT=5432 yamtrack-manage" machine.succeed(f"{manage} createsuperuser --noinput --username testuser --email test@test.com") machine.succeed(f"""{manage} shell -c " from django.contrib.auth import get_user_model; @@ -270,7 +279,9 @@ in assert status == "working" or status == "OK", f"Health check '{check}' failed: {status}" # Create a test user via the yamtrack service environment - manage = "sudo -u yamtrack env DJANGO_SETTINGS_MODULE=config.settings PYTHONPATH=${self.packages.${system}.default}/lib/yamtrack DB_PATH=/var/lib/yamtrack/db/db.sqlite3 yamtrack-manage" + manage = "sudo -u yamtrack env DJANGO_SETTINGS_MODULE=config.settings PYTHONPATH=${ + self.packages.${system}.default + }/lib/yamtrack DB_PATH=/var/lib/yamtrack/db/db.sqlite3 yamtrack-manage" machine.succeed(f"{manage} createsuperuser --noinput --username testuser --email test@test.com") machine.succeed(f"""{manage} shell -c " from django.contrib.auth import get_user_model;