diff --git a/l4d2web/services/overlay_creation.py b/l4d2web/services/overlay_creation.py new file mode 100644 index 0000000..a969747 --- /dev/null +++ b/l4d2web/services/overlay_creation.py @@ -0,0 +1,35 @@ +"""Overlay path generation and on-disk directory bootstrap. + +All new overlays (any type) get `path = str(overlay_id)`. The directory is +created with `exist_ok=False` so a stray folder from a prior failed delete +surfaces loudly instead of silently shadowing fresh content. Combined with +SQLite AUTOINCREMENT on `overlays.id`, that catches DB/disk drift. +""" +from __future__ import annotations + +import os + +from l4d2host.paths import get_left4me_root, validate_overlay_ref + +from l4d2web.models import Overlay + + +def generate_overlay_path(overlay_id: int) -> str: + """Return the canonical relative path for an overlay row. + + Validates the result through l4d2host's overlay-ref guard. Pure numeric IDs + always pass — this is just a belt-and-suspenders check that surfaces + immediately if someone changes the scheme. + """ + candidate = str(overlay_id) + return validate_overlay_ref(candidate) + + +def create_overlay_directory(overlay: Overlay) -> None: + """Create `LEFT4ME_ROOT/overlays/{overlay.path}/` with `exist_ok=False`. + + Raises `FileExistsError` if the directory already exists, surfacing the + rare DB/disk-drift state where a stray directory matches a fresh ID. + """ + target = get_left4me_root() / "overlays" / overlay.path + os.makedirs(target, exist_ok=False) diff --git a/l4d2web/services/workshop_paths.py b/l4d2web/services/workshop_paths.py new file mode 100644 index 0000000..a215fa4 --- /dev/null +++ b/l4d2web/services/workshop_paths.py @@ -0,0 +1,24 @@ +"""Cache-path helpers for workshop content. + +The cache lives at `$LEFT4ME_ROOT/workshop_cache/{steam_id}.vpk`. Steam IDs +are validated digit-only here so callers don't need to guard separately. +""" +from __future__ import annotations + +import re +from pathlib import Path + +from l4d2host.paths import get_left4me_root + + +_NUMERIC_ID_RE = re.compile(r"^\d+$") + + +def workshop_cache_root() -> Path: + return get_left4me_root() / "workshop_cache" + + +def cache_path(steam_id: str) -> Path: + if not isinstance(steam_id, str) or not _NUMERIC_ID_RE.fullmatch(steam_id): + raise ValueError(f"steam_id must be digits only: {steam_id!r}") + return workshop_cache_root() / f"{steam_id}.vpk" diff --git a/l4d2web/tests/test_overlay_creation.py b/l4d2web/tests/test_overlay_creation.py new file mode 100644 index 0000000..d4c62a3 --- /dev/null +++ b/l4d2web/tests/test_overlay_creation.py @@ -0,0 +1,35 @@ +"""Tests for overlay path generation and directory creation.""" +from pathlib import Path + +import pytest + +from l4d2web.models import Overlay +from l4d2web.services import overlay_creation + + +def test_generate_overlay_path_returns_str_id() -> None: + assert overlay_creation.generate_overlay_path(42) == "42" + + +def test_generate_overlay_path_validates_through_overlay_ref(monkeypatch) -> None: + # Sanity: numeric paths pass validate_overlay_ref. Anything bizarre would raise. + assert overlay_creation.generate_overlay_path(1) == "1" + + +def test_create_overlay_directory_makes_subtree(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + overlay = Overlay(id=7, name="test", path="7", type="workshop", user_id=None) + overlay_creation.create_overlay_directory(overlay) + expected = tmp_path / "overlays" / "7" + assert expected.is_dir() + + +def test_create_overlay_directory_raises_if_already_exists( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + overlay = Overlay(id=7, name="test", path="7", type="workshop", user_id=None) + (tmp_path / "overlays" / "7").mkdir(parents=True) + # exist_ok=False guards against a stray directory shadowing fresh content. + with pytest.raises(FileExistsError): + overlay_creation.create_overlay_directory(overlay) diff --git a/l4d2web/tests/test_workshop_paths.py b/l4d2web/tests/test_workshop_paths.py new file mode 100644 index 0000000..bcf775e --- /dev/null +++ b/l4d2web/tests/test_workshop_paths.py @@ -0,0 +1,23 @@ +"""Tests for workshop_paths cache-resolution helpers.""" +from pathlib import Path + +import pytest + +from l4d2web.services import workshop_paths + + +def test_workshop_cache_root_uses_left4me_root(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + assert workshop_paths.workshop_cache_root() == tmp_path / "workshop_cache" + + +def test_cache_path_returns_id_only_filename(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + assert workshop_paths.cache_path("12345") == tmp_path / "workshop_cache" / "12345.vpk" + + +@pytest.mark.parametrize("bad", ["abc", "", "12/34", "..", "../etc", "1 2", " 1"]) +def test_cache_path_rejects_non_digit_steam_id(monkeypatch, tmp_path: Path, bad: str) -> None: + monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path)) + with pytest.raises(ValueError): + workshop_paths.cache_path(bad)