"""App logos: matching a stack to a catalog slug, and the caching around it. Nothing here touches the network. The catalog is a file on disk, so the tests write one; the download paths are covered by pointing them at a stub client. What matters is the matching — it decides the icon of every stack that has not been given one by hand — and that a slug can never become a path. """ from __future__ import annotations import json import os import pytest CATALOG = [ {"Name": "Jellyfin", "Reference": "jellyfin"}, {"Name": "Plex", "Reference": "plex"}, {"Name": "AdGuard Home", "Reference": "adguard-home"}, {"Name": "Pi-hole", "Reference": "pi-hole"}, {"Name": "PostgreSQL", "Reference": "postgresql"}, {"Name": "Home Assistant", "Reference": "home-assistant"}, {"Name": "Homepage", "Reference": "homepage"}, {"Name": "Paperless-ngx", "Reference": "paperless-ngx"}, {"Name": "Vaultwarden", "Reference": "vaultwarden"}, {"Name": "Gitea", "Reference": "gitea"}, {"Name": "Web", "Reference": "web"}, {"Name": "Bad Slug", "Reference": "../../etc/passwd"}, ] @pytest.fixture def svc(db, tmp_path, monkeypatch): """logo_service with a catalog on disk and its caches cleared.""" from services import logo_service monkeypatch.setattr(logo_service.settings, "DATA_DIR", str(tmp_path)) os.makedirs(os.path.join(tmp_path, "stack-icons"), exist_ok=True) with open(logo_service.catalog_path(), "w", encoding="utf-8") as fh: json.dump(CATALOG, fh) logo_service._catalog = None logo_service._catalog_mtime = 0.0 logo_service._resolved.clear() logo_service.load_catalog() return logo_service # --------------------------------------------------------------------------- # # Matching # --------------------------------------------------------------------------- # @pytest.mark.parametrize( "name,slug", [ ("jellyfin", "jellyfin"), ("Jellyfin", "jellyfin"), # Display name with a space vs. a hyphenated slug, in both directions. ("AdGuard Home", "adguard-home"), ("adguard-home", "adguard-home"), # People drop the punctuation the catalog keeps. ("pihole", "pi-hole"), ("Paperless NGX", "paperless-ngx"), # The app's name inside a longer one. ("my-jellyfin-stack", "jellyfin"), ("Medien Plex Wohnzimmer", "plex"), # An alias for what the Docker image is called. ("postgres", "postgresql"), ], ) def test_a_stack_name_finds_its_app(svc, name, slug): assert svc.match_slug(name) == slug def test_the_longest_run_of_words_wins(svc): """"home assistant" must not lose to "home" or "homepage".""" assert svc.match_slug("Home Assistant") == "home-assistant" def test_a_name_that_means_nothing_gets_no_logo(svc): assert svc.match_slug("zzz-42") is None assert svc.match_slug("") is None def test_a_single_short_word_is_not_enough(svc): """"web" is a catalog entry, so a stack called "web ui" must not claim it — a wrong logo is worse than the generic glyph.""" assert svc.match_slug("web ui") is None # Spelled out on its own it is a deliberate match. assert svc.match_slug("web") == "web" def test_the_images_answer_when_the_name_does_not(svc): assert svc.match_slug("medienserver", ["jellyfin"]) == "jellyfin" # The name still wins when it matches by itself. assert svc.match_slug("gitea", ["jellyfin"]) == "gitea" def test_a_malformed_catalog_entry_is_ignored(svc): assert "../../etc/passwd" not in svc.load_catalog().slugs def test_no_catalog_means_no_logo(svc, tmp_path): os.remove(svc.catalog_path()) svc._catalog = None svc._catalog_mtime = 0.0 assert svc.load_catalog() is None assert svc.match_slug("jellyfin") is None # --------------------------------------------------------------------------- # # Image names out of a compose file # --------------------------------------------------------------------------- # def test_image_names_drop_registry_vendor_and_tag(svc, monkeypatch, tmp_path): stack_dir = tmp_path / "stacks" / "medien" stack_dir.mkdir(parents=True) (stack_dir / "compose.yaml").write_text( "services:\n" " app:\n" " image: lscr.io/linuxserver/jellyfin:latest\n" " db:\n" " image: postgres:16-alpine\n" ) monkeypatch.setattr( svc.compose_service, "stack_dir", lambda sid, override=None: str(stack_dir) ) assert svc.images_for("medien") == ["jellyfin", "postgres"] def test_a_stack_with_no_compose_file_has_no_images(svc, monkeypatch, tmp_path): monkeypatch.setattr( svc.compose_service, "stack_dir", lambda sid, override=None: str(tmp_path / "nope") ) assert svc.images_for("nope") == [] # --------------------------------------------------------------------------- # # Caching # --------------------------------------------------------------------------- # def test_the_match_is_recomputed_when_the_name_changes(svc, monkeypatch): calls = [] real = svc.match_slug monkeypatch.setattr( svc, "match_slug", lambda name, images=(): (calls.append(name), real(name, images))[1] ) assert svc.auto_slug("s1", "jellyfin") == "jellyfin" assert svc.auto_slug("s1", "jellyfin") == "jellyfin" assert len(calls) == 1, "a repeat lookup must come from the memo" assert svc.auto_slug("s1", "gitea") == "gitea" assert len(calls) == 2, "a rename must re-match" def test_forgetting_a_stack_drops_its_match(svc): svc.auto_slug("s1", "jellyfin") svc.forget("s1") assert "s1" not in svc._resolved # --------------------------------------------------------------------------- # # Slug → path # --------------------------------------------------------------------------- # @pytest.mark.parametrize( "slug", ["../../../etc/passwd", "..", "foo/bar", "foo.png", "", "UPPER", "-lead"] ) def test_a_slug_that_is_not_a_slug_has_no_path(svc, slug): assert svc.logo_path(slug) is None def test_a_real_slug_lands_in_the_logo_directory(svc): path = svc.logo_path("jellyfin") assert path == os.path.join(svc.logo_dir(), "jellyfin.png") # --------------------------------------------------------------------------- # # Search # --------------------------------------------------------------------------- # def test_search_ranks_exact_then_prefix_then_substring(svc): slugs = [row["slug"] for row in svc.search("home")] # "Home Assistant" starts with it; "AdGuard Home" only contains it. assert slugs.index("home-assistant") < slugs.index("adguard-home") def test_search_with_no_term_lists_the_catalog(svc): assert len(svc.search("", limit=5)) == 5 def test_search_without_a_catalog_is_empty_not_an_error(svc): os.remove(svc.catalog_path()) svc._catalog = None svc._catalog_mtime = 0.0 assert svc.search("jellyfin") == []